Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no persistent per-visitor identifiers: events are grouped into sessions via a daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw). - New "Analytics" section in the Payload admin, gated by a canViewAnalytics permission on Users, following the existing custom-admin-view pattern used by the calendar feature - Sessions, page views, unique visitors, top/entry/exit pages, events, device/browser breakdown, date range filter with period comparison - Raw events and daily aggregate rollups as their own collections, with configurable retention and a cron endpoint that rolls up before purging - Cookie consent banner (necessary/preferences/statistics) with categories and copy editable from a new CMS global; a track() helper that is a complete no-op without statistics consent, so nothing is ever sent before a visitor opts in - track() calls wired into the booking flow, contact form, and navigation
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user