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
9 changes: 8 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import "react-reflex/styles.css";
import "typeface-roboto-mono/index.css"; // Import the Roboto Mono font.

import { ThemeProvider } from "@/components/ThemeProvider";
import { isEUVisitor, shouldOptOutCapturing } from "@/lib/consent";

import "./App.css";
import { EmbeddedPlayground } from "./components/EmbeddedPlayground";
Expand Down Expand Up @@ -60,10 +61,16 @@ const config = AppConfig();
function PHProvider({ children }: PropsWithChildren) {
useEffect(() => {
if (config.posthog.apiKey && config.posthog.host) {
const optOut = shouldOptOutCapturing(isEUVisitor());

posthog.init(config.posthog.apiKey, {
api_host: config.posthog.host,
person_profiles: "identified_only",
defaults: "2025-11-30", // Enables automatic SPA pageview tracking via history API
cross_subdomain_cookie: true,
defaults: "2025-11-30",
opt_out_capturing_by_default: optOut,
opt_out_persistence_by_default: optOut,
cookieless_mode: "on_reject",
});
}
}, []);
Expand Down
19 changes: 19 additions & 0 deletions src/lib/consent/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Debug cookie names used by ConsentProvider to override detection. */
export const DEBUG_EU_COOKIE = "__consent_debug_eu";
export const DEBUG_GPC_COOKIE = "__consent_debug_gpc";

/**
* Read a debug override cookie by name. Returns null if not set or
* if running in production (Vercel production environment).
*/
export function getDebugCookie(name: string): string | null {
if (typeof document === "undefined") return null;
if (typeof process !== "undefined") {
const env =
(process.env as Record<string, string | undefined>).VERCEL_ENV ??
(process.env as Record<string, string | undefined>).NEXT_PUBLIC_VERCEL_ENV;
if (env === "production") return null;
}
const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`));
return match ? match.split("=")[1] : null;
}
70 changes: 70 additions & 0 deletions src/lib/consent/eu-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { DEBUG_EU_COOKIE, getDebugCookie } from "./debug";

const EU_TIMEZONES = new Set([
// EU member states
"Europe/Vienna",
"Europe/Brussels",
"Europe/Sofia",
"Europe/Zagreb",
"Asia/Famagusta",
"Asia/Nicosia",
"Europe/Prague",
"Europe/Copenhagen",
"Europe/Tallinn",
"Europe/Helsinki",
"Europe/Paris",
"Europe/Berlin",
"Europe/Busingen",
"Europe/Athens",
"Europe/Budapest",
"Europe/Dublin",
"Europe/Rome",
"Europe/Riga",
"Europe/Vilnius",
"Europe/Luxembourg",
"Europe/Malta",
"Europe/Amsterdam",
"Europe/Warsaw",
"Europe/Lisbon",
"Atlantic/Azores",
"Atlantic/Madeira",
"Europe/Bucharest",
"Europe/Bratislava",
"Europe/Ljubljana",
"Europe/Madrid",
"Africa/Ceuta",
"Atlantic/Canary",
"Europe/Stockholm",
// EEA (non-EU)
"Europe/Oslo",
"Arctic/Longyearbyen",
"Atlantic/Reykjavik",
"Europe/Vaduz",
// UK (still applies GDPR-equivalent)
"Europe/London",
"Europe/Belfast",
"Europe/Guernsey",
"Europe/Isle_of_Man",
"Europe/Jersey",
]);

let cached: boolean | undefined;

export function isEUVisitor(): boolean {
if (typeof window === "undefined") return true;
if (cached !== undefined) return cached;

const debugOverride = getDebugCookie(DEBUG_EU_COOKIE);
if (debugOverride !== null) {
cached = debugOverride === "true";
return cached;
}

try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
cached = EU_TIMEZONES.has(tz);
} catch {
cached = true;
}
return cached;
}
19 changes: 19 additions & 0 deletions src/lib/consent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @authzed/consent core (read-only consumer)
*
* Copied from authzed/web src/consent/core/.
* Portable consent utilities with zero external dependencies.
*
* This is a read-only consumer: it reads the `az-consent` cookie set by
* the marketing site (authzed.com) but does not write it or show a banner.
* Consent decisions are made on authzed.com and shared via the cookie.
*/
export {
readConsentCookie,
parseConsentCookie,
consentedIdentify,
shouldOptOutCapturing,
CONSENT_COOKIE_NAME,
} from "./storage";
export { isEUVisitor } from "./eu-detection";
export type { ConsentPreferences } from "./types";
55 changes: 55 additions & 0 deletions src/lib/consent/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ConsentPreferences } from "./types";

const COOKIE_NAME = "az-consent";
const COOKIE_VERSION = 1;

export function readConsentCookie(): ConsentPreferences | null {
if (typeof document === "undefined") return null;

const raw = getCookieValue(COOKIE_NAME);
if (!raw) return null;

try {
const parsed = JSON.parse(raw);
if (parsed.version === COOKIE_VERSION) return parsed as ConsentPreferences;
} catch {
// invalid cookie
}
return null;
}

export function parseConsentCookie(rawValue: string | undefined | null): ConsentPreferences | null {
if (!rawValue) return null;
try {
const parsed = JSON.parse(decodeURIComponent(rawValue));
if (parsed.version === COOKIE_VERSION) return parsed as ConsentPreferences;
} catch {
// invalid cookie
}
return null;
}

export const CONSENT_COOKIE_NAME = COOKIE_NAME;

export function consentedIdentify(
ph: { identify: (id: string, props?: Record<string, unknown>) => void },
distinctId: string,
properties?: Record<string, unknown>,
): void {
const consent = readConsentCookie();
if (consent?.statistics) {
ph.identify(distinctId, properties);
}
}

export function shouldOptOutCapturing(isEU: boolean): boolean {
const consent = readConsentCookie();
if (consent !== null) return !consent.statistics;
return isEU;
}

function getCookieValue(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`));
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
}
8 changes: 8 additions & 0 deletions src/lib/consent/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface ConsentPreferences {
version: 1;
necessary: true;
preferences: boolean;
statistics: boolean;
marketing: boolean;
updatedAt: string; // ISO-8601
}
Loading