3 Commits
Author SHA1 Message Date
maro 526a908c80 Redesign: warmer, more editorial visual pass; remove stray em dashes
CI / test (push) Successful in 3m42s
Release / test (push) Successful in 3m44s
Release / build-and-deploy (push) Successful in 8m59s
Replaces em dashes with en dashes across all hardcoded copy, emails, and
seed content, then reworks the site's visual language so it reads as
deliberately designed rather than assembled from generic components:

- New ArrowLink component replaces ad hoc "Mehr erfahren ->" links and
  ghost buttons; one shared FormField (TextField/TextAreaField) replaces
  7 near-duplicate field styles across every form
- Navbar loses its glassmorphism blur for a solid warm background, gets a
  subtle active-link underline and a softer, warmer mega-menu
- PageHeader supports per-page blob tone/position and a compact mode
  instead of an identical band on every interior page
- Homepage: the core offer is featured large instead of four equal cards,
  the philosophy section gets a single calmer accent blob, blog teasers
  now show their cover image like the real /aktuelles page does
- Booking calendar and widget: 44px touch targets throughout (month nav,
  day cells, time slots, cancel action), a visible "today" ring, and
  responsive padding so the calendar isn't cramped on small screens
- Quieter, warmer empty states on /termine and /aktuelles

No functional changes: all interaction logic (nav menus, forms, booking,
CMS data fetching) is untouched, only markup and styling.
2026-09-06 22:00:28 +02:00
maro 65b6eeac51 Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no
persistent per-visitor identifiers: events are grouped into sessions via a
daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw).

- New "Analytics" section in the Payload admin, gated by a canViewAnalytics
  permission on Users, following the existing custom-admin-view pattern
  used by the calendar feature
- Sessions, page views, unique visitors, top/entry/exit pages, events,
  device/browser breakdown, date range filter with period comparison
- Raw events and daily aggregate rollups as their own collections, with
  configurable retention and a cron endpoint that rolls up before purging
- Cookie consent banner (necessary/preferences/statistics) with categories
  and copy editable from a new CMS global; a track() helper that is a
  complete no-op without statistics consent, so nothing is ever sent
  before a visitor opts in
