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);