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:
@@ -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 <value>" 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
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
</main>
|
||||
<Footer />
|
||||
<Analytics
|
||||
consentVersion={cookieSettings.consentVersion}
|
||||
preferencesEnabled={cookieSettings.preferences?.enabled !== false}
|
||||
statisticsEnabled={cookieSettings.statistics?.enabled !== false}
|
||||
necessary={{ label: cookieSettings.necessary?.label ?? "Notwendig", description: cookieSettings.necessary?.description ?? "" }}
|
||||
preferences={{ label: cookieSettings.preferences?.label ?? "Präferenzen", description: cookieSettings.preferences?.description ?? "" }}
|
||||
statistics={{ label: cookieSettings.statistics?.label ?? "Statistik", description: cookieSettings.statistics?.description ?? "" }}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||
import { visitorHash } from "@/lib/analytics/salt";
|
||||
import { classifyBrowser, classifyDevice } from "@/lib/analytics/userAgent";
|
||||
import { resolveSessionId } from "@/lib/analytics/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MAX_PATH_LENGTH = 512;
|
||||
const MAX_EVENT_NAME_LENGTH = 100;
|
||||
|
||||
function normalizePath(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.length > MAX_PATH_LENGTH) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEventName(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_EVENT_NAME_LENGTH) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
|
||||
function referrerHostFrom(referrer: unknown): string | undefined {
|
||||
if (typeof referrer !== "string" || !referrer) return undefined;
|
||||
try {
|
||||
return new URL(referrer).hostname || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public tracking beacon, called by lib/analytics/client.ts only after the
|
||||
* visitor has granted statistics consent — but the client's own claim is
|
||||
* never trusted alone: this route re-checks the CookieSettings global's
|
||||
* `statistics.enabled` flag server-side before writing anything, so a
|
||||
* global kill-switch (or a stale/tampered client) can never bypass it.
|
||||
*
|
||||
* navigator.sendBeacon() can't reliably set a JSON Content-Type across
|
||||
* browsers, so this accepts both application/json and a raw text body.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const ip = getClientIp(request);
|
||||
if (!checkRateLimit(`analytics:${ip}`, { max: 120, windowMs: 60_000 })) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
let body: { eventName?: unknown; path?: unknown; referrer?: unknown };
|
||||
try {
|
||||
const raw = await request.text();
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const path = normalizePath(body.path);
|
||||
const eventName = normalizeEventName(body.eventName);
|
||||
if (!path || !eventName) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
|
||||
const cookieSettings = await payload.findGlobal({ slug: "cookie-settings" });
|
||||
if (cookieSettings.statistics?.enabled === false) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") ?? "";
|
||||
const host = request.headers.get("host") ?? "";
|
||||
const hash = visitorHash({ ip, userAgent, host });
|
||||
const sessionId = await resolveSessionId(payload, hash);
|
||||
|
||||
await payload.create({
|
||||
collection: "analytics-events",
|
||||
data: {
|
||||
sessionId,
|
||||
visitorHash: hash,
|
||||
path,
|
||||
eventName,
|
||||
referrerHost: referrerHostFrom(body.referrer),
|
||||
device: classifyDevice(userAgent),
|
||||
browser: classifyBrowser(userAgent),
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
console.error("analytics track failed", error);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { runAnalyticsCleanup } from "@/lib/analytics/cleanup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intended to be called by an external cron job once daily, e.g.:
|
||||
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
|
||||
* (a second, separate cron entry from /api/cron/event-reminders — reuses
|
||||
* the same CRON_SECRET, no new secret needed)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "CRON_SECRET is not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config });
|
||||
const result = await runAnalyticsCleanup(payload);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
console.error("analytics-cleanup cron failed", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
/**
|
||||
* One row per calendar day, upserted by the cleanup cron
|
||||
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
|
||||
* age out. This is what lets aggregated history survive well beyond the raw
|
||||
* retention window (default 90 days raw vs. 12 months aggregated — see
|
||||
* globals/AnalyticsSettings.ts) without keeping individual events around.
|
||||
*
|
||||
* Not currently read by the main Analytics dashboard filters (those all
|
||||
* stay within raw retention by design — see the implementation plan); this
|
||||
* exists so long-term aggregated history isn't silently lost once raw
|
||||
* events are purged, and is available for a future longer-range view.
|
||||
*/
|
||||
export const AnalyticsDailyStats: CollectionConfig = {
|
||||
slug: "analytics-daily-stats",
|
||||
labels: {
|
||||
singular: "Analytics-Tagesstatistik",
|
||||
plural: "Analytics-Tagesstatistiken",
|
||||
},
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: canViewAnalytics,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
timestamps: true,
|
||||
fields: [
|
||||
{
|
||||
name: "date",
|
||||
type: "date",
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { date: { pickerAppearance: "dayOnly" } },
|
||||
},
|
||||
{ name: "sessions", type: "number", required: true, defaultValue: 0 },
|
||||
{ name: "pageViews", type: "number", required: true, defaultValue: 0 },
|
||||
{ name: "uniqueVisitors", type: "number", required: true, defaultValue: 0 },
|
||||
// Each a { [key: string]: number } map, e.g. eventCounts.booking_started.
|
||||
{ name: "eventCounts", type: "json" },
|
||||
{ name: "deviceCounts", type: "json" },
|
||||
{ name: "browserCounts", type: "json" },
|
||||
{ name: "topPaths", type: "json" },
|
||||
{ name: "entryPaths", type: "json" },
|
||||
{ name: "exitPaths", type: "json" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
/**
|
||||
* One row per tracked event (page_view, booking_started, ...). Deliberately
|
||||
* append-only and never writable via the public/admin API — the only writer
|
||||
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
|
||||
* Local API (overrideAccess: true by default) to insert rows despite
|
||||
* `create` being locked to `() => false` here. The only deleter is the
|
||||
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
|
||||
*
|
||||
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
|
||||
* `visitorHash` is derived without persisting anything identifying.
|
||||
*/
|
||||
export const AnalyticsEvents: CollectionConfig = {
|
||||
slug: "analytics-events",
|
||||
labels: {
|
||||
singular: "Analytics-Event",
|
||||
plural: "Analytics-Events",
|
||||
},
|
||||
admin: {
|
||||
// High-volume, low-signal raw data — not meant for manual browsing.
|
||||
// The Analytics dashboard (a custom admin view) reads it programmatically.
|
||||
hidden: true,
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: canViewAnalytics,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
timestamps: true,
|
||||
indexes: [{ fields: ["visitorHash", "createdAt"] }, { fields: ["createdAt"] }],
|
||||
fields: [
|
||||
{
|
||||
name: "sessionId",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "visitorHash",
|
||||
type: "text",
|
||||
required: true,
|
||||
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
|
||||
// a raw IP/UA, never stable across days, never exposed to the client.
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "eventName",
|
||||
type: "text",
|
||||
required: true,
|
||||
index: true,
|
||||
// Deliberately free-text, not a select/enum: new event names (see
|
||||
// lib/analytics/events.ts) never require a schema migration.
|
||||
defaultValue: "page_view",
|
||||
},
|
||||
{
|
||||
name: "referrerHost",
|
||||
type: "text",
|
||||
// Hostname only (never a full URL/query string). document.referrer is
|
||||
// fixed for a tab's whole lifetime (it doesn't change on client-side
|
||||
// route transitions), so this naturally reflects how the visitor
|
||||
// originally arrived, sent unchanged with every event in the session.
|
||||
},
|
||||
{
|
||||
name: "device",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Mobile", value: "mobile" },
|
||||
{ label: "Desktop", value: "desktop" },
|
||||
{ label: "Tablet", value: "tablet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "browser",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Chrome", value: "chrome" },
|
||||
{ label: "Firefox", value: "firefox" },
|
||||
{ label: "Safari", value: "safari" },
|
||||
{ label: "Edge", value: "edge" },
|
||||
{ label: "Sonstige", value: "other" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -45,6 +45,22 @@ export const Users: CollectionConfig = {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canViewAnalytics",
|
||||
type: "checkbox",
|
||||
label: "Analytics sichtbar",
|
||||
defaultValue: true,
|
||||
// Admins always have access regardless of this flag (see
|
||||
// access/index.ts's canViewAnalytics) — this only matters once a
|
||||
// non-admin role exists, letting an admin grant/revoke Analytics
|
||||
// visibility per account without changing anyone's role.
|
||||
access: {
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
admin: {
|
||||
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "calendarFeedTokenHash",
|
||||
type: "text",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
.page {
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.filterBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.rangeLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e2e2;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rangeLink:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.rangeLinkActive {
|
||||
background: #8b616d;
|
||||
border-color: #8b616d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.customForm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.customForm input[type="date"] {
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.customForm button {
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compareToggle {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
.compareToggle a {
|
||||
color: #8b616d;
|
||||
text-decoration: none;
|
||||
}
|
||||
.compareToggle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.tileValue {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tileDelta {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.deltaUp {
|
||||
color: #3a4f30;
|
||||
}
|
||||
.deltaDown {
|
||||
color: #7a2020;
|
||||
}
|
||||
.deltaFlat {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.chartCard {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.tableGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #888;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
.table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tableEmpty {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.privacyPanel {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
background: #f7f4f1;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
}
|
||||
.privacyPanel h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
.privacyPanel ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.privacyPanel p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { AdminViewServerProps } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
import { RANGE_OPTIONS, resolveRange } from "@/lib/analytics/range";
|
||||
import { getDashboardComparison, percentDelta, type DashboardStats } from "@/lib/analytics/dashboardQueries";
|
||||
import { topEntries } from "@/lib/analytics/aggregate";
|
||||
import { analyticsEventLabel } from "@/lib/analytics/events";
|
||||
import { AnalyticsChart } from "./AnalyticsChart";
|
||||
import styles from "./AdminAnalyticsView.module.css";
|
||||
|
||||
const DEVICE_LABELS: Record<string, string> = { mobile: "Mobile", desktop: "Desktop", tablet: "Tablet" };
|
||||
const BROWSER_LABELS: Record<string, string> = { chrome: "Chrome", firefox: "Firefox", safari: "Safari", edge: "Edge", other: "Sonstige" };
|
||||
|
||||
function fmtInt(n: number): string {
|
||||
return n.toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
function DeltaBadge({ delta }: { delta: number | null }) {
|
||||
if (delta === null) return null;
|
||||
const cls = delta > 0 ? styles.deltaUp : delta < 0 ? styles.deltaDown : styles.deltaFlat;
|
||||
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
|
||||
return (
|
||||
<p className={`${styles.tileDelta} ${cls}`}>
|
||||
{arrow} {Math.abs(delta).toLocaleString("de-DE")}%
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, delta }: { label: string; value: number; delta: number | null | undefined }) {
|
||||
return (
|
||||
<div className={styles.tile}>
|
||||
<p className={styles.tileLabel}>{label}</p>
|
||||
<p className={styles.tileValue}>{fmtInt(value)}</p>
|
||||
{delta !== undefined && <DeltaBadge delta={delta} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryTable({ title, entries }: { title: string; entries: [string, number][] }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
{entries.length === 0 ? (
|
||||
<p className={styles.tableEmpty}>Keine Daten in diesem Zeitraum.</p>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<tbody>
|
||||
{entries.map(([key, count]) => (
|
||||
<tr key={key}>
|
||||
<td>{key}</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtInt(count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildHref(base: URLSearchParams, overrides: Record<string, string | null>): string {
|
||||
const params = new URLSearchParams(base);
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === null) params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "?";
|
||||
}
|
||||
|
||||
export async function AdminAnalyticsView({ initPageResult }: AdminViewServerProps) {
|
||||
const req = initPageResult.req;
|
||||
|
||||
if (!canViewAnalytics({ req })) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>Analytics</h1>
|
||||
<p className={styles.subtitle}>Du hast keine Berechtigung, diesen Bereich zu sehen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await req.payload.findGlobal({ slug: "analytics-settings" });
|
||||
const maxDaysBack = settings.rawRetentionDays ?? 90;
|
||||
const searchParams = req.searchParams;
|
||||
const compareEnabled = searchParams.get("compare") === "1";
|
||||
const { key: activeRange, range, compareRange } = resolveRange(searchParams, maxDaysBack);
|
||||
|
||||
const { current, previous }: { current: DashboardStats; previous: DashboardStats } = await getDashboardComparison(
|
||||
req.payload,
|
||||
range,
|
||||
compareRange,
|
||||
);
|
||||
|
||||
const delta = (metric: keyof Pick<DashboardStats, "sessions" | "pageViews" | "uniqueVisitors" | "avgPagesPerSession">) =>
|
||||
compareEnabled ? percentDelta(current[metric], previous[metric]) : undefined;
|
||||
|
||||
const eventDelta = (name: string) =>
|
||||
compareEnabled ? percentDelta(current.eventCounts[name] ?? 0, previous.eventCounts[name] ?? 0) : undefined;
|
||||
|
||||
const fromParam = range.from.toISOString().slice(0, 10);
|
||||
const toParam = range.to.toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>Analytics</h1>
|
||||
<p className={styles.subtitle}>Datensparsame Reichweitenmessung — keine personenbezogenen Profile. Rohdaten werden {maxDaysBack} Tage aufbewahrt.</p>
|
||||
|
||||
<div className={styles.filterBar}>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<a
|
||||
key={option.key}
|
||||
href={buildHref(searchParams, { range: option.key, from: null, to: null })}
|
||||
className={`${styles.rangeLink} ${activeRange === option.key ? styles.rangeLinkActive : ""}`}
|
||||
>
|
||||
{option.label}
|
||||
</a>
|
||||
))}
|
||||
<form method="get" className={styles.customForm}>
|
||||
<input type="hidden" name="range" value="custom" />
|
||||
{compareEnabled && <input type="hidden" name="compare" value="1" />}
|
||||
<input type="date" name="from" defaultValue={fromParam} max={toParam} />
|
||||
<span>bis</span>
|
||||
<input type="date" name="to" defaultValue={toParam} max={new Date().toISOString().slice(0, 10)} />
|
||||
<button type="submit">Anwenden</button>
|
||||
</form>
|
||||
<div className={styles.compareToggle}>
|
||||
<a href={buildHref(searchParams, { compare: compareEnabled ? null : "1" })}>
|
||||
{compareEnabled ? "Vergleich ausblenden" : "Mit Vorperiode vergleichen"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{RANGE_OPTIONS.some((o) => typeof o.days === "number" && o.days > maxDaysBack) && (
|
||||
<p style={{ color: "#888", fontSize: 12, marginTop: -16, marginBottom: 20 }}>
|
||||
Zeiträume sind auf die konfigurierte Rohdaten-Aufbewahrung von {maxDaysBack} Tagen begrenzt (siehe „Analytics-Einstellungen“).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.tiles}>
|
||||
<Tile label="Sessions" value={current.sessions} delta={delta("sessions")} />
|
||||
<Tile label="Seitenaufrufe" value={current.pageViews} delta={delta("pageViews")} />
|
||||
<Tile label="Eindeutige Besucher" value={current.uniqueVisitors} delta={delta("uniqueVisitors")} />
|
||||
<Tile label="Ø Seiten / Session" value={current.avgPagesPerSession} delta={delta("avgPagesPerSession")} />
|
||||
<Tile label="Buchungsstarts" value={current.eventCounts.booking_started ?? 0} delta={eventDelta("booking_started")} />
|
||||
<Tile label="Buchungen abgeschlossen" value={current.eventCounts.booking_completed ?? 0} delta={eventDelta("booking_completed")} />
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<h2 className={styles.sectionTitle}>Seitenaufrufe & Sessions</h2>
|
||||
<AnalyticsChart series={current.series} />
|
||||
</div>
|
||||
|
||||
<div className={styles.tableGrid}>
|
||||
<EntryTable title="Beliebteste Seiten" entries={topEntries(current.topPaths)} />
|
||||
<EntryTable title="Einstiegsseiten" entries={topEntries(current.entryPaths)} />
|
||||
<EntryTable title="Ausstiegsseiten" entries={topEntries(current.exitPaths)} />
|
||||
<EntryTable
|
||||
title="Events"
|
||||
entries={topEntries(current.eventCounts).map(([name, count]) => [analyticsEventLabel(name), count])}
|
||||
/>
|
||||
<EntryTable
|
||||
title="Geräte"
|
||||
entries={topEntries(current.deviceCounts).map(([key, count]) => [DEVICE_LABELS[key] ?? key, count])}
|
||||
/>
|
||||
<EntryTable
|
||||
title="Browser"
|
||||
entries={topEntries(current.browserCounts).map(([key, count]) => [BROWSER_LABELS[key] ?? key, count])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.privacyPanel}>
|
||||
<h3>Analytics & Datenschutz</h3>
|
||||
<p>Erhobene Daten:</p>
|
||||
<ul>
|
||||
<li>Seitenaufrufe und Events (siehe „Events“-Tabelle)</li>
|
||||
<li>Gerätetyp (Mobile/Desktop/Tablet)</li>
|
||||
<li>Browser-Familie</li>
|
||||
<li>Referrer-Hostname (nur die Domain, keine vollständige URL)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Keine Namen, E-Mail-Adressen, Telefonnummern, vollständigen IP-Adressen oder Formularinhalte. Kein Fingerprinting, keine
|
||||
dauerhaften individuellen Nutzerprofile — Besucher:innen werden über einen täglich rotierenden, nicht zurückverfolgbaren Hash
|
||||
gruppiert.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Aufbewahrung:</strong> {settings.rawRetentionDays} Tage für Rohdaten, {settings.aggregateRetentionMonths} Monate für
|
||||
aggregierte Tagesstatistiken (konfigurierbar unter „Analytics-Einstellungen“).
|
||||
</p>
|
||||
<p style={{ fontStyle: "italic", color: "#777" }}>
|
||||
Rechtliche Einordnung (z. B. ob dies für eure Website ausreichend ist) ist keine technische Aussage dieses Panels — bitte im
|
||||
Zweifel rechtlich prüfen lassen, wie auch beim Impressum/Datenschutztext der Website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import type { DailyPoint } from "@/lib/analytics/dashboardQueries";
|
||||
|
||||
const ACCENT = "#8b616d"; // matches the existing admin CSS modules' accent color
|
||||
const ACCENT_MUTED = "#c9a8ae";
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
export function AnalyticsChart({ series }: { series: DailyPoint[] }) {
|
||||
if (series.length === 0) {
|
||||
return <p style={{ color: "#888", fontSize: 14 }}>Noch keine Daten in diesem Zeitraum.</p>;
|
||||
}
|
||||
|
||||
const data = series.map((p) => ({ ...p, label: fmtDay(p.date) }));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="#eee" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12, fill: "#888" }} axisLine={{ stroke: "#e2e2e2" }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: "#888" }} axisLine={false} tickLine={false} allowDecimals={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 8, border: "1px solid #e2e2e2", fontSize: 13 }}
|
||||
labelFormatter={(label) => `Tag: ${label}`}
|
||||
/>
|
||||
<Line type="monotone" dataKey="pageViews" name="Seitenaufrufe" stroke={ACCENT} strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="sessions" name="Sessions" stroke={ACCENT_MUTED} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import type { ServerProps } from "payload";
|
||||
import { canViewAnalytics } from "@/access";
|
||||
|
||||
export function AnalyticsNavLink({ user }: ServerProps) {
|
||||
// Payload's custom views/nav links have no declarative `access` option —
|
||||
// this component must gate itself (see also AdminAnalyticsView.tsx, which
|
||||
// re-checks the same thing for direct navigation to /admin/analytics).
|
||||
if (!canViewAnalytics({ req: { user } } as Parameters<typeof canViewAnalytics>[0])) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 8px 8px" }}>
|
||||
<Link
|
||||
href="/admin/analytics"
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#8b616d",
|
||||
textDecoration: "none",
|
||||
border: "1px solid #e2e2e2",
|
||||
}}
|
||||
>
|
||||
📊 Analytics
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { canViewAnalytics, isAdmin } from "@/access";
|
||||
|
||||
/**
|
||||
* Retention configuration for the analytics system, enforced by the nightly
|
||||
* cleanup cron (lib/analytics/cleanup.ts) and shown in the "Analytics &
|
||||
* Datenschutz" panel of the dashboard (components/admin/AdminAnalyticsView.tsx).
|
||||
*/
|
||||
export const AnalyticsSettings: GlobalConfig = {
|
||||
slug: "analytics-settings",
|
||||
label: "Analytics-Einstellungen",
|
||||
admin: {
|
||||
description: "Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.",
|
||||
group: "Datenschutz",
|
||||
},
|
||||
access: {
|
||||
read: canViewAnalytics,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "rawRetentionDays",
|
||||
type: "number",
|
||||
label: "Rohdaten-Aufbewahrung (Tage)",
|
||||
required: true,
|
||||
defaultValue: 90,
|
||||
min: 7,
|
||||
admin: {
|
||||
description:
|
||||
"Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aggregateRetentionMonths",
|
||||
type: "number",
|
||||
label: "Aggregat-Aufbewahrung (Monate)",
|
||||
required: true,
|
||||
defaultValue: 12,
|
||||
min: 1,
|
||||
admin: {
|
||||
description: "Wie lange die täglichen Aggregat-Statistiken (ohne Einzel-Events) gespeichert bleiben.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
import { isAdmin } from "@/access";
|
||||
|
||||
/**
|
||||
* Consent-category configuration shown in the public cookie banner
|
||||
* (components/Analytics.tsx). Read access is intentionally public — the
|
||||
* unauthenticated frontend needs the labels/descriptions and the
|
||||
* enabled/disabled flags before a visitor has logged in anywhere.
|
||||
*
|
||||
* "necessary" deliberately has no `enabled` field at all, anywhere in this
|
||||
* schema — there is no toggle to accidentally switch off, so it can never
|
||||
* be disabled through the admin UI (see requirement: technically required
|
||||
* functionality must not be disable-able by an admin).
|
||||
*/
|
||||
export const CookieSettings: GlobalConfig = {
|
||||
slug: "cookie-settings",
|
||||
label: "Cookie-Einstellungen",
|
||||
admin: {
|
||||
description: "Kategorien und Texte für den Cookie-Consent-Banner der Website.",
|
||||
group: "Datenschutz",
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "consentVersion",
|
||||
type: "text",
|
||||
label: "Consent-Version",
|
||||
required: true,
|
||||
defaultValue: "1.0",
|
||||
admin: {
|
||||
description:
|
||||
"Bei Änderung müssen alle Besucher:innen erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
name: "necessary",
|
||||
label: "Notwendig",
|
||||
admin: { description: "Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden." },
|
||||
fields: [
|
||||
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Notwendig" },
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
label: "Beschreibung",
|
||||
required: true,
|
||||
defaultValue: "Für den Betrieb der Website technisch erforderlich (z. B. Login, Sitzung). Kann nicht deaktiviert werden.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
name: "preferences",
|
||||
label: "Präferenzen",
|
||||
fields: [
|
||||
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Präferenzen" },
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
label: "Beschreibung",
|
||||
required: true,
|
||||
defaultValue: "Speichert Einstellungen wie z. B. bevorzugte Anzeigeoptionen.",
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
label: "Kategorie aktiv",
|
||||
defaultValue: true,
|
||||
admin: { description: "Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten." },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
name: "statistics",
|
||||
label: "Statistik",
|
||||
fields: [
|
||||
{ name: "label", type: "text", label: "Bezeichnung", required: true, defaultValue: "Statistik" },
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
label: "Beschreibung",
|
||||
required: true,
|
||||
defaultValue: "Anonymisierte, datensparsame Reichweitenmessung (Seitenaufrufe, Ereignisse) ohne personenbezogene Profile.",
|
||||
},
|
||||
{
|
||||
name: "enabled",
|
||||
type: "checkbox",
|
||||
label: "Kategorie aktiv",
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description:
|
||||
"Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AnalyticsEvent } from "@/payload-types";
|
||||
|
||||
export type AggregatedStats = {
|
||||
sessions: number;
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgPagesPerSession: number;
|
||||
eventCounts: Record<string, number>;
|
||||
deviceCounts: Record<string, number>;
|
||||
browserCounts: Record<string, number>;
|
||||
topPaths: Record<string, number>;
|
||||
entryPaths: Record<string, number>;
|
||||
exitPaths: Record<string, number>;
|
||||
};
|
||||
|
||||
function bump(map: Record<string, number>, key: string | null | undefined) {
|
||||
if (!key) return;
|
||||
map[key] = (map[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
|
||||
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
|
||||
return Object.entries(map)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
|
||||
* one day for the cleanup rollup, or a whole dashboard-selected range for
|
||||
* live queries). Kept side-effect free and DB-agnostic so both callers
|
||||
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
|
||||
* one implementation.
|
||||
*/
|
||||
export function aggregateEvents(events: AnalyticsEvent[]): AggregatedStats {
|
||||
const eventCounts: Record<string, number> = {};
|
||||
const deviceCounts: Record<string, number> = {};
|
||||
const browserCounts: Record<string, number> = {};
|
||||
const topPaths: Record<string, number> = {};
|
||||
const entryPaths: Record<string, number> = {};
|
||||
const exitPaths: Record<string, number> = {};
|
||||
|
||||
const visitorHashes = new Set<string>();
|
||||
const sessionIds = new Set<string>();
|
||||
let pageViews = 0;
|
||||
|
||||
// Per-session first/last page_view, in chronological order, for entry/exit pages.
|
||||
const sessionPageViews = new Map<string, { first: { at: string; path: string }; last: { at: string; path: string } }>();
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
|
||||
for (const ev of sorted) {
|
||||
visitorHashes.add(ev.visitorHash);
|
||||
sessionIds.add(ev.sessionId);
|
||||
bump(eventCounts, ev.eventName);
|
||||
bump(deviceCounts, ev.device);
|
||||
bump(browserCounts, ev.browser);
|
||||
|
||||
if (ev.eventName === "page_view") {
|
||||
pageViews += 1;
|
||||
bump(topPaths, ev.path);
|
||||
|
||||
const existing = sessionPageViews.get(ev.sessionId);
|
||||
if (!existing) {
|
||||
sessionPageViews.set(ev.sessionId, {
|
||||
first: { at: ev.createdAt, path: ev.path },
|
||||
last: { at: ev.createdAt, path: ev.path },
|
||||
});
|
||||
} else {
|
||||
existing.last = { at: ev.createdAt, path: ev.path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { first, last } of sessionPageViews.values()) {
|
||||
bump(entryPaths, first.path);
|
||||
bump(exitPaths, last.path);
|
||||
}
|
||||
|
||||
const sessions = sessionIds.size;
|
||||
|
||||
return {
|
||||
sessions,
|
||||
pageViews,
|
||||
uniqueVisitors: visitorHashes.size,
|
||||
avgPagesPerSession: sessions > 0 ? Math.round((pageViews / sessions) * 10) / 10 : 0,
|
||||
eventCounts,
|
||||
deviceCounts,
|
||||
browserCounts,
|
||||
topPaths,
|
||||
entryPaths,
|
||||
exitPaths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Payload } from "payload";
|
||||
import { aggregateEvents } from "@/lib/analytics/aggregate";
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfUTCDay(date: Date): Date {
|
||||
return new Date(`${dateKey(date)}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC —
|
||||
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
|
||||
* analytics-events into analytics-daily-stats, THEN purges raw events older
|
||||
* than the configured retention, THEN purges old daily-stats rows. Order
|
||||
* matters: a day must be aggregated before its raw rows can ever be
|
||||
* deleted, or that history is gone for good.
|
||||
*
|
||||
* Only aggregates days that don't already have a daily-stats row (each day
|
||||
* is aggregated exactly once, ever) — safe to call this daily via
|
||||
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
|
||||
*/
|
||||
export async function runAnalyticsCleanup(payload: Payload) {
|
||||
const settings = await payload.findGlobal({ slug: "analytics-settings" });
|
||||
const rawRetentionDays = settings.rawRetentionDays ?? 90;
|
||||
const aggregateRetentionMonths = settings.aggregateRetentionMonths ?? 12;
|
||||
|
||||
const today = startOfUTCDay(new Date());
|
||||
|
||||
const { docs: earliestDocs } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
sort: "createdAt",
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
const earliest = earliestDocs[0];
|
||||
|
||||
let daysAggregated = 0;
|
||||
if (earliest) {
|
||||
let cursor = startOfUTCDay(new Date(earliest.createdAt));
|
||||
while (cursor < today) {
|
||||
const key = dateKey(cursor);
|
||||
const { totalDocs: alreadyAggregated } = await payload.count({
|
||||
collection: "analytics-daily-stats",
|
||||
where: { date: { equals: key } },
|
||||
});
|
||||
|
||||
if (alreadyAggregated === 0) {
|
||||
const dayEnd = addDays(cursor, 1);
|
||||
const { docs: dayEvents } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
where: {
|
||||
and: [
|
||||
{ createdAt: { greater_than_equal: cursor.toISOString() } },
|
||||
{ createdAt: { less_than: dayEnd.toISOString() } },
|
||||
],
|
||||
},
|
||||
limit: 0, // no limit — pull the whole day, small-site scale
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
if (dayEvents.length > 0) {
|
||||
const stats = aggregateEvents(dayEvents);
|
||||
await payload.create({
|
||||
collection: "analytics-daily-stats",
|
||||
data: { date: key, ...stats },
|
||||
});
|
||||
daysAggregated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rawCutoff = addDays(today, -rawRetentionDays).toISOString();
|
||||
const { docs: staleEvents } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
where: { createdAt: { less_than: rawCutoff } },
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
});
|
||||
for (const doc of staleEvents) {
|
||||
await payload.delete({ collection: "analytics-events", id: doc.id });
|
||||
}
|
||||
|
||||
const aggregateCutoff = new Date(today);
|
||||
aggregateCutoff.setUTCMonth(aggregateCutoff.getUTCMonth() - aggregateRetentionMonths);
|
||||
const { docs: staleDailyStats } = await payload.find({
|
||||
collection: "analytics-daily-stats",
|
||||
where: { date: { less_than: dateKey(aggregateCutoff) } },
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
});
|
||||
for (const doc of staleDailyStats) {
|
||||
await payload.delete({ collection: "analytics-daily-stats", id: doc.id });
|
||||
}
|
||||
|
||||
return {
|
||||
daysAggregated,
|
||||
rawEventsDeleted: staleEvents.length,
|
||||
dailyStatsDeleted: staleDailyStats.length,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Payload } from "payload";
|
||||
import type { AnalyticsEvent } from "@/payload-types";
|
||||
import { aggregateEvents, type AggregatedStats } from "@/lib/analytics/aggregate";
|
||||
|
||||
export type DashboardRange = { from: Date; to: Date };
|
||||
|
||||
export type DailyPoint = { date: string; pageViews: number; sessions: number };
|
||||
|
||||
export type DashboardStats = AggregatedStats & {
|
||||
series: DailyPoint[];
|
||||
};
|
||||
|
||||
function dateKey(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
async function fetchEvents(payload: Payload, range: DashboardRange): Promise<AnalyticsEvent[]> {
|
||||
const { docs } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
where: {
|
||||
and: [
|
||||
{ createdAt: { greater_than_equal: range.from.toISOString() } },
|
||||
{ createdAt: { less_than_equal: range.to.toISOString() } },
|
||||
],
|
||||
},
|
||||
limit: 0,
|
||||
depth: 0,
|
||||
sort: "createdAt",
|
||||
});
|
||||
return docs;
|
||||
}
|
||||
|
||||
function buildSeries(events: AnalyticsEvent[]): DailyPoint[] {
|
||||
const byDay = new Map<string, { pageViews: number; sessions: Set<string> }>();
|
||||
for (const ev of events) {
|
||||
const key = dateKey(ev.createdAt);
|
||||
const bucket = byDay.get(key) ?? { pageViews: 0, sessions: new Set<string>() };
|
||||
if (ev.eventName === "page_view") bucket.pageViews += 1;
|
||||
bucket.sessions.add(ev.sessionId);
|
||||
byDay.set(key, bucket);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, bucket]) => ({ date, pageViews: bucket.pageViews, sessions: bucket.sessions.size }));
|
||||
}
|
||||
|
||||
export async function getDashboardStats(payload: Payload, range: DashboardRange): Promise<DashboardStats> {
|
||||
const events = await fetchEvents(payload, range);
|
||||
return { ...aggregateEvents(events), series: buildSeries(events) };
|
||||
}
|
||||
|
||||
export type PercentDelta = number | null; // null = no comparison basis (previous value was 0)
|
||||
|
||||
export function percentDelta(current: number, previous: number): PercentDelta {
|
||||
if (previous === 0) return current === 0 ? 0 : null;
|
||||
return Math.round(((current - previous) / previous) * 1000) / 10;
|
||||
}
|
||||
|
||||
export async function getDashboardComparison(
|
||||
payload: Payload,
|
||||
range: DashboardRange,
|
||||
compareRange: DashboardRange,
|
||||
): Promise<{ current: DashboardStats; previous: DashboardStats }> {
|
||||
const [current, previous] = await Promise.all([
|
||||
getDashboardStats(payload, range),
|
||||
getDashboardStats(payload, compareRange),
|
||||
]);
|
||||
return { current, previous };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Central, extensible registry of known analytics event names. Adding a new
|
||||
* event elsewhere in the app never requires a migration — the database
|
||||
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
|
||||
* list exists purely so `track()` calls get autocomplete/type-checking and
|
||||
* the dashboard can show a nice German label instead of a raw event name.
|
||||
*
|
||||
* To add a new event: add one entry here, then call `track("your_event")`
|
||||
* anywhere in the app (see lib/analytics/client.ts).
|
||||
*/
|
||||
export const ANALYTICS_EVENTS = {
|
||||
page_view: "Seitenaufruf",
|
||||
booking_started: "Buchung gestartet",
|
||||
booking_completed: "Buchung abgeschlossen",
|
||||
contact_clicked: "Kontakt angeklickt",
|
||||
navigation_clicked: "Navigation geklickt",
|
||||
} as const;
|
||||
|
||||
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
|
||||
|
||||
/** Any string works (see the collection comment above) — known ones just get a label. */
|
||||
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
|
||||
|
||||
export function analyticsEventLabel(eventName: string): string {
|
||||
return (ANALYTICS_EVENTS as Record<string, string>)[eventName] ?? eventName;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DashboardRange } from "@/lib/analytics/dashboardQueries";
|
||||
|
||||
export type RangeKey = "today" | "7d" | "30d" | "90d" | "custom";
|
||||
|
||||
export const RANGE_OPTIONS: { key: RangeKey; label: string; days: number | "today" }[] = [
|
||||
{ key: "today", label: "Heute", days: "today" },
|
||||
{ key: "7d", label: "7 Tage", days: 7 },
|
||||
{ key: "30d", label: "30 Tage", days: 30 },
|
||||
{ key: "90d", label: "90 Tage", days: 90 },
|
||||
];
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function startOfUTCDay(date: Date): Date {
|
||||
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function endOfUTCDay(date: Date): Date {
|
||||
return new Date(`${date.toISOString().slice(0, 10)}T23:59:59.999Z`);
|
||||
}
|
||||
|
||||
function parseDateParam(value: string | null): Date | null {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
|
||||
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
|
||||
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
|
||||
* has already been purged. Also computes the matching "previous period" for
|
||||
* comparison (same length, immediately preceding).
|
||||
*/
|
||||
export function resolveRange(
|
||||
searchParams: URLSearchParams,
|
||||
maxDaysBack: number,
|
||||
): { key: RangeKey; range: DashboardRange; compareRange: DashboardRange } {
|
||||
const now = new Date();
|
||||
const earliestAllowed = startOfUTCDay(new Date(now.getTime() - maxDaysBack * DAY_MS));
|
||||
const rawKey = searchParams.get("range");
|
||||
const key: RangeKey = (["today", "7d", "30d", "90d", "custom"] as const).includes(rawKey as RangeKey)
|
||||
? (rawKey as RangeKey)
|
||||
: "7d";
|
||||
|
||||
if (key === "custom") {
|
||||
const fromParam = parseDateParam(searchParams.get("from"));
|
||||
const toParam = parseDateParam(searchParams.get("to"));
|
||||
let from = fromParam ? startOfUTCDay(fromParam) : startOfUTCDay(new Date(now.getTime() - 7 * DAY_MS));
|
||||
let to = toParam ? endOfUTCDay(toParam) : now;
|
||||
if (from < earliestAllowed) from = earliestAllowed;
|
||||
if (to > now) to = now;
|
||||
if (from > to) from = to;
|
||||
|
||||
const lengthMs = to.getTime() - from.getTime();
|
||||
const compareRange: DashboardRange = { from: new Date(from.getTime() - lengthMs), to: new Date(from.getTime() - 1) };
|
||||
return { key, range: { from, to }, compareRange };
|
||||
}
|
||||
|
||||
if (key === "today") {
|
||||
const from = startOfUTCDay(now);
|
||||
const yesterday = new Date(from.getTime() - DAY_MS);
|
||||
return { key, range: { from, to: now }, compareRange: { from: yesterday, to: new Date(from.getTime() - 1) } };
|
||||
}
|
||||
|
||||
const days = RANGE_OPTIONS.find((o) => o.key === key)?.days;
|
||||
const windowDays = typeof days === "number" ? Math.min(days, maxDaysBack) : 7;
|
||||
const from = new Date(Math.max(now.getTime() - windowDays * DAY_MS, earliestAllowed.getTime()));
|
||||
const compareRange: DashboardRange = { from: new Date(from.getTime() - windowDays * DAY_MS), to: new Date(from.getTime() - 1) };
|
||||
return { key, range: { from, to: now }, compareRange };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
|
||||
* for session grouping — the same approach used by privacy-friendly
|
||||
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
|
||||
* salted with a value that changes every calendar day. Raw IP/UA are never
|
||||
* persisted anywhere, only this hash — and since the salt rotates daily,
|
||||
* the same visitor hashes differently tomorrow, so there is no cross-day
|
||||
* linkage and no long-term individual profile.
|
||||
*
|
||||
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
|
||||
* codebase keeps one secret per purpose on purpose (see
|
||||
* MEETING_SESSION_SECRET's comment in .env.example).
|
||||
*/
|
||||
function secret(): string {
|
||||
const value = process.env.ANALYTICS_SALT_SECRET;
|
||||
if (!value) throw new Error("ANALYTICS_SALT_SECRET is not set");
|
||||
return value;
|
||||
}
|
||||
|
||||
function dateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dailySalt(date: Date): string {
|
||||
return createHmac("sha256", secret()).update(dateKey(date)).digest("hex");
|
||||
}
|
||||
|
||||
export function visitorHash(args: { ip: string; userAgent: string; host: string; date?: Date }): string {
|
||||
const salt = dailySalt(args.date ?? new Date());
|
||||
return createHmac("sha256", salt).update(`${args.ip}|${args.userAgent}|${args.host}`).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Payload } from "payload";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Groups events into sessions without any client-side identifier: looks up
|
||||
* the most recent event from the same `visitorHash` within the last 30
|
||||
* minutes and reuses its sessionId, or mints a new one. One extra indexed
|
||||
* read per tracked request — acceptable at this site's traffic volume, and
|
||||
* simpler/safer than trying to hold a lock across an unauthenticated,
|
||||
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
|
||||
* lock, an occasional extra spurious session here has no correctness
|
||||
* consequences worth the added complexity).
|
||||
*/
|
||||
export async function resolveSessionId(payload: Payload, visitorHash: string): Promise<string> {
|
||||
const since = new Date(Date.now() - SESSION_IDLE_WINDOW_MS).toISOString();
|
||||
const { docs } = await payload.find({
|
||||
collection: "analytics-events",
|
||||
where: {
|
||||
and: [{ visitorHash: { equals: visitorHash } }, { createdAt: { greater_than_equal: since } }],
|
||||
},
|
||||
sort: "-createdAt",
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return docs[0]?.sessionId ?? randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Coarse device/browser classification from the User-Agent header. No
|
||||
* library, no raw UA string ever stored — just one bucket per request,
|
||||
* computed on the fly and discarded after classification. Not meant to be
|
||||
* precise (e.g. WebView vs. real browser); good enough for the "device
|
||||
* type" / "browser" breakdown in the Analytics dashboard.
|
||||
*/
|
||||
|
||||
export type DeviceType = "mobile" | "desktop" | "tablet";
|
||||
export type BrowserFamily = "chrome" | "firefox" | "safari" | "edge" | "other";
|
||||
|
||||
export function classifyDevice(userAgent: string): DeviceType {
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (/ipad|tablet(?!.*mobile)|(android(?!.*mobile))/.test(ua)) return "tablet";
|
||||
if (/mobi|iphone|ipod|android/.test(ua)) return "mobile";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
export function classifyBrowser(userAgent: string): BrowserFamily {
|
||||
const ua = userAgent.toLowerCase();
|
||||
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
|
||||
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
|
||||
if (/edg\//.test(ua)) return "edge";
|
||||
if (/chrome\/|crios\//.test(ua)) return "chrome";
|
||||
if (/firefox\/|fxios\//.test(ua)) return "firefox";
|
||||
if (/safari\//.test(ua)) return "safari";
|
||||
return "other";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cache } from "react";
|
||||
import { getCMS } from "@/lib/payload/getPayload";
|
||||
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, Home, SeoSetting } from "@/payload-types";
|
||||
import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, CookieSetting, Home, SeoSetting } from "@/payload-types";
|
||||
|
||||
export const getHomeGlobal = cache(async (): Promise<Home> => {
|
||||
const payload = await getCMS();
|
||||
@@ -41,3 +41,8 @@ export const getSEOSettingsGlobal = cache(async (): Promise<SeoSetting> => {
|
||||
const payload = await getCMS();
|
||||
return payload.findGlobal({ slug: "seo-settings" });
|
||||
});
|
||||
|
||||
export const getCookieSettingsGlobal = cache(async (): Promise<CookieSetting> => {
|
||||
const payload = await getCMS();
|
||||
return payload.findGlobal({ slug: "cookie-settings" });
|
||||
});
|
||||
|
||||
Generated
+383
@@ -20,6 +20,7 @@
|
||||
"payload": "^3.88.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"recharts": "^3.10.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tsx": "^4.23.12",
|
||||
"ws": "^8.21.3"
|
||||
@@ -3509,6 +3510,32 @@
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -3516,6 +3543,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
@@ -3848,6 +3887,69 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
@@ -3993,6 +4095,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
@@ -5446,6 +5554,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -5549,6 +5778,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -6089,6 +6324,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -6601,6 +6848,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||
@@ -7278,6 +7531,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.3.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
|
||||
@@ -7325,6 +7588,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
||||
@@ -10703,6 +10975,29 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-select": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz",
|
||||
@@ -10761,6 +11056,51 @@
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -10814,6 +11154,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
@@ -11591,6 +11937,12 @@
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -12579,6 +12931,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8-byte-length": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||
@@ -12612,6 +12973,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"payload": "^3.88.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"recharts": "^3.10.1",
|
||||
"sharp": "^0.35.3",
|
||||
"tsx": "^4.23.12",
|
||||
"ws": "^8.21.3"
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface Config {
|
||||
'availability-overrides': AvailabilityOverride;
|
||||
'booking-requests': BookingRequest;
|
||||
redirects: Redirect;
|
||||
'analytics-events': AnalyticsEvent;
|
||||
'analytics-daily-stats': AnalyticsDailyStat;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
@@ -97,6 +99,8 @@ export interface Config {
|
||||
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
|
||||
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
'analytics-events': AnalyticsEventsSelect<false> | AnalyticsEventsSelect<true>;
|
||||
'analytics-daily-stats': AnalyticsDailyStatsSelect<false> | AnalyticsDailyStatsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
@@ -116,6 +120,8 @@ export interface Config {
|
||||
'meeting-settings': MeetingSetting;
|
||||
'booking-settings': BookingSetting;
|
||||
'seo-settings': SeoSetting;
|
||||
'cookie-settings': CookieSetting;
|
||||
'analytics-settings': AnalyticsSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
home: HomeSelect<false> | HomeSelect<true>;
|
||||
@@ -127,6 +133,8 @@ export interface Config {
|
||||
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
|
||||
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
|
||||
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
|
||||
'cookie-settings': CookieSettingsSelect<false> | CookieSettingsSelect<true>;
|
||||
'analytics-settings': AnalyticsSettingsSelect<false> | AnalyticsSettingsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
@@ -184,6 +192,10 @@ export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
role: 'admin';
|
||||
/**
|
||||
* Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.
|
||||
*/
|
||||
canViewAnalytics?: boolean | null;
|
||||
calendarFeedTokenHash?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -611,6 +623,89 @@ export interface Redirect {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-events".
|
||||
*/
|
||||
export interface AnalyticsEvent {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
visitorHash: string;
|
||||
path: string;
|
||||
eventName: string;
|
||||
referrerHost?: string | null;
|
||||
device?: ('mobile' | 'desktop' | 'tablet') | null;
|
||||
browser?: ('chrome' | 'firefox' | 'safari' | 'edge' | 'other') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-daily-stats".
|
||||
*/
|
||||
export interface AnalyticsDailyStat {
|
||||
id: number;
|
||||
date: string;
|
||||
sessions: number;
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
eventCounts?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
deviceCounts?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
browserCounts?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
topPaths?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
entryPaths?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
exitPaths?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
@@ -678,6 +773,14 @@ export interface PayloadLockedDocument {
|
||||
| ({
|
||||
relationTo: 'redirects';
|
||||
value: number | Redirect;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'analytics-events';
|
||||
value: number | AnalyticsEvent;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'analytics-daily-stats';
|
||||
value: number | AnalyticsDailyStat;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user:
|
||||
@@ -738,6 +841,7 @@ export interface PayloadMigration {
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
role?: T;
|
||||
canViewAnalytics?: T;
|
||||
calendarFeedTokenHash?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
@@ -1004,6 +1108,39 @@ export interface RedirectsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-events_select".
|
||||
*/
|
||||
export interface AnalyticsEventsSelect<T extends boolean = true> {
|
||||
sessionId?: T;
|
||||
visitorHash?: T;
|
||||
path?: T;
|
||||
eventName?: T;
|
||||
referrerHost?: T;
|
||||
device?: T;
|
||||
browser?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-daily-stats_select".
|
||||
*/
|
||||
export interface AnalyticsDailyStatsSelect<T extends boolean = true> {
|
||||
date?: T;
|
||||
sessions?: T;
|
||||
pageViews?: T;
|
||||
uniqueVisitors?: T;
|
||||
eventCounts?: T;
|
||||
deviceCounts?: T;
|
||||
browserCounts?: T;
|
||||
topPaths?: T;
|
||||
entryPaths?: T;
|
||||
exitPaths?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
@@ -1364,6 +1501,63 @@ export interface SeoSetting {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* Kategorien und Texte für den Cookie-Consent-Banner der Website.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "cookie-settings".
|
||||
*/
|
||||
export interface CookieSetting {
|
||||
id: number;
|
||||
/**
|
||||
* Bei Änderung müssen alle Besucher:innen erneut zustimmen — bereits gespeicherte Einwilligungen mit einer älteren Version werden ignoriert und der Banner erscheint erneut.
|
||||
*/
|
||||
consentVersion: string;
|
||||
/**
|
||||
* Immer aktiv — technisch erforderlich, kann nicht deaktiviert werden.
|
||||
*/
|
||||
necessary: {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
preferences: {
|
||||
label: string;
|
||||
description: string;
|
||||
/**
|
||||
* Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten.
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
statistics: {
|
||||
label: string;
|
||||
description: string;
|
||||
/**
|
||||
* Globaler Schalter für das gesamte Analytics-System. Wenn deaktiviert, wird diese Kategorie im Banner nicht mehr angeboten und Analytics-Daten werden serverseitig abgelehnt — auch bei einer noch gespeicherten alten Zustimmung.
|
||||
*/
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* Aufbewahrungsdauer für Analytics-Rohdaten und aggregierte Tagesstatistiken.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-settings".
|
||||
*/
|
||||
export interface AnalyticsSetting {
|
||||
id: number;
|
||||
/**
|
||||
* Wie lange einzelne Analytics-Events gespeichert bleiben. Begrenzt auch den wählbaren Zeitraum im Analytics-Dashboard.
|
||||
*/
|
||||
rawRetentionDays: number;
|
||||
/**
|
||||
* Wie lange die täglichen Aggregat-Statistiken (ohne Einzel-Events) gespeichert bleiben.
|
||||
*/
|
||||
aggregateRetentionMonths: number;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "home_select".
|
||||
@@ -1575,6 +1769,47 @@ export interface SeoSettingsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "cookie-settings_select".
|
||||
*/
|
||||
export interface CookieSettingsSelect<T extends boolean = true> {
|
||||
consentVersion?: T;
|
||||
necessary?:
|
||||
| T
|
||||
| {
|
||||
label?: T;
|
||||
description?: T;
|
||||
};
|
||||
preferences?:
|
||||
| T
|
||||
| {
|
||||
label?: T;
|
||||
description?: T;
|
||||
enabled?: T;
|
||||
};
|
||||
statistics?:
|
||||
| T
|
||||
| {
|
||||
label?: T;
|
||||
description?: T;
|
||||
enabled?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "analytics-settings_select".
|
||||
*/
|
||||
export interface AnalyticsSettingsSelect<T extends boolean = true> {
|
||||
rawRetentionDays?: T;
|
||||
aggregateRetentionMonths?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
|
||||
+29
-2
@@ -16,6 +16,8 @@ import { Availability } from "@/collections/Availability";
|
||||
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
|
||||
import { BookingRequests } from "@/collections/BookingRequests";
|
||||
import { Redirects } from "@/collections/Redirects";
|
||||
import { AnalyticsEvents } from "@/collections/AnalyticsEvents";
|
||||
import { AnalyticsDailyStats } from "@/collections/AnalyticsDailyStats";
|
||||
|
||||
import { Home } from "@/globals/Home";
|
||||
import { About } from "@/globals/About";
|
||||
@@ -26,6 +28,8 @@ import { Booking } from "@/globals/Booking";
|
||||
import { MeetingSettings } from "@/globals/MeetingSettings";
|
||||
import { BookingSettings } from "@/globals/BookingSettings";
|
||||
import { SEOSettings } from "@/globals/SEOSettings";
|
||||
import { CookieSettings } from "@/globals/CookieSettings";
|
||||
import { AnalyticsSettings } from "@/globals/AnalyticsSettings";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
@@ -41,7 +45,10 @@ export default buildConfig({
|
||||
titleSuffix: "— Anouma Admin",
|
||||
},
|
||||
components: {
|
||||
afterNavLinks: ["/components/admin/CalendarNavLink#CalendarNavLink"],
|
||||
afterNavLinks: [
|
||||
"/components/admin/CalendarNavLink#CalendarNavLink",
|
||||
"/components/admin/AnalyticsNavLink#AnalyticsNavLink",
|
||||
],
|
||||
views: {
|
||||
kalender: {
|
||||
Component: "/components/admin/AdminCalendarView#AdminCalendarView",
|
||||
@@ -49,6 +56,12 @@ export default buildConfig({
|
||||
exact: true,
|
||||
meta: { title: "Kalender" },
|
||||
},
|
||||
analytics: {
|
||||
Component: "/components/admin/AdminAnalyticsView#AdminAnalyticsView",
|
||||
path: "/analytics",
|
||||
exact: true,
|
||||
meta: { title: "Analytics" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -64,8 +77,22 @@ export default buildConfig({
|
||||
AvailabilityOverrides,
|
||||
BookingRequests,
|
||||
Redirects,
|
||||
AnalyticsEvents,
|
||||
AnalyticsDailyStats,
|
||||
],
|
||||
globals: [
|
||||
Home,
|
||||
About,
|
||||
AngeboteIntro,
|
||||
AktuellesIntro,
|
||||
Contact,
|
||||
Booking,
|
||||
MeetingSettings,
|
||||
BookingSettings,
|
||||
SEOSettings,
|
||||
CookieSettings,
|
||||
AnalyticsSettings,
|
||||
],
|
||||
globals: [Home, About, AngeboteIntro, AktuellesIntro, Contact, Booking, MeetingSettings, BookingSettings, SEOSettings],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || "",
|
||||
typescript: {
|
||||
|
||||
@@ -301,6 +301,7 @@ POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=anouma
|
||||
PAYLOAD_SECRET=
|
||||
MEETING_SESSION_SECRET=
|
||||
ANALYTICS_SALT_SECRET=
|
||||
CRON_SECRET=
|
||||
NEXT_PUBLIC_SERVER_URL=
|
||||
APP_PORT=3000
|
||||
@@ -329,6 +330,7 @@ fi
|
||||
# --- secrets --------------------------------------------------------------
|
||||
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
|
||||
env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert."
|
||||
env_ensure ANALYTICS_SALT_SECRET "$(gen_secret)" && log_info "ANALYTICS_SALT_SECRET generiert." || log_info "ANALYTICS_SALT_SECRET bereits vorhanden — unverändert."
|
||||
env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert."
|
||||
|
||||
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
|
||||
@@ -431,6 +433,7 @@ echo "POSTGRES_DB: $(env_get POSTGRES_DB)"
|
||||
echo "POSTGRES_PASSWORD: $(mask "$(env_get POSTGRES_PASSWORD)")"
|
||||
echo "PAYLOAD_SECRET: $(mask "$(env_get PAYLOAD_SECRET)")"
|
||||
echo "MEETING_SESSION_SECRET: $(mask "$(env_get MEETING_SESSION_SECRET)")"
|
||||
echo "ANALYTICS_SALT_SECRET: $(mask "$(env_get ANALYTICS_SALT_SECRET)")"
|
||||
echo "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")"
|
||||
echo "SMTP_HOST: $(env_get SMTP_HOST)"
|
||||
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"
|
||||
|
||||
Reference in New Issue
Block a user