- track() calls wired into the booking flow, contact form, and navigation
2026-09-06 21:59:59 +02:00
maro 82c4261a81 Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API
- Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock
- Reset email verification when a customer changes their email address
- Fix ICS calendar injection via unescaped carriage returns in event descriptions
- Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin
- Move the meeting signaling token out of the WebSocket query string into the first message
- Prevent duplicate signaling connections from leaking when the same participant reconnects
- Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy)
- Use constant-time comparison for the cron endpoint's bearer token
- Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway)
- Add baseline security headers and a CSP to next.config.ts
- Fix CI/CD script injection via unsanitized git ref names in release.yml
2026-09-06 21:59:39 +02:00
90 changed files with 3098 additions and 466 deletions
+5
View File
@@ -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
View File
@@ -23,6 +23,7 @@ jobs:
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
+25 -8
View File
@@ -19,6 +19,7 @@ jobs:
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
@@ -67,27 +68,43 @@ jobs:
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
# mechanism (the `gitea.*` vs `github.*` expression contexts are
# just aliases of each other for `${{ }}` templating).
#
# The tag name (github.ref_name / $GITHUB_REF_NAME) is
# attacker-influenced: anyone with tag-push rights controls it, and
# git allows shell metacharacters in tag names. It's read here via
# the automatic $GITHUB_REF_NAME env var rather than `${{ github.ref_name }}`
# so its value reaches bash as data (argv/envp), never spliced into
# the script text — `${{ }}` expressions are substituted into the
# YAML before bash ever sees the script, which is what makes
# interpolating attacker-controlled values that way a script-injection
# vector (arbitrary code execution in a job that holds the registry
# and production deploy credentials).
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
env:
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
- name: Push image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
+1 -1
View File
@@ -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 |
+11
View File
@@ -13,6 +13,17 @@ export const isAdmin: Access = ({ req: { user } }) =>
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
/**
* Admins always see Analytics; any other "users" account additionally needs
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
* granular permission the single "admin" role doesn't otherwise provide.
* Used both as a Payload Access/FieldAccess function and called directly
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
* and view components, which Payload does not gate via collection access.
*/
export const canViewAnalytics: Access = ({ req: { user } }) =>
user?.collection === "users" && (user.role === "admin" || user.canViewAnalytics === true);
/**
* Admins can read every document (including drafts); everyone else may only
* read documents whose Payload draft/publish status is "published".
+4 -3
View File
@@ -55,9 +55,10 @@ export default async function AktuellesPage() {
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Hier erscheinen bald die ersten Neuigkeiten</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">Schau gerne wieder vorbei.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
+2 -2
View File
@@ -19,7 +19,7 @@ import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> {
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch – gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby – melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
@@ -21,6 +21,7 @@ export default async function VerifyEmailPage({ params }: Args) {
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
+4 -4
View File
@@ -21,7 +21,7 @@ export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Anschrift]
[Platzhalter – Anschrift]
<br />
E-Mail: {contact.email}
</p>
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter — Details zum verwendeten Hosting-Anbieter ergänzen.]
Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
+3 -3
View File
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.";
const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma – Natur, Gemeinschaft und gemeinsame Räume.";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
@@ -36,11 +36,11 @@ export default async function ImpressionenPage() {
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
lead="Diese Galerie ist als Platzhalter-System angelegt – echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid auto-rows-[10rem] grid-cols-2 gap-4 sm:auto-rows-[14rem] sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
+6 -6
View File
@@ -23,7 +23,7 @@ export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Straße und Hausnummer]
[Platzhalter – Straße und Hausnummer]
<br />
[Platzhalter — PLZ und Ort]
[Platzhalter – PLZ und Ort]
</p>
</div>
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname, Anschrift wie oben]
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
</p>
</div>
+3
View File
@@ -50,6 +50,9 @@ export default async function KontaktPage() {
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
blobTone="sage"
blobPosition="top-left"
compact
/>
<Section tone="plain">
+2 -2
View File
@@ -44,12 +44,12 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
<LogoutButton className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
+2 -4
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
import { ArrowLink } from "@/components/ArrowLink";
export const metadata: Metadata = { title: "Mein Konto" };
@@ -61,9 +61,7 @@ export default async function KontoOverviewPage() {
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen →
</Link>
<ArrowLink href="/konto/termine">Alle Termine (inkl. vergangene) ansehen</ArrowLink>
</div>
)}
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} — für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
+13 -3
View File
@@ -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>
);
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function LoginPage() {
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
+80 -59
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { ArrowLink } from "@/components/ArrowLink";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { OrganicBlob } from "@/components/OrganicBlob";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
@@ -29,7 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
fallbackDescription: () =>
home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise – Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
@@ -53,6 +55,8 @@ export default async function Home() {
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
const featuredGroup = groups.find((g) => g.category === "prozessbegleitung");
const remainingGroups = featuredGroup ? groups.filter((g) => g !== featuredGroup) : groups;
const siteUrl = await getSiteUrl();
return (
@@ -73,9 +77,9 @@ export default async function Home() {
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
<ArrowLink href="/angebote" size="base" className="self-center">
Angebote entdecken
</Button>
</ArrowLink>
</>
}
>
@@ -89,24 +93,39 @@ export default async function Home() {
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen – jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{groups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
<div className="mt-14 space-y-6">
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
href={featuredGroup.offers.length === 1 ? `/angebote/${featuredGroup.offers[0].slug}` : featuredGroup.href}
title={featuredGroup.label}
tagline={featuredGroup.offers.length === 1 ? featuredGroup.offers[0].shortDescription : `${featuredGroup.offers.length} Angebote`}
mood={moodForOffer(featuredGroup.offers[0])}
imageSrc={mediaUrl(featuredGroup.offers[0].image, "card")}
size="large"
/>
</Reveal>
)}
<div className="grid gap-6 sm:grid-cols-3">
{remainingGroups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
</div>
</div>
</Section>
@@ -141,9 +160,7 @@ export default async function Home() {
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen →
</Button>
<ArrowLink href="/ueber-mich">Mehr über mich lesen</ArrowLink>
</div>
</Reveal>
</div>
@@ -151,24 +168,27 @@ export default async function Home() {
)}
{home.values && home.values.length > 0 && (
<Section tone="mauve">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
<Section tone="mauve" className="relative overflow-hidden">
<OrganicBlob tone="sage" className="-left-24 top-1/2 h-96 w-96 -translate-y-1/2 opacity-20" />
<div className="relative">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
„{wert.quote}“
</p>
</Reveal>
))}
</div>
</div>
</Section>
)}
@@ -177,9 +197,7 @@ export default async function Home() {
<Section tone="warm">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
<Button href="/termine" variant="ghost">
Alle Termine →
</Button>
<ArrowLink href="/termine">Alle Termine</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
@@ -195,23 +213,31 @@ export default async function Home() {
<Section tone="cream">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
<Button href="/aktuelles" variant="ghost">
Alle Neuigkeiten →
</Button>
<ArrowLink href="/aktuelles">Alle Neuigkeiten</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={i * 0.08}>
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
<h3 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
@@ -228,13 +254,8 @@ export default async function Home() {
</Reveal>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/impressionen"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Impressionen ansehen
</Link>
<div className="mt-10 flex justify-center">
<ArrowLink href="/impressionen">Alle Impressionen ansehen</ArrowLink>
</div>
</Section>
@@ -242,7 +263,7 @@ export default async function Home() {
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
+1
View File
@@ -24,6 +24,7 @@ export default async function RegisterPage() {
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
+1 -1
View File
@@ -98,7 +98,7 @@ export default async function TerminBuchenPage() {
title="Allgemeine Anfrage"
lead={
bookableOffers.length > 0
? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben."
? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben."
: undefined
}
align="left"
+2
View File
@@ -126,6 +126,8 @@ export default async function EventDetailPage({ params }: Args) {
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
+12 -4
View File
@@ -3,6 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { ArrowLink } from "@/components/ArrowLink";
import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
@@ -32,14 +33,21 @@ export default async function TerminePage() {
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören.
</p>
<div className="mt-5 flex justify-center">
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
</div>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
@@ -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 });
}
}
@@ -1,8 +1,16 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
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 every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -20,7 +20,7 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
let body: { name?: unknown; password?: unknown; clientId?: unknown };
try {
body = await request.json();
} catch {
@@ -29,6 +29,7 @@ export async function POST(request: Request, { params }: Args) {
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
if (!name) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
@@ -85,6 +86,10 @@ export async function POST(request: Request, { params }: Args) {
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
// Only meaningful for participants — a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place, see signalingServer.ts).
clientId: isHost ? undefined : clientId,
});
return NextResponse.json({
+52
View File
@@ -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" },
],
};
+92
View File
@@ -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" },
],
},
],
};
+29 -14
View File
@@ -1,24 +1,40 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdminFieldLevel } from "@/access";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
// Routes through the shared isAdmin check (role === "admin", not just
// collection === "users") so this stays correct if a non-admin role is ever
// added to Users — see the comment on isAdmin in access/index.ts.
const isSelfOrAdmin: typeof isAdmin = (args) => {
const user = args.req.user;
if (!user) return false;
if (user.collection === "users") return true;
if (user.collection === "users") return isAdmin(args);
if (user.collection === "customers") return { id: { equals: user.id } };
return false;
};
// Runs on every new customer account: issues a random, single-use,
// time-limited email-verification token. Only its hash is ever persisted
// (see the emailVerificationTokenHash field below) — the plaintext exists
// only in req.context for the afterChange hook below to email out, and is
// never written to the database or logged.
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation !== "create") return data;
// Runs on every new customer account, and again whenever an existing
// customer changes their email address: issues a random, single-use,
// time-limited email-verification token and resets emailVerified to false.
// Only the token's hash is ever persisted (see emailVerificationTokenHash
// below) — the plaintext exists only in req.context for the afterChange
// hook below to email out, and is never written to the database or logged.
//
// The email-change case matters because `update` access on this collection
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
// access of its own — without this, a customer could verify a throwaway
// address once and then repoint `email` at an address they don't own while
// keeping emailVerified: true (which gates booking-confirmation emails).
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
const emailChanged =
operation === "update" &&
typeof data.email === "string" &&
typeof originalDoc?.email === "string" &&
data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data;
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -27,8 +43,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
return data;
};
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create") return doc;
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
try {
@@ -69,7 +84,7 @@ export const Customers: CollectionConfig = {
create: () => true,
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
delete: isAdmin,
},
hooks: {
beforeChange: [issueVerificationToken],
+8 -1
View File
@@ -1,5 +1,5 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
@@ -162,6 +162,13 @@ export const Events: CollectionConfig = {
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
// Events are publicly readable (publishedOrAdmin) via the REST/
// GraphQL API, so without this the plaintext password would be
// returned to any visitor fetching the event — defeating the
// password check in app/(payload)/api/meetings/[slug]/join. Only
// exposed to admins there; the join route reads it via the Local
// API, which bypasses field access by default.
access: { read: isAdminFieldLevel },
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
+4 -1
View File
@@ -24,7 +24,10 @@ export const Media: CollectionConfig = {
},
upload: {
staticDir: path.resolve(dirname, "..", "media"),
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
// SVG deliberately excluded: it can carry inline <script>/event-handler
// payloads that execute if the raw file is ever opened directly (not via
// <img>) — not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
{ name: "card", width: 800, position: "centre" },
+5 -1
View File
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
function normalizePath(value: string): string {
const trimmed = value.trim();
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) — without this, "/Angebote/Doula" and
// "/angebote/doula" count as different values to the `unique` constraint
// on fromPath, letting near-duplicate redirects coexist.
const trimmed = value.trim().toLowerCase();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}
+16
View File
@@ -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",
+153
View File
@@ -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>
);
}
+2 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { ArrowIcon } from "@/components/ArrowIcon";
import type { ImageMood } from "@/lib/angebote";
type AngebotCardProps = {
@@ -42,9 +43,7 @@ export function AngebotCard({
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
Mehr erfahren
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<ArrowIcon />
</span>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual — animates on the parent's `group-hover`. */
export function ArrowIcon({ className = "" }: { className?: string }) {
return (
<svg
width="14"
height="10"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className={`shrink-0 transition-transform duration-300 group-hover:translate-x-1 ${className}`}
>
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowIcon } from "@/components/ArrowIcon";
type Tone = "mauve" | "plum" | "white";
type Size = "sm" | "base";
const tones: Record<Tone, string> = {
mauve: "text-anouma-mauve-dark",
plum: "text-anouma-plum",
white: "text-white",
};
const sizes: Record<Size, string> = {
sm: "gap-1.5 text-sm",
base: "gap-2 text-base",
};
type ArrowLinkProps = {
href: string;
children: ReactNode;
tone?: Tone;
size?: Size;
className?: string;
};
/**
* The site's one secondary-action pattern: a text link with a small arrow
* that nudges forward on hover. Used instead of a pill button wherever the
* action isn't the single primary one on screen — keeps most CTAs feeling
* like an invitation to read on rather than another button to click.
*/
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
return (
<Link
href={href}
className={`group inline-flex items-center font-medium ${tones[tone]} ${sizes[size]} ${className}`}
>
{children}
<ArrowIcon />
</Link>
);
}
+9 -22
View File
@@ -1,9 +1,8 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
type ContactFormProps = {
toEmail: string;
@@ -31,31 +30,19 @@ export function ContactForm({
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
const body = encodeURIComponent(`${message}\n\n– ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
// Event only — never the form's name/email/message content, see
// lib/analytics/events.ts and the data-minimization requirement.
track("contact_clicked");
setSent(true);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
Deine Nachricht
</label>
<textarea id="message" name="message" required rows={5} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
<button
type="submit"
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
+2 -16
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { Event } from "@/payload-types";
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
import { ArrowIcon } from "@/components/ArrowIcon";
export function EventTeaserCard({ event }: { event: Event }) {
const { day, month } = formatEventDateParts(event.date);
@@ -21,22 +22,7 @@ export function EventTeaserCard({ event }: { event: Event }) {
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
Mehr erfahren
<svg
width="12"
height="9"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M1 5h11.5M8 1l4.5 4L8 9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<ArrowIcon />
</span>
</div>
</Link>
+1 -1
View File
@@ -16,7 +16,7 @@ export async function Footer() {
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.
Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.
</p>
</div>
+62
View File
@@ -0,0 +1,62 @@
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
/**
* The one field style used by every form on the site (previously each form
* declared its own near-identical `fieldClass` constant with small drifts in
* padding/size/background — see ContactForm, auth/*, meeting/* history).
* Functionality is untouched: both components just spread the native
* input/textarea props through, so controlled and uncontrolled forms,
* validation attributes, autoComplete, etc. all keep working exactly as
* before.
*/
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) {
return (
<label htmlFor={htmlFor} className={hidden ? "sr-only" : "mb-2 block text-sm font-medium text-anouma-plum"}>
{label}
</label>
);
}
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
id: string;
/** Keeps the label for screen readers only — for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
hideLabel?: boolean;
wrapperClassName?: string;
};
export function TextField({ label, id, hideLabel = false, className = "", wrapperClassName = "", ...inputProps }: TextFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} hidden={hideLabel} />
<input id={id} className={`${fieldClass} ${className}`} {...inputProps} />
</div>
);
}
type TextAreaFieldProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label: string;
id: string;
wrapperClassName?: string;
};
export function TextAreaField({ label, id, className = "", wrapperClassName = "", ...textareaProps }: TextAreaFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} />
<textarea id={id} className={`${fieldClass} ${className}`} {...textareaProps} />
</div>
);
}
/** Shared error-message treatment (`role="alert"`) used below every form field/submit button. */
export function FormError({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<p role="alert" className={`text-sm text-red-700 ${className}`}>
{children}
</p>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma — warme, naturverbundene Begleitung"
label="Anouma – warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
+57 -48
View File
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import { track } from "@/lib/analytics/client";
import { OrganicBlob } from "@/components/OrganicBlob";
import type { Offer } from "@/payload-types";
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
@@ -63,7 +65,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
@@ -108,52 +110,55 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 overflow-hidden rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-lg shadow-anouma-plum/10"
>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
<div className="relative">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</div>
</motion.div>
)}
@@ -163,8 +168,9 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
onClick={() => track("navigation_clicked", { path: item.href })}
className={`relative pb-1 text-[15px] font-medium transition-colors after:absolute after:bottom-0 after:left-0 after:h-px after:bg-anouma-mauve-dark after:transition-all after:duration-300 after:content-[''] hover:text-anouma-mauve-dark hover:after:w-full ${
pathname === item.href ? "text-anouma-mauve-dark after:w-full" : "text-anouma-plum/90 after:w-0"
}`}
>
{item.title}
@@ -182,6 +188,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
@@ -300,6 +307,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
@@ -314,6 +322,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
+22 -3
View File
@@ -9,10 +9,23 @@ type PageHeaderProps = {
title: string;
lead?: ReactNode;
crumbs?: Crumb[];
/** Varies the header's decorative blob so every interior page doesn't open with the exact same rose-top-right treatment. */
blobTone?: "rose" | "sage" | "cream" | "mauve";
blobPosition?: "top-right" | "top-left";
/** Shorter vertical padding for pages that don't need much ceremony (e.g. Kontakt). */
compact?: boolean;
};
/** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
export async function PageHeader({
eyebrow,
title,
lead,
crumbs,
blobTone = "rose",
blobPosition = "top-right",
compact = false,
}: PageHeaderProps) {
const breadcrumbData = crumbs?.length
? await (async () => {
const siteUrl = await getSiteUrl();
@@ -24,9 +37,15 @@ export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderPro
})()
: null;
const blobPositionClass = blobPosition === "top-left" ? "-left-20 -top-20" : "-right-20 -top-20";
return (
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<section
className={`relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light ${
compact ? "pb-10 pt-10 sm:pb-12 sm:pt-12" : "pb-16 pt-14 sm:pb-20 sm:pt-20"
}`}
>
<OrganicBlob tone={blobTone} className={`${blobPositionClass} h-72 w-72`} />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{breadcrumbData && <JsonLd data={breadcrumbData} />}
{crumbs && <Breadcrumbs items={crumbs} />}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter — noch zu ergänzen:</span> {children}
<span className="font-medium">Platzhalter – noch zu ergänzen:</span> {children}
</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;
}
+196
View File
@@ -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 &amp; 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 &amp; 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>
);
}
+35
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+4 -20
View File
@@ -3,9 +3,7 @@
import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function LoginForm() {
const router = useRouter();
@@ -44,23 +42,9 @@ export function LoginForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField label="Passwort" id="password" name="password" type="password" required autoComplete="current-password" />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+3 -15
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField } from "@/components/FormField";
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
const router = useRouter();
@@ -35,18 +33,8 @@ export function ProfileForm({ id, name, phone }: { id: number; name: string; pho
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
Telefonnummer (optional)
</label>
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required defaultValue={name} />
<TextField label="Telefonnummer (optional)" id="phone" name="phone" type="tel" defaultValue={phone} />
<button
type="submit"
disabled={status === "saving"}
+13 -34
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm() {
const router = useRouter();
@@ -54,37 +52,18 @@ export function RegisterForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className={fieldClass}
autoComplete="new-password"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField
label="Passwort"
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+8 -6
View File
@@ -1,9 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { useId, useState, type FormEvent } from "react";
import { TextField } from "@/components/FormField";
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
try {
@@ -44,6 +42,7 @@ export function ResendVerificationButton({
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const emailFieldId = useId();
async function resendForSession() {
setStatus("sending");
@@ -108,12 +107,15 @@ export function ResendVerificationButton({
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
) : (
<form onSubmit={handleEmailSubmit} className="mt-2 flex flex-wrap items-start gap-2">
<input
<TextField
label="Deine E-Mail-Adresse"
hideLabel
id={emailFieldId}
type="email"
name="email"
required
placeholder="Deine E-Mail-Adresse"
className={`${fieldClass} max-w-xs flex-1`}
wrapperClassName="max-w-xs flex-1"
/>
<button
type="submit"
+2 -2
View File
@@ -97,7 +97,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
</a>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort — Details siehe Bestätigungs-E-Mail.</p>
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
@@ -107,7 +107,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
className="-ml-2 mt-4 inline-flex min-h-11 items-center rounded-full px-2 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 transition-colors hover:bg-anouma-cream-light hover:text-red-700"
>
Termin stornieren
</button>
+14 -18
View File
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { TextAreaField, FormError } from "@/components/FormField";
import { track } from "@/lib/analytics/client";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
@@ -116,6 +118,8 @@ export function BookingWidget({
return;
}
setResult({ ok: true });
// Event only — never the offer/date/message content.
track("booking_started");
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
setSubmitting(false);
@@ -144,7 +148,7 @@ export function BookingWidget({
key={slot.start}
type="button"
onClick={() => setSelectedSlot(slot.start)}
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
className={`inline-flex min-h-11 items-center justify-center rounded-full border px-4 text-sm font-medium transition-colors ${
selectedSlot === slot.start
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
@@ -189,24 +193,16 @@ export function BookingWidget({
</div>
</fieldset>
<div className="mt-4">
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Nachricht (optional)
</label>
<textarea
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
/>
</div>
<TextAreaField
label="Nachricht (optional)"
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
wrapperClassName="mt-4"
/>
{result?.error && (
<p role="alert" className="mt-3 text-sm text-red-700">
{result.error}
</p>
)}
{result?.error && <FormError className="mt-3">{result.error}</FormError>}
<button
type="button"
+5 -5
View File
@@ -66,13 +66,13 @@ export function MonthCalendar({
const today = dateKey(new Date());
return (
<div className="rounded-3xl bg-white p-6">
<div className="rounded-3xl bg-white p-4 sm:p-6">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
aria-label="Vorheriger Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
‹
</button>
@@ -81,7 +81,7 @@ export function MonthCalendar({
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
aria-label="Nächster Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
›
</button>
@@ -106,9 +106,9 @@ export function MonthCalendar({
key={j}
type="button"
onClick={() => onSelectDay?.(day)}
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
className={`flex aspect-square min-h-11 flex-col items-center justify-center rounded-xl text-sm transition-colors ${
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
} ${key === today && !isSelected ? "font-semibold" : ""}`}
} ${key === today && !isSelected ? "font-semibold ring-1 ring-inset ring-anouma-mauve-dark/50" : ""}`}
>
<span>{day.getDate()}</span>
{dayMarkers.length > 0 && (
+20 -2
View File
@@ -29,6 +29,11 @@ export function CallRoom({ slug }: { slug: string }) {
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const sessionRef = useRef<StoredMeetingSession | null>(null);
const remotesRef = useRef<Map<string, RemoteEntry>>(new Map());
useEffect(() => {
remotesRef.current = remotes;
}, [remotes]);
const send = useCallback((message: ClientToServerMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
@@ -149,9 +154,15 @@ export function CallRoom({ slug }: { slug: string }) {
if (cancelled) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling`);
wsRef.current = ws;
// Token travels as the first message, not a `?token=` query param, so
// it doesn't end up persisted in reverse-proxy/access logs.
ws.onopen = () => {
ws.send(JSON.stringify({ type: "auth", token: parsed.token }));
};
ws.onmessage = (event) => {
const message: ServerToClientMessage = JSON.parse(event.data);
handleServerMessage(message);
@@ -207,11 +218,18 @@ export function CallRoom({ slug }: { slug: string }) {
sessionStorage.removeItem(MEETING_SESSION_KEY);
break;
}
case "auth-error": {
setPhase("error");
setErrorMessage("Die Meeting-Sitzung ist abgelaufen oder ungültig.");
cleanup();
sessionStorage.removeItem(MEETING_SESSION_KEY);
break;
}
}
}
function remotesLookupName(participantId: string): string {
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
}
return () => {
+43 -39
View File
@@ -2,11 +2,29 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export const MEETING_SESSION_KEY = "anouma-meeting-session";
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
// A stable per-browser identity so a host's "kick" survives the removed
// participant simply reconnecting (which always gets a fresh participantId
// — see lib/meeting/token.ts). Deliberately in localStorage, not
// sessionStorage: it must outlive the "kicked" screen clearing the session
// and the "Erneut beitreten" link going back through this form again.
function getOrCreateClientId(): string {
try {
const existing = localStorage.getItem(MEETING_CLIENT_ID_KEY);
if (existing) return existing;
const created = crypto.randomUUID();
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
return created;
} catch {
// Storage unavailable (private browsing, etc.) — fall back to a
// one-off id; kicks just won't stick across reconnects in that case.
return crypto.randomUUID();
}
}
export type StoredMeetingSession = {
token: string;
@@ -33,7 +51,7 @@ export function JoinForm({ slug }: { slug: string }) {
const res = await fetch(`/api/meetings/${slug}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
credentials: "include",
});
const data = await res.json();
@@ -62,41 +80,27 @@ export function JoinForm({ slug }: { slug: string }) {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Dein Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name eingeben"
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Meeting-Passwort
</label>
<input
id="password"
type="text"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
className={fieldClass}
autoComplete="off"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField
label="Dein Name"
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name eingeben"
autoComplete="name"
/>
<TextField
label="Meeting-Passwort"
id="password"
type="text"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
autoComplete="off"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+21 -37
View File
@@ -1,9 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm({ slug }: { slug: string }) {
const [name, setName] = useState("");
@@ -37,46 +35,32 @@ export function RegisterForm({ slug }: { slug: string }) {
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung — wir freuen uns auf dich!
Danke für deine Anmeldung – wir freuen uns auf dich!
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Name
</label>
<input
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
autoComplete="email"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField
label="Name"
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
<TextField
label="E-Mail"
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={status === "loading"}
+45
View File
@@ -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.",
},
},
],
};
+102
View File
@@ -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.",
},
},
],
},
],
};
+94
View File
@@ -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,
};
}
+109
View File
@@ -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,
};
}
+92
View File
@@ -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(() => {});
}
+69
View File
@@ -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 };
}
+26
View File
@@ -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;
}
+71
View File
@@ -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 };
}
+33
View File
@@ -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");
}
+29
View File
@@ -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();
}
+28
View File
@@ -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";
}
+13 -1
View File
@@ -32,7 +32,19 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
}
export function getClientIp(request: Request): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
// *appends* the real client IP to any X-Forwarded-For it already
// received rather than replacing it, so a directly-connecting client
// could prepend arbitrary spoofed entries of their own. Only the
// right-most (last) entry — the one the trusted proxy itself appended —
// is trustworthy; taking the first entry would let a client bypass this
// rate limit entirely by sending a fake X-Forwarded-For header.
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0]!.trim();
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]!.trim();
}
return "unknown";
}
+70 -9
View File
@@ -3,6 +3,8 @@ import { WEEKDAYS } from "@/collections/Availability";
export type TimeSlot = { start: Date; end: Date };
const BERLIN_TZ = "Europe/Berlin";
const WEEKDAY_BY_INDEX = [
"sunday",
"monday",
@@ -13,18 +15,73 @@ const WEEKDAY_BY_INDEX = [
"saturday",
] as const;
type CalendarDate = { year: number; month: number; day: number };
// Availability/AvailabilityOverrides times are entered by the admin in their
// browser (Europe/Berlin) and stored as UTC instants. All wall-clock reads
// and writes here go through Intl's Berlin timezone data (not local Date
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
// own timezone (this app runs in UTC in production) never leaks into the
// computed availability — see lib/calendar/timezone.ts for the same pattern.
function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
}
function berlinOffsetMinutes(date: Date): number {
const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" })
.formatToParts(date)
.find((p) => p.type === "timeZoneName")?.value ?? "GMT+1";
const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/);
const hours = match ? Number(match[1]) : 1;
const minutes = match?.[2] ? Number(match[2]) : 0;
return hours * 60 + (hours < 0 ? -minutes : minutes);
}
/** Converts a Europe/Berlin wall-clock date+time into the UTC instant it represents. */
function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date {
const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute);
return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000);
}
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function calendarDate(date: Date): CalendarDate {
const { year, month, day } = berlinParts(date);
return { year, month, day };
}
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
// conversion happens here, so using the UTC constructor is safe and simple.
const utc = new Date(Date.UTC(year, month - 1, day + delta));
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
}
function weekdayOf({ year, month, day }: CalendarDate): (typeof WEEKDAY_BY_INDEX)[number] {
return WEEKDAY_BY_INDEX[new Date(Date.UTC(year, month - 1, day)).getUTCDay()];
}
function timeOfDayMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
const { hour, minute } = berlinParts(date);
return hour * 60 + minute;
}
function atMinutesOfDay(day: Date, minutes: number): Date {
const d = new Date(day);
d.setHours(0, Math.round(minutes), 0, 0);
return d;
function atMinutesOfDay(day: CalendarDate, minutes: number): Date {
const rounded = Math.round(minutes);
return berlinWallTimeToUTC(day, Math.floor(rounded / 60), rounded % 60);
}
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
@@ -67,8 +124,12 @@ export async function getAvailableSlots(
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
const now = new Date();
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
const key = dateKey(day);
for (
let day = calendarDate(from);
atMinutesOfDay(day, 0) <= to;
day = addCalendarDays(day, 1)
) {
const key = `${day.year}-${String(day.month).padStart(2, "0")}-${String(day.day).padStart(2, "0")}`;
const override = overridesByDate.get(key);
let windows: { startMinutes: number; endMinutes: number }[] = [];
@@ -83,7 +144,7 @@ export async function getAvailableSlots(
},
];
} else {
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
const weekdayName = weekdayOf(day);
windows = weekly
.filter((w) => w.weekday === weekdayName)
.map((w) => ({
+5
View File
@@ -15,6 +15,11 @@ function escapeICSText(value: string): string {
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
// \r must be stripped (not just \n escaped): many ICS parsers split
// lines on a bare CR too, so leaving it unescaped lets untrusted text
// (e.g. a customer's booking message ending up in DESCRIPTION) break
// out of the property value and inject extra iCalendar lines/properties.
.replace(/\r\n?/g, "\\n")
.replace(/\n/g, "\\n");
}
+1 -1
View File
@@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
]),
paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem Konto vorbei.", { small: true }),
paragraph("Melde dich gerne für einen neuen Termin – schau einfach wieder in deinem Konto vorbei.", { small: true }),
].join("\n"),
});
return { subject: "Deine Terminanfrage bei ANOUMA", html };
+1 -1
View File
@@ -41,7 +41,7 @@ export function renderFactBox(rows: { label: string; value: string }[]): string
/** Shared table-based, inline-styled email shell — same design language across all ANOUMA emails. */
export function renderEmailShell(args: { preheader?: string; bodyHtml: string; footerText?: string }): string {
const { bodyHtml, footerText = "Räume für Verbindung — mit dir selbst, miteinander und mit der Natur." } = args;
const { bodyHtml, footerText = "Räume für Verbindung – mit dir selbst, miteinander und mit der Natur." } = args;
return `<!doctype html>
<html lang="de">
+1 -1
View File
@@ -76,7 +76,7 @@ export function renderReminderEmailHtml(data: ReminderEmailData): string {
</tr>
<tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;">
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.</span>
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.</span>
</td>
</tr>
</table>
+3 -1
View File
@@ -7,6 +7,7 @@ export type ParticipantSummary = {
};
export type ClientToServerMessage =
| { type: "auth"; token: string }
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
@@ -19,4 +20,5 @@ export type ServerToClientMessage =
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
| { type: "kicked" };
| { type: "kicked" }
| { type: "auth-error"; reason: string };
+19 -3
View File
@@ -4,6 +4,7 @@ import type { MeetingRole } from "./token";
export type Participant = {
ws: WebSocket;
participantId: string;
clientId?: string;
name: string;
role: MeetingRole;
};
@@ -11,6 +12,11 @@ export type Participant = {
type Room = {
participants: Map<string, Participant>;
kickedParticipantIds: Set<string>;
// Keyed by the client-declared, localStorage-persisted clientId (see
// token.ts) rather than the per-connection participantId, so a kick
// survives the participant simply reconnecting (which always mints a
// fresh participantId) — see isKicked below.
kickedClientIds: Set<string>;
};
// In-memory only — this server process is the single source of truth for
@@ -22,19 +28,27 @@ const rooms = new Map<string, Room>();
function getOrCreateRoom(eventSlug: string): Room {
let room = rooms.get(eventSlug);
if (!room) {
room = { participants: new Map(), kickedParticipantIds: new Set() };
room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() };
rooms.set(eventSlug, room);
}
return room;
}
export function isKicked(eventSlug: string, participantId: string): boolean {
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
const room = rooms.get(eventSlug);
if (!room) return false;
if (room.kickedParticipantIds.has(participantId)) return true;
return clientId ? room.kickedClientIds.has(clientId) : false;
}
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
const room = getOrCreateRoom(eventSlug);
const existing = [...room.participants.values()];
// Two connections claiming the same participantId (e.g. two tabs replaying
// the same stored session) would otherwise silently overwrite the map
// entry here and orphan the first socket — close it first so it doesn't
// leak as an unreachable broadcast target.
room.participants.get(participant.participantId)?.ws.close(4002, "replaced-by-new-connection");
room.participants.set(participant.participantId, participant);
return existing;
}
@@ -50,6 +64,8 @@ export function kickFromRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.kickedParticipantIds.add(participantId);
const target = room.participants.get(participantId);
if (target?.clientId) room.kickedClientIds.add(target.clientId);
room.participants.delete(participantId);
}
+65 -40
View File
@@ -1,4 +1,3 @@
import type { IncomingMessage } from "node:http";
import type { WebSocket, WebSocketServer } from "ws";
import { verifyMeetingToken } from "./token";
import {
@@ -12,46 +11,61 @@ import {
} from "./rooms";
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
// A connection has this long to send its "auth" message before being
// dropped. The token travels as the first WS message rather than a
// `?token=` query parameter so it doesn't end up persisted in reverse-proxy
// or access logs; it's still HMAC-signed and short-lived either way.
const AUTH_TIMEOUT_MS = 10_000;
function participantSummary(p: Participant): ParticipantSummary {
return { participantId: p.participantId, name: p.name, role: p.role };
}
export function attachSignalingServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url ?? "", "http://internal");
const token = url.searchParams.get("token") ?? "";
const payload = verifyMeetingToken(token);
wss.on("connection", (ws: WebSocket) => {
let self: Participant | null = null;
let eventSlug: string | null = null;
if (!payload) {
ws.close(4000, "invalid-token");
return;
const authTimeout = setTimeout(() => {
if (!self) ws.close(4000, "auth-timeout");
}, AUTH_TIMEOUT_MS);
function handleAuth(token: string) {
const payload = verifyMeetingToken(token);
if (!payload) {
ws.send(JSON.stringify({ type: "auth-error", reason: "invalid-token" }));
ws.close(4000, "invalid-token");
return;
}
if (isKicked(payload.eventSlug, payload.participantId, payload.clientId)) {
ws.close(4001, "removed-by-host");
return;
}
eventSlug = payload.eventSlug;
self = {
ws,
participantId: payload.participantId,
clientId: payload.clientId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
}
if (isKicked(payload.eventSlug, payload.participantId)) {
ws.close(4001, "removed-by-host");
return;
}
const self: Participant = {
ws,
participantId: payload.participantId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(payload.eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
ws.on("message", (raw) => {
let message: ClientToServerMessage;
@@ -61,8 +75,17 @@ export function attachSignalingServer(wss: WebSocketServer) {
return;
}
if (message.type === "auth") {
clearTimeout(authTimeout);
if (self) return; // already authenticated on this connection
handleAuth(message.token);
return;
}
if (!self || !eventSlug) return; // no messages accepted before auth
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
const target = getParticipant(payload.eventSlug, message.to);
const target = getParticipant(eventSlug, message.to);
if (target && target.ws.readyState === target.ws.OPEN) {
target.ws.send(
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
@@ -73,18 +96,20 @@ export function attachSignalingServer(wss: WebSocketServer) {
if (message.type === "kick") {
if (self.role !== "host") return; // server-side authority, not client-claimed
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
const target = getParticipant(eventSlug, message.targetParticipantId);
if (!target) return;
kickFromRoom(payload.eventSlug, target.participantId);
kickFromRoom(eventSlug, target.participantId);
target.ws.send(JSON.stringify({ type: "kicked" }));
target.ws.close(4001, "removed-by-host");
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
broadcast(eventSlug, { type: "peer-left", participantId: target.participantId });
}
});
ws.on("close", () => {
leaveRoom(payload.eventSlug, self.participantId);
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
clearTimeout(authTimeout);
if (!self || !eventSlug) return;
leaveRoom(eventSlug, self.participantId);
broadcast(eventSlug, { type: "peer-left", participantId: self.participantId });
});
});
}
+16
View File
@@ -5,6 +5,16 @@ export type MeetingRole = "host" | "participant";
export type MeetingTokenPayload = {
eventSlug: string;
participantId: string;
// Self-declared, persisted client-side (localStorage, not sessionStorage)
// identity for participants — participantId is fresh per /join call, so
// without this a kick could always be undone by simply rejoining. Only
// used to make a kick stick across a rejoin (see rooms.ts kickedClientIds);
// never used to key live room membership, so a client choosing an
// arbitrary/colliding value can only affect their own future kick status,
// never another participant's connection. Still bypassable by clearing
// localStorage or joining from a different browser — there's no real
// identity to anchor to for an unauthenticated participant.
clientId?: string;
name: string;
role: MeetingRole;
exp: number; // unix ms
@@ -29,12 +39,18 @@ export function createMeetingToken(args: {
eventSlug: string;
name: string;
role: MeetingRole;
clientId?: string;
ttlMs?: number;
}): { token: string; participantId: string } {
const participantId = randomUUID();
const clientId =
typeof args.clientId === "string" && args.clientId.length > 0 && args.clientId.length <= 100
? args.clientId
: undefined;
const payload: MeetingTokenPayload = {
eventSlug: args.eventSlug,
participantId,
clientId,
name: args.name,
role: args.role,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
+6 -1
View File
@@ -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" });
});
+2 -2
View File
@@ -1,9 +1,9 @@
export const siteConfig = {
name: "Anouma",
domain: "https://anouma.org",
title: "Anouma — Prozessbegleitung, Doula & Gemeinschaft",
title: "Anouma – Prozessbegleitung, Doula & Gemeinschaft",
description:
"Anouma begleitet dich in Lebensphasen, Schwangerschaft und Geburt sowie in Kinder- und Singkreisen — mit Naturverbundenheit, Vertrauen und einem geschützten Raum für deinen eigenen Weg.",
"Anouma begleitet dich in Lebensphasen, Schwangerschaft und Geburt sowie in Kinder- und Singkreisen – mit Naturverbundenheit, Vertrauen und einem geschützten Raum für deinen eigenen Weg.",
locale: "de_DE",
} as const;
+47
View File
@@ -6,6 +6,47 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// Applied to every route, including /admin and /api — none of these
// constrain what the page can load/render, so they can't break the Payload
// admin bundle. Camera/microphone/display-capture are allowed for the
// site's own origin because the WebRTC call pages (app/(call)/**) need them.
const baseSecurityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()" },
// Browsers only ever honor this over an actual HTTPS connection, so it's
// harmless to send in local http:// dev too.
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
];
// Deliberately NOT applied to /admin or /api: Payload's admin bundle isn't
// audited here for CSP compatibility, and API routes serve data, not HTML,
// so a content policy doesn't apply to them anyway. connect-src stays broad
// (rather than 'self') because the WebRTC call pages connect to whatever
// STUN/TURN server is configured via env vars at runtime (see
// lib/meeting/iceServers.ts) — that host isn't known here. style-src needs
// 'unsafe-inline' because framer-motion drives its animations through
// inline style attributes. script-src allows 'unsafe-eval' only in dev,
// matching Next's own documented CSP guidance — React uses eval there for
// server-error-stack reconstruction; neither React nor Next use it in a
// production build.
const isDev = process.env.NODE_ENV === "development";
const frontendCSP = [
"default-src 'self'",
`script-src 'self'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https: wss: stun: turn: turns:",
"media-src 'self' blob:",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join("; ");
const nextConfig: NextConfig = {
// NOT "standalone": this project uses a custom server (server.ts) for the
// WebRTC signaling WebSocket, and standalone output can't be combined with
@@ -25,6 +66,12 @@ const nextConfig: NextConfig = {
turbopack: {
root: path.resolve(dirname),
},
async headers() {
return [
{ source: "/:path*", headers: baseSecurityHeaders },
{ source: "/((?!admin|api).*)", headers: [{ key: "Content-Security-Policy", value: frontendCSP }] },
];
},
};
export default withPayload(nextConfig, { devBundleServerPackages: false });
+383
View File
@@ -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",
+1
View File
@@ -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"
+235
View File
@@ -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
View File
@@ -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: {
+9 -9
View File
@@ -95,7 +95,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
bookable: true,
price: "auf Anfrage",
durationMinutes: 60,
shortDescription: "Persönliche Einzelbegleitung — vor Ort oder online.",
shortDescription: "Persönliche Einzelbegleitung – vor Ort oder online.",
description: lexicalFromParagraphs(prozessbegleitung.sections[0].paragraphs),
},
];
@@ -138,13 +138,13 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
},
{
title: "Verbindung",
quote: "Räume für Verbindung — mit uns selbst, miteinander und mit der Natur.",
quote: "Räume für Verbindung – mit uns selbst, miteinander und mit der Natur.",
},
{ title: "Natur", quote: "Wir sind nicht nur in der Natur — wir sind ein Teil von ihr." },
{ title: "Natur", quote: "Wir sind nicht nur in der Natur – wir sind ein Teil von ihr." },
],
ctaTitle: "Kennenlernen & Termine vereinbaren",
ctaLead:
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch.",
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch.",
},
});
console.log(" ↺ Global „Startseite“ gesetzt");
@@ -166,7 +166,7 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
slug: "angebote-intro",
data: {
title: "Angebote",
lead: "Jeder Raum ist anders — und darf so genau das sein, was gerade gebraucht wird. Ein Überblick über meine Begleitung für Erwachsene, Kinder und Gemeinschaft.",
lead: "Jeder Raum ist anders – und darf so genau das sein, was gerade gebraucht wird. Ein Überblick über meine Begleitung für Erwachsene, Kinder und Gemeinschaft.",
},
});
console.log(" ↺ Global „Angebote (Einleitung)“ gesetzt");
@@ -186,11 +186,11 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
data: {
eyebrow: "Kontakt",
title: "Ich freue mich, von dir zu hören",
lead: "Ob Frage, Anliegen oder der Wunsch nach einem Kennenlerngespräch — schreib mir gerne.",
lead: "Ob Frage, Anliegen oder der Wunsch nach einem Kennenlerngespräch – schreib mir gerne.",
// Placeholder contact details — replace with the real ones in the admin.
email: "hallo@anouma.org",
phone: "+49 000 00 00 00",
region: "Platzhalter — Ort / Region",
region: "Platzhalter – Ort / Region",
},
});
console.log(" ↺ Global „Kontakt“ gesetzt (mit Platzhalter-Kontaktdaten — bitte im Admin ersetzen)");
@@ -200,11 +200,11 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
data: {
eyebrow: "Termin buchen",
title: "Kennenlernen & Termine vereinbaren",
lead: "Der erste Schritt ist oft der wichtigste. Melde dich gerne — unverbindlich und in deinem Tempo.",
lead: "Der erste Schritt ist oft der wichtigste. Melde dich gerne – unverbindlich und in deinem Tempo.",
steps: [
{
title: "Nachricht schreiben",
text: "Schildere kurz dein Anliegen und welches Angebot dich interessiert — über das Formular unten oder direkt per E-Mail.",
text: "Schildere kurz dein Anliegen und welches Angebot dich interessiert – über das Formular unten oder direkt per E-Mail.",
},
{
title: "Kennenlernen",
+3
View File
@@ -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)")"