Wire PostHog into the cookie-banner React example

Add a deferred PostHog wiring under examples/cookie-banner-react that
boots posthog.init() inside the probo-ready handler and derives
cookieless_mode and opt_out_capturing_by_default from the consent
snapshot for the category flagged with posthog_consent.

Driving the init args off the snapshot rather than consent_mode plugs
two cases the simpler "consent_mode alone" rule got wrong:

  * OPT_OUT regulation, returning rejector: init would have booted in
    on_reject + capture-on, fired a $pageview synchronously, and only
    then called opt_out_capturing(). That single captured pageview
    (and the posthog cookie) leaked on every page load.
  * OPT_IN regulation, returning acceptor: init would have forced
    "always" + opt-out, costing the visitor cookies and a one-tick
    capture delay even though they had already consented.

The snapshot already encodes the regulation default
(buildDefaultConsentData on the cookie-banner client returns true for
non-necessary categories under OPT_OUT and false under OPT_IN) and any
persisted answer from a prior visit, so a single boolean drives both
init args.

Re-export the public domain types (BannerConfig, Category, Regulation,
ConsentAction, ConsentRecord, CookieItem, VisitorConsent) from
@probo/cookie-banner so the example can type the probo-ready event
detail without duck-typing it.

Adopt the PUBLIC_ env prefix in Vite so the example reads the same env
var names (PUBLIC_COOKIE_BANNER_ID, PUBLIC_COOKIE_BANNER_API_BASE_URL,
PUBLIC_POSTHOG_API_KEY) already used on getprobo.com, and add a
matching .env.example.

Ignore *.tsbuildinfo at the repo root; TypeScript's incremental cache
is machine-local and does not belong in the tree.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-27 13:06:02 +02:00
parent caba961d7c
commit a447c62e19
11 changed files with 774 additions and 9 deletions

View File

