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
154 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|