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,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getConsent, hasCurrentConsentDecision, setAnalyticsConfig, setConsent, track } from "@/lib/analytics/client";
|
||||
|
||||
type CategoryText = { label: string; description: string };
|
||||
|
||||
type AnalyticsProps = {
|
||||
consentVersion: string;
|
||||
preferencesEnabled: boolean;
|
||||
statisticsEnabled: boolean;
|
||||
necessary: CategoryText;
|
||||
preferences: CategoryText;
|
||||
statistics: CategoryText;
|
||||
};
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
|
||||
|
||||
/**
|
||||
* Mounted once in app/(frontend)/layout.tsx. Combines two independent jobs:
|
||||
* (1) the cookie-consent banner, and (2) automatic page_view tracking on
|
||||
* every client-side route change. Both only ever act through track()/
|
||||
* setAnalyticsConfig() from lib/analytics/client.ts — no network call is
|
||||
* possible from this component before a visitor has actually consented.
|
||||
*/
|
||||
export function Analytics({ consentVersion, preferencesEnabled, statisticsEnabled, necessary, preferences, statistics }: AnalyticsProps) {
|
||||
// Plain assignment during render (not an effect): must be set before the
|
||||
// page_view effect below ever fires, on every render including the first.
|
||||
setAnalyticsConfig({ statisticsEnabled, consentVersion });
|
||||
|
||||
const pathname = usePathname();
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [prefChoice, setPrefChoice] = useState(false);
|
||||
const [statChoice, setStatChoice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCurrentConsentDecision()) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShowBanner(true);
|
||||
const existing = getConsent();
|
||||
setPrefChoice(existing?.preferences ?? false);
|
||||
setStatChoice(existing?.statistics ?? false);
|
||||
}
|
||||
}, [consentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
track("page_view", { path: pathname });
|
||||
}, [pathname]);
|
||||
|
||||
function acceptAll() {
|
||||
setConsent({ preferences: preferencesEnabled, statistics: statisticsEnabled }, consentVersion);
|
||||
setShowBanner(false);
|
||||
}
|
||||
|
||||
function acceptNecessaryOnly() {
|
||||
setConsent({ preferences: false, statistics: false }, consentVersion);
|
||||
setShowBanner(false);
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
setConsent({ preferences: preferencesEnabled && prefChoice, statistics: statisticsEnabled && statChoice }, consentVersion);
|
||||
setShowBanner(false);
|
||||
}
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Cookie-Einstellungen"
|
||||
className="fixed inset-x-0 bottom-0 z-[90] border-t border-anouma-taupe/30 bg-anouma-cream-light px-6 py-6 shadow-[0_-4px_24px_rgba(0,0,0,0.08)] sm:px-8"
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-xl font-medium text-anouma-plum">Cookie-Einstellungen</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">
|
||||
Wir nutzen Cookies, um die Website technisch bereitzustellen{statisticsEnabled ? " und, mit deiner Zustimmung, datensparsame Statistiken zu erheben" : ""}. Du
|
||||
kannst deine Auswahl jederzeit ändern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="grid gap-3 rounded-2xl border border-anouma-taupe/30 bg-white/60 p-4 sm:grid-cols-3">
|
||||
<label className="flex items-start gap-2 text-sm text-anouma-plum opacity-70">
|
||||
<input type="checkbox" checked disabled className="mt-1" />
|
||||
<span>
|
||||
<strong className="block">{necessary.label}</strong>
|
||||
{necessary.description}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{preferencesEnabled && (
|
||||
<label className="flex items-start gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefChoice}
|
||||
onChange={(e) => setPrefChoice(e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<strong className="block">{preferences.label}</strong>
|
||||
{preferences.description}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{statisticsEnabled && (
|
||||
<label className="flex items-start gap-2 text-sm text-anouma-plum">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statChoice}
|
||||
onChange={(e) => setStatChoice(e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<strong className="block">{statistics.label}</strong>
|
||||
{statistics.description}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={acceptAll} className={`${buttonBase} bg-anouma-mauve-dark text-white hover:bg-anouma-plum`}>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptNecessaryOnly}
|
||||
className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}
|
||||
>
|
||||
Nur notwendige
|
||||
</button>
|
||||
{!expanded && (
|
||||
<button type="button" onClick={() => setExpanded(true)} className="text-sm text-anouma-plum underline underline-offset-4">
|
||||
Einstellungen anpassen
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<button type="button" onClick={saveSelection} className={`${buttonBase} border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white`}>
|
||||
Auswahl speichern
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user