@@ -7,9 +7,11 @@ export interface Config {
const STORAGE_KEY = "probo-example-config";
// Seed defaults from Vite env (loaded from ../../../getprobo.com/.env) so the
// example is usable without any manual setup when those vars are present.
const defaultConfig: Config = {
bannerId: "",
baseUrl: "",
bannerId: import.meta.env.PUBLIC_COOKIE_BANNER_ID ?? "",
baseUrl: import.meta.env.PUBLIC_COOKIE_BANNER_API_BASE_URL ?? "",
};
function getSnapshot(): Config {

View File

@@ -0,0 +1,195 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import posthog from "posthog-js";
import type { BannerConfig } from "@probo/cookie-banner";
import { getConsent, type ConsentData } from "@probo/cookie-banner/consent";
export type ConsentMode = BannerConfig["consent_mode"];
/**
* Fallback category slug to gate PostHog when the banner config does not flag
* any category with `posthog_consent: true`. Most Probo banners ship with an
* "analytics" category, hence this default.
*/
const FALLBACK_CATEGORY_SLUG = "analytics";
let subscribed = false;
let initialized = false;
let unsubscribeConsent: (() => void) | null = null;
let categorySlug: string = FALLBACK_CATEGORY_SLUG;
let consentMode: ConsentMode | null = null;
const statusListeners = new Set<() => void>();
export interface PosthogStatus {
initialized: boolean;
consentMode: ConsentMode | null;
optedIn: boolean;
optedOut: boolean;
distinctId: string | null;
}
// Cached snapshot. `useSyncExternalStore` compares references with `Object.is`,
// so `getPosthogStatus()` must return a stable reference until something
// actually changes — otherwise React loops itself into a stack overflow.
let cachedStatus: PosthogStatus = {
initialized: false,
consentMode: null,
optedIn: false,
optedOut: false,
distinctId: null,
};
/**
* Wire up the consent subscription so that any future opt-in / opt-out
* decisions are mirrored to PostHog.
*
* Note: this does NOT call `posthog.init()`. We can't choose `cookieless_mode`
* until the banner config tells us the regulation's `consent_mode`, so the
* actual SDK init is deferred to {@link configurePosthogFromBanner}, which
* the `probo-ready` event handler should invoke with the banner config.
*
* Safe to call multiple times; only the first call wires the subscription.
*/
export function initPosthog(): void {
if (subscribed) return;
if (!import.meta.env.PUBLIC_POSTHOG_API_KEY) {
console.warn(
"[posthog] PUBLIC_POSTHOG_API_KEY is not set; skipping PostHog init. " +
"Copy .env.example to .env in examples/cookie-banner-react/ and fill it in.",
);
return;
}
subscribed = true;
const consent = getConsent();
unsubscribeConsent = consent.subscribe((data: ConsentData) => {
syncCapturing(data);
refreshStatus();
});
}
/**
* Initialize PostHog (on first call) and route opt-in / opt-out decisions
* through the category flagged with `posthog_consent: true` in the banner
* config. Call this from the `probo-ready` event handler.
*
* The init options are derived from the current consent snapshot, which the
* banner client has already populated with either the persisted answer (cookie
* or API) or the per-regulation default (`true` for non-necessary categories
* under `OPT_OUT`, `false` under `OPT_IN`):
* - Analytics allowed → cookies and capture on from the start; the
* subscription downgrades to cookieless if the visitor later rejects.
* - Analytics denied → fully cookieless and opted-out; the subscription opts
* in (still cookieless for the rest of the session) if the visitor later
* accepts.
*
* Driving the init off the snapshot rather than `consent_mode` plugs the race
* where `posthog.init()` fires a `$pageview` (and sets the posthog cookie)
* before we can call `opt_out_capturing()` for an `OPT_OUT` visitor who
* already rejected on a prior visit.
*
* Subsequent calls leave PostHog initialized and just refresh the category
* slug / consent mode in the cached status snapshot.
*/
export function configurePosthogFromBanner(config: BannerConfig): void {
const flagged = config.categories.find((c) => c.posthog_consent);
const slug = flagged?.slug ?? FALLBACK_CATEGORY_SLUG;
const consent = getConsent();
if (!initialized) {
const apiKey = import.meta.env.PUBLIC_POSTHOG_API_KEY;
if (!apiKey) return;
const analyticsAllowed = consent.getAll()[slug] === true;
posthog.init(apiKey, {
api_host: "https://t.probo.com",
ui_host: "https://us.posthog.com",
cookieless_mode: analyticsAllowed ? "on_reject" : "always",
opt_out_capturing_by_default: !analyticsAllowed,
person_profiles: "identified_only",
respect_dnt: true,
debug: import.meta.env.DEV,
});
initialized = true;
}
consentMode = config.consent_mode;
categorySlug = slug;
syncCapturing(consent.getAll());
refreshStatus();
}
/** Tear down the consent subscription. Does not un-initialize PostHog itself. */
export function teardownPosthog(): void {
if (unsubscribeConsent) {
unsubscribeConsent();
unsubscribeConsent = null;
}
subscribed = false;
}
/** Subscribe to PostHog status changes (init / opt-in / opt-out / category). */
export function subscribePosthogStatus(cb: () => void): () => void {
statusListeners.add(cb);
return () => statusListeners.delete(cb);
}
export function getPosthogStatus(): PosthogStatus {
return cachedStatus;
}
function syncCapturing(data: ConsentData): void {
if (!initialized) return;
if (data[categorySlug]) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
}
function refreshStatus(): void {
const next: PosthogStatus = initialized
? {
initialized: true,
consentMode,
optedIn: posthog.has_opted_in_capturing(),
optedOut: posthog.has_opted_out_capturing(),
distinctId: posthog.get_distinct_id?.() ?? null,
}
: {
initialized: false,
consentMode,
optedIn: false,
optedOut: false,
distinctId: null,
};
if (statusEqual(cachedStatus, next)) return;
cachedStatus = next;
for (const cb of statusListeners) cb();
}
function statusEqual(a: PosthogStatus, b: PosthogStatus): boolean {
return (
a.initialized === b.initialized &&
a.consentMode === b.consentMode &&
a.optedIn === b.optedIn &&
a.optedOut === b.optedOut &&
a.distinctId === b.distinctId
);
}

View File

@@ -1,6 +1,15 @@
import { useCallback, useEffect, useRef } from "react";
import { registerCookieBanner } from "@probo/cookie-banner";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import posthog from "posthog-js";
import { registerCookieBanner, type BannerConfig } from "@probo/cookie-banner";
import { useConfig } from "../hooks/useConfig";
import {
configurePosthogFromBanner,
getPosthogStatus,
initPosthog,
subscribePosthogStatus,
type PosthogStatus,
} from "../lib/posthog";
import { PosthogPanel } from "./_components/PosthogPanel";
import type { EventEntry } from "../App";
let registered = false;
@@ -13,12 +22,15 @@ interface ThemedBannerTabProps {
export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
const [config] = useConfig();
const elRef = useRef<HTMLElement | null>(null);
const posthogStatus = usePosthogStatus();
const [manualPing, setManualPing] = useState<string | null>(null);
useEffect(() => {
if (!registered) {
registerCookieBanner();
registered = true;
}
initPosthog();
}, []);
const attachListeners = useCallback(
@@ -26,9 +38,15 @@ export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
elRef.current = el;
if (!el) return;
el.addEventListener("probo-ready", (e: Event) =>
pushEvent("probo-ready", (e as CustomEvent).detail),
);
el.addEventListener("probo-ready", (e: Event) => {
const detail = (e as CustomEvent).detail as {
config?: BannerConfig;
};
if (detail?.config) {
configurePosthogFromBanner(detail.config);
}
pushEvent("probo-ready", (e as CustomEvent).detail);
});
el.addEventListener("probo-consent", (e: Event) =>
pushEvent("probo-consent", (e as CustomEvent).detail),
);
@@ -36,6 +54,11 @@ export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
[pushEvent],
);
const sendPing = useCallback(() => {
posthog.capture("themed_tab_manual_ping", { source: "example" });
setManualPing(new Date().toISOString());
}, []);
if (!config.bannerId || !config.baseUrl) {
return (
<div>
@@ -56,6 +79,12 @@ export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
bottom-right corner.
</p>
<PosthogPanel
status={posthogStatus}
manualPing={manualPing}
onSendPing={sendPing}
/>
<probo-cookie-banner
ref={attachListeners}
banner-id={config.bannerId}
@@ -92,3 +121,7 @@ export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
</div>
);
}
function usePosthogStatus(): PosthogStatus {
return useSyncExternalStore(subscribePosthogStatus, getPosthogStatus, getPosthogStatus);
}

View File

@@ -0,0 +1,92 @@
import type { PosthogStatus } from "../../lib/posthog";
interface PosthogPanelProps {
status: PosthogStatus;
manualPing: string | null;
onSendPing: () => void;
}
export function PosthogPanel({ status, manualPing, onSendPing }: PosthogPanelProps) {
return (
<div
style={{
border: "1px solid #ccc",
padding: 12,
background: "#fafafa",
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>PostHog</h3>
<p style={{ color: "#666", margin: "0 0 8px 0", fontSize: 13 }}>
Init is deferred until the banner config arrives, then{" "}
<code>cookieless_mode</code> and{" "}
<code>opt_out_capturing_by_default</code> are derived from the consent
snapshot for the category flagged with <code>posthog_consent</code>.
The snapshot already encodes both the regulation's default
(<code>OPT_IN</code> → off, <code>OPT_OUT</code> → on) and any
persisted answer from a prior visit, so a returning visitor who
accepted boots straight into <code>"on_reject"</code> +{" "}
<code>false</code> while one who rejected (or a fresh{" "}
<code>OPT_IN</code> visitor) boots into <code>"always"</code> +{" "}
<code>true</code>. The <code>getConsent().subscribe()</code> callback
then flips <code>opt_in_capturing()</code> /{" "}
<code>opt_out_capturing()</code> on subsequent banner actions.
</p>
<table
style={{
borderCollapse: "collapse",
fontFamily: "monospace",
fontSize: 13,
marginBottom: 8,
}}
>
<tbody>
<Row label="initialized" value={String(status.initialized)} ok={status.initialized} />
<Row label="consent mode" value={status.consentMode ?? "(pending)"} />
<Row
label="opted in"
value={String(status.optedIn)}
ok={status.optedIn}
/>
<Row
label="opted out"
value={String(status.optedOut)}
warn={status.optedOut}
/>
<Row label="distinct_id" value={status.distinctId ?? "(none)"} />
</tbody>
</table>
<button
onClick={onSendPing}
disabled={!status.initialized}
style={{ padding: "6px 12px", fontSize: 13 }}
>
Capture test event
</button>
{manualPing && (
<span style={{ marginLeft: 12, color: "#666", fontSize: 13 }}>
last sent: {manualPing} (only delivered when opted in)
</span>
)}
</div>
);
}
interface RowProps {
label: string;
value: string;
ok?: boolean;
warn?: boolean;
}
function Row({ label, value, ok, warn }: RowProps) {
const color = ok ? "green" : warn ? "tomato" : undefined;
return (
<tr>
<td style={{ padding: "2px 16px 2px 0", color: "#666" }}>{label}</td>
<td style={{ padding: "2px 0", color, fontWeight: ok || warn ? "bold" : "normal" }}>
{value}
</td>
</tr>
);
}

View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly PUBLIC_COOKIE_BANNER_ID?: string;
readonly PUBLIC_COOKIE_BANNER_API_BASE_URL?: string;
readonly PUBLIC_POSTHOG_API_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}