diff --git a/.claude/settings.json b/.claude/settings.json index 8a9d6e387bfc..e8a0ee9c5362 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,12 +1,11 @@ { "hooks": { - "PostToolUse": [ + "Stop": [ { - "matcher": "Edit|MultiEdit|Write", "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path' | { read file_path; yarn run -T prettier --write \"$file_path\"; }" + "command": "git diff --name-only --diff-filter=ACMR | grep -E '\\.(ts|tsx|js|jsx|json|md)$' | xargs -r yarn run -T prettier --write" } ] } diff --git a/.github/workflows/add-framer-rewrites.yml b/.github/workflows/add-framer-rewrites.yml new file mode 100644 index 000000000000..459db29ad3c5 --- /dev/null +++ b/.github/workflows/add-framer-rewrites.yml @@ -0,0 +1,70 @@ +name: Add Framer Rewrites + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +env: + CI: 1 + +defaults: + run: + shell: bash + +jobs: + add-rewrites: + name: 'Add Framer Rewrites' + timeout-minutes: 10 + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Run our setup + uses: ./.github/actions/setup + + - name: Check for changes before running script + id: before + run: | + echo "BEFORE_HASH=$(md5sum apps/docs/next.config.js | cut -d' ' -f1)" >> $GITHUB_OUTPUT + + - name: Run add-framer-rewrites script + run: yarn add-framer-rewrites + + - name: Check for changes after running script + id: after + run: | + echo "AFTER_HASH=$(md5sum apps/docs/next.config.js | cut -d' ' -f1)" >> $GITHUB_OUTPUT + + - name: Create PR if changes detected + if: steps.before.outputs.BEFORE_HASH != steps.after.outputs.AFTER_HASH + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH_NAME="framer-rewrites-$(date +%Y%m%d-%H%M%S)" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -b "$BRANCH_NAME" + git add apps/docs/next.config.js + git commit -m "Add missing Framer rewrites to next.config.js" + git push origin "$BRANCH_NAME" + + gh pr create \ + --title "Add missing Framer rewrites" \ + --body "Adds missing rewrites for new Framer marketing pages to \`apps/docs/next.config.js\`. + + ### Change type + + - [x] \`improvement\`" \ + --label "docs-hotfix-please" + + - name: No changes detected + if: steps.before.outputs.BEFORE_HASH == steps.after.outputs.AFTER_HASH + run: | + echo "✔ No changes detected. All Framer paths already have rewrites configured." diff --git a/.github/workflows/deploy-analytics.yml b/.github/workflows/deploy-analytics.yml new file mode 100644 index 000000000000..ac388ee750da --- /dev/null +++ b/.github/workflows/deploy-analytics.yml @@ -0,0 +1,49 @@ +name: Deploy analytics worker +permissions: + contents: read + deployments: write + +on: + pull_request: + push: + branches: + - main + - production + +env: + CI: 1 + PRINT_GITHUB_ANNOTATIONS: 1 + TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + +defaults: + run: + shell: bash + +jobs: + deploy: + name: Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} + timeout-minutes: 10 + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }} + concurrency: analytics-worker-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }} + + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + submodules: true + + - uses: ./.github/actions/setup + + - name: Build types + run: yarn build-types + + - name: Deploy + run: yarn tsx internal/scripts/deploy-analytics.ts + env: + RELEASE_COMMIT_HASH: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} diff --git a/.github/workflows/deploy-dotcom.yml b/.github/workflows/deploy-dotcom.yml index 9505d4b227bc..5d14d2b6f3b8 100644 --- a/.github/workflows/deploy-dotcom.yml +++ b/.github/workflows/deploy-dotcom.yml @@ -68,7 +68,7 @@ jobs: fi - name: Delete Neon Branch - uses: neondatabase/delete-branch-action@v3.1.3 + uses: neondatabase/delete-branch-action@v3.2.0 if: contains(github.event.pull_request.labels.*.name, 'reset-preview-db') with: project_id: ${{ vars.NEON_PROJECT_ID }} @@ -158,3 +158,4 @@ jobs: VITE_GA4_MEASUREMENT_ID: ${{ secrets.VITE_GA4_MEASUREMENT_ID }} WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }} FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + PIERRE_KEY: ${{ secrets.PIERRE_KEY }} diff --git a/.github/workflows/i18n-download-strings.yml b/.github/workflows/i18n-download-strings.yml index 7339431ca26d..04377b4e3f26 100644 --- a/.github/workflows/i18n-download-strings.yml +++ b/.github/workflows/i18n-download-strings.yml @@ -43,18 +43,25 @@ jobs: run: 'yarn i18n-check -f react-intl --locales ./apps/dotcom/client/public/tla/locales/ --source en' - name: Commit and push changes - if: always() env: GH_TOKEN: ${{ github.token }} run: | git config --global user.name 'huppy-bot[bot]' git config --global user.email '128400622+huppy-bot[bot]@users.noreply.github.com' - git checkout -b update-i18n-strings - git add "*.json" - if git diff-index --quiet HEAD --; then + + # Check if there are any changes to JSON files + if ! git diff --quiet '*.json' || ! git diff --cached --quiet '*.json'; then + echo "Changes detected, creating PR..." + else echo "No changes to commit" exit 0 fi + + # Delete the branch if it already exists + git push origin --delete update-i18n-strings 2>/dev/null || true + + git checkout -b update-i18n-strings + git add "*.json" git commit --no-verify -m '[automated] update i18n strings' git push --set-upstream origin update-i18n-strings gh pr create --title "[automated] update i18n strings" --body "This PR updates the i18n strings. diff --git a/.prettierignore b/.prettierignore index 2931a5db746d..53e92c50dad6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -30,6 +30,7 @@ apps/docs/content/reference/**/* **/.out/* **/.temp/* apps/dotcom/client/public/**/*.* +apps/analytics/public/**/* **/.clasp.json diff --git a/apps/analytics-worker/.gitignore b/apps/analytics-worker/.gitignore new file mode 100644 index 000000000000..b3e55cbeabd1 --- /dev/null +++ b/apps/analytics-worker/.gitignore @@ -0,0 +1,2 @@ +build +.wrangler \ No newline at end of file diff --git a/apps/analytics-worker/CONTEXT.md b/apps/analytics-worker/CONTEXT.md new file mode 100644 index 000000000000..cf7b55441b4b --- /dev/null +++ b/apps/analytics-worker/CONTEXT.md @@ -0,0 +1,127 @@ +# Analytics worker + +A Cloudflare Worker that determines whether users require explicit cookie consent based on their geographic location. + +## Purpose + +This worker supports the tldraw analytics system by providing geographic consent checking. It helps ensure compliance with privacy regulations like GDPR, UK PECR, FADP, and LGPD by identifying users in regions that require explicit opt-in for tracking. + +The worker is deployed to `tldraw-consent.workers.dev` and is called by the analytics app (`apps/analytics/`) during initialization. + +## How it works + +1. Receives GET request from analytics client +2. Reads user's country code from CloudFlare's `CF-IPCountry` header +3. Checks if country requires explicit consent +4. Returns JSON response indicating whether consent is required +5. Includes CORS headers for allowed tldraw origins + +## API + +**Endpoint**: `https://tldraw-consent.workers.dev` (or environment-specific variants) + +**Method**: `GET` + +**Response**: + +```json +{ + "requires_consent": boolean, + "country_code": string | null +} +``` + +**Caching**: Responses are cached for 1 hour (`Cache-Control: public, max-age=3600`) + +## Geographic consent rules + +Users in the following countries/regions require explicit consent: + +**EU Member States (GDPR)**: + +- Austria, Belgium, Bulgaria, Croatia, Cyprus, Czech Republic, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden + +**EEA/EFTA (GDPR)**: + +- Iceland, Liechtenstein, Norway + +**Other regions**: + +- United Kingdom (UK PECR) +- Switzerland (FADP) +- Brazil (LGPD) + +**Default behavior**: If country code cannot be determined, the worker defaults to requiring consent (conservative approach for privacy compliance). + +## CORS configuration + +The worker allows cross-origin requests from: + +- `http://localhost:3000` - Local development +- `http://localhost:5420` - Local development +- `https://meet.google.com` - Google Meet integration +- `*.tldraw.com` - Production domains +- `*.tldraw.dev` - Development domains +- `*.tldraw.club` - Alternative domains +- `*.tldraw.xyz` - Alternative domains +- `*.tldraw.workers.dev` - Worker preview domains +- `*-tldraw.vercel.app` - Vercel preview deployments + +## Development + +### Running locally + +```bash +yarn dev # Start local development server +``` + +### Testing + +```bash +yarn test # Run tests +yarn test-ci # Run tests in CI mode +``` + +### Deployment + +The worker is deployed via GitHub Actions (`.github/workflows/deploy-analytics.yml`) which runs `internal/scripts/deploy-analytics.ts`. + +**Environments**: + +- **dev**: `tldraw-consent-dev` (for testing) +- **staging**: `tldraw-consent-staging` (deployed on push to `main`) +- **production**: `tldraw-consent` (deployed on push to `production`) + +## File structure + +- `src/worker.ts` - Main worker code +- `wrangler.toml` - Cloudflare Worker configuration +- `package.json` - Package configuration and scripts +- `tsconfig.json` - TypeScript configuration +- `vitest.config.ts` - Test configuration + +## Integration + +This worker is called by the analytics app during initialization: + +1. Analytics app loads in user's browser +2. If no existing consent preference is stored +3. App calls this worker to check if consent is required +4. Based on response, app either: + - Shows consent banner (requires_consent: true) + - Assumes implicit consent (requires_consent: false) + +See `apps/analytics/src/utils/consent-check.ts` for the client-side integration. + +## Dependencies + +- `@cloudflare/workers-types` - TypeScript types for Cloudflare Workers +- `wrangler` - Cloudflare Workers CLI tool + +## Notes + +- This is a standalone worker (not part of the analytics app bundle) +- Uses CloudFlare's edge network for low-latency responses worldwide +- Conservative default (require consent) ensures compliance even if geo-detection fails +- CORS preflight requests are handled with 24-hour cache +- Responses are cached to reduce load and improve performance diff --git a/apps/analytics-worker/package.json b/apps/analytics-worker/package.json new file mode 100644 index 000000000000..a00cc0927f0d --- /dev/null +++ b/apps/analytics-worker/package.json @@ -0,0 +1,28 @@ +{ + "name": "@tldraw/analytics-worker", + "description": "tldraw infinite canvas SDK (analytics worker).", + "version": "0.0.0", + "private": true, + "author": { + "name": "tldraw GB Ltd.", + "email": "hello@tldraw.com" + }, + "main": "./src/worker.ts", + "/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here", + "files": [], + "scripts": { + "dev": "yarn run -T tsx ../../internal/scripts/workers/dev.ts", + "test-ci": "yarn run -T vitest run --passWithNoTests", + "test": "yarn run -T vitest --passWithNoTests", + "test-coverage": "yarn run -T vitest run --coverage --passWithNoTests", + "check-bundle-size": "yarn run -T tsx ../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000", + "lint": "yarn run -T tsx ../../internal/scripts/lint.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250913.0", + "esbuild": "^0.25.6", + "lazyrepo": "0.0.0-alpha.27", + "typescript": "^5.8.3", + "wrangler": "^4.37.1" + } +} diff --git a/apps/analytics-worker/src/worker.ts b/apps/analytics-worker/src/worker.ts new file mode 100644 index 000000000000..2ba7ade46a75 --- /dev/null +++ b/apps/analytics-worker/src/worker.ts @@ -0,0 +1,117 @@ +/// +/// + +/** + * Simple CloudFlare worker that returns whether the user requires cookie consent + * based on their geographic location (via CF-IPCountry header) + */ + +/** + * Validates if an origin is allowed to make cross-origin requests. + * Returns the origin if allowed, or undefined if blocked. + */ +function isAllowedOrigin(origin: string | null): string | undefined { + if (!origin) return undefined + if (origin === 'http://localhost:3000') return origin + if (origin === 'http://localhost:3001') return origin + if (origin === 'http://localhost:5420') return origin + if (origin === 'https://meet.google.com') return origin + if (origin === 'https://tldraw.dev') return origin + if (origin.endsWith('.tldraw.com')) return origin + if (origin.endsWith('.tldraw.dev')) return origin + if (origin.endsWith('.tldraw.club')) return origin + if (origin.endsWith('.tldraw.xyz')) return origin + if (origin.endsWith('.tldraw.workers.dev')) return origin + if (origin.endsWith('-tldraw.vercel.app')) return origin + return undefined +} + +const EXPLICIT_CONSENT_COUNTRIES = [ + // EU Member States (GDPR) + 'AT', + 'BE', + 'BG', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IE', + 'IT', + 'LV', + 'LT', + 'LU', + 'MT', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + // EEA/EFTA (GDPR) + 'IS', + 'LI', + 'NO', + // Post-Brexit UK (UK PECR) + 'GB', + // Switzerland (FADP) + 'CH', + // Brazil (LGPD) + 'BR', +] + +export default { + async fetch(request: Request): Promise { + const origin = request.headers.get('origin') + const allowedOrigin = isAllowedOrigin(origin) + + // Handle CORS preflight + if (request.method === 'OPTIONS') { + const headers: Record = { + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + } + if (allowedOrigin) { + headers['Access-Control-Allow-Origin'] = allowedOrigin + } + return new Response(null, { headers }) + } + + // Only allow GET requests + if (request.method !== 'GET') { + return new Response('Method not allowed', { status: 405 }) + } + + // Get country code from CloudFlare header + const countryCode = request.headers.get('CF-IPCountry') + + // Check if consent is required + // Default to requiring consent if country is unknown + const requiresConsent = !countryCode || EXPLICIT_CONSENT_COUNTRIES.includes(countryCode) + + // Return response with CORS headers + const headers: Record = { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + } + if (allowedOrigin) { + headers['Access-Control-Allow-Origin'] = allowedOrigin + } + + return new Response( + JSON.stringify({ + requires_consent: requiresConsent, + country_code: countryCode, + }), + { headers } + ) + }, +} diff --git a/apps/analytics-worker/tsconfig.json b/apps/analytics-worker/tsconfig.json new file mode 100644 index 000000000000..71ee8ba34d3c --- /dev/null +++ b/apps/analytics-worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../internal/config/tsconfig.base.json", + "include": ["src", "scripts"], + "exclude": ["node_modules", "dist", ".tsbuild*"], + "compilerOptions": { "noEmit": true, "emitDeclarationOnly": false }, + "references": [] +} diff --git a/apps/analytics-worker/vitest.config.ts b/apps/analytics-worker/vitest.config.ts new file mode 100644 index 000000000000..28709f2f5e13 --- /dev/null +++ b/apps/analytics-worker/vitest.config.ts @@ -0,0 +1,15 @@ +/// +import { mergeConfig } from 'vitest/config' +import baseConfig from '../../internal/config/vitest/node-preset' + +// bemo-worker is a Node.js/Cloudflare Worker environment +export default mergeConfig(baseConfig, { + test: { + // Use node environment instead of jsdom for worker apps + environment: 'node', + // Module name mapping for path aliases + alias: { + '^~(.*)$': './src/$1', + }, + }, +}) diff --git a/apps/analytics-worker/wrangler.toml b/apps/analytics-worker/wrangler.toml new file mode 100644 index 000000000000..c2844486b1eb --- /dev/null +++ b/apps/analytics-worker/wrangler.toml @@ -0,0 +1,44 @@ +main = "src/worker.ts" +compatibility_date = "2024-06-20" +upload_source_maps = true + +#################### Environment names #################### + +# Dev environment +[env.dev] +name = "tldraw-consent-dev" + +# Staging environment +[env.staging] +name = "tldraw-consent-staging" + +# Staging environment +[env.preview] +name = "tldraw-consent-preview" + +# Production environment +[env.production] +name = "tldraw-consent" + +#################### Routes #################### +# in production, we use a custom domain. others get a default *.tldraw.workers.dev domain +[[env.production.routes]] +pattern = 'consent.tldraw.xyz' +custom_domain = true +zone_name = 'tldraw.xyz' + +#################### Version metadata #################### +[version_metadata] +binding = "CF_VERSION_METADATA" + +[env.dev.version_metadata] +binding = "CF_VERSION_METADATA" + +[env.preview.version_metadata] +binding = "CF_VERSION_METADATA" + +[env.staging.version_metadata] +binding = "CF_VERSION_METADATA" + +[env.production.version_metadata] +binding = "CF_VERSION_METADATA" diff --git a/apps/analytics/CONTEXT.md b/apps/analytics/CONTEXT.md index db0e90f0bb83..52ae6ad4a472 100644 --- a/apps/analytics/CONTEXT.md +++ b/apps/analytics/CONTEXT.md @@ -1,135 +1,201 @@ -# @tldraw/analytics +# Analytics app -The analytics app provides a privacy-compliant analytics tracking system for tldraw applications. It creates a standalone JavaScript library that can be embedded into websites to handle cookie consent, user tracking, and privacy settings. +A unified analytics library for tldraw.com that integrates multiple analytics services with cookie consent management. ## Purpose -This app builds a UMD (Universal Module Definition) library that provides: +This package provides a single, consolidated analytics interface that: -- Cookie consent banner with opt-in/opt-out functionality -- Privacy settings dialog -- Integration with multiple analytics providers (PostHog, Google Analytics 4, HubSpot, Reo) -- GDPR-compliant tracking with user consent management +- Integrates multiple analytics providers (PostHog, Google Analytics 4, HubSpot, Reo) +- Manages cookie consent with a UI banner and privacy settings dialog +- Exposes a unified API for tracking events, page views, and user identification +- Ensures analytics only run when users have opted in -## Architecture - -### Build system +The analytics script is built as a standalone JavaScript bundle that can be included in any tldraw web application. -- **Vite-based build**: Creates a single UMD library (`tl-analytics.js`) -- **React components**: Built with React 18 but bundled for standalone usage -- **CSS injection**: Styles are inlined and injected at runtime -- **External globals**: React and ReactDOM are expected as global dependencies +## Architecture ### Core components -**Analytics.tsx** - Main analytics component +**Analytics class** (`src/index.ts`) -- Cookie consent banner with theme detection -- Consent state management using cookies -- Integration with multiple analytics providers -- Conditional loading of tracking scripts based on consent +- Main entry point that orchestrates all analytics services +- Manages consent state and enables/disables services accordingly +- Exposes global `window.tlanalytics` API with methods: + - `identify(userId, properties)` - Identify a user across all services + - `reset()` - Reset user identity across all services (called on logout) + - `track(name, data)` - Track an event + - `page()` - Track a page view + - `gtag(...args)` - Access Google Analytics gtag function + - `openPrivacySettings()` - Show privacy settings dialog -**PrivacySettings.tsx** - Privacy settings dialog +**Analytics services** (`src/analytics-services/`) -- User-facing privacy controls -- Toggle analytics consent on/off -- Accessible dialog implementation using Radix UI +- Base `AnalyticsService` class defines common interface +- Each service (PostHog, GA4, HubSpot, Reo) extends the base class +- Lifecycle methods: + - `initialize()` - Set up service (runs regardless of consent) + - `enable()` - Enable tracking when consent is granted + - `disable()` - Disable tracking when consent is revoked + - `identify(userId, properties)` - Identify user + - `reset()` - Reset user identity (called on logout) + - `trackEvent(name, data)` - Track custom event + - `trackPageview()` - Track page view -**index.ts** - Entry point and global API +**State management** (`src/state/`) -- Exposes global `window.tlanalytics` object -- Provides functions: `page()`, `identify()`, `track()`, `gtag()`, `openPrivacySettings()` -- Handles DOM injection and component initialization +- `AnalyticsState` - Simple reactive state class with subscribe/notify pattern +- `CookieConsentState` - Manages consent state ('unknown' | 'opted-in' | 'opted-out') +- `ThemeState` - Manages theme for UI components ('light' | 'dark') -### Analytics providers +**UI components** (`src/components/`) -**PostHog** +- `CookieConsentBanner` - Shows cookie consent prompt when consent is unknown +- `PrivacySettingsDialog` - Allows users to review and change their consent preferences -- Primary analytics provider -- Hosted at `https://analytics.tldraw.com/i` -- Memory-only persistence when consent is denied -- Full localStorage+cookie persistence when opted in +### Consent flow -**Google Analytics 4** +1. On initialization, analytics reads consent from `allowTracking` cookie +2. If no existing consent decision: + - Check user location via CloudFlare worker (`shouldRequireConsent()`) + - Users in GDPR/LGPD regions default to 'unknown' (must opt in) + - Users in other regions default to 'opted-in' (implicit consent) +3. If consent is unknown, banner is shown +4. User actions (accept/opt-out) update the consent state +5. Consent state changes trigger enable/disable on all services +6. Only opted-in users have their events tracked -- Configured via `window.TL_GA4_MEASUREMENT_ID` -- Supports Google Ads integration via `window.TL_GOOGLE_ADS_ID` -- Consent-aware configuration -- Anonymized IP when consent is denied +**Geographic consent checking** (`src/utils/consent-check.ts`) -**HubSpot** +- Calls CloudFlare worker at `https://tldraw-consent.workers.dev` +- Worker uses `CF-IPCountry` header to determine user's country +- Requires explicit consent for EU, EEA, UK, Switzerland, Brazil +- Falls back to requiring consent if check fails or times out (2s timeout) +- Conservative default ensures compliance with privacy regulations -- Loaded conditionally with consent -- EU-hosted (js-eu1.hs-scripts.com) -- Account ID: 145620695 +### Integration with services -**Reo Analytics** +**PostHog** (`src/analytics-services/posthog.ts`) -- User session recording and analytics -- Client ID: 47839e47a5ed202 -- Conditional loading based on consent +- Product analytics and session recording +- Switches between memory and localStorage persistence based on consent -## Development +**Google Analytics 4** (`src/analytics-services/ga4.ts`) -### Scripts +- Web analytics via gtag.js +- Measurement ID provided via `window.TL_GA4_MEASUREMENT_ID` -- `yarn dev` - Start development server with Vite -- `yarn build` - Build UMD library to `public/tl-analytics.js` -- `yarn lint` - Run ESLint -- `yarn test` - Run Vitest (currently no tests) +**HubSpot** (`src/analytics-services/hubspot.ts`) -### Dependencies +- Marketing automation and CRM tracking +- Loaded via external script -- **js-cookie**: Cookie management -- **posthog-js**: PostHog analytics client -- **radix-ui**: Accessible UI primitives for dialogs and switches -- **react-ga4**: Google Analytics 4 integration -- **react/react-dom**: UI framework +**Reo** (`src/analytics-services/reo.ts`) -## Usage +- Analytics service +- Loaded via external script -The built library is designed to be included via script tag: +### CloudFlare consent worker -```html - - +```bash +yarn dev # Start development server +yarn build # Build for production +yarn test # Run tests ``` -## Privacy & compliance +### Testing -### Cookie management +Uses Vitest for unit testing. Test files are colocated with source files (e.g., `state.test.ts`). -- Uses `allowTracking` cookie to store consent -- Three states: 'unknown', 'opted-in', 'opted-out' -- Consent banner appears only for 'unknown' state +### Build output -### Data collection +Vite builds the app into two outputs: -- **Without consent**: Anonymous, memory-only tracking -- **With consent**: Full tracking with persistent storage -- **GDPR compliance**: User can opt-out at any time via privacy settings +1. JavaScript bundle in `public/` directory (via `vite build --outDir public`) +2. TypeScript compiled output in `dist/` (via `tsc`) -### Theme support +The built script can be included in HTML via: -- Automatic theme detection from document root styles -- Observes changes to `color-scheme` CSS property -- Applies appropriate styling for light/dark themes +```html + +``` -## Deployment +## Usage + +After the script loads, use the global API: + +```javascript +// Identify a user +window.tlanalytics.identify('user-123', { plan: 'pro' }) + +// Reset user identity (on logout) +window.tlanalytics.reset() + +// Track an event +window.tlanalytics.track('button_clicked', { button: 'upgrade' }) + +// Track a page view +window.tlanalytics.page() + +// Open privacy settings +window.tlanalytics.openPrivacySettings() +``` -The built library (`public/tl-analytics.js`) is deployed via Vercel configuration and can be hosted on CDN for inclusion in other tldraw applications and websites. +## Configuration + +Analytics services are configured via constants in `src/constants.ts` and environment variables: + +- `window.TL_GA4_MEASUREMENT_ID` - Google Analytics measurement ID +- `window.TL_GOOGLE_ADS_ID` - Google Ads ID (optional) +- PostHog, HubSpot, and Reo use hardcoded configuration in constants + +## Key files + +- `src/index.ts` - Main Analytics class and initialization +- `src/types.ts` - TypeScript types and global declarations +- `src/analytics-services/analytics-service.ts` - Base service class +- `src/analytics-services/*.ts` - Individual service implementations (PostHog, GA4, HubSpot, Reo) +- `src/state/state.ts` - Reactive state base class +- `src/state/cookie-consent-state.ts` - Consent management +- `src/state/theme-state.ts` - Theme management for UI components +- `src/utils/consent-check.ts` - Geographic consent checking utility +- `src/components/CookieConsentBanner.ts` - Consent banner UI +- `src/components/PrivacySettingsDialog.ts` - Privacy settings dialog UI +- `src/styles.css` - Component styles (inlined via Vite) + +## Dependencies + +- `posthog-js` - PostHog SDK +- `js-cookie` - Cookie management +- `vite` - Build tool +- `vitest` - Testing framework + +## Notes + +- This app does NOT use tldraw's `@tldraw/state` library - it has its own simple reactive state implementation +- The app is framework-agnostic - uses vanilla JavaScript/TypeScript +- All UI is created with vanilla DOM manipulation (no React) +- Styles are injected at runtime from the bundled CSS +- Services are enabled/disabled dynamically based on consent without page reload +- Error handling is implemented during initialization to prevent analytics failures from breaking the page +- User identity persists in memory during session for re-identification if consent changes +- Geographic consent checking uses a conservative approach - defaults to requiring consent on failure diff --git a/apps/analytics/index.html b/apps/analytics/index.html new file mode 100644 index 000000000000..4a6c37358bef --- /dev/null +++ b/apps/analytics/index.html @@ -0,0 +1,40 @@ + + + + + + Analytics Banner Preview + + + + + + + + + diff --git a/apps/analytics/package.json b/apps/analytics/package.json index c937c38317f4..c98386f6fc02 100644 --- a/apps/analytics/package.json +++ b/apps/analytics/package.json @@ -14,18 +14,14 @@ }, "dependencies": { "js-cookie": "^3.0.5", - "posthog-js": "^1.248.1", - "radix-ui": "^1.4.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-ga4": "^2.1.0" + "posthog-js": "^1.281.0" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20250913.0", "@types/js-cookie": "^3.0.6", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.6.0", "typescript": "^5.8.3", - "vite": "^7.0.1" + "vite": "^7.0.1", + "vitest": "^3.2.4", + "wrangler": "^4.37.1" } } diff --git a/apps/analytics/src/analytics-services/analytics-service.ts b/apps/analytics/src/analytics-services/analytics-service.ts new file mode 100644 index 000000000000..2fcb6f2f25f4 --- /dev/null +++ b/apps/analytics/src/analytics-services/analytics-service.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +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: { + consent_analytics: 'granted' | 'denied' + consent_marketing: 'granted' | 'denied' + consent_opt_in_type: 'manual' | 'auto' + }): 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 + 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 +} diff --git a/apps/analytics/src/analytics-services/ga4.ts b/apps/analytics/src/analytics-services/ga4.ts new file mode 100644 index 000000000000..d643036c564e --- /dev/null +++ b/apps/analytics/src/analytics-services/ga4.ts @@ -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() diff --git a/apps/analytics/src/analytics-services/gtm.ts b/apps/analytics/src/analytics-services/gtm.ts new file mode 100644 index 000000000000..a1d09a9f5f4c --- /dev/null +++ b/apps/analytics/src/analytics-services/gtm.ts @@ -0,0 +1,192 @@ +import { GTM_SCRIPT_ID } from '../constants' +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', + }) + + dataLayerPush({ + event: 'consent_default', + consent: { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + wait_for_update: 500, + }, + }) + + // 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() { + if (this.isEnabled) return + this.isEnabled = true + } + + override disable() { + if (!this.isEnabled) return + 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 }) { + if (!this.isEnabled) return + dataLayerPush({ + event: name, + ...data, + }) + } + + override trackPageview() { + if (!this.isEnabled) return + 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: { + consent_analytics: 'granted' | 'denied' + consent_marketing: 'granted' | 'denied' + consent_opt_in_type: 'manual' | 'auto' + }) { + 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 + }) { + if (!this.isEnabled) return + 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 + 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 + }) { + if (!this.isEnabled) return + const payload: any = { + event: 'generate_lead', + id: crypto.randomUUID(), + page: { + category: 'enquiry', + }, + data: { + enquiry_type: data.enquiry_type.toLowerCase(), + }, + user: { + email: data.user_email.toLowerCase(), + email_sha256: data.user_email_sha256.toLowerCase(), + first_name: data.user_first_name.toLowerCase(), + last_name: data.user_last_name.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() + if (data.user_phone_number) payload.user.phone_number = data.user_phone_number + + dataLayerPush(payload) + } +} + +export const gtmService = new GTMAnalyticsService() diff --git a/apps/analytics/src/analytics-services/hubspot.ts b/apps/analytics/src/analytics-services/hubspot.ts new file mode 100644 index 000000000000..e72acdb7a006 --- /dev/null +++ b/apps/analytics/src/analytics-services/hubspot.ts @@ -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() diff --git a/apps/analytics/src/analytics-services/posthog.ts b/apps/analytics/src/analytics-services/posthog.ts new file mode 100644 index 000000000000..9202a8f32247 --- /dev/null +++ b/apps/analytics/src/analytics-services/posthog.ts @@ -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() diff --git a/apps/analytics/src/analytics-services/reo.ts b/apps/analytics/src/analytics-services/reo.ts new file mode 100644 index 000000000000..45957fb55377 --- /dev/null +++ b/apps/analytics/src/analytics-services/reo.ts @@ -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() diff --git a/apps/analytics/src/analytics.tsx b/apps/analytics/src/analytics.tsx deleted file mode 100644 index e7b83aa51411..000000000000 --- a/apps/analytics/src/analytics.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import Cookies from 'js-cookie' -import posthog from 'posthog-js' -import { Dialog as _Dialog, Switch as _Switch } from 'radix-ui' -import { useEffect, useState } from 'react' -import ReactGA from 'react-ga4' -import './styles.css' - -type CookieConsent = 'unknown' | 'opted-in' | 'opted-out' -let isConfigured = false -let storedUserId: string = '' -let storedProperties: { [key: string]: any } | undefined = undefined -let storedHasConsent: CookieConsent = 'unknown' -window.posthog = posthog - -// Add theme detection -function getTheme() { - const html = document.documentElement - const colorScheme = html.getAttribute('style')?.includes('color-scheme: dark') ? 'dark' : 'light' - return colorScheme -} - -export default function Analytics() { - const [isLoaded, setIsLoaded] = useState(false) - const [hasConsent, setHasConsent] = useState('unknown') - const [theme, setTheme] = useState<'light' | 'dark'>(getTheme()) - - // Add theme observer - useEffect(() => { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName === 'style') { - setTheme(getTheme()) - } - }) - }) - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style'], - }) - - return () => observer.disconnect() - }, []) - - const onConsentChanged = (hasConsent: boolean) => { - Cookies.set('allowTracking', hasConsent ? 'true' : 'false') - setHasConsent(hasConsent ? 'opted-in' : 'opted-out') - track('consent_changed', { consent: hasConsent }) - } - - useEffect(() => { - const consent = Cookies.get('allowTracking') - setHasConsent(consent === 'true' ? 'opted-in' : consent === 'false' ? 'opted-out' : 'unknown') - setIsLoaded(true) - }, []) - - useEffect(() => { - if (!isConfigured) { - posthog.init('phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6', { - api_host: 'https://analytics.tldraw.com/i', - ui_host: 'https://eu.i.posthog.com', - persistence: 'memory', - capture_pageview: 'history_change', - }) - - if (window.TL_GA4_MEASUREMENT_ID) { - ReactGA.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, - }) - ReactGA.initialize(window.TL_GA4_MEASUREMENT_ID, { - gaOptions: { - anonymize_ip: true, - }, - }) - - // Add Google Ads configuration if present - if (window.TL_GOOGLE_ADS_ID) { - ReactGA.gtag('config', window.TL_GOOGLE_ADS_ID) - } - } - - isConfigured = true - } - - storedHasConsent = hasConsent - if (hasConsent === 'opted-in') { - posthog.set_config({ persistence: 'localStorage+cookie' }) - posthog.opt_in_capturing() - ReactGA.set({ anonymize_ip: false }) - - ReactGA.gtag('consent', 'update', { - ad_user_data: 'granted', - ad_personalization: 'granted', - ad_storage: 'granted', - analytics_storage: 'granted', - }) - - if (storedUserId && storedProperties) { - identify(storedUserId, storedProperties) - } - - // Add Hubspot analytics - if (!document.getElementById('hs-script-loader')) { - const hubspotScriptTag = document.createElement('script') - hubspotScriptTag.id = 'hs-script-loader' - hubspotScriptTag.src = `https://js-eu1.hs-scripts.com/145620695.js` - hubspotScriptTag.defer = true - document.head.appendChild(hubspotScriptTag) - } - - // Add Reo analytics - if (!document.getElementById('reo-script-loader')) { - const reoId = '47839e47a5ed202' - const reoScriptTag = document.createElement('script') - reoScriptTag.id = 'reo-script-loader' - reoScriptTag.src = `https://static.reo.dev/${reoId}/reo.js` - reoScriptTag.defer = true - reoScriptTag.onload = () => window.Reo.init({ clientID: reoId }) - document.head.appendChild(reoScriptTag) - } - } else { - posthog.setPersonProperties({ analytics_consent: false }) - posthog.reset() - posthog.set_config({ persistence: 'memory' }) - posthog.opt_out_capturing() - ReactGA.reset() - ReactGA.set({ anonymize_ip: true }) - ReactGA.gtag('consent', 'update', { - ad_user_data: 'denied', - ad_personalization: 'denied', - ad_storage: 'denied', - analytics_storage: 'denied', - }) - window.Reo?.reset?.() - } - }, [hasConsent]) - - if (!isLoaded || hasConsent !== 'unknown') return null - - return ( -
-

