93 lines
3.5 KiB
TypeScript
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(() => {});
|
|
}
|