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
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 41 additions & 16 deletions app/(main)/cookies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,26 @@ export default function CookiesPage() {
Cookie Policy
</h1>
<p className="text-gray-700">
<strong>Last Updated:</strong> May 25, 2026
<strong>Last Updated:</strong> May 30, 2026
</p>
</header>

<div className="prose prose-lg max-w-none">
{/* Summary */}
<section className="bg-green-50 p-8 rounded-large border-2 border-green-400 mb-8">
<h2 className="text-2xl font-bold text-green-900 mb-4">
First-Party Functional Cookies Only
Two Functional Cookies + Privacy-Respecting Analytics
</h2>
<p className="text-gray-800 leading-relaxed mb-4">
Being&rsquo;s website (<strong>www.being.fyi</strong>) uses two small first-party
cookies for site functionality. We don&rsquo;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.
</p>
<p className="text-gray-800 leading-relaxed">
We honor the <strong>Global Privacy Control (Sec-GPC)</strong> signal automatically — no
banner, no preference center.
We honor the <strong>Global Privacy Control (Sec-GPC)</strong> 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.
</p>
</section>

Expand All @@ -61,13 +63,26 @@ export default function CookiesPage() {
<h3 className="text-xl font-semibold text-gray-900 mt-6 mb-2">
<code className="bg-gray-100 px-2 py-1 rounded text-base">being_gpc</code>
</h3>
<ul className="space-y-2 text-gray-700">
<ul className="space-y-2 text-gray-700 mb-6">
<li><strong>Purpose:</strong> Caches the Global Privacy Control signal so the page can display an acknowledgement.</li>
<li><strong>Type:</strong> First-party functional.</li>
<li><strong>Value:</strong> <code>1</code> when your browser sends <code>Sec-GPC: 1</code>; otherwise the cookie is cleared.</li>
<li><strong>Lifetime:</strong> 24 hours; automatically cleared on any subsequent request that doesn&rsquo;t carry the header.</li>
<li><strong>Shared with third parties:</strong> No.</li>
</ul>

<h3 className="text-xl font-semibold text-gray-900 mt-6 mb-2">
<code className="bg-gray-100 px-2 py-1 rounded text-base">ph_*</code> (PostHog)
</h3>
<ul className="space-y-2 text-gray-700">
<li><strong>Purpose:</strong> Anonymous product analytics — pageviews and waitlist signup events only. Helps us understand which marketing pages drive waitlist signups.</li>
<li><strong>Type:</strong> Third-party analytics (PostHog Inc., EU data residency — Frankfurt).</li>
<li><strong>Value:</strong> A random distinct identifier (no email, no name, no PII).</li>
<li><strong>Lifetime:</strong> 365 days (PostHog default).</li>
<li><strong>Shared with third parties:</strong> Yes, with PostHog. No further onward sharing or sale.</li>
<li><strong>Not set when GPC is detected.</strong> If your browser sends <code>Sec-GPC: 1</code> or exposes <code>navigator.globalPrivacyControl === true</code>, PostHog does not load and no <code>ph_*</code> cookie is set. See <em>Global Privacy Control</em> section below.</li>
<li><strong>What we do NOT capture:</strong> No autocapture (no recording of all clicks/forms), no session replay, no heatmaps, no raw email or other PII. See our <a href="/privacy" className="text-accent-600 hover:underline font-medium">Privacy Policy §5.2</a> for the full web-analytics scope.</li>
</ul>
</section>

{/* What We Don't Use */}
Expand All @@ -82,19 +97,23 @@ export default function CookiesPage() {
</li>
<li className="flex items-start">
<span className="text-red-600 mr-2">✗</span>
<span><strong>No Analytics Cookies:</strong> No Google Analytics, PostHog, Mixpanel, or similar.</span>
<span><strong>No Google Analytics, Mixpanel, Amplitude, or Segment.</strong> We use PostHog (disclosed above) for pageviews + waitlist conversion events only — no other analytics vendors.</span>
</li>
<li className="flex items-start">
<span className="text-red-600 mr-2">✗</span>
<span><strong>No Social Media Cookies:</strong> No social media widgets or share buttons with tracking.</span>
</li>
<li className="flex items-start">
<span className="text-red-600 mr-2">✗</span>
<span><strong>No Third-Party Cookies:</strong> No external services set cookies on our pages.</span>
<span><strong>No Cross-Site Tracking Cookies:</strong> No advertising network cookies that follow you across the web.</span>
</li>
<li className="flex items-start">
<span className="text-red-600 mr-2">✗</span>
<span><strong>No Fingerprinting or Session Replay:</strong> No FullStory, LogRocket, Hotjar, or similar. PostHog session replay is explicitly disabled in our configuration.</span>
</li>
<li className="flex items-start">
<span className="text-red-600 mr-2">✗</span>
<span><strong>No Fingerprinting or Session Replay:</strong> No FullStory, LogRocket, Hotjar, or similar.</span>
<span><strong>No Autocapture:</strong> 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.</span>
</li>
</ul>
</section>
Expand All @@ -110,10 +129,13 @@ export default function CookiesPage() {
personal information under CCPA, TDPSA, CPA, and CTDPA.
</p>
<p className="text-gray-700 leading-relaxed mb-4">
In practice, because we don&rsquo;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 <code>X-GPC-Honored: 1</code> response
header confirms detection to anyone inspecting the request.
When the signal is present, PostHog does not load — no script is fetched, no
<code> ph_*</code> cookie is set, and no event is sent. The detection is structurally
enforced by our <code>AnalyticsGate</code> component, which checks both the
<code> being_gpc</code> cookie (set by middleware when <code>Sec-GPC: 1</code> is
received) and the browser&rsquo;s <code>navigator.globalPrivacyControl</code> JS API.
An <code>X-GPC-Honored: 1</code> response header confirms detection to anyone
inspecting the request.
</p>
<p className="text-gray-700 leading-relaxed">
See the{' '}
Expand Down Expand Up @@ -194,10 +216,13 @@ export default function CookiesPage() {
Your Choices
</h2>
<p className="text-gray-700 leading-relaxed mb-4">
You can clear either cookie at any time via your browser&rsquo;s site-data settings.
You can clear any of the cookies at any time via your browser&rsquo;s site-data settings.
Clearing <code>being_ab_variant</code> just reassigns a variant on your next visit;
clearing <code>being_gpc</code> is harmless because the cookie reflects the header,
not a stored preference.
not a stored preference; clearing <code>ph_*</code> opts you out of any PostHog
session continuity (you&rsquo;ll appear as a new anonymous visitor next time).
Enabling Global Privacy Control in your browser is the simplest way to suppress
PostHog entirely.
</p>
<p className="text-gray-700 leading-relaxed">
For questions about server logs, mobile app data storage, or anything else, see our{' '}
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/download/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function DownloadPage() {
Get early access + a free month trial when we launch. We&rsquo;ll email you the
moment Being is in the App Store and Google Play.
</p>
<WaitlistSignupForm />
<WaitlistSignupForm source="download" />
</div>
</section>

Expand Down
4 changes: 4 additions & 0 deletions app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,6 +40,9 @@ export default function MainLayout({

{/* Mobile Bottom Tabs (hidden on desktop) */}
<MobileBottomTabs />

{/* PostHog analytics — gated by GPC / config / SSR-safe-default */}
<AnalyticsGate />
</>
);
}
9 changes: 8 additions & 1 deletion app/(standalone)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { redirect } from 'next/navigation';
import { AnalyticsGate } from '@/components/analytics/AnalyticsGate';

export const metadata = {
title: 'Being - Launching Soon',
Expand All @@ -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 */}
<AnalyticsGate />
</>
);
}
2 changes: 1 addition & 1 deletion app/(standalone)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function ComingSoon() {
</div>

{/* Email Capture Form */}
<WaitlistSignupForm />
<WaitlistSignupForm source="splash" />

{/* Trust Signals */}
<div className="pt-4 space-y-3">
Expand Down
64 changes: 64 additions & 0 deletions components/analytics/AnalyticsGate.tsx
Original file line number Diff line number Diff line change
@@ -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 <PosthogProvider />;
}
51 changes: 51 additions & 0 deletions components/analytics/PosthogProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<Window['__posthog']>;
},
});
});
}, []);

return null;
}
22 changes: 19 additions & 3 deletions components/shared/WaitlistSignupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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.');
}
Expand Down
20 changes: 20 additions & 0 deletions lib/posthog/config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading