Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
/// <reference types="astro/client" />

interface ImportMetaEnv {
readonly PUBLIC_POSTHOG_KEY?: string;
readonly PUBLIC_POSTHOG_HOST?: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
44 changes: 13 additions & 31 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ const spanishKeywords =
grantConsent,
denyConsent,
getConsent,
hasConsent,
track,
setSuperProps,
} from '@/lib/analytics';
Expand All @@ -179,8 +178,6 @@ const spanishKeywords =
const scrollMilestones = new Set<number>();
let pageEnterAt = performance.now();
let maxScrollDepth = 0;
let lastVisibleSince = pageEnterAt;
let hiddenAccumMs = 0;
let engagedFired = false;

function currentPageProps() {
Expand Down Expand Up @@ -208,27 +205,17 @@ 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;
s.firstSeenAt = null;
}
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() {
Expand Down Expand Up @@ -305,13 +292,16 @@ 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<HTMLElement>('[data-track-cta]');
if (cta) {
const ctx = ctaContext(cta);
const href = cta.getAttribute('href');
track('cta_clicked', {
...ctx,
destination: href,
pointer_type: pointerType,
modifier_key: ev.metaKey || ev.ctrlKey ? 'meta' : ev.shiftKey ? 'shift' : null,
...currentPageProps(),
});
Expand All @@ -325,6 +315,7 @@ const spanishKeywords =
track('contact_intent', {
method: cta.dataset.trackCta || 'email_click',
section: ctx.section,
pointer_type: pointerType,
});
}
}
Expand All @@ -340,6 +331,7 @@ const spanishKeywords =
href,
host: url.host,
section: section?.dataset.trackSection || null,
pointer_type: pointerType,
});
}
} catch {
Expand All @@ -350,6 +342,7 @@ const spanishKeywords =
track('contact_intent', {
method: 'email_click',
section: section?.dataset.trackSection || null,
pointer_type: pointerType,
});
}
}
Expand All @@ -362,24 +355,20 @@ 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;
const visibleRatio =
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);
Expand All @@ -402,7 +391,6 @@ const spanishKeywords =
if (btn.dataset.consent === 'grant') {
grantConsent();
track('consent_granted');
track('page_viewed', currentPageProps());
} else {
denyConsent();
}
Expand All @@ -419,8 +407,6 @@ const spanishKeywords =
function startTrackingForPage() {
pageEnterAt = performance.now();
maxScrollDepth = 0;
hiddenAccumMs = 0;
lastVisibleSince = pageEnterAt;
engagedFired = false;
scrollMilestones.clear();
sectionStates.clear();
Expand All @@ -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 = [];
}
Expand All @@ -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);
</script>

<script>
Expand Down
76 changes: 39 additions & 37 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import mixpanel, { type Dict } from 'mixpanel-browser';
import posthog, { type Properties } from 'posthog-js';

const CONSENT_KEY = 'bx_analytics_consent';
const TOKEN = import.meta.env.PUBLIC_MIXPANEL_TOKEN as string | undefined;
const KEY = import.meta.env.PUBLIC_POSTHOG_KEY as string | undefined;
const HOST = (import.meta.env.PUBLIC_POSTHOG_HOST as string | undefined) || 'https://us.i.posthog.com';
const IS_PROD = import.meta.env.PROD;

export type ConsentState = 'granted' | 'denied' | 'unset';
Expand All @@ -22,7 +23,7 @@ function writeConsent(state: Exclude<ConsentState, 'unset'>) {
}
}

function deriveSuperProps(): Dict {
function deriveSuperProps(): Properties {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
const conn = nav && (nav as unknown as { connection?: { effectiveType?: string; saveData?: boolean } }).connection;
const win = typeof window !== 'undefined' ? window : undefined;
Expand All @@ -42,51 +43,51 @@ function deriveSuperProps(): Dict {
};
}

function parseUtm(): Dict {
if (typeof window === 'undefined') return {};
const params = new URLSearchParams(window.location.search);
const out: Dict = {};
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
const v = params.get(key);
if (v) out[key] = v;
}
const ref = document.referrer || null;
if (ref) {
out.referrer = ref;
// PostHog captures utm_* and referrer on its own; this collapses them into a
// single breakdown-friendly value: utm_source → referrer host → "direct".
function deriveTrafficSource(): string {
const utm = new URLSearchParams(window.location.search).get('utm_source');
if (utm) return utm;
if (document.referrer) {
try {
out.referrer_host = new URL(ref).host;
const host = new URL(document.referrer).host;
if (host !== window.location.host) return host;
} catch {
/* malformed referrer */
}
}
return out;
return 'direct';
}

export function initAnalytics(): boolean {
if (initialized) return true;
if (!IS_PROD) return false;
if (!TOKEN) {
console.warn('[analytics] PUBLIC_MIXPANEL_TOKEN not set; Mixpanel disabled.');
if (!KEY) {
console.warn('[analytics] PUBLIC_POSTHOG_KEY not set; PostHog disabled.');
return false;
}

mixpanel.init(TOKEN, {
debug: false,
track_pageview: false,
posthog.init(KEY, {
api_host: HOST,
defaults: '2026-05-30',
person_profiles: 'identified_only',
persistence: 'localStorage',
ignore_dnt: false,
opt_out_tracking_by_default: true,
api_host: 'https://api.mixpanel.com',
capture_pageview: 'history_change',
capture_pageleave: true,
disable_session_recording: true,
// Unused features; skipping them keeps pre-consent network traffic minimal.
disable_surveys: true,
advanced_disable_flags: true,
opt_out_capturing_by_default: true,
});

mixpanel.register(deriveSuperProps());
mixpanel.register_once(parseUtm());
posthog.register(deriveSuperProps());
posthog.register_once({ traffic_source: deriveTrafficSource() });

const consent = readConsent();
if (consent === 'granted') {
mixpanel.opt_in_tracking();
if (readConsent() === 'granted') {
posthog.opt_in_capturing({ captureEventName: null });
} else {
mixpanel.opt_out_tracking();
posthog.opt_out_capturing();
}

initialized = true;
Expand All @@ -104,24 +105,25 @@ export function getConsent(): ConsentState {
export function grantConsent() {
writeConsent('granted');
if (initialized) {
mixpanel.opt_in_tracking();
mixpanel.register(deriveSuperProps());
posthog.register(deriveSuperProps());
// Also sends the initial $pageview that was held back while opted out.
posthog.opt_in_capturing({ captureEventName: null });
}
}

export function denyConsent() {
writeConsent('denied');
if (initialized) {
mixpanel.opt_out_tracking();
posthog.opt_out_capturing();
}
}

export function track(event: string, props?: Dict) {
export function track(event: string, props?: Properties) {
if (!initialized || !hasConsent()) return;
mixpanel.track(event, props);
posthog.capture(event, props);
}

export function setSuperProps(props: Dict) {
export function setSuperProps(props: Properties) {
if (!initialized) return;
mixpanel.register(props);
posthog.register(props);
}
Loading
Loading