"use client"; /** * Public developer API + consent storage for the analytics system. Importing * this module has zero side effects — no network call, nothing initialized * — until `track()` is actually called, and even then it's a no-op unless * consent was actually granted. Callers never check consent themselves: * * track("booking_started"); * * The CMS-configured part of consent (whether the "statistics" category is * enabled at all, and the current consent version) is pushed in once by * components/Analytics.tsx via setAnalyticsConfig() — see that file. Until * that happens, `cmsConfig` is null and track() fails safe (no-op), so a * component whose effects happen to run before Analytics.tsx's can never * accidentally send data before the live config is known. */ const CONSENT_COOKIE = "anouma-consent"; export type ConsentState = { necessary: true; preferences: boolean; statistics: boolean; version: string; decidedAt: string; }; type CmsConfig = { statisticsEnabled: boolean; consentVersion: string }; let cmsConfig: CmsConfig | null = null; export function setAnalyticsConfig(config: CmsConfig) { cmsConfig = config; } export function getConsent(): ConsentState | null { if (typeof document === "undefined") return null; const match = document.cookie.match(/(?:^|;\s*)anouma-consent=([^;]*)/); if (!match) return null; try { const parsed = JSON.parse(decodeURIComponent(match[1])); if (parsed && typeof parsed === "object" && typeof parsed.version === "string") return parsed as ConsentState; return null; } catch { return null; } } export function setConsent(choice: { preferences: boolean; statistics: boolean }, version: string): ConsentState { const state: ConsentState = { necessary: true, preferences: choice.preferences, statistics: choice.statistics, version, decidedAt: new Date().toISOString(), }; const maxAgeSeconds = 60 * 60 * 24 * 180; // 180 days const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : ""; document.cookie = `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(state))}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax${secure}`; return state; } /** True once the visitor has made a decision that still matches the current CMS consent version. */ export function hasCurrentConsentDecision(): boolean { if (!cmsConfig) return false; const consent = getConsent(); return Boolean(consent && consent.version === cmsConfig.consentVersion); } function statisticsAllowed(): boolean { if (!cmsConfig || !cmsConfig.statisticsEnabled) return false; const consent = getConsent(); return Boolean(consent && consent.version === cmsConfig.consentVersion && consent.statistics === true); } export function track(eventName: string, extra?: { path?: string }): void { if (typeof window === "undefined") return; if (!statisticsAllowed()) return; const path = extra?.path ?? window.location.pathname; const body = JSON.stringify({ eventName, path, referrer: document.referrer || undefined }); // navigator.sendBeacon can't reliably set Content-Type: application/json // across browsers, so the server route also accepts a plain-text body. if (typeof navigator !== "undefined" && navigator.sendBeacon) { navigator.sendBeacon("/api/analytics/track", new Blob([body], { type: "text/plain" })); return; } fetch("/api/analytics/track", { method: "POST", body, keepalive: true, headers: { "Content-Type": "text/plain" } }).catch(() => {}); }