- We use cookies on this website. -
Learn more in our{' '} - - Cookie Policy - - . -

-
- - -
-
- ) -} - -export function page() { - posthog.capture('$pageview') - ReactGA.send('pageview') -} - -export function identify(userId: string, properties?: { [key: string]: any }) { - storedUserId = userId - storedProperties = properties - - if (storedHasConsent !== 'opted-in') return - - posthog.identify(userId, { - properties, - analytics_consent: true, - }) - ReactGA.set({ userId }) - ReactGA.set(properties) - window.Reo?.identify?.({ - ...properties, - userId, - firstname: properties?.name || '', - username: properties?.email || '', - type: 'email', - }) -} - -export function track(name: string, data?: { [key: string]: any }) { - posthog.capture(name, data) - ReactGA.event(name, data) -} - -export function gtag(...args: any[]) { - if (storedHasConsent !== 'opted-in') return - // @ts-ignore - ReactGA.gtag accepts variable arguments - ReactGA.gtag(...args) -} - -export function PrivacySettings() { - const hasConsent: CookieConsent = - Cookies.get('allowTracking') === 'true' ? 'opted-in' : 'opted-out' - const [isChecked, setIsChecked] = useState(hasConsent === 'opted-in') - const [theme, setTheme] = useState<'light' | 'dark'>(getTheme()) - - // Add theme observer - useEffect(() => { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName === 'style') { - setTheme(getTheme()) - } - }) - }) - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style'], - }) - - return () => observer.disconnect() - }, []) - - const onChange = (checked: boolean) => { - Cookies.set('allowTracking', checked ? 'true' : 'false') - setIsChecked(checked) - } - - const onHide = () => { - // Need this for the settings to take effect on the consent banner. - window.location.reload() - } - - return ( - <_Dialog.Root open> - <_Dialog.Portal> - <_Dialog.Overlay className="tl-analytics-dialog" data-theme={theme} /> -
- <_Dialog.Content - onInteractOutside={onHide} - onPointerDownOutside={onHide} - onEscapeKeyDown={onHide} - className="tl-analytics-dialog-content" - > - <_Dialog.Title className="tl-analytics-dialog-title">Privacy settings - <_Dialog.Description className="tl-analytics-dialog-body"> - This website uses cookies to collect analytics from visitors. Read our{' '} - - cookie policy - {' '} - to learn more. - - <_Dialog.Close className="tl-analytics-dialog-close" asChild> - - - -
- -
- <_Switch.Root - className="tl-analytics-checkbox" - id="privacy-analytics" - checked={isChecked} - onCheckedChange={onChange} - > - <_Switch.Thumb /> - -
-
- -
- - - ) -} diff --git a/apps/analytics/src/components/CookieConsentBanner.ts b/apps/analytics/src/components/CookieConsentBanner.ts new file mode 100644 index 000000000000..7440434999df --- /dev/null +++ b/apps/analytics/src/components/CookieConsentBanner.ts @@ -0,0 +1,85 @@ +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) + + // Create content + banner.innerHTML = ` +

