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