Files

93 lines
3.5 KiB
TypeScript

"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(() => {});
}