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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user