Add cookie-banner React example app

Interactive playground with themed banner, headless components,
and debug tabs demonstrating programmatic consent access via
getConsent() across separate bundles.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-18 19:02:54 +04:00
parent 10e35c6afc
commit 5902f2790c
14 changed files with 1393 additions and 2 deletions

View File

@@ -0,0 +1,85 @@
import { useCallback, useState } from "react";
import { ConfigTab } from "./tabs/ConfigTab";
import { ThemedBannerTab } from "./tabs/ThemedBannerTab";
import { HeadlessTab } from "./tabs/HeadlessTab";
import { DebugTab } from "./tabs/DebugTab";
export interface EventEntry {
time: string;
type: string;
detail: unknown;
}
const tabs = [
{ id: "config", label: "Config" },
{ id: "themed", label: "Themed Banner" },
{ id: "headless", label: "Headless" },
{ id: "debug", label: "Debug" },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function App() {
const [activeTab, setActiveTab] = useState<TabId>("config");
const [themedEvents, setThemedEvents] = useState<EventEntry[]>([]);
const [headlessEvents, setHeadlessEvents] = useState<EventEntry[]>([]);
const pushThemedEvent = useCallback((type: string, detail: unknown) => {
setThemedEvents((prev) => [
{ time: new Date().toISOString(), type, detail },
...prev,
]);
}, []);
const pushHeadlessEvent = useCallback((type: string, detail: unknown) => {
setHeadlessEvents((prev) => [
{ time: new Date().toISOString(), type, detail },
...prev,
]);
}, []);
return (
<div style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900, margin: "0 auto", padding: 24 }}>
<h1 style={{ marginBottom: 4 }}>
@probo/cookie-banner
</h1>
<p style={{ color: "#666", marginTop: 0, marginBottom: 24 }}>
SDK example &mdash; React
</p>
<nav
style={{
display: "flex",
gap: 0,
borderBottom: "2px solid #ddd",
marginBottom: 24,
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: "8px 16px",
border: "none",
borderBottom:
activeTab === tab.id ? "2px solid #333" : "2px solid transparent",
background: "none",
fontWeight: activeTab === tab.id ? "bold" : "normal",
cursor: "pointer",
marginBottom: -2,
fontSize: 14,
}}
>
{tab.label}
</button>
))}
</nav>
{activeTab === "config" && <ConfigTab />}
{activeTab === "themed" && <ThemedBannerTab events={themedEvents} pushEvent={pushThemedEvent} />}
{activeTab === "headless" && <HeadlessTab events={headlessEvents} pushEvent={pushHeadlessEvent} />}
{activeTab === "debug" && <DebugTab />}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import "react";
type CE<T = object> = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & T,
HTMLElement
>;
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"probo-cookie-banner-root": CE<{
"banner-id"?: string;
"base-url"?: string;
lang?: string;
}>;
"probo-banner": CE;
"probo-preference-panel": CE;
"probo-category-list": CE;
"probo-category-toggle": CE;
"probo-cookie-list": CE;
"probo-accept-button": CE;
"probo-reject-button": CE;
"probo-customize-button": CE;
"probo-save-button": CE;
"probo-settings-button": CE<{ position?: string }>;
"probo-cookie-banner": CE<{
"banner-id"?: string;
"base-url"?: string;
position?: string;
"reopen-widget"?: string;
lang?: string;
}>;
}
}
}

View File

@@ -0,0 +1,47 @@
import { useCallback, useSyncExternalStore } from "react";
export interface Config {
bannerId: string;
baseUrl: string;
}
const STORAGE_KEY = "probo-example-config";
const defaultConfig: Config = {
bannerId: "",
baseUrl: "",
};
function getSnapshot(): Config {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as Config;
} catch {
// ignore
}
return defaultConfig;
}
let cached = getSnapshot();
const listeners = new Set<() => void>();
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
function snapshot(): Config {
return cached;
}
function persist(next: Config): void {
cached = next;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
for (const cb of listeners) cb();
}
export function useConfig(): [Config, (next: Config) => void] {
const config = useSyncExternalStore(subscribe, snapshot);
const setConfig = useCallback((next: Config) => persist(next), []);
return [config, setConfig];
}

