From c3c9f0d69a0c83266844f1321e1975db9cddab33 Mon Sep 17 00:00:00 2001
From: Max Pengilly <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 30 May 2026 14:02:00 -0700
Subject: [PATCH] feat(analytics): GPC-gated PostHog with pageviews + waitlist
events (#42)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(analytics): GPC-gated PostHog with pageviews + waitlist events
Adds PostHog product analytics to being.fyi as the first analytics ever
on the site. Output of a 5-step agent review sequence (compliance →
product → architect → product priority → compliance) — see plan file.
Compliance gave conditional sign-off pending three items (all
addressed: upstream PR, Notion disclosure, /privacy/multi-state link).
# Scope (Tier B-trimmed)
Three events only:
- $pageview (PostHog default)
- waitlist_signup_submitted { source }
- waitlist_signup_failed { source, reason }
No autocapture. No session replay. No heatmaps. No surveys. No email
hash identify (deferred to PR #2). EU data residency (Frankfurt).
# GPC structural kill
AnalyticsGate (client component, useSyncExternalStore) returns null when
ANY of:
- being_gpc=1 cookie set by middleware on Sec-GPC: 1
- navigator.globalPrivacyControl === true (browsers exposing the JS API
without the HTTP header, e.g. older Brave)
- NEXT_PUBLIC_POSTHOG_KEY missing (local dev)
- SSR (getServerSnapshot=true, safe default)
Null → PosthogProvider never mounts → dynamic import('posthog-js') never
fires → posthog-js chunk never fetched → no init, no ph_* cookie, no
beacon. Structurally enforced; not "suppressed after load."
Diverged from architect's server-component design because cookies() in
next/headers would force the parent layout into dynamic rendering,
undoing force-static on legal pages and re-introducing the per-request
marked() parse that PR #33 fixed. Client-side gate preserves both
force-static AND the no-load guarantee. Confirmed by compliance
re-review.
# CI structural-regression gate
tests/analytics-gpc-gate.test.tsx test #6 is a grep gate: if any file
under app/, components/, lib/ statically imports posthog-js (outside
PosthogProvider.tsx's dynamic import), the build fails. This is the
regression vector that would bypass the gate; locking it in CI.
# CSP additions
- script-src += eu-assets.i.posthog.com
- connect-src += eu.i.posthog.com eu-assets.i.posthog.com
- img-src += eu.i.posthog.com (PostHog pixel fallback)
- worker-src 'self' blob: (PostHog web worker for batching)
# /cookies page rewrite
- Removed "No PostHog" denial (was false the moment this PR ships)
- Headline: "Two Functional Cookies + Privacy-Respecting Analytics"
- New ph_* cookie disclosure with EU residency, kill conditions, scope
- "What We Don't Use" updated: no GA/Mixpanel/Amplitude/Segment, no
autocapture, no session replay (explicit even for PostHog)
- GPC section: structurally enforced kill described accurately
# Hard dependency
This PR depends on mp2ez/being#106 landing first (privacy policy §5.2
split into in-app vs web subsections + Notion added to §5.1 service
providers). CI sync pulls from being@development; until #106 merges,
the website ships analytics with a stale policy = FTC §5 issue.
# Out of scope (PR #2+)
- cta_clicked, crisis_resource_clicked, ab_variant_assigned events
- Email-hash identify (server-side SHA-256 + identifyByEmailHash helper)
- PostHog feature flags / experiments (we have being_ab_variant)
- Session replay / heatmaps / surveys (categorically off)
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(analytics): register surface: 'web' super-property
Tags every PostHog event from the marketing site with surface: 'web' so
the website's data stays distinguishable from the being-app's data in
the shared PostHog project (free tier = 1 project per account; sharing
is the right call pre-launch and a deliberate design choice).
The app mirrors this with posthog.register({ surface: 'app' }) in a
parallel change to ~/dev/being's PostHogProvider.
Co-Authored-By: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Claude Opus 4.7 (1M context)
---
.github/workflows/deploy.yml | 4 +
app/(main)/cookies/page.tsx | 57 ++-
app/(main)/download/page.tsx | 2 +-
app/(main)/layout.tsx | 4 +
app/(standalone)/layout.tsx | 9 +-
app/(standalone)/page.tsx | 2 +-
components/analytics/AnalyticsGate.tsx | 64 ++++
components/analytics/PosthogProvider.tsx | 51 +++
components/shared/WaitlistSignupForm.tsx | 22 +-
lib/posthog/config.ts | 20 ++
lib/posthog/events.ts | 49 +++
next.config.ts | 11 +-
package-lock.json | 431 +++++++++++++++++++++++
package.json | 1 +
tests/analytics-gpc-gate.test.tsx | 149 ++++++++
15 files changed, 851 insertions(+), 25 deletions(-)
create mode 100644 components/analytics/AnalyticsGate.tsx
create mode 100644 components/analytics/PosthogProvider.tsx
create mode 100644 lib/posthog/config.ts
create mode 100644 lib/posthog/events.ts
create mode 100644 tests/analytics-gpc-gate.test.tsx
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2540605..01e5c32 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -54,6 +54,10 @@ jobs:
# an emergency-fallback option but the runtime redirect carries
# / → /home. /download serves as the pre-launch waitlist page.
NEXT_PUBLIC_SHOW_FULL_SITE: 'true'
+ # PostHog product analytics (EU cloud). When this secret is missing
+ # or empty, AnalyticsGate renders nothing — graceful degradation.
+ # GPC kill happens client-side regardless. See lib/posthog/config.ts.
+ NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v4
diff --git a/app/(main)/cookies/page.tsx b/app/(main)/cookies/page.tsx
index bea2864..7b08207 100644
--- a/app/(main)/cookies/page.tsx
+++ b/app/(main)/cookies/page.tsx
@@ -20,7 +20,7 @@ export default function CookiesPage() {
Cookie Policy
- Last Updated: May 25, 2026
+ Last Updated: May 30, 2026
@@ -28,16 +28,18 @@ export default function CookiesPage() {
{/* Summary */}
- First-Party Functional Cookies Only
+ Two Functional Cookies + Privacy-Respecting Analytics
Being’s website (www.being.fyi) uses two small first-party
- cookies for site functionality. We don’t use third-party advertising or analytics
- cookies, tracking pixels, or cross-site trackers.
+ cookies for site functionality, plus PostHog product analytics (EU cloud) scoped
+ tightly to pageviews and waitlist signup events. No advertising trackers, no
+ session replay, no autocapture, no cross-site profiling.
- We honor the Global Privacy Control (Sec-GPC) signal automatically — no
- banner, no preference center.
+ We honor the Global Privacy Control (Sec-GPC) signal automatically — when
+ your browser sends it, PostHog does not load, no analytics cookie is set, and no
+ event is transmitted. No banner, no preference center.
@@ -61,13 +63,26 @@ export default function CookiesPage() {
being_gpc
-
+
- Purpose: Caches the Global Privacy Control signal so the page can display an acknowledgement.
- Type: First-party functional.
- Value:
1 when your browser sends Sec-GPC: 1; otherwise the cookie is cleared.
- Lifetime: 24 hours; automatically cleared on any subsequent request that doesn’t carry the header.
- Shared with third parties: No.
+
+
+ ph_* (PostHog)
+
+
+ - Purpose: Anonymous product analytics — pageviews and waitlist signup events only. Helps us understand which marketing pages drive waitlist signups.
+ - Type: Third-party analytics (PostHog Inc., EU data residency — Frankfurt).
+ - Value: A random distinct identifier (no email, no name, no PII).
+ - Lifetime: 365 days (PostHog default).
+ - Shared with third parties: Yes, with PostHog. No further onward sharing or sale.
+ - Not set when GPC is detected. If your browser sends
Sec-GPC: 1 or exposes navigator.globalPrivacyControl === true, PostHog does not load and no ph_* cookie is set. See Global Privacy Control section below.
+ - What we do NOT capture: No autocapture (no recording of all clicks/forms), no session replay, no heatmaps, no raw email or other PII. See our Privacy Policy §5.2 for the full web-analytics scope.
+
{/* What We Don't Use */}
@@ -82,7 +97,7 @@ export default function CookiesPage() {
-
✗
- No Analytics Cookies: No Google Analytics, PostHog, Mixpanel, or similar.
+ No Google Analytics, Mixpanel, Amplitude, or Segment. We use PostHog (disclosed above) for pageviews + waitlist conversion events only — no other analytics vendors.
-
✗
@@ -90,11 +105,15 @@ export default function CookiesPage() {
-
✗
- No Third-Party Cookies: No external services set cookies on our pages.
+ No Cross-Site Tracking Cookies: No advertising network cookies that follow you across the web.
+
+ -
+ ✗
+ No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar. PostHog session replay is explicitly disabled in our configuration.
-
✗
- No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar.
+ No Autocapture: PostHog supports auto-recording all clicks, form fields, and DOM interactions — we have this turned off. Only the named events (pageviews + waitlist signup success/failure) are sent.
@@ -110,10 +129,13 @@ export default function CookiesPage() {
personal information under CCPA, TDPSA, CPA, and CTDPA.
- In practice, because we don’t use third-party analytics or advertising trackers,
- there is no sale or sharing to opt out of — but the signal is recorded and acknowledged
- with a visible notice on our privacy pages, and an X-GPC-Honored: 1 response
- header confirms detection to anyone inspecting the request.
+ When the signal is present, PostHog does not load — no script is fetched, no
+ ph_* cookie is set, and no event is sent. The detection is structurally
+ enforced by our AnalyticsGate component, which checks both the
+ being_gpc cookie (set by middleware when Sec-GPC: 1 is
+ received) and the browser’s navigator.globalPrivacyControl JS API.
+ An X-GPC-Honored: 1 response header confirms detection to anyone
+ inspecting the request.
See the{' '}
@@ -194,10 +216,13 @@ export default function CookiesPage() {
Your Choices
- You can clear either cookie at any time via your browser’s site-data settings.
+ You can clear any of the cookies at any time via your browser’s site-data settings.
Clearing being_ab_variant just reassigns a variant on your next visit;
clearing being_gpc is harmless because the cookie reflects the header,
- not a stored preference.
+ not a stored preference; clearing ph_* opts you out of any PostHog
+ session continuity (you’ll appear as a new anonymous visitor next time).
+ Enabling Global Privacy Control in your browser is the simplest way to suppress
+ PostHog entirely.
For questions about server logs, mobile app data storage, or anything else, see our{' '}
diff --git a/app/(main)/download/page.tsx b/app/(main)/download/page.tsx
index 2f62885..bd7fd4d 100644
--- a/app/(main)/download/page.tsx
+++ b/app/(main)/download/page.tsx
@@ -32,7 +32,7 @@ export default function DownloadPage() {
Get early access + a free month trial when we launch. We’ll email you the
moment Being is in the App Store and Google Play.
-
+
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index 8c5027c..266387e 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -7,6 +7,7 @@ import DesktopNav from "@/components/navigation/DesktopNav";
import MobileHeader from "@/components/navigation/MobileHeader";
import MobileBottomTabs from "@/components/navigation/MobileBottomTabs";
import Footer from "@/components/navigation/Footer";
+import { AnalyticsGate } from "@/components/analytics/AnalyticsGate";
export default function MainLayout({
children,
@@ -39,6 +40,9 @@ export default function MainLayout({
{/* Mobile Bottom Tabs (hidden on desktop) */}
+
+ {/* PostHog analytics — gated by GPC / config / SSR-safe-default */}
+
>
);
}
diff --git a/app/(standalone)/layout.tsx b/app/(standalone)/layout.tsx
index 6d65e36..de5f378 100644
--- a/app/(standalone)/layout.tsx
+++ b/app/(standalone)/layout.tsx
@@ -9,6 +9,7 @@
*/
import { redirect } from 'next/navigation';
+import { AnalyticsGate } from '@/components/analytics/AnalyticsGate';
export const metadata = {
title: 'Being - Launching Soon',
@@ -23,5 +24,11 @@ export default function StandaloneLayout({
if (process.env.NEXT_PUBLIC_SHOW_FULL_SITE === 'true') {
redirect('/home');
}
- return <>{children}>;
+ return (
+ <>
+ {children}
+ {/* PostHog analytics — gated by GPC / config / SSR-safe-default */}
+
+ >
+ );
}
diff --git a/app/(standalone)/page.tsx b/app/(standalone)/page.tsx
index 921fae2..5595766 100644
--- a/app/(standalone)/page.tsx
+++ b/app/(standalone)/page.tsx
@@ -39,7 +39,7 @@ export default function ComingSoon() {
{/* Email Capture Form */}
-
+
{/* Trust Signals */}
diff --git a/components/analytics/AnalyticsGate.tsx b/components/analytics/AnalyticsGate.tsx
new file mode 100644
index 0000000..2b20a45
--- /dev/null
+++ b/components/analytics/AnalyticsGate.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+/**
+ * AnalyticsGate — the structural GPC kill for PostHog.
+ *
+ * Returns null (no PosthogProvider mounted, no posthog-js dynamic import
+ * triggered, no script loaded, no cookie set, no beacon sent) when ANY
+ * of these is true:
+ * - `being_gpc=1` cookie present (set by middleware when Sec-GPC: 1)
+ * - `navigator.globalPrivacyControl === true` (JS API; some browsers
+ * expose this without sending the HTTP header, e.g. older Brave)
+ * - PostHog key is not configured (local dev, env unset)
+ * - Component hasn't mounted yet (SSR default = safe = null)
+ *
+ * Why client-side, not server-side:
+ * - A server-component gate would force its parent layout into dynamic
+ * rendering (via `cookies()` from `next/headers`), which would undo
+ * the `force-static` rendering for legal pages and re-introduce the
+ * per-request marked() parsing that PR #33 fixed.
+ * - Client-side gate keeps pages prerendered AND preserves the
+ * "PostHog doesn't load on GPC" guarantee: the dynamic import inside
+ * PosthogProvider only fires when this gate renders the provider.
+ * If the gate returns null, posthog-js is never fetched, init never
+ * runs, no cookie is set, no beacon is sent.
+ *
+ * Compliance: see being repo PR #106 (§5.2 update) — this gate is the
+ * implementation of the "Global Privacy Control (GPC) hard kill"
+ * commitment in the web-side analytics disclosure.
+ */
+
+import { useSyncExternalStore } from 'react';
+import { PosthogProvider } from './PosthogProvider';
+import { isPosthogConfigured } from '@/lib/posthog/config';
+
+function subscribe(): () => void {
+ return () => {};
+}
+
+function getClientSnapshot(): boolean {
+ // Returns TRUE when analytics should be suppressed.
+ if (typeof document !== 'undefined') {
+ if (document.cookie.split(';').some((c) => c.trim() === 'being_gpc=1')) {
+ return true;
+ }
+ }
+ if (typeof navigator !== 'undefined') {
+ const gpc = (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl;
+ if (gpc === true) return true;
+ }
+ return false;
+}
+
+function getServerSnapshot(): boolean {
+ return true; // SSR default = suppress = safe
+}
+
+export function AnalyticsGate() {
+ const suppressed = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
+
+ if (suppressed) return null;
+ if (!isPosthogConfigured()) return null;
+
+ return
;
+}
diff --git a/components/analytics/PosthogProvider.tsx b/components/analytics/PosthogProvider.tsx
new file mode 100644
index 0000000..5f7f3e6
--- /dev/null
+++ b/components/analytics/PosthogProvider.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+/**
+ * PostHog initializer — dynamic-imports posthog-js and runs init() once.
+ *
+ * This component is rendered ONLY by AnalyticsGate, which has already
+ * verified that GPC is not set and the PostHog key is configured.
+ * Mounting this component is what kicks off the dynamic import + init.
+ *
+ * Init options chosen to match privacy-policy.md §5.2 web-side commitments:
+ * - api_host: EU cloud only (no US fallback)
+ * - autocapture: false (we capture only the named events)
+ * - disable_session_recording: true (categorically off per /cookies)
+ * - capture_pageview: true (the one default we DO want)
+ * - persistence: cookie (per /cookies disclosure)
+ * - opt_out_capturing_by_default: false (opt-out posture per product agent
+ * + scoped to non-GPC visitors per §5.2)
+ *
+ * @see lib/posthog/events.ts for the event-emit helpers
+ */
+
+import { useEffect } from 'react';
+import { POSTHOG_KEY, POSTHOG_HOST } from '@/lib/posthog/config';
+
+export function PosthogProvider() {
+ useEffect(() => {
+ const key = POSTHOG_KEY;
+ if (!key) return;
+ if (window.__posthog) return; // already initialized in this isolate
+
+ void import('posthog-js').then(({ default: posthog }) => {
+ posthog.init(key, {
+ api_host: POSTHOG_HOST,
+ autocapture: false,
+ disable_session_recording: true,
+ capture_pageview: true,
+ persistence: 'cookie',
+ opt_out_capturing_by_default: false,
+ loaded: (ph) => {
+ // Tag every event with the surface so the website's data stays
+ // distinguishable from the being-app's data in the shared PostHog
+ // project. The app mirrors this with posthog.register({ surface: 'app' }).
+ ph.register({ surface: 'web' });
+ window.__posthog = ph as unknown as NonNullable
;
+ },
+ });
+ });
+ }, []);
+
+ return null;
+}
diff --git a/components/shared/WaitlistSignupForm.tsx b/components/shared/WaitlistSignupForm.tsx
index 875ab3a..bbc3b04 100644
--- a/components/shared/WaitlistSignupForm.tsx
+++ b/components/shared/WaitlistSignupForm.tsx
@@ -4,17 +4,30 @@
* Waitlist signup form.
*
* Used on:
- * - / (splash) when NEXT_PUBLIC_SHOW_FULL_SITE !== 'true'
- * - /download (pre-launch state until the app is in the stores)
+ * - / (splash) when NEXT_PUBLIC_SHOW_FULL_SITE !== 'true' (source='splash')
+ * - /download (pre-launch state until the app is in the stores) (source='download')
*
* POSTs to the existing /api/waitlist endpoint (Notion-backed).
* Self-contained: idle / submitting / success / error states are
* managed internally.
+ *
+ * Fires PostHog events on success/failure (no-op when PostHog isn't loaded
+ * — e.g., GPC kill, missing key). See lib/posthog/events.ts.
*/
import { useState, FormEvent } from 'react';
+import {
+ trackWaitlistSubmitted,
+ trackWaitlistFailed,
+ type WaitlistSource,
+} from '@/lib/posthog/events';
+
+interface WaitlistSignupFormProps {
+ /** Where on the site this form instance lives — flows through to PostHog events. */
+ source: WaitlistSource;
+}
-export function WaitlistSignupForm() {
+export function WaitlistSignupForm({ source }: WaitlistSignupFormProps) {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
@@ -32,12 +45,15 @@ export function WaitlistSignupForm() {
});
if (!response.ok) {
+ trackWaitlistFailed({ source, reason: `http_${response.status}` });
throw new Error('Failed to join waitlist');
}
+ trackWaitlistSubmitted({ source });
setStatus('success');
setEmail('');
} catch {
+ trackWaitlistFailed({ source, reason: 'network_error' });
setStatus('error');
setErrorMessage('Something went wrong. Please try again.');
}
diff --git a/lib/posthog/config.ts b/lib/posthog/config.ts
new file mode 100644
index 0000000..5af639a
--- /dev/null
+++ b/lib/posthog/config.ts
@@ -0,0 +1,20 @@
+/**
+ * PostHog configuration — single source of truth for analytics setup.
+ *
+ * EU data residency is required per privacy-policy.md §5.1/§5.2.
+ *
+ * If the key is missing (e.g., local dev), `isPosthogConfigured()` returns
+ * false and the AnalyticsGate renders nothing → graceful degradation,
+ * never a hard failure.
+ *
+ * @see INFRA-website-posthog-disclosure (being repo PR #106)
+ */
+
+export const POSTHOG_HOST = 'https://eu.i.posthog.com';
+export const POSTHOG_ASSETS_HOST = 'https://eu-assets.i.posthog.com';
+
+export const POSTHOG_KEY: string | undefined = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+export function isPosthogConfigured(): boolean {
+ return typeof POSTHOG_KEY === 'string' && POSTHOG_KEY.length > 0;
+}
diff --git a/lib/posthog/events.ts b/lib/posthog/events.ts
new file mode 100644
index 0000000..b0d1221
--- /dev/null
+++ b/lib/posthog/events.ts
@@ -0,0 +1,49 @@
+/**
+ * PostHog event helpers — type-safe wrappers for the PR #1 event set.
+ *
+ * Design notes:
+ * - No `posthog-js` import at module scope — these helpers reach the
+ * PostHog instance via `window.__posthog`, which is only set after
+ * PosthogProvider has dynamic-imported the library AND init'd it.
+ * - If PostHog never loaded (GPC kill, missing key, SSR), every helper
+ * is a silent no-op. No throws, no console noise.
+ * - PR #1 event set: $pageview (PostHog default — fires automatically
+ * on init via capture_pageview: true), trackWaitlistSubmitted,
+ * trackWaitlistFailed.
+ * - Future helpers (cta_clicked, crisis_resource_clicked, ab_variant_assigned,
+ * identifyByEmailHash) ship in PR #2+.
+ */
+
+declare global {
+ interface Window {
+ __posthog?: {
+ capture: (event: string, props?: Record) => void;
+ };
+ }
+}
+
+export type WaitlistSource = 'home' | 'features' | 'download' | 'splash';
+
+export interface WaitlistSubmittedProps {
+ source: WaitlistSource;
+}
+
+export interface WaitlistFailedProps {
+ source: WaitlistSource;
+ reason: string;
+}
+
+function capture(event: string, props?: Record): void {
+ if (typeof window === 'undefined') return;
+ const ph = window.__posthog;
+ if (!ph) return;
+ ph.capture(event, props);
+}
+
+export function trackWaitlistSubmitted(props: WaitlistSubmittedProps): void {
+ capture('waitlist_signup_submitted', { source: props.source });
+}
+
+export function trackWaitlistFailed(props: WaitlistFailedProps): void {
+ capture('waitlist_signup_failed', { source: props.source, reason: props.reason });
+}
diff --git a/next.config.ts b/next.config.ts
index 2b4fca3..ca35472 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -16,12 +16,17 @@ const securityHeaders = [
key: "Content-Security-Policy",
value: [
"default-src 'self'",
- "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
+ // PostHog hosts its JS at eu-assets.i.posthog.com (EU region)
+ "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://eu-assets.i.posthog.com",
"style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: blob:",
+ // PostHog uses pixel-style image beacons as a fallback
+ "img-src 'self' data: blob: https://eu.i.posthog.com",
"font-src 'self' data:",
- "connect-src 'self' https://api.notion.com https://challenges.cloudflare.com",
+ // PostHog event ingestion + asset CDN endpoints (EU region only)
+ "connect-src 'self' https://api.notion.com https://challenges.cloudflare.com https://eu.i.posthog.com https://eu-assets.i.posthog.com",
"frame-src https://challenges.cloudflare.com",
+ // PostHog spawns a web worker for batching even with session replay off
+ "worker-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
diff --git a/package-lock.json b/package-lock.json
index 0cd1b74..71d3cd7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@mp2ez/being-design-system": "^1.8.0",
"marked": "^17.0.1",
"next": "^16.0.7",
+ "posthog-js": "^1.376.4",
"raw-loader": "^4.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -11141,6 +11142,252 @@
"wrangler": "^4.49.1"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
+ "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
+ "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/exporter-logs-otlp-http": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
+ "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/otlp-exporter-base": "0.208.0",
+ "@opentelemetry/otlp-transformer": "0.208.0",
+ "@opentelemetry/sdk-logs": "0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-exporter-base": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
+ "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/otlp-transformer": "0.208.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-transformer": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
+ "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0",
+ "@opentelemetry/sdk-logs": "0.208.0",
+ "@opentelemetry/sdk-metrics": "2.2.0",
+ "@opentelemetry/sdk-trace-base": "2.2.0",
+ "protobufjs": "^7.3.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
+ "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.7.1",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
+ "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-logs": {
+ "version": "0.208.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
+ "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.208.0",
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.4.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-metrics": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
+ "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.9.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
+ "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/resources": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
+ "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.2.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.41.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
+ "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
@@ -11197,6 +11444,84 @@
"license": "MIT",
"peer": true
},
+ "node_modules/@posthog/core": {
+ "version": "1.29.13",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.13.tgz",
+ "integrity": "sha512-7Me5zaeAue/wmA364Go8ChYbsVAfNAHbtDxXopWu3D6hq9PVScUcauRgjD1njgvP8NzN91SrIllE+pri3XvJVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "1.376.4"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.376.4",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.376.4.tgz",
+ "integrity": "sha512-EoDEvA925lf6yxPpbP4wozlXgu4b9WEqxZlFBUDd4k2akP5R/RWyHpvQT8aYyfY6BtSLn8TnVwxPQOM4b90isA==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -13490,6 +13815,13 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
@@ -15329,6 +15661,17 @@
"node": ">=6.6.0"
}
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -15535,6 +15878,15 @@
"license": "MIT",
"peer": true
},
+ "node_modules/dompurify": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
+ "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -16605,6 +16957,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
+ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -18413,6 +18771,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -19766,6 +20130,37 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/posthog-js": {
+ "version": "1.376.4",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.376.4.tgz",
+ "integrity": "sha512-SGhZWMBpd9GyV1+Klhx/vRwyy/reRRpJGYc1n1rYG9+LAML/YUEIrfl3Y2CLvuwmhKbPVY5W98Z7pAVQ88Qf1A==",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/api-logs": "^0.208.0",
+ "@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
+ "@opentelemetry/resources": "^2.2.0",
+ "@opentelemetry/sdk-logs": "^0.208.0",
+ "@posthog/core": "1.29.13",
+ "@posthog/types": "1.376.4",
+ "core-js": "^3.38.1",
+ "dompurify": "^3.3.2",
+ "fflate": "^0.4.8",
+ "preact": "^10.28.2",
+ "query-selector-shadow-dom": "^1.0.1",
+ "web-vitals": "^5.1.0"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.29.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
+ "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -19826,6 +20221,30 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/protobufjs": {
+ "version": "7.6.1",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
+ "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.2",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.3.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -19865,6 +20284,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/query-selector-shadow-dom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
+ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -22096,6 +22521,12 @@
"node": ">= 14"
}
},
+ "node_modules/web-vitals": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
+ "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
diff --git a/package.json b/package.json
index db1594c..3d457fe 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"@mp2ez/being-design-system": "^1.8.0",
"marked": "^17.0.1",
"next": "^16.0.7",
+ "posthog-js": "^1.376.4",
"raw-loader": "^4.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/tests/analytics-gpc-gate.test.tsx b/tests/analytics-gpc-gate.test.tsx
new file mode 100644
index 0000000..3de95a0
--- /dev/null
+++ b/tests/analytics-gpc-gate.test.tsx
@@ -0,0 +1,149 @@
+// @vitest-environment happy-dom
+
+/**
+ * Analytics GPC gate — the compliance-critical test surface.
+ *
+ * Failure modes this file MUST catch:
+ *
+ * 1. AnalyticsGate forgets to suppress when GPC is set
+ * → PostHog loads on a user who explicitly opted out → CCPA "sharing"
+ * violation + FTC §5 deceptive practice (we publicly promise GPC
+ * kill on /cookies and in privacy-policy §5.2).
+ *
+ * 2. AnalyticsGate forgets to no-op when key is missing
+ * → posthog.init() called with undefined → noisy console errors or
+ * worse, accidentally captures with no project = data loss + bug.
+ *
+ * 3. Someone accidentally adds `import posthog from 'posthog-js'` to a
+ * component that always renders → posthog-js loads on every page even
+ * with GPC set. This is the regression vector that defeats the gate.
+ *
+ * 4. Event helpers throw when window.__posthog is undefined → breaks pages
+ * for users whose PostHog didn't load (GPC, missing key, SSR).
+ */
+
+import { execSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render } from '@testing-library/react';
+import { trackWaitlistSubmitted, trackWaitlistFailed } from '@/lib/posthog/events';
+
+const ORIGINAL_ENV = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+describe('analytics GPC gate', () => {
+ beforeEach(() => {
+ document.cookie = '';
+ delete (window as unknown as { __posthog?: unknown }).__posthog;
+ });
+
+ afterEach(() => {
+ if (ORIGINAL_ENV === undefined) {
+ delete process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ } else {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY = ORIGINAL_ENV;
+ }
+ vi.restoreAllMocks();
+ });
+
+ it('renders null when being_gpc=1 cookie is set', async () => {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY = 'phc_test_key';
+ document.cookie = 'being_gpc=1';
+
+ vi.resetModules();
+ const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate');
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null when PostHog key is missing', async () => {
+ delete process.env.NEXT_PUBLIC_POSTHOG_KEY;
+
+ vi.resetModules();
+ const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate');
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null when navigator.globalPrivacyControl === true', async () => {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY = 'phc_test_key';
+ Object.defineProperty(navigator, 'globalPrivacyControl', {
+ value: true,
+ configurable: true,
+ });
+
+ vi.resetModules();
+ const { AnalyticsGate } = await import('@/components/analytics/AnalyticsGate');
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+
+ Object.defineProperty(navigator, 'globalPrivacyControl', {
+ value: undefined,
+ configurable: true,
+ });
+ });
+
+ it('event helpers are silent no-ops when window.__posthog is undefined', () => {
+ // No throws, no console noise.
+ expect(() => trackWaitlistSubmitted({ source: 'home' })).not.toThrow();
+ expect(() => trackWaitlistFailed({ source: 'splash', reason: 'http_500' })).not.toThrow();
+ });
+
+ it('event helpers call window.__posthog.capture when PostHog is loaded', () => {
+ const captureMock = vi.fn();
+ (window as unknown as { __posthog: { capture: typeof captureMock } }).__posthog = {
+ capture: captureMock,
+ };
+
+ trackWaitlistSubmitted({ source: 'download' });
+ trackWaitlistFailed({ source: 'splash', reason: 'network_error' });
+
+ expect(captureMock).toHaveBeenCalledTimes(2);
+ expect(captureMock).toHaveBeenNthCalledWith(1, 'waitlist_signup_submitted', {
+ source: 'download',
+ });
+ expect(captureMock).toHaveBeenNthCalledWith(2, 'waitlist_signup_failed', {
+ source: 'splash',
+ reason: 'network_error',
+ });
+ });
+
+ /**
+ * The structural regression gate: posthog-js must only be statically
+ * imported from `components/analytics/PosthogProvider.tsx`, and even
+ * there only inside a dynamic `import()` expression.
+ *
+ * If anyone adds `import posthog from 'posthog-js'` to an always-rendered
+ * component, posthog-js ships in the page bundle and loads regardless of
+ * the GPC gate. This test runs grep to enforce.
+ */
+ it('only PosthogProvider may import posthog-js, and only dynamically', () => {
+ const matches = execSync(
+ "grep -rln \"['\\\"]posthog-js['\\\"]\" app components lib --include='*.ts' --include='*.tsx' || true",
+ { encoding: 'utf-8' },
+ )
+ .trim()
+ .split('\n')
+ .filter((line) => line.length > 0);
+
+ const allowedFile = 'components/analytics/PosthogProvider.tsx';
+ const unauthorized = matches.filter((file) => !file.endsWith(allowedFile));
+ expect(
+ unauthorized,
+ `Only ${allowedFile} may reference posthog-js. Found in: ${unauthorized.join(', ')}`,
+ ).toEqual([]);
+
+ // And the one allowed reference must be inside a dynamic import().
+ const providerSource = readFileSync(allowedFile, 'utf-8');
+ expect(
+ providerSource,
+ "PosthogProvider.tsx must use dynamic import('posthog-js'), not a static import",
+ ).toMatch(/import\(['"]posthog-js['"]\)/);
+ expect(
+ providerSource,
+ "PosthogProvider.tsx must NOT use a static `import ... from 'posthog-js'`",
+ ).not.toMatch(/^import .* from ['"]posthog-js['"]/m);
+ });
+});