From 65b6eeac515ee6951bc4373209f59befcd51f7ea Mon Sep 17 00:00:00 2001 From: maro Date: Sun, 6 Sep 2026 21:59:59 +0200 Subject: [PATCH] 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 --- .env.example | 5 + DEPLOYMENT.md | 2 +- app/(frontend)/layout.tsx | 16 +- app/(payload)/api/analytics/track/route.ts | 97 +++++ .../api/cron/analytics-cleanup/route.ts | 41 ++ collections/AnalyticsDailyStats.ts | 52 +++ collections/AnalyticsEvents.ts | 92 +++++ collections/Users.ts | 16 + components/Analytics.tsx | 153 +++++++ .../admin/AdminAnalyticsView.module.css | 188 +++++++++ components/admin/AdminAnalyticsView.tsx | 196 +++++++++ components/admin/AnalyticsChart.tsx | 35 ++ components/admin/AnalyticsNavLink.tsx | 30 ++ globals/AnalyticsSettings.ts | 45 ++ globals/CookieSettings.ts | 102 +++++ lib/analytics/aggregate.ts | 94 +++++ lib/analytics/cleanup.ts | 109 +++++ lib/analytics/client.ts | 92 +++++ lib/analytics/dashboardQueries.ts | 69 ++++ lib/analytics/events.ts | 26 ++ lib/analytics/range.ts | 71 ++++ lib/analytics/salt.ts | 33 ++ lib/analytics/session.ts | 29 ++ lib/analytics/userAgent.ts | 28 ++ lib/payload/globals.ts | 7 +- package-lock.json | 383 ++++++++++++++++++ package.json | 1 + payload-types.ts | 235 +++++++++++ payload.config.ts | 31 +- setup.sh | 3 + 30 files changed, 2274 insertions(+), 7 deletions(-) create mode 100644 app/(payload)/api/analytics/track/route.ts create mode 100644 app/(payload)/api/cron/analytics-cleanup/route.ts create mode 100644 collections/AnalyticsDailyStats.ts create mode 100644 collections/AnalyticsEvents.ts create mode 100644 components/Analytics.tsx create mode 100644 components/admin/AdminAnalyticsView.module.css create mode 100644 components/admin/AdminAnalyticsView.tsx create mode 100644 components/admin/AnalyticsChart.tsx create mode 100644 components/admin/AnalyticsNavLink.tsx create mode 100644 globals/AnalyticsSettings.ts create mode 100644 globals/CookieSettings.ts create mode 100644 lib/analytics/aggregate.ts create mode 100644 lib/analytics/cleanup.ts create mode 100644 lib/analytics/client.ts create mode 100644 lib/analytics/dashboardQueries.ts create mode 100644 lib/analytics/events.ts create mode 100644 lib/analytics/range.ts create mode 100644 lib/analytics/salt.ts create mode 100644 lib/analytics/session.ts create mode 100644 lib/analytics/userAgent.ts diff --git a/.env.example b/.env.example index 284d22e..8bc44b2 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ PAYLOAD_SECRET=replace-with-a-long-random-secret # Generate with: openssl rand -hex 32 MEETING_SESSION_SECRET=replace-with-a-long-random-secret +# Salts the daily-rotating, non-reversible visitor hash used for Analytics +# session grouping (kept separate from PAYLOAD_SECRET on purpose, see +# lib/analytics/salt.ts). Generate with: openssl rand -hex 32 +ANALYTICS_SALT_SECRET=replace-with-a-long-random-secret + # Sent as "Authorization: Bearer " by the external cron job that # triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32 CRON_SECRET=replace-with-a-long-random-secret diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c5d1cee..8e9eb37 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -150,7 +150,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file | Variable | Purpose | | --- | --- | | `DATABASE_URI`, `POSTGRES_*` | Postgres connection | -| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards | +| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards | | `NEXT_PUBLIC_SERVER_URL` | Public URL of the site | | `SMTP_*` | The existing ANOUMA mail system | | `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers | diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx index 94afb7d..2fbd36a 100644 --- a/app/(frontend)/layout.tsx +++ b/app/(frontend)/layout.tsx @@ -2,10 +2,11 @@ import type { Metadata } from "next"; import { Cormorant_Garamond, Inter } from "next/font/google"; import { Navbar } from "@/components/Navbar"; import { Footer } from "@/components/Footer"; +import { Analytics } from "@/components/Analytics"; import { siteConfig } from "@/lib/site"; import { getOffers } from "@/lib/payload/content"; import { getCurrentCustomer } from "@/lib/auth/customer"; -import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; +import { getBookingSettingsGlobal, getContactGlobal, getCookieSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals"; import { getSiteUrl } from "@/lib/seo/config"; import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld"; import "./globals.css"; @@ -30,7 +31,7 @@ export const metadata: Metadata = { metadataBase: new URL(siteConfig.domain), title: { default: siteConfig.title, - template: `%s — ${siteConfig.name}`, + template: `%s – ${siteConfig.name}`, }, description: siteConfig.description, openGraph: { @@ -51,13 +52,14 @@ export const metadata: Metadata = { }; export default async function RootLayout({ children }: LayoutProps<"/">) { - const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([ + const [offers, customer, seoSettings, bookingSettings, contact, siteUrl, cookieSettings] = await Promise.all([ getOffers(), getCurrentCustomer(), getSEOSettingsGlobal(), getBookingSettingsGlobal(), getContactGlobal(), getSiteUrl(), + getCookieSettingsGlobal(), ]); return ( @@ -84,6 +86,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { {children}