View File

@@ -0,0 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<App />
);

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useConfig } from "../hooks/useConfig";
export function ConfigTab() {
const [config, setConfig] = useConfig();
const [bannerId, setBannerId] = useState(config.bannerId);
const [baseUrl, setBaseUrl] = useState(config.baseUrl);
const dirty =
bannerId !== config.bannerId || baseUrl !== config.baseUrl;
const save = () => {
setConfig({ bannerId, baseUrl });
};
return (
<div>
<h2>Configuration</h2>
<p style={{ color: "#666", marginBottom: 16 }}>
Set the banner ID and base URL for the cookie banner API. These values
are persisted to localStorage and used by every other tab.
</p>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", marginBottom: 4, fontWeight: "bold" }}>
Banner ID
</label>
<input
type="text"
value={bannerId}
onChange={(e) => setBannerId(e.target.value)}
placeholder="e.g. cm9xkz5ab000208jx1yy99abc"
style={{
width: "100%",
maxWidth: 500,
padding: "6px 8px",
fontFamily: "monospace",
fontSize: 14,
border: "1px solid #ccc",
boxSizing: "border-box",
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: "block", marginBottom: 4, fontWeight: "bold" }}>
Base URL
</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="e.g. https://cookie-banner.getprobo.com/v1/banners/"
style={{
width: "100%",
maxWidth: 500,
padding: "6px 8px",
fontFamily: "monospace",
fontSize: 14,
border: "1px solid #ccc",
boxSizing: "border-box",
}}
/>
</div>
<button
onClick={save}
disabled={!dirty || !bannerId || !baseUrl}
style={{
padding: "8px 16px",
fontWeight: "bold",
cursor: dirty && bannerId && baseUrl ? "pointer" : "not-allowed",
opacity: dirty && bannerId && baseUrl ? 1 : 0.5,
}}
>
Save
</button>
{config.bannerId && config.baseUrl && (
<div style={{ marginTop: 24 }}>
<h3>Current saved config</h3>
<pre
style={{
background: "#f5f5f5",
padding: 12,
border: "1px solid #ddd",
overflow: "auto",
}}
>
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { getConsent } from "@probo/cookie-banner/consent";
import type { ConsentData } from "@probo/cookie-banner/consent";
import { useConfig } from "../hooks/useConfig";
interface ConsentSnapshot {
ready: boolean;
hasResponse: boolean;
data: ConsentData;
}
function readSnapshot(): ConsentSnapshot {
const mgr = getConsent();
return {
ready: mgr.ready,
hasResponse: mgr.hasResponse,
data: mgr.getAll(),
};
}
function readVisitorId(bannerId: string): string | null {
if (!bannerId) return null;
try {
return localStorage.getItem(`probo_consent:${bannerId}:vid`);
} catch {
return null;
}
}
function readCookie(): string | null {
try {
const prefix = "probo_consent=";
const entry = document.cookie
.split("; ")
.find((c) => c.startsWith(prefix));
if (!entry) return null;
return decodeURIComponent(entry.substring(prefix.length));
} catch {
return null;
}
}
export function DebugTab() {
const [config] = useConfig();
const [snapshot, setSnapshot] = useState<ConsentSnapshot>(readSnapshot);
const [visitorId, setVisitorId] = useState<string | null>(() =>
readVisitorId(config.bannerId),
);
const [cookie, setCookie] = useState<string | null>(readCookie);
useEffect(() => {
const mgr = getConsent();
return mgr.subscribe(() => {
setSnapshot(readSnapshot());
setVisitorId(readVisitorId(config.bannerId));
setCookie(readCookie());
});
}, [config.bannerId]);
useEffect(() => {
setVisitorId(readVisitorId(config.bannerId));
setCookie(readCookie());
}, [config.bannerId]);
return (
<div>
<h2>Debug</h2>
<div
style={{
border: "1px solid #ccc",
padding: 12,
background: "#fafafa",
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>getConsent() State</h3>
<pre
style={{
background: "#f0f0f0",
padding: 8,
border: "1px solid #ddd",
overflow: "auto",
margin: "0 0 12px 0",
}}
>
{JSON.stringify(
{ ready: snapshot.ready, hasResponse: snapshot.hasResponse },
null,
2,
)}
</pre>
{Object.keys(snapshot.data).length === 0 ? (
<p style={{ color: "#999", margin: 0 }}>
No consent data yet. Interact with the banner to generate consent.
</p>
) : (
<table
style={{
borderCollapse: "collapse",
fontFamily: "monospace",
fontSize: 14,
}}
>
<thead>
<tr>
<th
style={{
textAlign: "left",
padding: "4px 16px 4px 0",
borderBottom: "1px solid #ccc",
}}
>
Category
</th>
<th
style={{
textAlign: "left",
padding: "4px 0",
borderBottom: "1px solid #ccc",
}}
>
has()
</th>
</tr>
</thead>
<tbody>
{Object.entries(snapshot.data).map(([cat, granted]) => (
<tr key={cat}>
<td style={{ padding: "4px 16px 4px 0" }}>{cat}</td>
<td
style={{
padding: "4px 0",
color: granted ? "green" : "red",
fontWeight: "bold",
}}
>
{String(granted)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div
style={{
border: "1px solid #ccc",
padding: 12,
background: "#fafafa",
marginBottom: 16,
}}
>
<h3 style={{ marginTop: 0 }}>Storage</h3>
<div style={{ marginBottom: 12 }}>
<strong>Visitor ID</strong>{" "}
<span style={{ fontFamily: "monospace", fontSize: 13, color: "#666" }}>
(localStorage: probo_consent:{config.bannerId || "?"}:vid)
</span>
<pre
style={{
background: "#f0f0f0",
padding: 8,
border: "1px solid #ddd",
overflow: "auto",
margin: "4px 0 0 0",
}}
>
{visitorId ?? "(not set)"}
</pre>
</div>
<div>
<strong>probo_consent cookie</strong>
<pre
style={{
background: "#f0f0f0",
padding: 8,
border: "1px solid #ddd",
overflow: "auto",
margin: "4px 0 0 0",
}}
>
{cookie
? JSON.stringify(JSON.parse(cookie), null, 2)
: "(not set)"}
</pre>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { useEffect, useRef } from "react";
import { registerHeadlessComponents } from "@probo/cookie-banner/headless";
import { useConfig } from "../hooks/useConfig";
import type { EventEntry } from "../App";
let registered = false;
interface HeadlessTabProps {
events: EventEntry[];
pushEvent: (type: string, detail: unknown) => void;
}
export function HeadlessTab({ events, pushEvent }: HeadlessTabProps) {
const [config] = useConfig();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!registered) {
registerHeadlessComponents();
registered = true;
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container || !config.bannerId || !config.baseUrl) return;
container.innerHTML = "";
container.innerHTML = `
<style>probo-banner, probo-preference-panel { display: block !important; }</style>
<probo-cookie-banner-root banner-id="${config.bannerId}" base-url="${config.baseUrl}">
<probo-banner>
<div style="border:2px solid #333;padding:12px;margin-bottom:8px;">
<strong>[probo-banner]</strong>
<div style="margin-top:8px;">
<probo-accept-button><button>Accept All</button></probo-accept-button>
<probo-reject-button><button style="margin-left:8px;">Reject All</button></probo-reject-button>
<probo-customize-button><button style="margin-left:8px;">Customize</button></probo-customize-button>
</div>
</div>
</probo-banner>
<probo-preference-panel>
<div style="border:2px dashed #666;padding:12px;margin-bottom:8px;">
<strong>[probo-preference-panel]</strong>
<probo-category-list>
<template>
<div style="border:1px solid #aaa;padding:8px;margin:4px 0;">
<span data-slot="name" style="font-weight:bold;"></span>:
<span data-slot="description"></span>
<probo-category-toggle>
<label style="margin-left:8px;"><input type="checkbox" /> toggle</label>
</probo-category-toggle>
<probo-cookie-list hidden>
<template>
<div style="padding:4px 0 4px 16px;font-size:13px;">
<span data-slot="name" style="font-weight:bold;"></span>
&mdash; <span data-slot="description"></span>
</div>
</template>
</probo-cookie-list>
</div>
</template>
</probo-category-list>
<div style="margin-top:8px;">
<probo-accept-button><button>Accept All</button></probo-accept-button>
<probo-reject-button><button style="margin-left:8px;">Reject All</button></probo-reject-button>
<probo-save-button><button style="margin-left:8px;">Save Preferences</button></probo-save-button>
</div>
</div>
</probo-preference-panel>
</probo-cookie-banner-root>
`;
const root = container.querySelector("probo-cookie-banner-root");
if (root) {
root.addEventListener("probo-ready", (e: Event) =>
pushEvent("probo-ready", (e as CustomEvent).detail),
);
root.addEventListener("probo-consent", (e: Event) =>
pushEvent("probo-consent", (e as CustomEvent).detail),
);
}
return () => {
container.innerHTML = "";
};
}, [config.bannerId, config.baseUrl, pushEvent]);
if (!config.bannerId || !config.baseUrl) {
return (
<div>
<h2>Headless Components</h2>
<p style={{ color: "tomato" }}>
Set banner ID and base URL in the Config tab first.
</p>
</div>
);
}
return (
<div>
<h2>Headless Components</h2>
<p style={{ color: "#666", marginBottom: 16 }}>
Uses <code>registerHeadlessComponents()</code> and renders raw headless
elements with no themed styling. Borders show element boundaries.
</p>
<div ref={containerRef} />
<h3>Events ({events.length})</h3>
{events.length === 0 ? (
<p style={{ color: "#999" }}>No events yet.</p>
) : (
events.map((ev, i) => (
<div
key={i}
style={{
marginBottom: 8,
border: "1px solid #ddd",
padding: 8,
background: "#f5f5f5",
}}
>
<div style={{ fontWeight: "bold", marginBottom: 4 }}>
{ev.type}{" "}
<span style={{ fontWeight: "normal", color: "#999" }}>
{ev.time}
</span>
</div>
<pre style={{ margin: 0, overflow: "auto" }}>
{JSON.stringify(ev.detail, null, 2)}
</pre>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } from "react";
import { registerCookieBanner } from "@probo/cookie-banner";
import { useConfig } from "../hooks/useConfig";
import type { EventEntry } from "../App";
let registered = false;
interface ThemedBannerTabProps {
events: EventEntry[];
pushEvent: (type: string, detail: unknown) => void;
}
export function ThemedBannerTab({ events, pushEvent }: ThemedBannerTabProps) {
const [config] = useConfig();
const elRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!registered) {
registerCookieBanner();
registered = true;
}
}, []);
const attachListeners = useCallback(
(el: HTMLElement | null) => {
elRef.current = el;
if (!el) return;
el.addEventListener("probo-ready", (e: Event) =>
pushEvent("probo-ready", (e as CustomEvent).detail),
);
el.addEventListener("probo-consent", (e: Event) =>
pushEvent("probo-consent", (e as CustomEvent).detail),
);
},
[pushEvent],
);
if (!config.bannerId || !config.baseUrl) {
return (
<div>
<h2>Themed Banner</h2>
<p style={{ color: "tomato" }}>
Set banner ID and base URL in the Config tab first.
</p>
</div>
);
}
return (
<div>
<h2>Themed Banner</h2>
<p style={{ color: "#666", marginBottom: 16 }}>
Uses <code>registerCookieBanner()</code> and renders{" "}
<code>&lt;probo-cookie-banner&gt;</code>. The banner appears in the
bottom-right corner.
</p>
<probo-cookie-banner
ref={attachListeners}
banner-id={config.bannerId}
base-url={config.baseUrl}
position="bottom-right"
/>
<h3>Events ({events.length})</h3>
{events.length === 0 ? (
<p style={{ color: "#999" }}>No events yet.</p>
) : (
events.map((ev, i) => (
<div
key={i}
style={{
marginBottom: 8,
border: "1px solid #ddd",
padding: 8,
background: "#f5f5f5",
}}
>
<div style={{ fontWeight: "bold", marginBottom: 4 }}>
{ev.type}{" "}
<span style={{ fontWeight: "normal", color: "#999" }}>
{ev.time}
</span>
</div>
<pre style={{ margin: 0, overflow: "auto" }}>
{JSON.stringify(ev.detail, null, 2)}
</pre>
</div>
))
)}
</div>
);
}