diff --git a/.env.example b/.env.example index 37b3de9..ef6e22f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ -# Mixpanel project token (client-side, public). Copy to .env and fill in. -# Get it from: https://mixpanel.com/settings/project (Access Keys → Project Token) -PUBLIC_MIXPANEL_TOKEN= +# PostHog project API key (client-side, public). Copy to .env and fill in. +# Get it from: PostHog → Project settings → Project API key (starts with phc_) +PUBLIC_POSTHOG_KEY= +# Ingestion host. Swap for a reverse-proxy domain later if needed. +PUBLIC_POSTHOG_HOST=https://us.i.posthog.com diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1ecb7fb..c56cedb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,6 +26,10 @@ jobs: - name: Install, build, and upload your site uses: withastro/action@v3 + env: + # Public client key, set under Settings → Secrets and variables → Actions → Variables. + PUBLIC_POSTHOG_KEY: ${{ vars.PUBLIC_POSTHOG_KEY }} + PUBLIC_POSTHOG_HOST: ${{ vars.PUBLIC_POSTHOG_HOST }} # with: # path: . # The root location of your Astro project inside the repository. # node-version: 20 # The specific version of Node that should be used to build your site. Defaults to 20. (optional) diff --git a/package.json b/package.json index 0ec0c6b..892f0e2 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", - "mixpanel-browser": "^2.79.0", "motion": "^13.4.2", + "posthog-js": "^1.434.11", "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.6.0" @@ -42,7 +42,6 @@ "@storybook/addon-themes": "^10.1.11", "@storybook/react": "^10.1.11", "@storybook/react-vite": "^10.1.11", - "@types/mixpanel-browser": "^2.66.0", "@types/node": "^25.0.9", "@types/react": "^18.3.16", "@types/react-dom": "^18.3.5", diff --git a/src/env.d.ts b/src/env.d.ts index f964fe0..5aa9ff2 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly PUBLIC_POSTHOG_KEY?: string; + readonly PUBLIC_POSTHOG_HOST?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index b6122ed..7da3482 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -163,7 +163,6 @@ const spanishKeywords = grantConsent, denyConsent, getConsent, - hasConsent, track, setSuperProps, } from '@/lib/analytics'; @@ -179,8 +178,6 @@ const spanishKeywords = const scrollMilestones = new Set(); let pageEnterAt = performance.now(); let maxScrollDepth = 0; - let lastVisibleSince = pageEnterAt; - let hiddenAccumMs = 0; let engagedFired = false; function currentPageProps() { @@ -208,13 +205,10 @@ const spanishKeywords = }); } - function flushPageExit(reason: 'unload' | 'navigate') { + // Time on page and max scroll ride on PostHog's native $pageleave; + // this only flushes pending section dwell times. + function flushSections() { const now = performance.now(); - if (document.visibilityState === 'visible') { - hiddenAccumMs += 0; - } - const totalMs = now - pageEnterAt; - const activeMs = totalMs - hiddenAccumMs; sectionStates.forEach((s) => { if (s.firstSeenAt != null) { s.dwellMs += now - s.firstSeenAt; @@ -222,13 +216,6 @@ const spanishKeywords = } emitSection(s); }); - track('page_exited', { - reason, - time_on_page_ms: Math.round(totalMs), - active_time_ms: Math.round(Math.max(activeMs, 0)), - max_scroll_depth: maxScrollDepth, - ...currentPageProps(), - }); } function bindScrollDepth() { @@ -305,6 +292,8 @@ const spanishKeywords = const onClick = (ev: MouseEvent) => { const t = ev.target as HTMLElement | null; if (!t) return; + // Clicks are PointerEvents in modern browsers: "touch" | "mouse" | "pen". + const pointerType = (ev as PointerEvent).pointerType || null; const cta = t.closest('[data-track-cta]'); if (cta) { const ctx = ctaContext(cta); @@ -312,6 +301,7 @@ const spanishKeywords = track('cta_clicked', { ...ctx, destination: href, + pointer_type: pointerType, modifier_key: ev.metaKey || ev.ctrlKey ? 'meta' : ev.shiftKey ? 'shift' : null, ...currentPageProps(), }); @@ -325,6 +315,7 @@ const spanishKeywords = track('contact_intent', { method: cta.dataset.trackCta || 'email_click', section: ctx.section, + pointer_type: pointerType, }); } } @@ -340,6 +331,7 @@ const spanishKeywords = href, host: url.host, section: section?.dataset.trackSection || null, + pointer_type: pointerType, }); } } catch { @@ -350,6 +342,7 @@ const spanishKeywords = track('contact_intent', { method: 'email_click', section: section?.dataset.trackSection || null, + pointer_type: pointerType, }); } } @@ -362,16 +355,13 @@ const spanishKeywords = const onVisibility = () => { const now = performance.now(); if (document.visibilityState === 'hidden') { - hiddenAccumMs += now - lastVisibleSince; sectionStates.forEach((s) => { if (s.firstSeenAt != null) { s.dwellMs += now - s.firstSeenAt; s.firstSeenAt = null; } }); - track('page_hidden', { active_time_ms: Math.round(now - pageEnterAt - hiddenAccumMs) }); } else { - lastVisibleSince = now; sectionStates.forEach((s, el) => { const rect = (el as HTMLElement).getBoundingClientRect(); const vh = window.innerHeight; @@ -379,7 +369,6 @@ const spanishKeywords = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0)) / vh; if (visibleRatio >= 0.35 && !s.emitted) s.firstSeenAt = now; }); - track('page_visible'); } }; document.addEventListener('visibilitychange', onVisibility); @@ -402,7 +391,6 @@ const spanishKeywords = if (btn.dataset.consent === 'grant') { grantConsent(); track('consent_granted'); - track('page_viewed', currentPageProps()); } else { denyConsent(); } @@ -419,8 +407,6 @@ const spanishKeywords = function startTrackingForPage() { pageEnterAt = performance.now(); maxScrollDepth = 0; - hiddenAccumMs = 0; - lastVisibleSince = pageEnterAt; engagedFired = false; scrollMilestones.clear(); sectionStates.clear(); @@ -430,18 +416,14 @@ const spanishKeywords = viewport_height: window.innerHeight, }); - if (hasConsent()) { - track('page_viewed', currentPageProps()); - } - cleanups.push(bindScrollDepth()); cleanups.push(bindSectionObserver()); cleanups.push(bindClickDelegation()); cleanups.push(bindVisibility()); } - function stopTrackingForPage(reason: 'unload' | 'navigate') { - flushPageExit(reason); + function stopTrackingForPage() { + flushSections(); cleanups.forEach((fn) => fn()); cleanups = []; } @@ -455,11 +437,11 @@ const spanishKeywords = if (document.readyState !== 'loading') boot(); else document.addEventListener('DOMContentLoaded', boot); - document.addEventListener('astro:before-swap', () => stopTrackingForPage('navigate')); + document.addEventListener('astro:before-swap', stopTrackingForPage); document.addEventListener('astro:page-load', () => { if (cleanups.length === 0) startTrackingForPage(); }); - window.addEventListener('pagehide', () => flushPageExit('unload'), { once: false }); + window.addEventListener('pagehide', flushSections);