+ We use cookies on this website. +
+ Learn more in our Cookie Policy. +

+
+ + +
+ ` + + // 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 +} diff --git a/apps/analytics/src/components/PrivacySettingsDialog.ts b/apps/analytics/src/components/PrivacySettingsDialog.ts new file mode 100644 index 000000000000..4993b48ad951 --- /dev/null +++ b/apps/analytics/src/components/PrivacySettingsDialog.ts @@ -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 = ` +
+

Privacy settings

+ +
+

+ This website uses cookies to collect analytics from visitors. Read our + cookie policy + to learn more. +

+
+ +
+ +
+
+ ` + + // 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 +} diff --git a/apps/analytics/src/constants.ts b/apps/analytics/src/constants.ts new file mode 100644 index 000000000000..e5a0f9c3c1dc --- /dev/null +++ b/apps/analytics/src/constants.ts @@ -0,0 +1,23 @@ +// 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 CONSENT_COOKIE_NAME = 'allowTracking' +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' diff --git a/apps/analytics/src/index.ts b/apps/analytics/src/index.ts index d35083c7bd87..296efdbd4017 100644 --- a/apps/analytics/src/index.ts +++ b/apps/analytics/src/index.ts @@ -1,53 +1,311 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import Analytics, { gtag, identify, page, PrivacySettings, track } from './analytics' +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 { + clearCookieValue, + CookieConsentState, + cookieConsentToCookieValue, + cookieValueToCookieConsent, + getCookieValue, + setCookieValue, +} from './state/cookie-consent-state' +import { ThemeState } from './state/theme-state' import styles from './styles.css?inline' +import { ConsentPreferences, CookieConsent } from './types' +import { shouldRequireConsent } from './utils/consent-check' -// Inject styles -const style = document.createElement('style') -style.textContent = styles -document.head.appendChild(style) - -// Create a container for our analytics component -const container = document.createElement('div') -container.id = 'tl-analytics-root' -document.body.appendChild(container) - -// Initialize the analytics component -const root = createRoot(container) -root.render(React.createElement(Analytics)) - -// Expose global functions -declare global { - interface Window { - tlanalytics: { - openPrivacySettings(): void - page(): void - identify(userId: string, properties?: { [key: string]: any }): void - track(name: string, data?: { [key: string]: any }): void - gtag(...args: any[]): void - } - TL_GA4_MEASUREMENT_ID: string | undefined - TL_GOOGLE_ADS_ID?: string - Reo: any - posthog: any +function cookieConsentToPreferences(consent: CookieConsent): ConsentPreferences { + if (consent === 'opted-in') { + return { analytics: 'granted', marketing: 'granted' } } + return { analytics: 'denied', marketing: 'denied' } } -// Create a container for the privacy settings dialog -const privacyContainer = document.createElement('div') -privacyContainer.id = 'tl-analytics-privacy-root' -document.body.appendChild(privacyContainer) - -const privacyRoot = createRoot(privacyContainer) - -// Expose the global function to open privacy settings -window.tlanalytics = { - openPrivacySettings: () => { - privacyRoot.render(React.createElement(PrivacySettings)) - }, - page, - identify, - track, - gtag, +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] + + 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) => { + // Set (or clear) the cookie value + const cookieValue = cookieConsentToCookieValue(consent) + if (cookieValue) setCookieValue(cookieValue) + else clearCookieValue() + + // If the consent is the same as the current consent, do nothing + if (consent === this.consent) return + + const wasUnknown = this.consent === 'unknown' + this.consent = consent + + // Track the consent change (after enabling or disabling) + const consentState = + consent === 'opted-in' + ? { + ad_user_data: 'granted', + ad_personalization: 'granted', + ad_storage: 'granted', + analytics_storage: 'granted', + } + : { + ad_user_data: 'denied', + ad_personalization: 'denied', + ad_storage: 'denied', + analytics_storage: 'denied', + } + + if (this.consent === 'opted-in') { + // Enable the analytics services only when consent is granted + for (const service of this.services) { + service.enable() + } + + // We have to enable services first, then track the event + this.track('consent_update', { consent: 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 { + // We need to track the event first, then disable services or the opt out won't get tracked + this.track('consent_update', { consent: 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 && wasUnknown && consent !== 'unknown') { + const preferences = cookieConsentToPreferences(consent) + for (const service of this.services) { + service.trackConsentBannerSelected?.({ + consent_analytics: preferences.analytics, + consent_marketing: preferences.marketing, + consent_opt_in_type: this.consentOptInType, + }) + } + } + + // Notify consent callbacks + if (consent !== 'unknown') { + const preferences = cookieConsentToPreferences(consent) + for (const callback of this.consentCallbacks) { + callback(preferences) + } + } + }) + + // ...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), + 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 initialCookieValue = getCookieValue() + let initialConsent: CookieConsent + + if (initialCookieValue) { + // User has previously made a consent decision - respect it + initialConsent = cookieValueToCookieConsent(initialCookieValue) + } 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 + 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) } diff --git a/apps/analytics/src/state/cookie-consent-state.ts b/apps/analytics/src/state/cookie-consent-state.ts new file mode 100644 index 000000000000..f4bb8ab476c8 --- /dev/null +++ b/apps/analytics/src/state/cookie-consent-state.ts @@ -0,0 +1,35 @@ +import Cookies from 'js-cookie' +import { CONSENT_COOKIE_NAME } from '../constants' +import { type CookieConsent } from '../types' +import { AnalyticsState } from './state' + +export function cookieConsentToCookieValue(consent: CookieConsent): string | undefined { + switch (consent) { + case 'opted-in': + return 'true' + case 'opted-out': + return 'false' + case 'unknown': + return undefined + } +} + +export function getCookieValue(): string | undefined { + return Cookies.get(CONSENT_COOKIE_NAME) +} + +export function setCookieValue(value: string): void { + Cookies.set(CONSENT_COOKIE_NAME, value) +} + +export function clearCookieValue() { + Cookies.remove(CONSENT_COOKIE_NAME) +} + +export function cookieValueToCookieConsent(cookieValue: string | undefined): CookieConsent { + if (cookieValue === 'true') return 'opted-in' + if (cookieValue === 'false') return 'opted-out' + return 'unknown' +} + +export class CookieConsentState extends AnalyticsState {} diff --git a/apps/analytics/src/state/state.test.ts b/apps/analytics/src/state/state.test.ts new file mode 100644 index 000000000000..486bf4c04fa6 --- /dev/null +++ b/apps/analytics/src/state/state.test.ts @@ -0,0 +1,57 @@ +import { AnalyticsState } from './state' + +describe('mini reactive state', () => { + test('creates a state with an initial value', () => { + class MyState extends AnalyticsState {} + 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 {} + const myState = new MyState(10) + myState.setValue(20) + expect(myState.getValue()).toBe(20) + }) + + test('handles subscribers', () => { + class MyState extends AnalyticsState {} + 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 {} + 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 { + 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() + }) +}) diff --git a/apps/analytics/src/state/state.ts b/apps/analytics/src/state/state.ts new file mode 100644 index 000000000000..492ed76da2a1 --- /dev/null +++ b/apps/analytics/src/state/state.ts @@ -0,0 +1,59 @@ +export class AnalyticsState { + // 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() + } +} diff --git a/apps/analytics/src/state/theme-state.ts b/apps/analytics/src/state/theme-state.ts new file mode 100644 index 000000000000..539852462778 --- /dev/null +++ b/apps/analytics/src/state/theme-state.ts @@ -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 + } + } +} diff --git a/apps/analytics/src/styles.css b/apps/analytics/src/styles.css index 4e0193669109..20eaf9ef9ee3 100644 --- a/apps/analytics/src/styles.css +++ b/apps/analytics/src/styles.css @@ -1,138 +1,126 @@ :root { - --tl-analytics-button-bg: #000000; - --tl-analytics-button-text: #ffffff; - --tl-analytics-bg: white; - --tl-analytics-text: #111827; - --tl-analytics-text-secondary: #374151; - --tl-analytics-border: #e5e7eb; - --tl-analytics-link: #3b82f6; - --tl-analytics-link-hover: #2563eb; - --tl-analytics-overlay: rgba(255, 255, 255, 0.9); - --tl-analytics-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -[data-theme='dark'] { - --tl-analytics-button-bg: #ffffff; - --tl-analytics-button-text: #000000; - --tl-analytics-bg: #18181b; - --tl-analytics-text: #f4f4f5; - --tl-analytics-text-secondary: #a1a1aa; - --tl-analytics-border: #27272a; + --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.9); - --tl-analytics-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --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); } -#tl-analytics-root { - font-weight: 400; +[data-theme='dark'] { + --tl-analytics-button-bg: hsl(0, 0%, 0%); + --tl-analytics-button-text: hsl(0, 0%, 100%); + --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: 14px; + padding: 6px; border-radius: 8px; box-shadow: var(--tl-analytics-shadow); - border: 1px solid var(--tl-analytics-border); background-color: var(--tl-analytics-bg); display: flex; flex-direction: column; - gap: 12px; + gap: 4px; /* Inter is usually available on our properties */ font-family: 'Inter', sans-serif; + font-weight: 400; /* Safe area aware overrides */ - bottom: max(8px, env(safe-area-inset-bottom)); - left: max(8px, env(safe-area-inset-left)); - max-width: calc( - 100% - max(8px, env(safe-area-inset-left)) - max(8px, env(safe-area-inset-right)) - ); + bottom: max(4px, env(safe-area-inset-bottom)); + right: max(4px, env(safe-area-inset-right)); + max-width: 240px; /* Slide-in animation */ - transform: translateY(120%); - animation: slideUp 5s ease-in-out forwards; + transform: translateY(110%); + animation: slideUp 5s ease-in forwards; animation-delay: 2s; } @keyframes slideUp { - from { - transform: translateY(120%); + 0% { + transform: translateY(110%); } - to { + 100% { transform: translateY(0); } } -@media (min-width: 640px) { - .tl-analytics-banner { - flex-direction: row; - align-items: center; - gap: 32px; - } -} - -.tl-analytics-banner p { +.tl-analytics-banner__text { font-size: 12px; - line-height: 1.5; + line-height: 1.4; color: var(--tl-analytics-text); margin: 0; + padding: 8px 8px 12px; } -.tl-analytics-banner a { +.tl-analytics-banner__link { color: var(--tl-analytics-link); text-decoration: none; } -.tl-analytics-banner a:hover { +.tl-analytics-banner__link:hover { color: var(--tl-analytics-link-hover); } -.tl-analytics-buttons { +.tl-analytics-banner__buttons { display: flex; - gap: 16px; + gap: 4px; justify-content: space-between; width: 100%; -} - -@media (min-width: 640px) { - .tl-analytics-buttons { - width: auto; - } + flex-wrap: wrap; } .tl-analytics-button { - font-size: 14px; + font-size: 12px; cursor: pointer; text-decoration: none; border: none; - padding: 8px 16px; - border-radius: 100px; + padding: 4px 8px; + border-radius: 6px; + white-space: nowrap; + flex-shrink: 0; } -.tl-analytics-button-primary { +.tl-analytics-button--primary { + font-weight: 500; background-color: var(--tl-analytics-button-bg); color: var(--tl-analytics-button-text); - font-weight: 500; overflow: hidden; position: relative; + padding: 6px 6px; + min-width: 96px; } -.tl-analytics-button-text-wrapper { +.tl-analytics-button__text-wrapper { overflow: hidden; } -.tl-analytics-button-text { +.tl-analytics-button__text { display: inline-block; - animation: marquee 3s linear infinite; + animation: marquee 2.5s linear infinite; white-space: nowrap; - transform: translateX(100%); + transform: translateX(72px); } @keyframes marquee { 0% { - transform: translateX(100%); + transform: translateX(72px); } 100% { - transform: translateX(-100%); + transform: translateX(-72px); } } @@ -143,102 +131,125 @@ transform: translateY(0); } - .tl-analytics-button-text { + .tl-analytics-button__text { animation: none; transform: translateX(0); } } -.tl-analytics-button-secondary { +.tl-analytics-button--secondary { background: none; - color: var(--tl-analytics-text-secondary); - opacity: 0.82; - padding: 0px 0px; - margin: 0px 16px; -} - -.tl-analytics-button-secondary:hover { color: var(--tl-analytics-text); } .tl-analytics-dialog { + font-family: 'Inter', sans-serif; + color: var(--tl-analytics-text); position: fixed; inset: 0; - z-index: 100; - background-color: var(--tl-analytics-overlay); -} - -.tl-analytics-dialog-wrapper { - position: fixed; - inset: 0; - z-index: 150; + z-index: 10000; display: flex; flex-direction: column; align-items: center; justify-content: center; - height: 100vh; } -.tl-analytics-dialog-content { +.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: 32rem; - padding: 2rem; + max-width: 350px; + min-width: 350px; background-color: var(--tl-analytics-bg); - border-radius: 0.375rem; + border-radius: 8px; box-shadow: var(--tl-analytics-shadow); - font-size: 1rem; + font-size: 12px; + line-height: 1.6; text-align: left; } -.tl-analytics-dialog-title { - font-weight: 700; +.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 0 1rem 0; + margin: 0; } -.tl-analytics-dialog-close { - position: absolute; - top: 8px; - right: 8px; +.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-secondary); + color: var(--tl-analytics-text); } -.tl-analytics-dialog-close:hover { - 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-top: 1.5rem; +.tl-analytics-dialog__body { + margin: 12px; } -.tl-analytics-dialog-body p { - margin: 0 0 1rem 0; +.tl-analytics-dialog__body p { + margin: 0; color: var(--tl-analytics-text-secondary); } -.tl-analytics-dialog-body a { +.tl-analytics-dialog__body a { + display: inline-block; color: var(--tl-analytics-link); text-decoration: none; } -.tl-analytics-dialog-body a:hover { +.tl-analytics-dialog__body a:hover { color: var(--tl-analytics-link-hover); } -.tl-analytics-checkbox-group { +.tl-analytics-dialog__checkbox-group { display: flex; align-items: center; - gap: 8px; - margin-bottom: 1rem; + gap: 12px; + margin: 12px; + padding-bottom: 12px; } .tl-analytics-checkbox { - width: 3rem; - height: 1.75rem; + width: 32px; + height: 20px; background-color: var(--tl-analytics-text-secondary); border-radius: 9999px; position: relative; @@ -247,33 +258,36 @@ padding: 0; } -.tl-analytics-checkbox[data-state='checked'] { - background-color: var(--tl-analytics-link); -} - -.tl-analytics-checkbox > span { +.tl-analytics-checkbox__thumb { display: block; - width: 1.25rem; - height: 1.25rem; + width: 16px; + height: 16px; background-color: var(--tl-analytics-bg); border-radius: 9999px; transition: transform 100ms; - transform: translateX(0.25rem); + transform: translateX(2px); } -.tl-analytics-checkbox[data-state='checked'] > span { - transform: translateX(1.5rem); +.tl-analytics-checkbox[aria-checked='true'] { + background-color: var(--tl-analytics-link); } -.tl-analytics-checkbox-label { - margin-top: 1.5rem; - margin-bottom: 0.5rem; - color: var(--tl-analytics-text-secondary); +.tl-analytics-checkbox[aria-checked='true'] .tl-analytics-checkbox__thumb { + transform: translateX(14px); } -.tl-analytics-dialog-footer { - margin-top: 1.5rem; +.tl-analytics-checkbox__label { + margin: 0; + font-size: 12px; +} + +.tl-analytics-dialog__footer { + padding: 0 8px 8px; display: flex; justify-content: flex-end; - gap: 8px; + gap: 4px; +} + +body:has(.tl-analytics-dialog) .tl-analytics-banner { + opacity: 0; } diff --git a/apps/analytics/src/types.ts b/apps/analytics/src/types.ts new file mode 100644 index 000000000000..86e5bd1fb19f --- /dev/null +++ b/apps/analytics/src/types.ts @@ -0,0 +1,46 @@ +// 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 type CookieConsent = 'unknown' | 'opted-in' | 'opted-out' + +export interface ConsentPreferences { + analytics: 'granted' | 'denied' + marketing: 'granted' | 'denied' +} diff --git a/apps/analytics/src/utils/consent-check.ts b/apps/analytics/src/utils/consent-check.ts new file mode 100644 index 000000000000..eb35bf1e98ea --- /dev/null +++ b/apps/analytics/src/utils/consent-check.ts @@ -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 { + // 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' +} diff --git a/apps/analytics/src/types.d.ts b/apps/analytics/src/vite-env.d.ts similarity index 67% rename from apps/analytics/src/types.d.ts rename to apps/analytics/src/vite-env.d.ts index ac8e774f165e..379944592791 100644 --- a/apps/analytics/src/types.d.ts +++ b/apps/analytics/src/vite-env.d.ts @@ -1,3 +1,5 @@ +/// + declare module '*.css?inline' { const content: string export default content diff --git a/apps/analytics/tsconfig.json b/apps/analytics/tsconfig.json index f74263ee2f1c..832e34e2662a 100644 --- a/apps/analytics/tsconfig.json +++ b/apps/analytics/tsconfig.json @@ -2,9 +2,16 @@ "extends": "../../internal/config/tsconfig.base.json", "compilerOptions": { "moduleResolution": "bundler", + "composite": false, + "declaration": false, + "declarationMap": false, "noEmit": true, - "emitDeclarationOnly": false + "emitDeclarationOnly": false, + "incremental": false, + "types": ["vite/client", "vitest/globals"], + "lib": ["dom", "DOM.Iterable", "esnext"] }, - "include": ["src", "src/analytics.tsx", "./vite.config.ts"], + "exclude": ["node_modules", "public", ".tsbuild*", "worker"], + "include": ["src", "./vite.config.ts", "./vitest.config.ts"], "references": [] } diff --git a/apps/analytics/vercel.json b/apps/analytics/vercel.json index f362bb1ab4bf..eafff625f38b 100644 --- a/apps/analytics/vercel.json +++ b/apps/analytics/vercel.json @@ -8,8 +8,14 @@ { "source": "/(.*)", "headers": [ - { "key": "Access-Control-Allow-Origin", "value": "*" }, - { "key": "Access-Control-Allow-Methods", "value": "GET, POST" } + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, POST" + } ] } ], diff --git a/apps/analytics/vite.config.ts b/apps/analytics/vite.config.ts index 0410b5d13aa6..0fcf629914aa 100644 --- a/apps/analytics/vite.config.ts +++ b/apps/analytics/vite.config.ts @@ -1,8 +1,7 @@ -import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ - plugins: [react()], + plugins: [], define: { 'process.env.NODE_ENV': JSON.stringify('production'), }, @@ -15,12 +14,7 @@ export default defineConfig({ formats: ['umd'], }, rollupOptions: { - output: { - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, + // React and ReactDOM are now bundled instead of external }, }, }) diff --git a/apps/analytics/vitest.config.ts b/apps/analytics/vitest.config.ts new file mode 100644 index 000000000000..686bc41cd365 --- /dev/null +++ b/apps/analytics/vitest.config.ts @@ -0,0 +1,6 @@ +/// +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, {}) diff --git a/apps/docs/app/analytics.tsx b/apps/docs/app/analytics.tsx index 125105cf6abb..91ff97e50d7e 100644 --- a/apps/docs/app/analytics.tsx +++ b/apps/docs/app/analytics.tsx @@ -7,6 +7,7 @@ export default function Analytics() { useEffect(() => { window.TL_GA4_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID window.TL_GOOGLE_ADS_ID = process.env.NEXT_PUBLIC_GOOGLE_ADS_ID + window.TL_GTM_CONTAINER_ID = process.env.NEXT_PUBLIC_GTM_CONTAINER_ID }, []) useEffect(() => { @@ -14,8 +15,13 @@ export default function Analytics() { const isWithinCodeBlock = (copyEvent.target as HTMLElement | null)?.closest('pre, code') if (isWithinCodeBlock) { const copiedText = window.getSelection()?.toString() || '' - const isInstall = copiedText.trim() === 'npm install tldraw' - track('docs.copy.code-block', { isInstall }) + + if (window.tlanalytics?.trackCopyCode) { + window.tlanalytics.trackCopyCode({ + page_category: 'docs', + text_snippet: copiedText, + }) + } // Track Google Ads conversion for code block copies if (window.tlanalytics?.gtag) { @@ -33,6 +39,11 @@ export default function Analytics() { } }, []) + const analyticsScriptSrc = + process.env.NODE_ENV === 'development' + ? 'http://localhost:5173/tl-analytics.js' + : 'https://analytics.tldraw.com/tl-analytics.js' + return ( <>