snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
2
apps/analytics/.gitignore
vendored
Normal file
2
apps/analytics/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.yarn
|
||||
public
|
||||
40
apps/analytics/index.html
Normal file
40
apps/analytics/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Analytics Banner Preview</title>
|
||||
<style>
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
body.dark {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="toggle-theme">toggle theme</button>
|
||||
<button id="cookie-dialog">show cookie preferences</button>
|
||||
</body>
|
||||
<script>
|
||||
const button = document.getElementById('toggle-theme')
|
||||
button.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark')
|
||||
document.body.setAttribute(
|
||||
'data-theme',
|
||||
document.body.classList.contains('dark') ? 'dark' : 'light'
|
||||
)
|
||||
})
|
||||
|
||||
const cookieButton = document.getElementById('cookie-dialog')
|
||||
cookieButton.addEventListener('click', () => {
|
||||
window.tlanalytics.openPrivacySettings()
|
||||
})
|
||||
</script>
|
||||
<script src="./public/tl-analytics.js"></script>
|
||||
</html>
|
||||
27
apps/analytics/package.json
Normal file
27
apps/analytics/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@tldraw/analytics",
|
||||
"version": "0.1.0",
|
||||
"license": "",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc && vite build --outDir public",
|
||||
"dev": "vite",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-cookie": "^3.0.5",
|
||||
"posthog-js": "^1.281.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.0",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal file
48
apps/analytics/src/analytics-services/analytics-service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { ConsentPreferences } from '../types'
|
||||
|
||||
export class AnalyticsService {
|
||||
protected isEnabled = false
|
||||
// Initialize the service (regardless of cookie consent)
|
||||
initialize(): void {}
|
||||
// Clean up the service.
|
||||
dispose(): void {}
|
||||
// Enable the service when cookie consent is granted.
|
||||
enable(): void {}
|
||||
// Disable the service when cookie consent is revoked.
|
||||
disable(): void {}
|
||||
// Identify the user.
|
||||
identify(userId: string, properties?: { [key: string]: any }): void {}
|
||||
// Reset the user identity (call on logout).
|
||||
reset(): void {}
|
||||
// Track an event.
|
||||
trackEvent(name: string, data?: { [key: string]: any }): void {}
|
||||
// Track a pageview.
|
||||
trackPageview(): void {}
|
||||
// Track when consent banner is displayed (called before consent is granted).
|
||||
trackConsentBannerDisplayed?(data: { consent_opt_in_type: 'manual' | 'auto' }): void
|
||||
// Track when user selects consent preferences (called before consent is granted).
|
||||
trackConsentBannerSelected?(data: ConsentPreferences): void
|
||||
// Track when user copies a code snippet.
|
||||
trackCopyCode?(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
// Track when user submits a form.
|
||||
trackFormSubmission?(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
}
|
||||
119
apps/analytics/src/analytics-services/ga4.ts
Normal file
119
apps/analytics/src/analytics-services/ga4.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
// Extend Window with gtag and dataLayer (other Window properties declared in types.ts)
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?(...args: any[]): void
|
||||
dataLayer?: any[]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to safely call gtag
|
||||
function gtag(...args: any[]) {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
window.gtag(...args)
|
||||
}
|
||||
}
|
||||
|
||||
export function ga4Gtag(...args: any[]) {
|
||||
gtag(...args)
|
||||
}
|
||||
|
||||
class GA4AnalyticsService extends AnalyticsService {
|
||||
private measurementId: string | undefined
|
||||
private googleAdsId: string | undefined
|
||||
|
||||
override initialize() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
this.measurementId = window.TL_GA4_MEASUREMENT_ID ?? undefined
|
||||
this.googleAdsId = window.TL_GOOGLE_ADS_ID ?? undefined
|
||||
|
||||
if (this.measurementId) {
|
||||
gtag('consent', 'default', {
|
||||
ad_storage: 'denied',
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
// Wait for our cookie to load.
|
||||
wait_for_update: 500,
|
||||
})
|
||||
|
||||
// Initialize dataLayer
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.gtag = function () {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
window.dataLayer!.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
|
||||
// Load the GA4 script
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.id = `gtag-${this.measurementId}`
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`
|
||||
document.head.appendChild(script)
|
||||
|
||||
gtag('config', this.measurementId, {
|
||||
anonymize_ip: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.googleAdsId) {
|
||||
gtag('config', this.googleAdsId)
|
||||
}
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const script = document.getElementById(`gtag-${this.measurementId}`)
|
||||
if (script) script.remove()
|
||||
}
|
||||
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
gtag('set', { anonymize_ip: false })
|
||||
gtag('consent', 'update', {
|
||||
ad_user_data: 'granted',
|
||||
ad_personalization: 'granted',
|
||||
ad_storage: 'granted',
|
||||
analytics_storage: 'granted',
|
||||
})
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
// Clear user properties
|
||||
if (this.measurementId) {
|
||||
gtag('config', this.measurementId, {
|
||||
client_id: undefined,
|
||||
user_id: undefined,
|
||||
})
|
||||
}
|
||||
gtag('set', { anonymize_ip: true })
|
||||
gtag('consent', 'update', {
|
||||
ad_user_data: 'denied',
|
||||
ad_personalization: 'denied',
|
||||
ad_storage: 'denied',
|
||||
analytics_storage: 'denied',
|
||||
})
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
gtag('set', { user_id: userId })
|
||||
if (properties) {
|
||||
gtag('set', properties)
|
||||
}
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
gtag('event', name, data)
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
gtag('event', 'page_view')
|
||||
}
|
||||
}
|
||||
|
||||
export const ga4Service = new GA4AnalyticsService()
|
||||
182
apps/analytics/src/analytics-services/gtm.ts
Normal file
182
apps/analytics/src/analytics-services/gtm.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { GTM_SCRIPT_ID } from '../constants'
|
||||
import { ConsentPreferences } from '../types'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
// Extend Window with dataLayer (other Window properties declared in types.ts)
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: any[]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to safely push to dataLayer (internal use only)
|
||||
function dataLayerPush(data: any) {
|
||||
if (typeof window !== 'undefined' && window.dataLayer) {
|
||||
window.dataLayer.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
class GTMAnalyticsService extends AnalyticsService {
|
||||
private gtmContainerId: string | undefined
|
||||
|
||||
override initialize() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
this.gtmContainerId = window.TL_GTM_CONTAINER_ID ?? undefined
|
||||
|
||||
if (!this.gtmContainerId) return
|
||||
|
||||
// Set default consent state
|
||||
window.dataLayer = window.dataLayer || []
|
||||
dataLayerPush({
|
||||
'gtm.start': new Date().getTime(),
|
||||
event: 'gtm.js',
|
||||
})
|
||||
|
||||
// Load the GTM script
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.id = GTM_SCRIPT_ID
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${this.gtmContainerId}`
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const script = document.getElementById(GTM_SCRIPT_ID)
|
||||
if (script) script.remove()
|
||||
}
|
||||
|
||||
override enable() {
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
if (!this.isEnabled) return
|
||||
dataLayerPush({
|
||||
event: 'user_identify',
|
||||
user_id: userId,
|
||||
user_properties: properties,
|
||||
})
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
dataLayerPush({
|
||||
event: name,
|
||||
...data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
dataLayerPush({
|
||||
event: 'page_view',
|
||||
})
|
||||
}
|
||||
|
||||
override trackConsentBannerDisplayed(data: { consent_opt_in_type: 'manual' | 'auto' }) {
|
||||
dataLayerPush({
|
||||
event: 'display_consent_banner',
|
||||
id: crypto.randomUUID(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackConsentBannerSelected(data: ConsentPreferences) {
|
||||
dataLayerPush({
|
||||
event: 'select_consent_banner',
|
||||
id: crypto.randomUUID(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
override trackCopyCode(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
const payload: any = {
|
||||
event: 'click_copy_code',
|
||||
id: crypto.randomUUID(),
|
||||
page: {
|
||||
category: data.page_category.toLowerCase(),
|
||||
},
|
||||
data: {
|
||||
text_snippet: data.text_snippet.substring(0, 100),
|
||||
},
|
||||
_clear: true,
|
||||
}
|
||||
|
||||
// Only include user object if we have user data
|
||||
if (
|
||||
data.user_email ||
|
||||
data.user_email_sha256 ||
|
||||
data.user_first_name ||
|
||||
data.user_last_name ||
|
||||
data.user_phone_number
|
||||
) {
|
||||
payload.user = {}
|
||||
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
|
||||
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
|
||||
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
|
||||
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
|
||||
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
|
||||
}
|
||||
|
||||
dataLayerPush(payload)
|
||||
}
|
||||
|
||||
override trackFormSubmission(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
const payload: any = {
|
||||
event: 'generate_lead',
|
||||
id: crypto.randomUUID(),
|
||||
page: {
|
||||
category: (data.page_category || 'enquiry').toLowerCase(),
|
||||
},
|
||||
data: {
|
||||
enquiry_type: data.enquiry_type.toLowerCase(),
|
||||
},
|
||||
_clear: true,
|
||||
}
|
||||
|
||||
// Add optional company data
|
||||
if (data.company_size) payload.data.company_size = data.company_size.toLowerCase()
|
||||
if (data.company_website) payload.data.company_website = data.company_website.toLowerCase()
|
||||
|
||||
// Only include user object if we have user data
|
||||
if (
|
||||
data.user_email ||
|
||||
data.user_email_sha256 ||
|
||||
data.user_first_name ||
|
||||
data.user_last_name ||
|
||||
data.user_phone_number
|
||||
) {
|
||||
payload.user = {}
|
||||
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
|
||||
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
|
||||
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
|
||||
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
|
||||
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
|
||||
}
|
||||
|
||||
dataLayerPush(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export const gtmService = new GTMAnalyticsService()
|
||||
23
apps/analytics/src/analytics-services/hubspot.ts
Normal file
23
apps/analytics/src/analytics-services/hubspot.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { HUBSPOT_SCRIPT_ID, HUBSPOT_SCRIPT_URL } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class HubspotAnalyticsService extends AnalyticsService {
|
||||
// Does not need to be initialized
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
const hubspotScriptTag = document.createElement('script')
|
||||
hubspotScriptTag.id = HUBSPOT_SCRIPT_ID
|
||||
hubspotScriptTag.src = HUBSPOT_SCRIPT_URL
|
||||
hubspotScriptTag.defer = true
|
||||
document.head.appendChild(hubspotScriptTag)
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const hubspotScriptTag = document.getElementById(HUBSPOT_SCRIPT_ID)
|
||||
if (hubspotScriptTag) hubspotScriptTag.remove()
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
export const hubspotService = new HubspotAnalyticsService()
|
||||
53
apps/analytics/src/analytics-services/posthog.ts
Normal file
53
apps/analytics/src/analytics-services/posthog.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import _posthog from 'posthog-js'
|
||||
import { POSTHOG_API_HOST, POSTHOG_TOKEN, POSTHOG_UI_HOST } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class PosthogAnalyticsService extends AnalyticsService {
|
||||
override initialize() {
|
||||
// Make posthog available globally
|
||||
if (typeof window !== 'undefined') {
|
||||
window.posthog = _posthog
|
||||
}
|
||||
|
||||
_posthog.init(POSTHOG_TOKEN, {
|
||||
api_host: POSTHOG_API_HOST,
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: 'history_change',
|
||||
cookieless_mode: 'on_reject',
|
||||
})
|
||||
}
|
||||
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
_posthog.opt_in_capturing()
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
_posthog.setPersonProperties({ analytics_consent: false })
|
||||
_posthog.opt_out_capturing()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
_posthog.identify(userId, {
|
||||
...properties,
|
||||
analytics_consent: true,
|
||||
})
|
||||
}
|
||||
|
||||
override reset() {
|
||||
_posthog.reset()
|
||||
}
|
||||
|
||||
override trackEvent(name: string, data?: { [key: string]: any }) {
|
||||
_posthog.capture(name, data)
|
||||
}
|
||||
|
||||
override trackPageview() {
|
||||
_posthog.capture('$pageview')
|
||||
}
|
||||
}
|
||||
|
||||
export const posthogService = new PosthogAnalyticsService()
|
||||
39
apps/analytics/src/analytics-services/reo.ts
Normal file
39
apps/analytics/src/analytics-services/reo.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { REO_CLIENT_ID, REO_SCRIPT_ID, REO_SCRIPT_URL } from '../constants'
|
||||
import { AnalyticsService } from './analytics-service'
|
||||
|
||||
class ReoAnalyticsService extends AnalyticsService {
|
||||
override enable() {
|
||||
if (this.isEnabled) return
|
||||
const reoScriptTag = document.createElement('script')
|
||||
reoScriptTag.id = REO_SCRIPT_ID
|
||||
reoScriptTag.src = REO_SCRIPT_URL
|
||||
reoScriptTag.defer = true
|
||||
reoScriptTag.onload = () => window.Reo?.init?.({ clientID: REO_CLIENT_ID })
|
||||
document.head.appendChild(reoScriptTag)
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
override disable() {
|
||||
if (!this.isEnabled) return
|
||||
window.Reo?.reset?.()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
const reoScriptTag = document.getElementById(REO_SCRIPT_ID)
|
||||
if (reoScriptTag) reoScriptTag.remove()
|
||||
this.isEnabled = false
|
||||
}
|
||||
|
||||
override identify(userId: string, properties?: { [key: string]: any }) {
|
||||
window.Reo?.identify?.({
|
||||
...properties,
|
||||
userId,
|
||||
firstname: properties?.name || '',
|
||||
username: properties?.email || '',
|
||||
type: 'email',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const reoService = new ReoAnalyticsService()
|
||||
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal file
90
apps/analytics/src/components/CookieConsentBanner.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
|
||||
import { CookieConsentState } from '../state/cookie-consent-state'
|
||||
import { ThemeState } from '../state/theme-state'
|
||||
|
||||
export function createCookieConsentBanner(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState
|
||||
): HTMLElement | null {
|
||||
const consent = cookieConsentState.getValue()
|
||||
const theme = themeState.getValue()
|
||||
|
||||
// Don't show banner if consent is already given
|
||||
if (consent !== 'unknown') return null
|
||||
|
||||
// Create banner element
|
||||
const banner = document.createElement('div')
|
||||
banner.className = 'tl-analytics-banner'
|
||||
banner.setAttribute('data-theme', theme)
|
||||
|
||||
// Enable pointer events after animation completes
|
||||
setTimeout(() => {
|
||||
banner.style.pointerEvents = 'auto'
|
||||
}, 3000)
|
||||
|
||||
// Create content
|
||||
banner.innerHTML = `
|
||||
<p class="tl-analytics-banner__text">
|
||||
We use cookies on this website.
|
||||
<br>
|
||||
Learn more in our <a class="tl-analytics-banner__link" href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">Cookie Policy</a>.
|
||||
</p>
|
||||
<div class="tl-analytics-banner__buttons">
|
||||
<button class="tl-analytics-button tl-analytics-button--secondary" data-action="opt-out">
|
||||
Opt out
|
||||
</button>
|
||||
<button class="tl-analytics-button tl-analytics-button--primary" data-action="accept">
|
||||
<div class="tl-analytics-button__text-wrapper">
|
||||
<div class="tl-analytics-button__text">Accept all</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add event listeners
|
||||
banner.addEventListener('click', (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const action = target.closest('[data-action]')?.getAttribute('data-action')
|
||||
if (action === 'opt-out') {
|
||||
cookieConsentState.setValue('opted-out')
|
||||
} else if (action === 'accept') {
|
||||
cookieConsentState.setValue('opted-in')
|
||||
}
|
||||
})
|
||||
|
||||
const consentCleanup = cookieConsentState.subscribe((consent) => {
|
||||
if (consent !== 'unknown') {
|
||||
banner.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for theme changes
|
||||
const themeCleanup = themeState.subscribe((theme) => {
|
||||
banner.setAttribute('data-theme', theme)
|
||||
})
|
||||
|
||||
// Clean up observer when banner is removed
|
||||
const originalRemove = banner.remove.bind(banner)
|
||||
banner.remove = function () {
|
||||
originalRemove()
|
||||
themeCleanup()
|
||||
consentCleanup()
|
||||
}
|
||||
|
||||
return banner
|
||||
}
|
||||
|
||||
// Auto-mount function for easy integration
|
||||
export function mountCookieConsentBanner(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState,
|
||||
container: HTMLElement
|
||||
): HTMLElement | null {
|
||||
const existingBanner = document.querySelector('.tl-analytics-banner')
|
||||
if (existingBanner) existingBanner.remove()
|
||||
|
||||
const banner = createCookieConsentBanner(cookieConsentState, themeState)
|
||||
if (banner) container.appendChild(banner)
|
||||
|
||||
return banner
|
||||
}
|
||||
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal file
132
apps/analytics/src/components/PrivacySettingsDialog.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { DOT_DEV_COOKIE_POLICY_URL } from '../constants'
|
||||
import { CookieConsentState } from '../state/cookie-consent-state'
|
||||
import { ThemeState } from '../state/theme-state'
|
||||
|
||||
export function createPrivacySettingsDialog(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState
|
||||
): HTMLElement {
|
||||
const consent = cookieConsentState.getValue()
|
||||
const theme = themeState.getValue()
|
||||
|
||||
// Create dialog container
|
||||
const dialogContainer = document.createElement('div')
|
||||
dialogContainer.className = 'tl-analytics-dialog'
|
||||
|
||||
// Create overlay and dialog
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'tl-analytics-dialog__overlay'
|
||||
overlay.setAttribute('data-theme', theme)
|
||||
|
||||
const dialogWrapper = document.createElement('div')
|
||||
dialogWrapper.className = 'tl-analytics-dialog__wrapper'
|
||||
dialogWrapper.setAttribute('data-theme', theme)
|
||||
|
||||
const dialog = document.createElement('div')
|
||||
dialog.className = 'tl-analytics-dialog__content'
|
||||
|
||||
// Create dialog content
|
||||
dialog.innerHTML = `
|
||||
<div class="tl-analytics-dialog__header">
|
||||
<h2 class="tl-analytics-dialog__title">Privacy settings</h2>
|
||||
<button class="tl-analytics-dialog__close" aria-label="Close">
|
||||
<svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="tl-analytics-dialog__body">
|
||||
This website uses cookies to collect analytics from visitors. Read our
|
||||
<a href="${DOT_DEV_COOKIE_POLICY_URL}" target="_blank" rel="noreferrer">cookie policy</a>
|
||||
to learn more.
|
||||
</p>
|
||||
<div class="tl-analytics-dialog__checkbox-group">
|
||||
<label class="tl-analytics-checkbox__label" for="privacy-analytics">
|
||||
<strong>Analytics</strong> help us understand how people use this website so that we can make it better.
|
||||
</label>
|
||||
<div>
|
||||
<button class="tl-analytics-checkbox ${consent === 'opted-in' ? 'checked' : ''}" id="privacy-analytics" role="switch" aria-checked="${consent === 'opted-in'}">
|
||||
<span class="tl-analytics-checkbox__thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add event listeners
|
||||
const closeButton = dialog.querySelector('.tl-analytics-dialog__close') as HTMLButtonElement
|
||||
const switchButton = dialog.querySelector('.tl-analytics-checkbox') as HTMLButtonElement
|
||||
|
||||
function closeDialog() {
|
||||
dialogContainer.remove()
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', closeDialog)
|
||||
|
||||
// Close on overlay click
|
||||
overlay.addEventListener('click', closeDialog)
|
||||
|
||||
// Prevent closing when clicking inside dialog
|
||||
dialog.addEventListener('click', (e: Event) => e.stopPropagation())
|
||||
|
||||
// Handle ESC key
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// Handle switch toggle
|
||||
switchButton.addEventListener('click', () => {
|
||||
const currentConsent = cookieConsentState.getValue()
|
||||
cookieConsentState.setValue(currentConsent === 'opted-in' ? 'opted-out' : 'opted-in')
|
||||
})
|
||||
|
||||
// Watch for consent changes
|
||||
const cleanupConsent = cookieConsentState.subscribe((consent) => {
|
||||
if (consent === 'opted-in') {
|
||||
switchButton.classList.add('checked')
|
||||
switchButton.setAttribute('aria-checked', 'true')
|
||||
} else {
|
||||
switchButton.classList.remove('checked')
|
||||
switchButton.setAttribute('aria-checked', 'false')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for theme changes
|
||||
const cleanupTheme = themeState.subscribe((theme) => {
|
||||
overlay.setAttribute('data-theme', theme)
|
||||
dialogWrapper.setAttribute('data-theme', theme)
|
||||
})
|
||||
|
||||
// Clean up observer when dialog is removed
|
||||
const originalRemove = dialogContainer.remove.bind(dialogContainer)
|
||||
dialogContainer.remove = function () {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
originalRemove()
|
||||
cleanupTheme()
|
||||
cleanupConsent()
|
||||
}
|
||||
|
||||
// Assemble dialog
|
||||
dialogWrapper.appendChild(dialog)
|
||||
dialogContainer.appendChild(overlay)
|
||||
dialogContainer.appendChild(dialogWrapper)
|
||||
|
||||
return dialogContainer
|
||||
}
|
||||
|
||||
// Auto-mount function for easy integration
|
||||
export function mountPrivacySettingsDialog(
|
||||
cookieConsentState: CookieConsentState,
|
||||
themeState: ThemeState,
|
||||
container: HTMLElement
|
||||
): HTMLElement {
|
||||
const existingDialog = document.querySelector('.tl-analytics-dialog')
|
||||
if (existingDialog) existingDialog.remove()
|
||||
|
||||
const dialog = createPrivacySettingsDialog(cookieConsentState, themeState)
|
||||
if (dialog) container.appendChild(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
24
apps/analytics/src/constants.ts
Normal file
24
apps/analytics/src/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// All analytics use the same cookie policy URL.
|
||||
export const DOT_DEV_COOKIE_POLICY_URL = 'https://tldraw.notion.site/devcookiepolicy'
|
||||
|
||||
// All analytics use the same cookie to store consent.
|
||||
export const LEGACY_CONSENT_COOKIE_NAME = 'allowTracking'
|
||||
export const CONSENT_COOKIE_NAME = 'trackingConsent'
|
||||
export const CONSENT_CHANGED_EVENT = 'tl-analytics-consent-changed'
|
||||
|
||||
// All analytics are fed into the same HubSpot project.
|
||||
export const HUBSPOT_SCRIPT_ID = 'hs-script-loader'
|
||||
export const HUBSPOT_SCRIPT_URL = 'https://js-eu1.hs-scripts.com/145620695.js'
|
||||
|
||||
// All analytics are fed into the same Reo project.
|
||||
export const REO_SCRIPT_ID = 'reo-script-loader'
|
||||
export const REO_CLIENT_ID = '47839e47a5ed202'
|
||||
export const REO_SCRIPT_URL = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`
|
||||
|
||||
// All analytics are fed into the same PostHog project.
|
||||
export const POSTHOG_TOKEN = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
|
||||
export const POSTHOG_API_HOST = 'https://analytics.tldraw.com/i'
|
||||
export const POSTHOG_UI_HOST = 'https://eu.i.posthog.com'
|
||||
|
||||
// Google Tag Manager configuration.
|
||||
export const GTM_SCRIPT_ID = 'gtm-script-loader'
|
||||
305
apps/analytics/src/index.ts
Normal file
305
apps/analytics/src/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { ga4Gtag, ga4Service } from './analytics-services/ga4'
|
||||
import { gtmService } from './analytics-services/gtm'
|
||||
import { hubspotService } from './analytics-services/hubspot'
|
||||
import { posthogService } from './analytics-services/posthog'
|
||||
import { reoService } from './analytics-services/reo'
|
||||
import { mountCookieConsentBanner } from './components/CookieConsentBanner'
|
||||
import { mountPrivacySettingsDialog } from './components/PrivacySettingsDialog'
|
||||
import {
|
||||
CookieConsentState,
|
||||
getCookieConsent,
|
||||
setOrClearCookieConsent,
|
||||
} from './state/cookie-consent-state'
|
||||
import { ThemeState } from './state/theme-state'
|
||||
import { ConsentPreferences, CookieConsent } from './types'
|
||||
import { shouldRequireConsent } from './utils/consent-check'
|
||||
import styles from './styles.css?inline'
|
||||
|
||||
function cookieConsentToPreferences(
|
||||
consent: CookieConsent,
|
||||
consentOptInType: 'manual' | 'auto'
|
||||
): ConsentPreferences {
|
||||
if (consent === 'opted-in') {
|
||||
return { analytics: 'granted', marketing: 'granted', opt_in_type: consentOptInType }
|
||||
}
|
||||
return { analytics: 'denied', marketing: 'denied', opt_in_type: consentOptInType }
|
||||
}
|
||||
|
||||
class Analytics {
|
||||
private userId = '' as string
|
||||
private userProperties = {} as { [key: string]: any } | undefined
|
||||
private consent = 'unknown' as CookieConsent
|
||||
private consentOptInType: 'manual' | 'auto' = 'manual'
|
||||
private consentCallbacks: Array<(preferences: ConsentPreferences) => void> = []
|
||||
private isInitialized = false
|
||||
|
||||
private services = [posthogService, ga4Service, gtmService, hubspotService, reoService]
|
||||
|
||||
private maybeTrackConsentUpdate(consent: ConsentPreferences) {
|
||||
if (this.isInitialized) {
|
||||
this.track('consent_update', consent)
|
||||
} else {
|
||||
gtmService.trackEvent('consent_update', consent)
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Inject styles
|
||||
const style = document.createElement('style')
|
||||
style.textContent = styles
|
||||
document.head.appendChild(style)
|
||||
|
||||
// Initialize the analytics services
|
||||
for (const service of this.services) {
|
||||
service.initialize()
|
||||
}
|
||||
|
||||
// Set up our mini reactive states
|
||||
|
||||
const themeState = new ThemeState('light')
|
||||
themeState.initialize()
|
||||
|
||||
const cookieConsentState = new CookieConsentState('unknown')
|
||||
cookieConsentState.initialize()
|
||||
|
||||
// Subscribe to consent changes
|
||||
cookieConsentState.subscribe((consent) => {
|
||||
// If the user changed the setting we want to change opt in type to manual
|
||||
if (this.isInitialized && consent !== 'unknown' && consent !== this.consent) {
|
||||
this.consentOptInType = 'manual'
|
||||
}
|
||||
// Set (or clear) the cookie value
|
||||
setOrClearCookieConsent(consent, this.consentOptInType)
|
||||
|
||||
const consentState = cookieConsentToPreferences(consent, this.consentOptInType)
|
||||
|
||||
// If the consent is the same as the current consent, do nothing
|
||||
if (consent === this.consent) {
|
||||
// We still send the event to gtm, so that we can send data to gtm on website reload
|
||||
gtmService.trackEvent('consent_update', consentState)
|
||||
return
|
||||
}
|
||||
|
||||
this.consent = consent
|
||||
|
||||
if (this.consent === 'opted-in') {
|
||||
// Enable the analytics services only when consent is granted
|
||||
for (const service of this.services) {
|
||||
service.enable()
|
||||
}
|
||||
|
||||
this.maybeTrackConsentUpdate(consentState)
|
||||
|
||||
// Identify the user if we have a userId. Most of the time
|
||||
// identify() should be called off of the window.tlanalytics object, ie. in an app
|
||||
// where we have a user id and properties for that app (like tldraw computer). We do
|
||||
// it here, too, so that we can re-identify a user who has opted out of analytics
|
||||
// and then opts back in within the same session. We would have their user id and properties
|
||||
// in memory (from the first time they called identify) and would re-use them here.
|
||||
if (this.userId) {
|
||||
this.identify(this.userId, this.userProperties)
|
||||
}
|
||||
} else {
|
||||
this.maybeTrackConsentUpdate(consentState)
|
||||
// Disable the analytics services when consent is revoked or when consent is unknown
|
||||
for (const service of this.services) {
|
||||
service.disable()
|
||||
}
|
||||
}
|
||||
|
||||
// Track consent selection only if user actually interacted with banner (not during initialization)
|
||||
if (this.isInitialized && consent !== 'unknown') {
|
||||
for (const service of this.services) {
|
||||
service.trackConsentBannerSelected?.(consentState)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify consent callbacks
|
||||
if (consent !== 'unknown') {
|
||||
for (const callback of this.consentCallbacks) {
|
||||
callback(consentState)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ...also we stash a few things onto the window in case we need them elsewhere
|
||||
window.tlanalytics = {
|
||||
identify: this.identify.bind(this),
|
||||
reset: this.reset.bind(this),
|
||||
track: this.track.bind(this),
|
||||
page: this.page.bind(this),
|
||||
gtag: this.gtag.bind(this),
|
||||
getConsentState: () => cookieConsentToPreferences(this.consent, this.consentOptInType),
|
||||
onConsentUpdate: (callback: (preferences: ConsentPreferences) => void) => {
|
||||
this.consentCallbacks.push(callback)
|
||||
return () => {
|
||||
const index = this.consentCallbacks.indexOf(callback)
|
||||
if (index > -1) this.consentCallbacks.splice(index, 1)
|
||||
}
|
||||
},
|
||||
trackCopyCode: this.trackCopyCode.bind(this),
|
||||
trackFormSubmission: this.trackFormSubmission.bind(this),
|
||||
openPrivacySettings() {
|
||||
mountPrivacySettingsDialog(cookieConsentState, themeState, document.body)
|
||||
},
|
||||
}
|
||||
|
||||
// Now that we have our subscriber set up, determine the initial consent state.
|
||||
// If the user has already made a choice (cookie exists), use that.
|
||||
// Otherwise, check their location to determine if we need explicit consent.
|
||||
const cookieData = getCookieConsent()
|
||||
let initialConsent: CookieConsent
|
||||
|
||||
if (cookieData) {
|
||||
// User has previously made a consent decision - respect it and restore opt-in type
|
||||
initialConsent = cookieData.consent
|
||||
this.consentOptInType = cookieData.optInType
|
||||
} else {
|
||||
// No existing consent decision - check if we need to ask based on location
|
||||
const requiresConsent = await shouldRequireConsent()
|
||||
if (requiresConsent === 'requires-consent') {
|
||||
initialConsent = 'unknown'
|
||||
this.consentOptInType = 'manual'
|
||||
} else {
|
||||
initialConsent = 'opted-in'
|
||||
this.consentOptInType = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
// This will trigger the subscriber we set up earlier, which will
|
||||
// enable/disable the analytics services accordingly
|
||||
cookieConsentState.setValue(initialConsent)
|
||||
|
||||
// Now that we have the consent value in memory, we can initialize
|
||||
// the cookie consent banner and start showing some UI (if consent is unknown)
|
||||
const banner = mountCookieConsentBanner(cookieConsentState, themeState, document.body)
|
||||
|
||||
// Track banner display if banner was shown
|
||||
if (banner) {
|
||||
for (const service of this.services) {
|
||||
service.trackConsentBannerDisplayed?.({
|
||||
consent_opt_in_type: this.consentOptInType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mark initialization as complete - any consent changes after this are user interactions
|
||||
this.isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap and guard google analytics tag. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param args - The arguments to pass to the google analytics tag
|
||||
* @returns void
|
||||
*/
|
||||
gtag(...args: any[]) {
|
||||
if (this.consent !== 'opted-in') return
|
||||
ga4Gtag(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the user across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param userId - The user id to identify the user by
|
||||
* @param properties - The properties to identify the user by
|
||||
* @returns void
|
||||
*/
|
||||
identify(userId: string, properties?: { [key: string]: any }) {
|
||||
this.userId = userId
|
||||
this.userProperties = properties
|
||||
|
||||
if (this.consent !== 'opted-in') return
|
||||
|
||||
for (const service of this.services) {
|
||||
service.identify(userId, properties)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the user identity across all analytics services. This should be called from window.tlanalytics when a user logs out.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
reset() {
|
||||
this.userId = ''
|
||||
this.userProperties = undefined
|
||||
|
||||
for (const service of this.services) {
|
||||
service.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an event across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @param name - The name of the event to track
|
||||
* @param data - The data to track with the event
|
||||
* @returns void
|
||||
*/
|
||||
track(name: string, data?: { [key: string]: any }) {
|
||||
for (const service of this.services) {
|
||||
service.trackEvent(name, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a page view across all analytics services. This should be called from window.tlanalytics, ie. in an app where we have a user id and properties for that app (like tldraw computer).
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
page() {
|
||||
for (const service of this.services) {
|
||||
service.trackPageview()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track code copy event. This should be called from window.tlanalytics when a user copies a code snippet.
|
||||
*
|
||||
* @param data - The code copy event data
|
||||
* @returns void
|
||||
*/
|
||||
trackCopyCode(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
for (const service of this.services) {
|
||||
service.trackCopyCode?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track form submission event. This should be called from window.tlanalytics when a user submits a form.
|
||||
*
|
||||
* @param data - The form submission event data
|
||||
* @returns void
|
||||
*/
|
||||
trackFormSubmission(data: {
|
||||
enquiry_type: string
|
||||
page_category?: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}) {
|
||||
for (const service of this.services) {
|
||||
service.trackFormSubmission?.(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const analytics = new Analytics()
|
||||
analytics.initialize()
|
||||
} catch (error) {
|
||||
console.error('Error initializing analytics manager:', error)
|
||||
}
|
||||
68
apps/analytics/src/state/cookie-consent-state.ts
Normal file
68
apps/analytics/src/state/cookie-consent-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { CONSENT_COOKIE_NAME, LEGACY_CONSENT_COOKIE_NAME } from '../constants'
|
||||
import {
|
||||
CONSENT_OPT_IN_TYPES,
|
||||
COOKIE_CONSENT_VALUES,
|
||||
type ConsentOptInType,
|
||||
type CookieConsent,
|
||||
type CookieConsentData,
|
||||
} from '../types'
|
||||
import { AnalyticsState } from './state'
|
||||
|
||||
function isValidCookieConsentData(data: any): data is CookieConsentData {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
COOKIE_CONSENT_VALUES.includes(data.consent) &&
|
||||
CONSENT_OPT_IN_TYPES.includes(data.optInType)
|
||||
)
|
||||
}
|
||||
|
||||
export function getCookieConsent(): CookieConsentData | undefined {
|
||||
// Check new cookie format first - takes precedence
|
||||
const cookieValue = Cookies.get(CONSENT_COOKIE_NAME)
|
||||
if (cookieValue) {
|
||||
// Parse JSON format
|
||||
try {
|
||||
const parsed = JSON.parse(cookieValue)
|
||||
if (isValidCookieConsentData(parsed)) {
|
||||
// Clean up legacy cookie if it exists
|
||||
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to legacy check
|
||||
}
|
||||
}
|
||||
|
||||
// Check for legacy cookie and migrate
|
||||
const legacyCookieValue = Cookies.get(LEGACY_CONSENT_COOKIE_NAME)
|
||||
if (legacyCookieValue) {
|
||||
let migrated: CookieConsentData | undefined
|
||||
|
||||
if (legacyCookieValue === 'true') {
|
||||
migrated = { consent: 'opted-in', optInType: 'manual' }
|
||||
} else if (legacyCookieValue === 'false') {
|
||||
migrated = { consent: 'opted-out', optInType: 'manual' }
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
// Write to new cookie and delete old one
|
||||
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify(migrated))
|
||||
Cookies.remove(LEGACY_CONSENT_COOKIE_NAME)
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setOrClearCookieConsent(consent: CookieConsent, optInType: ConsentOptInType): void {
|
||||
if (consent === 'unknown') {
|
||||
Cookies.remove(CONSENT_COOKIE_NAME)
|
||||
} else {
|
||||
Cookies.set(CONSENT_COOKIE_NAME, JSON.stringify({ consent, optInType }))
|
||||
}
|
||||
}
|
||||
|
||||
export class CookieConsentState extends AnalyticsState<CookieConsent> {}
|
||||
57
apps/analytics/src/state/state.test.ts
Normal file
57
apps/analytics/src/state/state.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AnalyticsState } from './state'
|
||||
|
||||
describe('mini reactive state', () => {
|
||||
test('creates a state with an initial value', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
expect(myState.getValue()).toBe(10)
|
||||
const myOtherState = new MyState(20)
|
||||
expect(myOtherState.getValue()).toBe(20)
|
||||
})
|
||||
|
||||
test('updates the state', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
myState.setValue(20)
|
||||
expect(myState.getValue()).toBe(20)
|
||||
})
|
||||
|
||||
test('handles subscribers', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
const fn = vi.fn()
|
||||
myState.subscribe((value) => fn(value))
|
||||
myState.setValue(20)
|
||||
expect(fn).toHaveBeenCalledWith(20)
|
||||
expect(fn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('removes subscribers', () => {
|
||||
class MyState extends AnalyticsState<number> {}
|
||||
const myState = new MyState(10)
|
||||
const fn = vi.fn()
|
||||
const unsubscribe = myState.subscribe((value) => fn(value))
|
||||
unsubscribe()
|
||||
myState.setValue(20)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handles initializer / dispose', () => {
|
||||
const someState = {} as { something?: boolean }
|
||||
|
||||
class MyState extends AnalyticsState<number> {
|
||||
override initialize(): void {
|
||||
someState.something = true
|
||||
}
|
||||
override dispose(): void {
|
||||
delete someState.something
|
||||
}
|
||||
}
|
||||
const myState = new MyState(10)
|
||||
expect(someState.something).not.toBeDefined()
|
||||
myState.initialize()
|
||||
expect(someState.something).toBeDefined()
|
||||
myState.dispose()
|
||||
expect(someState.something).not.toBeDefined()
|
||||
})
|
||||
})
|
||||
59
apps/analytics/src/state/state.ts
Normal file
59
apps/analytics/src/state/state.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export class AnalyticsState<T> {
|
||||
// The current value of the state.
|
||||
protected value: T
|
||||
|
||||
// Set of listeners to notify when the state changes.
|
||||
protected listeners = new Set<(value: T) => void>()
|
||||
|
||||
constructor(initialValue: T) {
|
||||
this.value = initialValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the state change.
|
||||
*
|
||||
* @param listener - The listener to notify when the state changes.
|
||||
* @returns A function to unsubscribe the listener.
|
||||
*/
|
||||
subscribe(listener: (value: T) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of the state change.
|
||||
*/
|
||||
notify() {
|
||||
this.listeners.forEach((listener) => listener(this.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the state.
|
||||
*/
|
||||
initialize(): void {}
|
||||
|
||||
/**
|
||||
* Clean up the state.
|
||||
*/
|
||||
dispose(): void {}
|
||||
|
||||
/**
|
||||
* Get the current value of the state.
|
||||
*
|
||||
* @returns The current value.
|
||||
*/
|
||||
getValue(): T {
|
||||
return this.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the state and notify all listeners.
|
||||
*
|
||||
* @param value - The new value to set.
|
||||
*/
|
||||
setValue(value: T): void {
|
||||
if (value === this.value) return
|
||||
this.value = value
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
47
apps/analytics/src/state/theme-state.ts
Normal file
47
apps/analytics/src/state/theme-state.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// This module provides theme detection utilities for analytics components
|
||||
// Since the analytics library is used on many sites, which all may use different themes,
|
||||
// we use the most general way of detecting light or dark theme by checking the
|
||||
// `color-scheme` CSS property on the `html` element.
|
||||
|
||||
import { AnalyticsState } from './state'
|
||||
|
||||
export class ThemeState extends AnalyticsState<'light' | 'dark'> {
|
||||
private observer: MutationObserver | null = null
|
||||
|
||||
override initialize(): void {
|
||||
// If document is not available, default to light theme
|
||||
if (typeof document === 'undefined') {
|
||||
this.value = 'light'
|
||||
return
|
||||
}
|
||||
|
||||
// Read initial theme from document
|
||||
const docElm = document.documentElement
|
||||
this.value = docElm.getAttribute('style')?.includes('color-scheme: dark') ? 'dark' : 'light'
|
||||
|
||||
if (typeof MutationObserver === 'undefined') {
|
||||
// weird browser, we just won't set up the observer (we won't be able to detect theme changes)
|
||||
} else {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((mutation) => mutation.attributeName === 'style')) {
|
||||
this.setValue(
|
||||
docElm.getAttribute('style')?.includes('color-scheme: dark') ? 'dark' : 'light'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
|
||||
this.observer = observer
|
||||
}
|
||||
}
|
||||
override dispose() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
this.observer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
297
apps/analytics/src/styles.css
Normal file
297
apps/analytics/src/styles.css
Normal file
@@ -0,0 +1,297 @@
|
||||
:root {
|
||||
--tl-analytics-button-bg: hsl(0, 0%, 100%);
|
||||
--tl-analytics-button-text: hsl(235, 6.8%, 13.5%);
|
||||
--tl-analytics-bg: hsl(235, 6.8%, 13.5%);
|
||||
--tl-analytics-text: hsl(0, 9%, 94%);
|
||||
--tl-analytics-text-secondary: hsl(204, 4%, 75%);
|
||||
--tl-analytics-link: #60a5fa;
|
||||
--tl-analytics-link-hover: #93c5fd;
|
||||
--tl-analytics-overlay: rgba(0, 0, 0, 0.2);
|
||||
--tl-analytics-shadow:
|
||||
0px 1px 3px hsl(0, 0%, 0%, 66.6%), 0px 2px 6px hsl(0, 0%, 0%, 33%),
|
||||
inset 0px 0px 0px 1px var(--tl-color-panel-contrast);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--tl-analytics-button-bg: hsl(0, 0%, 0%);
|
||||
--tl-analytics-button-text: hsl(0, 0%, 99%);
|
||||
--tl-analytics-bg: hsl(0, 0%, 99%);
|
||||
--tl-analytics-text: hsl(0, 0%, 0%);
|
||||
--tl-analytics-text-secondary: hsl(204, 4%, 45%);
|
||||
--tl-analytics-link: #3b82f6;
|
||||
--tl-analytics-link-hover: #2563eb;
|
||||
--tl-analytics-overlay: rgba(0, 0, 0, 0.45);
|
||||
--tl-analytics-shadow:
|
||||
0px 0px 2px hsl(0, 0%, 0%, 16%), 0px 2px 3px hsl(0, 0%, 0%, 24%),
|
||||
0px 2px 6px hsl(0, 0%, 0%, 0.1), inset 0px 0px 0px 1px var(--tl-color-panel-contrast);
|
||||
}
|
||||
|
||||
.tl-analytics-banner {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--tl-analytics-shadow);
|
||||
background-color: var(--tl-analytics-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* Inter is usually available on our properties */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400;
|
||||
/* Safe area aware overrides */
|
||||
bottom: max(4px, env(safe-area-inset-bottom));
|
||||
right: max(4px, env(safe-area-inset-right));
|
||||
max-width: 240px;
|
||||
/* Slide-in animation */
|
||||
transform: translateY(110%);
|
||||
animation: slideUp 3s ease-in forwards;
|
||||
animation-delay: 2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
transform: translateY(110%);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tl-analytics-banner__text {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--tl-analytics-text);
|
||||
margin: 0;
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.tl-analytics-banner__link {
|
||||
color: var(--tl-analytics-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tl-analytics-banner__link:hover {
|
||||
color: var(--tl-analytics-link-hover);
|
||||
}
|
||||
|
||||
.tl-analytics-banner__buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tl-analytics-button {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tl-analytics-button--primary {
|
||||
font-weight: 500;
|
||||
background-color: var(--tl-analytics-button-bg);
|
||||
color: var(--tl-analytics-button-text);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 6px 6px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.tl-analytics-button__text-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tl-analytics-button__text {
|
||||
display: inline-block;
|
||||
animation: marquee 2.5s linear infinite;
|
||||
white-space: nowrap;
|
||||
transform: translateX(72px);
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(72px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-72px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect user's motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tl-analytics-banner {
|
||||
animation: none;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tl-analytics-button__text {
|
||||
animation: none;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tl-analytics-button--secondary {
|
||||
background: none;
|
||||
color: var(--tl-analytics-text);
|
||||
}
|
||||
|
||||
.tl-analytics-dialog {
|
||||
font-family: 'Inter', sans-serif;
|
||||
/* The dialog renders inside the host page, so reset inherited text styles
|
||||
like tldraw.com's letter-spacing (#9363) */
|
||||
letter-spacing: normal;
|
||||
color: var(--tl-analytics-text);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--tl-analytics-overlay);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__wrapper {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__content {
|
||||
position: relative;
|
||||
max-width: 350px;
|
||||
min-width: 350px;
|
||||
background-color: var(--tl-analytics-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--tl-analytics-shadow);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__header {
|
||||
height: 40px;
|
||||
padding-left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__title {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--tl-analytics-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__close {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--tl-analytics-text);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tl-analytics-dialog__close::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
background-color: var(--tl-analytics-overlay);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
}
|
||||
.tl-analytics-dialog__close:hover::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__body {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__body p {
|
||||
margin: 0;
|
||||
color: var(--tl-analytics-text-secondary);
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__body a {
|
||||
display: inline-block;
|
||||
color: var(--tl-analytics-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__body a:hover {
|
||||
color: var(--tl-analytics-link-hover);
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.tl-analytics-checkbox {
|
||||
width: 32px;
|
||||
height: 20px;
|
||||
background-color: var(--tl-analytics-text-secondary);
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tl-analytics-checkbox__thumb {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--tl-analytics-bg);
|
||||
border-radius: 9999px;
|
||||
transition: transform 100ms;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.tl-analytics-checkbox[aria-checked='true'] {
|
||||
background-color: var(--tl-analytics-link);
|
||||
}
|
||||
|
||||
.tl-analytics-checkbox[aria-checked='true'] .tl-analytics-checkbox__thumb {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.tl-analytics-checkbox__label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tl-analytics-dialog__footer {
|
||||
padding: 0 8px 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
body:has(.tl-analytics-dialog) .tl-analytics-banner {
|
||||
opacity: 0;
|
||||
}
|
||||
56
apps/analytics/src/types.ts
Normal file
56
apps/analytics/src/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Expose global functions
|
||||
declare global {
|
||||
interface Window {
|
||||
tlanalytics: {
|
||||
openPrivacySettings(): void
|
||||
page(): void
|
||||
identify(userId: string, properties?: { [key: string]: any }): void
|
||||
reset(): void
|
||||
track(name: string, data?: { [key: string]: any }): void
|
||||
gtag(...args: any[]): void
|
||||
getConsentState(): ConsentPreferences
|
||||
onConsentUpdate(callback: (preferences: ConsentPreferences) => void): () => void
|
||||
trackCopyCode(data: {
|
||||
page_category: string
|
||||
text_snippet: string
|
||||
user_email?: string
|
||||
user_email_sha256?: string
|
||||
user_first_name?: string
|
||||
user_last_name?: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
trackFormSubmission(data: {
|
||||
enquiry_type: string
|
||||
company_size?: string
|
||||
company_website?: string
|
||||
user_email: string
|
||||
user_email_sha256: string
|
||||
user_first_name: string
|
||||
user_last_name: string
|
||||
user_phone_number?: string
|
||||
}): void
|
||||
}
|
||||
TL_GA4_MEASUREMENT_ID: string | undefined
|
||||
TL_GOOGLE_ADS_ID?: string
|
||||
TL_GTM_CONTAINER_ID?: string
|
||||
Reo: any
|
||||
posthog: any
|
||||
}
|
||||
}
|
||||
|
||||
export const COOKIE_CONSENT_VALUES = ['unknown', 'opted-in', 'opted-out'] as const
|
||||
export type CookieConsent = (typeof COOKIE_CONSENT_VALUES)[number]
|
||||
|
||||
export const CONSENT_OPT_IN_TYPES = ['manual', 'auto'] as const
|
||||
export type ConsentOptInType = (typeof CONSENT_OPT_IN_TYPES)[number]
|
||||
|
||||
export interface CookieConsentData {
|
||||
consent: CookieConsent
|
||||
optInType: ConsentOptInType
|
||||
}
|
||||
|
||||
export interface ConsentPreferences {
|
||||
analytics: 'granted' | 'denied'
|
||||
marketing: 'granted' | 'denied'
|
||||
opt_in_type: 'manual' | 'auto'
|
||||
}
|
||||
27
apps/analytics/src/utils/consent-check.ts
Normal file
27
apps/analytics/src/utils/consent-check.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
type ConsentCheckResult = 'requires-consent' | 'no-consent-needed'
|
||||
|
||||
/**
|
||||
* Checks if consent is required based on CloudFlare geolocation headers
|
||||
*
|
||||
* @returns Promise<'requires-consent' | 'no-consent-needed'> - 'requires-consent' if explicit consent is required, 'no-consent-needed' if confident it's not
|
||||
*/
|
||||
export async function shouldRequireConsent(): Promise<ConsentCheckResult> {
|
||||
// CloudFlare provides the CF-IPCountry header on all requests
|
||||
// Our consent worker checks this and returns whether consent is required
|
||||
try {
|
||||
const response = await fetch('https://consent.tldraw.xyz')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Worker returns { requires_consent: boolean, country_code: string }
|
||||
if (typeof data.requires_consent === 'boolean') {
|
||||
return data.requires_consent ? 'requires-consent' : 'no-consent-needed'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Fetch failed or timed out - will default to requiring consent
|
||||
console.warn('Consent check failed, defaulting to requiring consent:', error)
|
||||
}
|
||||
|
||||
// Conservative default: require consent
|
||||
return 'requires-consent'
|
||||
}
|
||||
6
apps/analytics/src/vite-env.d.ts
vendored
Normal file
6
apps/analytics/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css?inline' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
17
apps/analytics/tsconfig.json
Normal file
17
apps/analytics/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"incremental": false,
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"]
|
||||
},
|
||||
"exclude": ["node_modules", "public", ".tsbuild*", "worker"],
|
||||
"include": ["src", "./vite.config.ts", "./vitest.config.ts"],
|
||||
"references": []
|
||||
}
|
||||
44
apps/analytics/vercel.json
Normal file
44
apps/analytics/vercel.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 2,
|
||||
"ignoreCommand": "bash ../../internal/scripts/vercel/should-build-analytics.sh",
|
||||
"installCommand": "yarn workspaces focus @tldraw/analytics @tldraw/monorepo config",
|
||||
"buildCommand": "yarn build",
|
||||
"outputDirectory": "public",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Methods",
|
||||
"value": "GET, POST"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/ingest/static/:path(.*)",
|
||||
"destination": "https://eu-assets.i.posthog.com/static/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/ingest/:path(.*)",
|
||||
"destination": "https://eu.i.posthog.com/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/i/static/:path(.*)",
|
||||
"destination": "https://eu-assets.i.posthog.com/static/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/i/:path(.*)",
|
||||
"destination": "https://eu.i.posthog.com/:path*"
|
||||
},
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
20
apps/analytics/vite.config.ts
Normal file
20
apps/analytics/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
outDir: 'public',
|
||||
lib: {
|
||||
entry: 'src/index.ts',
|
||||
name: 'TLAnalytics',
|
||||
fileName: () => `tl-analytics.js`,
|
||||
formats: ['umd'],
|
||||
},
|
||||
rolldownOptions: {
|
||||
// React and ReactDOM are now bundled instead of external
|
||||
},
|
||||
},
|
||||
})
|
||||
6
apps/analytics/vitest.config.ts
Normal file
6
apps/analytics/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// State package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
Reference in New Issue
Block a user