Files
anouma/components/Analytics.tsx
T
maro 65b6eeac51 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
2026-09-06 21:59:59 +02:00

154 lines
5.9 KiB
TypeScript

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