Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526a908c80 | ||
|
|
65b6eeac51 | ||
|
|
82c4261a81 | ||
|
|
1b7dbad843 | ||
|
|
8d60d48bd0 |
@@ -25,6 +25,11 @@ PAYLOAD_SECRET=replace-with-a-long-random-secret
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
|
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
|
# Sent as "Authorization: Bearer <value>" by the external cron job that
|
||||||
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
|
# triggers /api/cron/event-reminders. Generate with: openssl rand -hex 32
|
||||||
CRON_SECRET=replace-with-a-long-random-secret
|
CRON_SECRET=replace-with-a-long-random-secret
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ jobs:
|
|||||||
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
MEETING_SESSION_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
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ jobs:
|
|||||||
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
MEETING_SESSION_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
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
steps:
|
steps:
|
||||||
@@ -67,27 +68,43 @@ jobs:
|
|||||||
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
||||||
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
||||||
# just aliases of each other for `${{ }}` templating).
|
# 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: |
|
run: |
|
||||||
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
||||||
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$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:
|
env:
|
||||||
REGISTRY: git.maro.run
|
REGISTRY: git.maro.run
|
||||||
REGISTRY_REPO: maro/anouma
|
REGISTRY_REPO: maro/anouma
|
||||||
|
|
||||||
- name: Log in to registry
|
- 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
|
- name: Build image
|
||||||
run: |
|
env:
|
||||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||||
VERSION="${{ steps.image.outputs.version }}"
|
VERSION: ${{ steps.image.outputs.version }}
|
||||||
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
||||||
|
|
||||||
- name: Push image
|
- name: Push image
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||||
|
VERSION: ${{ steps.image.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
|
||||||
VERSION="${{ steps.image.outputs.version }}"
|
|
||||||
docker push "$IMAGE:$VERSION"
|
docker push "$IMAGE:$VERSION"
|
||||||
docker push "$IMAGE:latest"
|
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 |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
|
| `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 |
|
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
|
||||||
| `SMTP_*` | The existing ANOUMA mail system |
|
| `SMTP_*` | The existing ANOUMA mail system |
|
||||||
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
|
| `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 } }) =>
|
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
|
||||||
user?.collection === "users" && user.role === "admin";
|
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
|
* Admins can read every document (including drafts); everyone else may only
|
||||||
* read documents whose Payload draft/publish status is "published".
|
* read documents whose Payload draft/publish status is "published".
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export default async function AktuellesPage() {
|
|||||||
|
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
{posts.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
<p className="text-lg text-anouma-plum">
|
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
|
||||||
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
|
<p className="font-serif text-xl font-medium text-anouma-plum">Hier erscheinen bald die ersten Neuigkeiten</p>
|
||||||
</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">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{posts.map((post, i) => (
|
{posts.map((post, i) => (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { siteConfig } from "@/lib/site";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const FALLBACK_DESCRIPTION =
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
|
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
|
||||||
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
|
|||||||
|
|
||||||
<CTA
|
<CTA
|
||||||
title="Unsicher, welcher Raum passt?"
|
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
|
<CTA
|
||||||
title="Deine Stimme erklingen lassen"
|
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"
|
eyebrow="Konto"
|
||||||
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
|
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
|
||||||
crumbs={[{ title: "E-Mail bestätigen" }]}
|
crumbs={[{ title: "E-Mail bestätigen" }]}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-md text-center">
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default async function DatenschutzPage() {
|
|||||||
const contact = await getContactGlobal();
|
const contact = await getContactGlobal();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
|
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} compact />
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-2xl space-y-8">
|
<div className="mx-auto max-w-2xl space-y-8">
|
||||||
<PlaceholderNote>
|
<PlaceholderNote>
|
||||||
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
|
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter — Vor- und Nachname]
|
[Platzhalter – Vor- und Nachname]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter — Anschrift]
|
[Platzhalter – Anschrift]
|
||||||
<br />
|
<br />
|
||||||
E-Mail: {contact.email}
|
E-Mail: {contact.email}
|
||||||
</p>
|
</p>
|
||||||
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
|
|||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
|
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
|
||||||
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
|
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
|
||||||
@@ -36,11 +36,11 @@ export default async function ImpressionenPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Impressionen"
|
eyebrow="Impressionen"
|
||||||
title="Einblicke in gemeinsame Räume"
|
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" }]}
|
crumbs={[{ title: "Impressionen" }]}
|
||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<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) => (
|
{gallery.map((item, i) => (
|
||||||
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
|
<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" />
|
<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();
|
const contact = await getContactGlobal();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
|
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} compact />
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-2xl space-y-8">
|
<div className="mx-auto max-w-2xl space-y-8">
|
||||||
<PlaceholderNote>
|
<PlaceholderNote>
|
||||||
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
|
<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">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter — Vor- und Nachname]
|
[Platzhalter – Vor- und Nachname]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter — Straße und Hausnummer]
|
[Platzhalter – Straße und Hausnummer]
|
||||||
<br />
|
<br />
|
||||||
[Platzhalter — PLZ und Ort]
|
[Platzhalter – PLZ und Ort]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
|
|||||||
Umsatzsteuer-Identifikationsnummer
|
Umsatzsteuer-Identifikationsnummer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
|
|||||||
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 leading-relaxed text-anouma-plum">
|
<p className="mt-4 leading-relaxed text-anouma-plum">
|
||||||
[Platzhalter — Vor- und Nachname, Anschrift wie oben]
|
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export default async function KontaktPage() {
|
|||||||
title={contact.title || "Ich freue mich, von dir zu hören"}
|
title={contact.title || "Ich freue mich, von dir zu hören"}
|
||||||
lead={contact.lead}
|
lead={contact.lead}
|
||||||
crumbs={[{ title: "Kontakt" }]}
|
crumbs={[{ title: "Kontakt" }]}
|
||||||
|
blobTone="sage"
|
||||||
|
blobPosition="top-left"
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ export default async function KontoLayout({ children }: { children: ReactNode })
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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}
|
{item.title}
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
<div className="min-w-0">{children}</div>
|
<div className="min-w-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
import { getCustomerBookings } from "@/lib/booking/queries";
|
import { getCustomerBookings } from "@/lib/booking/queries";
|
||||||
import { BookingCard } from "@/components/booking/BookingCard";
|
import { BookingCard } from "@/components/booking/BookingCard";
|
||||||
|
import { ArrowLink } from "@/components/ArrowLink";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Mein Konto" };
|
export const metadata: Metadata = { title: "Mein Konto" };
|
||||||
|
|
||||||
@@ -61,9 +61,7 @@ export default async function KontoOverviewPage() {
|
|||||||
|
|
||||||
{past.length > 0 && (
|
{past.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
|
<ArrowLink href="/konto/termine">Alle Termine (inkl. vergangene) ansehen</ArrowLink>
|
||||||
Alle Termine (inkl. vergangene) ansehen →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
|
|||||||
<div className="mt-6 rounded-3xl bg-white p-6">
|
<div className="mt-6 rounded-3xl bg-white p-6">
|
||||||
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
|
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
|
||||||
<p className="mt-6 text-xs text-anouma-plum/60">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type { Metadata } from "next";
|
|||||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
|
import { Analytics } from "@/components/Analytics";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
import { getOffers } from "@/lib/payload/content";
|
import { getOffers } from "@/lib/payload/content";
|
||||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
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 { getSiteUrl } from "@/lib/seo/config";
|
||||||
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
|
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -30,7 +31,7 @@ export const metadata: Metadata = {
|
|||||||
metadataBase: new URL(siteConfig.domain),
|
metadataBase: new URL(siteConfig.domain),
|
||||||
title: {
|
title: {
|
||||||
default: siteConfig.title,
|
default: siteConfig.title,
|
||||||
template: `%s — ${siteConfig.name}`,
|
template: `%s – ${siteConfig.name}`,
|
||||||
},
|
},
|
||||||
description: siteConfig.description,
|
description: siteConfig.description,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -51,13 +52,14 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
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(),
|
getOffers(),
|
||||||
getCurrentCustomer(),
|
getCurrentCustomer(),
|
||||||
getSEOSettingsGlobal(),
|
getSEOSettingsGlobal(),
|
||||||
getBookingSettingsGlobal(),
|
getBookingSettingsGlobal(),
|
||||||
getContactGlobal(),
|
getContactGlobal(),
|
||||||
getSiteUrl(),
|
getSiteUrl(),
|
||||||
|
getCookieSettingsGlobal(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +86,14 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
|
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} compact />
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-md">
|
<div className="mx-auto max-w-md">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
+80
-59
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Hero } from "@/components/Hero";
|
import { Hero } from "@/components/Hero";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
|
import { ArrowLink } from "@/components/ArrowLink";
|
||||||
import { Section, SectionHeading } from "@/components/Section";
|
import { Section, SectionHeading } from "@/components/Section";
|
||||||
import { AngebotCard } from "@/components/AngebotCard";
|
import { AngebotCard } from "@/components/AngebotCard";
|
||||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||||
|
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||||
import { RichText } from "@/components/RichText";
|
import { RichText } from "@/components/RichText";
|
||||||
import { CTA } from "@/components/CTA";
|
import { CTA } from "@/components/CTA";
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
@@ -29,7 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
fallbackDescription: () =>
|
fallbackDescription: () =>
|
||||||
home.heroSupporting ||
|
home.heroSupporting ||
|
||||||
seoSettings.defaultDescription ||
|
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,
|
fallbackImage: home.heroImage,
|
||||||
defaultOgImage: seoSettings.defaultOgImage,
|
defaultOgImage: seoSettings.defaultOgImage,
|
||||||
});
|
});
|
||||||
@@ -53,6 +55,8 @@ export default async function Home() {
|
|||||||
getUpcomingEvents(3),
|
getUpcomingEvents(3),
|
||||||
]);
|
]);
|
||||||
const groups = groupOffersByCategory(offers);
|
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();
|
const siteUrl = await getSiteUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,9 +77,9 @@ export default async function Home() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button href="/termin-buchen">Termin buchen</Button>
|
<Button href="/termin-buchen">Termin buchen</Button>
|
||||||
<Button href="/angebote" variant="secondary">
|
<ArrowLink href="/angebote" size="base" className="self-center">
|
||||||
Angebote entdecken
|
Angebote entdecken
|
||||||
</Button>
|
</ArrowLink>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -89,24 +93,39 @@ export default async function Home() {
|
|||||||
<SectionHeading
|
<SectionHeading
|
||||||
eyebrow="Angebote"
|
eyebrow="Angebote"
|
||||||
title="Räume, die zu deinem Weg passen"
|
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">
|
<div className="mt-14 space-y-6">
|
||||||
{groups.map((group, i) => {
|
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
|
||||||
const isSingle = group.offers.length === 1;
|
{featuredGroup && (
|
||||||
const first = group.offers[0];
|
<Reveal>
|
||||||
return (
|
<AngebotCard
|
||||||
<Reveal key={group.category} delay={i * 0.08}>
|
href={featuredGroup.offers.length === 1 ? `/angebote/${featuredGroup.offers[0].slug}` : featuredGroup.href}
|
||||||
<AngebotCard
|
title={featuredGroup.label}
|
||||||
href={isSingle ? `/angebote/${first.slug}` : group.href}
|
tagline={featuredGroup.offers.length === 1 ? featuredGroup.offers[0].shortDescription : `${featuredGroup.offers.length} Angebote`}
|
||||||
title={group.label}
|
mood={moodForOffer(featuredGroup.offers[0])}
|
||||||
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
|
imageSrc={mediaUrl(featuredGroup.offers[0].image, "card")}
|
||||||
mood={moodForOffer(first)}
|
size="large"
|
||||||
imageSrc={mediaUrl(first.image, "card")}
|
/>
|
||||||
/>
|
</Reveal>
|
||||||
</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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -141,9 +160,7 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Button href="/ueber-mich" variant="ghost">
|
<ArrowLink href="/ueber-mich">Mehr über mich lesen</ArrowLink>
|
||||||
Mehr über mich lesen →
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,24 +168,27 @@ export default async function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{home.values && home.values.length > 0 && (
|
{home.values && home.values.length > 0 && (
|
||||||
<Section tone="mauve">
|
<Section tone="mauve" className="relative overflow-hidden">
|
||||||
<SectionHeading
|
<OrganicBlob tone="sage" className="-left-24 top-1/2 h-96 w-96 -translate-y-1/2 opacity-20" />
|
||||||
eyebrow={home.valuesEyebrow ?? undefined}
|
<div className="relative">
|
||||||
title={home.valuesTitle || "Philosophie"}
|
<SectionHeading
|
||||||
align="center"
|
eyebrow={home.valuesEyebrow ?? undefined}
|
||||||
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
|
title={home.valuesTitle || "Philosophie"}
|
||||||
/>
|
align="center"
|
||||||
<div className="mt-16 grid gap-10 sm:grid-cols-3">
|
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
|
||||||
{home.values.map((wert, i) => (
|
/>
|
||||||
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
|
<div className="mt-16 grid gap-10 sm:grid-cols-3">
|
||||||
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
|
{home.values.map((wert, i) => (
|
||||||
{wert.title}
|
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
|
||||||
</p>
|
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
|
||||||
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
|
{wert.title}
|
||||||
„{wert.quote}“
|
</p>
|
||||||
</p>
|
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
|
||||||
</Reveal>
|
„{wert.quote}“
|
||||||
))}
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
@@ -177,9 +197,7 @@ export default async function Home() {
|
|||||||
<Section tone="warm">
|
<Section tone="warm">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
|
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
|
||||||
<Button href="/termine" variant="ghost">
|
<ArrowLink href="/termine">Alle Termine</ArrowLink>
|
||||||
Alle Termine →
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||||||
{events.map((event, i) => (
|
{events.map((event, i) => (
|
||||||
@@ -195,23 +213,31 @@ export default async function Home() {
|
|||||||
<Section tone="cream">
|
<Section tone="cream">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
|
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
|
||||||
<Button href="/aktuelles" variant="ghost">
|
<ArrowLink href="/aktuelles">Alle Neuigkeiten</ArrowLink>
|
||||||
Alle Neuigkeiten →
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||||||
{posts.map((post, i) => (
|
{posts.map((post, i) => (
|
||||||
<Reveal key={post.slug} delay={i * 0.08}>
|
<Reveal key={post.slug} delay={i * 0.08}>
|
||||||
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
|
<Link href={`/aktuelles/${post.slug}`} className="group block">
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
|
<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", {
|
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
|
<h3 className="mt-2 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>
|
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
@@ -228,13 +254,8 @@ export default async function Home() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 flex justify-center">
|
||||||
<Link
|
<ArrowLink href="/impressionen">Alle Impressionen ansehen</ArrowLink>
|
||||||
href="/impressionen"
|
|
||||||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Alle Impressionen ansehen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -242,7 +263,7 @@ export default async function Home() {
|
|||||||
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
||||||
lead={
|
lead={
|
||||||
home.ctaLead ||
|
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"
|
title="Konto erstellen"
|
||||||
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
|
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
|
||||||
crumbs={[{ title: "Konto erstellen" }]}
|
crumbs={[{ title: "Konto erstellen" }]}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-md">
|
<div className="mx-auto max-w-md">
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default async function TerminBuchenPage() {
|
|||||||
title="Allgemeine Anfrage"
|
title="Allgemeine Anfrage"
|
||||||
lead={
|
lead={
|
||||||
bookableOffers.length > 0
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
align="left"
|
align="left"
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export default async function EventDetailPage({ params }: Args) {
|
|||||||
eyebrow={categoryLabel}
|
eyebrow={categoryLabel}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
|
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
|
||||||
|
blobTone="mauve"
|
||||||
|
blobPosition="top-left"
|
||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
<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 { Section } from "@/components/Section";
|
||||||
import { Reveal } from "@/components/Reveal";
|
import { Reveal } from "@/components/Reveal";
|
||||||
import { EventTeaserCard } from "@/components/EventTeaserCard";
|
import { EventTeaserCard } from "@/components/EventTeaserCard";
|
||||||
|
import { ArrowLink } from "@/components/ArrowLink";
|
||||||
import { getAllEvents } from "@/lib/payload/content";
|
import { getAllEvents } from "@/lib/payload/content";
|
||||||
import { buildMetadata } from "@/lib/seo/metadata";
|
import { buildMetadata } from "@/lib/seo/metadata";
|
||||||
import { getSiteUrl } from "@/lib/seo/config";
|
import { getSiteUrl } from "@/lib/seo/config";
|
||||||
@@ -32,14 +33,21 @@ export default async function TerminePage() {
|
|||||||
title="Aktuelle Termine"
|
title="Aktuelle Termine"
|
||||||
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
|
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
|
||||||
crumbs={[{ title: "Termine" }]}
|
crumbs={[{ title: "Termine" }]}
|
||||||
|
blobTone="mauve"
|
||||||
|
blobPosition="top-left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<p className="text-lg text-anouma-plum">
|
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
|
||||||
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
|
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
|
||||||
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
|
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
|
||||||
</p>
|
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">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{upcoming.map((event, i) => (
|
{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 { NextResponse } from "next/server";
|
||||||
import { runEventReminders } from "@/lib/meeting/reminders";
|
import { runEventReminders } from "@/lib/meeting/reminders";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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.:
|
* 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
|
* 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");
|
const authHeader = request.headers.get("authorization");
|
||||||
if (authHeader !== `Bearer ${secret}`) {
|
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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) {
|
export async function POST(request: Request, { params }: Args) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
let body: { name?: unknown; password?: unknown };
|
let body: { name?: unknown; password?: unknown; clientId?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,6 +29,7 @@ export async function POST(request: Request, { params }: Args) {
|
|||||||
|
|
||||||
const name = typeof body.name === "string" ? body.name.trim() : "";
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||||
const password = typeof body.password === "string" ? body.password.trim() : "";
|
const password = typeof body.password === "string" ? body.password.trim() : "";
|
||||||
|
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
|
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,
|
eventSlug: slug,
|
||||||
name,
|
name,
|
||||||
role: isHost ? "host" : "participant",
|
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({
|
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 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 { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
|
||||||
import { verificationEmail } from "@/lib/email/authTemplates";
|
import { verificationEmail } from "@/lib/email/authTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
|
|
||||||
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
|
// Routes through the shared isAdmin check (role === "admin", not just
|
||||||
const user = req.user;
|
// 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) return false;
|
||||||
if (user.collection === "users") return true;
|
if (user.collection === "users") return isAdmin(args);
|
||||||
if (user.collection === "customers") return { id: { equals: user.id } };
|
if (user.collection === "customers") return { id: { equals: user.id } };
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Runs on every new customer account: issues a random, single-use,
|
// Runs on every new customer account, and again whenever an existing
|
||||||
// time-limited email-verification token. Only its hash is ever persisted
|
// customer changes their email address: issues a random, single-use,
|
||||||
// (see the emailVerificationTokenHash field below) — the plaintext exists
|
// time-limited email-verification token and resets emailVerified to false.
|
||||||
// only in req.context for the afterChange hook below to email out, and is
|
// Only the token's hash is ever persisted (see emailVerificationTokenHash
|
||||||
// never written to the database or logged.
|
// below) — the plaintext exists only in req.context for the afterChange
|
||||||
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
|
// hook below to email out, and is never written to the database or logged.
|
||||||
if (operation !== "create") return data;
|
//
|
||||||
|
// 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();
|
const token = generateVerificationToken();
|
||||||
data.emailVerified = false;
|
data.emailVerified = false;
|
||||||
data.emailVerificationTokenHash = hashVerificationToken(token);
|
data.emailVerificationTokenHash = hashVerificationToken(token);
|
||||||
@@ -27,8 +43,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
|
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
|
||||||
if (operation !== "create") return doc;
|
|
||||||
const token = req.context.pendingVerificationToken;
|
const token = req.context.pendingVerificationToken;
|
||||||
if (typeof token !== "string") return doc;
|
if (typeof token !== "string") return doc;
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +84,7 @@ export const Customers: CollectionConfig = {
|
|||||||
create: () => true,
|
create: () => true,
|
||||||
read: isSelfOrAdmin,
|
read: isSelfOrAdmin,
|
||||||
update: isSelfOrAdmin,
|
update: isSelfOrAdmin,
|
||||||
delete: ({ req }) => req.user?.collection === "users",
|
delete: isAdmin,
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [issueVerificationToken],
|
beforeChange: [issueVerificationToken],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
|
||||||
import { isAdmin, publishedOrAdmin } from "@/access";
|
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
|
||||||
import { slugField } from "@/fields/slug";
|
import { slugField } from "@/fields/slug";
|
||||||
import { seoFields } from "@/fields/seo";
|
import { seoFields } from "@/fields/seo";
|
||||||
import { generateMeetingPassword } from "@/lib/meeting/password";
|
import { generateMeetingPassword } from "@/lib/meeting/password";
|
||||||
@@ -162,6 +162,13 @@ export const Events: CollectionConfig = {
|
|||||||
name: "meetingPassword",
|
name: "meetingPassword",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Meeting-Passwort",
|
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: {
|
admin: {
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
condition: (data) => Boolean(data?.isOnline),
|
condition: (data) => Boolean(data?.isOnline),
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export const Media: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
staticDir: path.resolve(dirname, "..", "media"),
|
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: [
|
imageSizes: [
|
||||||
{ name: "thumbnail", width: 400, position: "centre" },
|
{ name: "thumbnail", width: 400, position: "centre" },
|
||||||
{ name: "card", width: 800, position: "centre" },
|
{ name: "card", width: 800, position: "centre" },
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
|
|||||||
import { isAdmin } from "@/access";
|
import { isAdmin } from "@/access";
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
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}`;
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ export const Users: CollectionConfig = {
|
|||||||
update: isAdminFieldLevel,
|
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",
|
name: "calendarFeedTokenHash",
|
||||||
type: "text",
|
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 Link from "next/link";
|
||||||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||||||
|
import { ArrowIcon } from "@/components/ArrowIcon";
|
||||||
import type { ImageMood } from "@/lib/angebote";
|
import type { ImageMood } from "@/lib/angebote";
|
||||||
|
|
||||||
type AngebotCardProps = {
|
type AngebotCardProps = {
|
||||||
@@ -42,9 +43,7 @@ export function AngebotCard({
|
|||||||
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
|
<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">
|
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
|
||||||
Mehr erfahren
|
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">
|
<ArrowIcon />
|
||||||
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { track } from "@/lib/analytics/client";
|
||||||
const fieldClass =
|
import { TextField, TextAreaField } from "@/components/FormField";
|
||||||
"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";
|
|
||||||
|
|
||||||
type ContactFormProps = {
|
type ContactFormProps = {
|
||||||
toEmail: string;
|
toEmail: string;
|
||||||
@@ -31,31 +30,19 @@ export function ContactForm({
|
|||||||
const message = String(data.get("message") ?? "");
|
const message = String(data.get("message") ?? "");
|
||||||
|
|
||||||
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
|
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}`;
|
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);
|
setSent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
|
||||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
||||||
Name
|
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
|
||||||
</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>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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 Link from "next/link";
|
||||||
import type { Event } from "@/payload-types";
|
import type { Event } from "@/payload-types";
|
||||||
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
|
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
|
||||||
|
import { ArrowIcon } from "@/components/ArrowIcon";
|
||||||
|
|
||||||
export function EventTeaserCard({ event }: { event: Event }) {
|
export function EventTeaserCard({ event }: { event: Event }) {
|
||||||
const { day, month } = formatEventDateParts(event.date);
|
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>}
|
{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">
|
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
|
||||||
Mehr erfahren
|
Mehr erfahren
|
||||||
<svg
|
<ArrowIcon />
|
||||||
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>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function Footer() {
|
|||||||
{siteConfig.name.toUpperCase()}
|
{siteConfig.name.toUpperCase()}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
|
||||||
<ImagePlaceholder
|
<ImagePlaceholder
|
||||||
mood={mood}
|
mood={mood}
|
||||||
label="Anouma — warme, naturverbundene Begleitung"
|
label="Anouma – warme, naturverbundene Begleitung"
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={imageAlt}
|
alt={imageAlt}
|
||||||
priority
|
priority
|
||||||
|
|||||||
+57
-48
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { groupOffersByCategory } from "@/lib/angebote";
|
import { groupOffersByCategory } from "@/lib/angebote";
|
||||||
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
|
||||||
|
import { track } from "@/lib/analytics/client";
|
||||||
|
import { OrganicBlob } from "@/components/OrganicBlob";
|
||||||
import type { Offer } from "@/payload-types";
|
import type { Offer } from "@/payload-types";
|
||||||
|
|
||||||
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
|
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
|
||||||
@@ -63,7 +65,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
@@ -108,52 +110,55 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 8 }}
|
exit={{ opacity: 0, y: 8 }}
|
||||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
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">
|
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
|
||||||
{groups.map((group) => {
|
<div className="relative">
|
||||||
const isSingle = group.offers.length === 1;
|
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
|
||||||
const groupHref = isSingle
|
{groups.map((group) => {
|
||||||
? `/angebote/${group.offers[0].slug}`
|
const isSingle = group.offers.length === 1;
|
||||||
: group.href;
|
const groupHref = isSingle
|
||||||
return (
|
? `/angebote/${group.offers[0].slug}`
|
||||||
<div key={group.category}>
|
: group.href;
|
||||||
<Link
|
return (
|
||||||
href={groupHref}
|
<div key={group.category}>
|
||||||
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
|
<Link
|
||||||
>
|
href={groupHref}
|
||||||
{group.label}
|
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
|
||||||
</Link>
|
>
|
||||||
{!isSingle && (
|
{group.label}
|
||||||
<ul className="mt-3 space-y-2">
|
</Link>
|
||||||
{group.offers.map((offer) => (
|
{!isSingle && (
|
||||||
<li key={offer.slug}>
|
<ul className="mt-3 space-y-2">
|
||||||
<Link
|
{group.offers.map((offer) => (
|
||||||
href={`/angebote/${offer.slug}`}
|
<li key={offer.slug}>
|
||||||
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
|
<Link
|
||||||
>
|
href={`/angebote/${offer.slug}`}
|
||||||
{offer.title}
|
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
|
||||||
</Link>
|
>
|
||||||
</li>
|
{offer.title}
|
||||||
))}
|
</Link>
|
||||||
</ul>
|
</li>
|
||||||
)}
|
))}
|
||||||
{isSingle && (
|
</ul>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
)}
|
||||||
{group.offers[0].shortDescription}
|
{isSingle && (
|
||||||
</p>
|
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
|
||||||
)}
|
{group.offers[0].shortDescription}
|
||||||
</div>
|
</p>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
);
|
||||||
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
|
})}
|
||||||
<Link
|
</div>
|
||||||
href="/angebote"
|
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
|
||||||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
<Link
|
||||||
>
|
href="/angebote"
|
||||||
Alle Angebote im Überblick
|
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
||||||
</Link>
|
>
|
||||||
|
Alle Angebote im Überblick
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -163,8 +168,9 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
|
onClick={() => track("navigation_clicked", { path: item.href })}
|
||||||
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
|
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}
|
{item.title}
|
||||||
@@ -182,6 +188,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={ctaNav.href}
|
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"
|
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}
|
{ctaNav.title}
|
||||||
@@ -300,6 +307,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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"
|
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -314,6 +322,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={ctaNav.href}
|
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"
|
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
|
||||||
>
|
>
|
||||||
{ctaNav.title}
|
{ctaNav.title}
|
||||||
|
|||||||
@@ -9,10 +9,23 @@ type PageHeaderProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
lead?: ReactNode;
|
lead?: ReactNode;
|
||||||
crumbs?: Crumb[];
|
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. */
|
/** 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
|
const breadcrumbData = crumbs?.length
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
const siteUrl = await getSiteUrl();
|
const siteUrl = await getSiteUrl();
|
||||||
@@ -24,9 +37,15 @@ export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderPro
|
|||||||
})()
|
})()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const blobPositionClass = blobPosition === "top-left" ? "-left-20 -top-20" : "-right-20 -top-20";
|
||||||
|
|
||||||
return (
|
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">
|
<section
|
||||||
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
|
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">
|
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
|
||||||
{breadcrumbData && <JsonLd data={breadcrumbData} />}
|
{breadcrumbData && <JsonLd data={breadcrumbData} />}
|
||||||
{crumbs && <Breadcrumbs items={crumbs} />}
|
{crumbs && <Breadcrumbs items={crumbs} />}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
export function PlaceholderNote({ children }: { children: ReactNode }) {
|
||||||
return (
|
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">
|
<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>
|
</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 { useState, type FormEvent } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { ResendVerificationButton } from "./ResendVerificationButton";
|
import { ResendVerificationButton } from "./ResendVerificationButton";
|
||||||
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,23 +42,9 @@ export function LoginForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
||||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
<TextField label="Passwort" id="password" name="password" type="password" required autoComplete="current-password" />
|
||||||
E-Mail
|
{error && <FormError>{error}</FormError>}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TextField } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
|
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,18 +33,8 @@ export function ProfileForm({ id, name, phone }: { id: number; name: string; pho
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField label="Name" id="name" name="name" type="text" required defaultValue={name} />
|
||||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
<TextField label="Telefonnummer (optional)" id="phone" name="phone" type="tel" defaultValue={phone} />
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "saving"}
|
disabled={status === "saving"}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -54,37 +52,18 @@ export function RegisterForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
|
||||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
||||||
Name
|
<TextField
|
||||||
</label>
|
label="Passwort"
|
||||||
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
|
id="password"
|
||||||
</div>
|
name="password"
|
||||||
<div>
|
type="password"
|
||||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
|
required
|
||||||
E-Mail
|
minLength={8}
|
||||||
</label>
|
autoComplete="new-password"
|
||||||
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
|
/>
|
||||||
</div>
|
{error && <FormError>{error}</FormError>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useId, useState, type FormEvent } from "react";
|
||||||
|
import { TextField } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
|
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +42,7 @@ export function ResendVerificationButton({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const emailFieldId = useId();
|
||||||
|
|
||||||
async function resendForSession() {
|
async function resendForSession() {
|
||||||
setStatus("sending");
|
setStatus("sending");
|
||||||
@@ -108,12 +107,15 @@ export function ResendVerificationButton({
|
|||||||
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
|
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleEmailSubmit} className="mt-2 flex flex-wrap items-start gap-2">
|
<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"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder="Deine E-Mail-Adresse"
|
placeholder="Deine E-Mail-Adresse"
|
||||||
className={`${fieldClass} max-w-xs flex-1`}
|
wrapperClassName="max-w-xs flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{booking.appointmentType === "onsite" && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -107,7 +107,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
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
|
Termin stornieren
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
|
||||||
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
|
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 }[]> };
|
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
|
||||||
|
|
||||||
@@ -116,6 +118,8 @@ export function BookingWidget({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResult({ ok: true });
|
setResult({ ok: true });
|
||||||
|
// Event only — never the offer/date/message content.
|
||||||
|
track("booking_started");
|
||||||
} catch {
|
} catch {
|
||||||
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -144,7 +148,7 @@ export function BookingWidget({
|
|||||||
key={slot.start}
|
key={slot.start}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedSlot(slot.start)}
|
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
|
selectedSlot === slot.start
|
||||||
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
|
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
|
||||||
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
|
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
|
||||||
@@ -189,24 +193,16 @@ export function BookingWidget({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-4">
|
<TextAreaField
|
||||||
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
label="Nachricht (optional)"
|
||||||
Nachricht (optional)
|
id="booking-message"
|
||||||
</label>
|
value={message}
|
||||||
<textarea
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
id="booking-message"
|
rows={3}
|
||||||
value={message}
|
wrapperClassName="mt-4"
|
||||||
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>
|
|
||||||
|
|
||||||
{result?.error && (
|
{result?.error && <FormError className="mt-3">{result.error}</FormError>}
|
||||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
|
||||||
{result.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ export function MonthCalendar({
|
|||||||
const today = dateKey(new Date());
|
const today = dateKey(new Date());
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
|
||||||
aria-label="Vorheriger Monat"
|
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>
|
</button>
|
||||||
@@ -81,7 +81,7 @@ export function MonthCalendar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
|
||||||
aria-label="Nächster Monat"
|
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>
|
</button>
|
||||||
@@ -106,9 +106,9 @@ export function MonthCalendar({
|
|||||||
key={j}
|
key={j}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectDay?.(day)}
|
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"
|
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>
|
<span>{day.getDate()}</span>
|
||||||
{dayMarkers.length > 0 && (
|
{dayMarkers.length > 0 && (
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
const localStreamRef = useRef<MediaStream | null>(null);
|
const localStreamRef = useRef<MediaStream | null>(null);
|
||||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||||
const sessionRef = useRef<StoredMeetingSession | 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) => {
|
const send = useCallback((message: ClientToServerMessage) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
@@ -149,9 +154,15 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
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;
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||||
handleServerMessage(message);
|
handleServerMessage(message);
|
||||||
@@ -207,11 +218,18 @@ export function CallRoom({ slug }: { slug: string }) {
|
|||||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||||
break;
|
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 {
|
function remotesLookupName(participantId: string): string {
|
||||||
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -2,11 +2,29 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
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 = {
|
export type StoredMeetingSession = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -33,7 +51,7 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, password }),
|
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -62,41 +80,27 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<TextField
|
||||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
label="Dein Name"
|
||||||
Dein Name
|
id="name"
|
||||||
</label>
|
type="text"
|
||||||
<input
|
required
|
||||||
id="name"
|
value={name}
|
||||||
type="text"
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
placeholder="Name eingeben"
|
||||||
value={name}
|
autoComplete="name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
/>
|
||||||
placeholder="Name eingeben"
|
<TextField
|
||||||
className={fieldClass}
|
label="Meeting-Passwort"
|
||||||
autoComplete="name"
|
id="password"
|
||||||
/>
|
type="text"
|
||||||
</div>
|
required
|
||||||
<div>
|
value={password}
|
||||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
Meeting-Passwort
|
placeholder="Passwort eingeben"
|
||||||
</label>
|
autoComplete="off"
|
||||||
<input
|
/>
|
||||||
id="password"
|
{error && <FormError>{error}</FormError>}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
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";
|
|
||||||
|
|
||||||
export function RegisterForm({ slug }: { slug: string }) {
|
export function RegisterForm({ slug }: { slug: string }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -37,46 +35,32 @@ export function RegisterForm({ slug }: { slug: string }) {
|
|||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return (
|
return (
|
||||||
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
|
<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>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<TextField
|
||||||
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
label="Name"
|
||||||
Name
|
id="reg-name"
|
||||||
</label>
|
type="text"
|
||||||
<input
|
required
|
||||||
id="reg-name"
|
value={name}
|
||||||
type="text"
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
autoComplete="name"
|
||||||
value={name}
|
/>
|
||||||
onChange={(e) => setName(e.target.value)}
|
<TextField
|
||||||
className={fieldClass}
|
label="E-Mail"
|
||||||
autoComplete="name"
|
id="reg-email"
|
||||||
/>
|
type="email"
|
||||||
</div>
|
required
|
||||||
<div>
|
value={email}
|
||||||
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
E-Mail
|
autoComplete="email"
|
||||||
</label>
|
/>
|
||||||
<input
|
{error && <FormError>{error}</FormError>}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading"}
|
||||||
|
|||||||
@@ -61,13 +61,19 @@ trap 'rm -f "$LOCK_FILE"' EXIT
|
|||||||
# .env helpers — same idempotent read/write approach as setup.sh.
|
# .env helpers — same idempotent read/write approach as setup.sh.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||||||
|
# Quoted the same way as setup.sh's env_set — this file is `source`d as a
|
||||||
|
# bash script below, so an unquoted value with shell metacharacters would
|
||||||
|
# break parsing.
|
||||||
env_set() {
|
env_set() {
|
||||||
local key="$1" value="$2"
|
local key="$1" value="$2" escaped quoted
|
||||||
|
escaped="${value//\\/\\\\}"
|
||||||
|
escaped="${escaped//\"/\\\"}"
|
||||||
|
quoted="\"${escaped}\""
|
||||||
if grep -qE "^$key=" .env; then
|
if grep -qE "^$key=" .env; then
|
||||||
local tmp; tmp="$(mktemp)"
|
local tmp; tmp="$(mktemp)"
|
||||||
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
awk -v k="$key" -v v="$quoted" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
||||||
else
|
else
|
||||||
printf '%s=%s\n' "$key" "$value" >> .env
|
printf '%s=%s\n' "$key" "$quoted" >> .env
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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");
|
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";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-9
@@ -3,6 +3,8 @@ import { WEEKDAYS } from "@/collections/Availability";
|
|||||||
|
|
||||||
export type TimeSlot = { start: Date; end: Date };
|
export type TimeSlot = { start: Date; end: Date };
|
||||||
|
|
||||||
|
const BERLIN_TZ = "Europe/Berlin";
|
||||||
|
|
||||||
const WEEKDAY_BY_INDEX = [
|
const WEEKDAY_BY_INDEX = [
|
||||||
"sunday",
|
"sunday",
|
||||||
"monday",
|
"monday",
|
||||||
@@ -13,18 +15,73 @@ const WEEKDAY_BY_INDEX = [
|
|||||||
"saturday",
|
"saturday",
|
||||||
] as const;
|
] 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 {
|
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 {
|
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 {
|
function atMinutesOfDay(day: CalendarDate, minutes: number): Date {
|
||||||
const d = new Date(day);
|
const rounded = Math.round(minutes);
|
||||||
d.setHours(0, Math.round(minutes), 0, 0);
|
return berlinWallTimeToUTC(day, Math.floor(rounded / 60), rounded % 60);
|
||||||
return d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
|
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 overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
|
for (
|
||||||
const key = dateKey(day);
|
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);
|
const override = overridesByDate.get(key);
|
||||||
|
|
||||||
let windows: { startMinutes: number; endMinutes: number }[] = [];
|
let windows: { startMinutes: number; endMinutes: number }[] = [];
|
||||||
@@ -83,7 +144,7 @@ export async function getAvailableSlots(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
|
const weekdayName = weekdayOf(day);
|
||||||
windows = weekly
|
windows = weekly
|
||||||
.filter((w) => w.weekday === weekdayName)
|
.filter((w) => w.weekday === weekdayName)
|
||||||
.map((w) => ({
|
.map((w) => ({
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ function escapeICSText(value: string): string {
|
|||||||
.replace(/\\/g, "\\\\")
|
.replace(/\\/g, "\\\\")
|
||||||
.replace(/;/g, "\\;")
|
.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");
|
.replace(/\n/g, "\\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s
|
|||||||
{ label: "Datum", value: args.dateLabel },
|
{ label: "Datum", value: args.dateLabel },
|
||||||
{ label: "Uhrzeit", value: args.timeLabel },
|
{ 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"),
|
].join("\n"),
|
||||||
});
|
});
|
||||||
return { subject: "Deine Terminanfrage bei ANOUMA", html };
|
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. */
|
/** 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 {
|
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>
|
return `<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function renderReminderEmailHtml(data: ReminderEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 32px;background:${colors.cream};text-align:center;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ParticipantSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ClientToServerMessage =
|
export type ClientToServerMessage =
|
||||||
|
| { type: "auth"; token: string }
|
||||||
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
|
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
|
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
|
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
|
||||||
@@ -19,4 +20,5 @@ export type ServerToClientMessage =
|
|||||||
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
|
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
|
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
|
||||||
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
|
| { 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 = {
|
export type Participant = {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
clientId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
};
|
};
|
||||||
@@ -11,6 +12,11 @@ export type Participant = {
|
|||||||
type Room = {
|
type Room = {
|
||||||
participants: Map<string, Participant>;
|
participants: Map<string, Participant>;
|
||||||
kickedParticipantIds: Set<string>;
|
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
|
// 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 {
|
function getOrCreateRoom(eventSlug: string): Room {
|
||||||
let room = rooms.get(eventSlug);
|
let room = rooms.get(eventSlug);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
room = { participants: new Map(), kickedParticipantIds: new Set() };
|
room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() };
|
||||||
rooms.set(eventSlug, room);
|
rooms.set(eventSlug, room);
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isKicked(eventSlug: string, participantId: string): boolean {
|
export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
|
||||||
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
|
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[] {
|
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
|
||||||
const room = getOrCreateRoom(eventSlug);
|
const room = getOrCreateRoom(eventSlug);
|
||||||
const existing = [...room.participants.values()];
|
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);
|
room.participants.set(participant.participantId, participant);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@@ -50,6 +64,8 @@ export function kickFromRoom(eventSlug: string, participantId: string) {
|
|||||||
const room = rooms.get(eventSlug);
|
const room = rooms.get(eventSlug);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
room.kickedParticipantIds.add(participantId);
|
room.kickedParticipantIds.add(participantId);
|
||||||
|
const target = room.participants.get(participantId);
|
||||||
|
if (target?.clientId) room.kickedClientIds.add(target.clientId);
|
||||||
room.participants.delete(participantId);
|
room.participants.delete(participantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
|
||||||
import type { WebSocket, WebSocketServer } from "ws";
|
import type { WebSocket, WebSocketServer } from "ws";
|
||||||
import { verifyMeetingToken } from "./token";
|
import { verifyMeetingToken } from "./token";
|
||||||
import {
|
import {
|
||||||
@@ -12,46 +11,61 @@ import {
|
|||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
|
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 {
|
function participantSummary(p: Participant): ParticipantSummary {
|
||||||
return { participantId: p.participantId, name: p.name, role: p.role };
|
return { participantId: p.participantId, name: p.name, role: p.role };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachSignalingServer(wss: WebSocketServer) {
|
export function attachSignalingServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
const url = new URL(req.url ?? "", "http://internal");
|
let self: Participant | null = null;
|
||||||
const token = url.searchParams.get("token") ?? "";
|
let eventSlug: string | null = null;
|
||||||
const payload = verifyMeetingToken(token);
|
|
||||||
|
|
||||||
if (!payload) {
|
const authTimeout = setTimeout(() => {
|
||||||
ws.close(4000, "invalid-token");
|
if (!self) ws.close(4000, "auth-timeout");
|
||||||
return;
|
}, 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) => {
|
ws.on("message", (raw) => {
|
||||||
let message: ClientToServerMessage;
|
let message: ClientToServerMessage;
|
||||||
@@ -61,8 +75,17 @@ export function attachSignalingServer(wss: WebSocketServer) {
|
|||||||
return;
|
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") {
|
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) {
|
if (target && target.ws.readyState === target.ws.OPEN) {
|
||||||
target.ws.send(
|
target.ws.send(
|
||||||
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
|
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 (message.type === "kick") {
|
||||||
if (self.role !== "host") return; // server-side authority, not client-claimed
|
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;
|
if (!target) return;
|
||||||
kickFromRoom(payload.eventSlug, target.participantId);
|
kickFromRoom(eventSlug, target.participantId);
|
||||||
target.ws.send(JSON.stringify({ type: "kicked" }));
|
target.ws.send(JSON.stringify({ type: "kicked" }));
|
||||||
target.ws.close(4001, "removed-by-host");
|
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", () => {
|
ws.on("close", () => {
|
||||||
leaveRoom(payload.eventSlug, self.participantId);
|
clearTimeout(authTimeout);
|
||||||
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
|
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 = {
|
export type MeetingTokenPayload = {
|
||||||
eventSlug: string;
|
eventSlug: string;
|
||||||
participantId: 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;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
exp: number; // unix ms
|
exp: number; // unix ms
|
||||||
@@ -29,12 +39,18 @@ export function createMeetingToken(args: {
|
|||||||
eventSlug: string;
|
eventSlug: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: MeetingRole;
|
role: MeetingRole;
|
||||||
|
clientId?: string;
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
}): { token: string; participantId: string } {
|
}): { token: string; participantId: string } {
|
||||||
const participantId = randomUUID();
|
const participantId = randomUUID();
|
||||||
|
const clientId =
|
||||||
|
typeof args.clientId === "string" && args.clientId.length > 0 && args.clientId.length <= 100
|
||||||
|
? args.clientId
|
||||||
|
: undefined;
|
||||||
const payload: MeetingTokenPayload = {
|
const payload: MeetingTokenPayload = {
|
||||||
eventSlug: args.eventSlug,
|
eventSlug: args.eventSlug,
|
||||||
participantId,
|
participantId,
|
||||||
|
clientId,
|
||||||
name: args.name,
|
name: args.name,
|
||||||
role: args.role,
|
role: args.role,
|
||||||
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { getCMS } from "@/lib/payload/getPayload";
|
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> => {
|
export const getHomeGlobal = cache(async (): Promise<Home> => {
|
||||||
const payload = await getCMS();
|
const payload = await getCMS();
|
||||||
@@ -41,3 +41,8 @@ export const getSEOSettingsGlobal = cache(async (): Promise<SeoSetting> => {
|
|||||||
const payload = await getCMS();
|
const payload = await getCMS();
|
||||||
return payload.findGlobal({ slug: "seo-settings" });
|
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 = {
|
export const siteConfig = {
|
||||||
name: "Anouma",
|
name: "Anouma",
|
||||||
domain: "https://anouma.org",
|
domain: "https://anouma.org",
|
||||||
title: "Anouma — Prozessbegleitung, Doula & Gemeinschaft",
|
title: "Anouma – Prozessbegleitung, Doula & Gemeinschaft",
|
||||||
description:
|
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",
|
locale: "de_DE",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,807 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_users_role" AS ENUM('admin');
|
||||||
|
CREATE TYPE "public"."enum_events_category" AS ENUM('prozessbegleitung', 'doula', 'erdenkinder', 'maedchenkreis', 'singen-im-kreis', 'singen-fuer-schwangere', 'singen-fuer-mamas-mit-baby', 'sonstiges');
|
||||||
|
CREATE TYPE "public"."enum_events_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum__events_v_version_category" AS ENUM('prozessbegleitung', 'doula', 'erdenkinder', 'maedchenkreis', 'singen-im-kreis', 'singen-fuer-schwangere', 'singen-fuer-mamas-mit-baby', 'sonstiges');
|
||||||
|
CREATE TYPE "public"."enum__events_v_version_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum_offers_category" AS ENUM('prozessbegleitung', 'doula-begleitung', 'kindergruppen', 'singkreise');
|
||||||
|
CREATE TYPE "public"."enum_offers_visibility" AS ENUM('public', 'private');
|
||||||
|
CREATE TYPE "public"."enum_offers_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum__offers_v_version_category" AS ENUM('prozessbegleitung', 'doula-begleitung', 'kindergruppen', 'singkreise');
|
||||||
|
CREATE TYPE "public"."enum__offers_v_version_visibility" AS ENUM('public', 'private');
|
||||||
|
CREATE TYPE "public"."enum__offers_v_version_status" AS ENUM('draft', 'published');
|
||||||
|
CREATE TYPE "public"."enum_event_registrations_status" AS ENUM('registered', 'cancelled', 'attended');
|
||||||
|
CREATE TYPE "public"."enum_availability_weekday" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
|
||||||
|
CREATE TYPE "public"."enum_availability_overrides_type" AS ENUM('unavailable', 'custom-hours');
|
||||||
|
CREATE TYPE "public"."enum_availability_overrides_reason" AS ENUM('urlaub', 'feiertag', 'krankheit', 'sonderoeffnungszeit', 'sonstiges');
|
||||||
|
CREATE TYPE "public"."enum_booking_requests_appointment_type" AS ENUM('onsite', 'online');
|
||||||
|
CREATE TYPE "public"."enum_booking_requests_status" AS ENUM('pending', 'confirmed', 'rejected', 'cancelled');
|
||||||
|
CREATE TYPE "public"."enum_redirects_type" AS ENUM('permanent', 'temporary');
|
||||||
|
CREATE TYPE "public"."enum_seo_settings_opening_hours_days" AS ENUM('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday');
|
||||||
|
CREATE TABLE "users_sessions" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone,
|
||||||
|
"expires_at" timestamp(3) with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"role" "enum_users_role" DEFAULT 'admin' NOT NULL,
|
||||||
|
"calendar_feed_token_hash" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"reset_password_token" varchar,
|
||||||
|
"reset_password_expiration" timestamp(3) with time zone,
|
||||||
|
"salt" varchar,
|
||||||
|
"hash" varchar,
|
||||||
|
"login_attempts" numeric DEFAULT 0,
|
||||||
|
"lock_until" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "customers_sessions" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone,
|
||||||
|
"expires_at" timestamp(3) with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "customers" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"phone" varchar,
|
||||||
|
"email_verified" boolean DEFAULT false,
|
||||||
|
"email_verification_token_hash" varchar,
|
||||||
|
"email_verification_expires" timestamp(3) with time zone,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"reset_password_token" varchar,
|
||||||
|
"reset_password_expiration" timestamp(3) with time zone,
|
||||||
|
"salt" varchar,
|
||||||
|
"hash" varchar,
|
||||||
|
"login_attempts" numeric DEFAULT 0,
|
||||||
|
"lock_until" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"alt" varchar NOT NULL,
|
||||||
|
"caption" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"url" varchar,
|
||||||
|
"thumbnail_u_r_l" varchar,
|
||||||
|
"filename" varchar,
|
||||||
|
"mime_type" varchar,
|
||||||
|
"filesize" numeric,
|
||||||
|
"width" numeric,
|
||||||
|
"height" numeric,
|
||||||
|
"focal_x" numeric,
|
||||||
|
"focal_y" numeric,
|
||||||
|
"sizes_thumbnail_url" varchar,
|
||||||
|
"sizes_thumbnail_width" numeric,
|
||||||
|
"sizes_thumbnail_height" numeric,
|
||||||
|
"sizes_thumbnail_mime_type" varchar,
|
||||||
|
"sizes_thumbnail_filesize" numeric,
|
||||||
|
"sizes_thumbnail_filename" varchar,
|
||||||
|
"sizes_card_url" varchar,
|
||||||
|
"sizes_card_width" numeric,
|
||||||
|
"sizes_card_height" numeric,
|
||||||
|
"sizes_card_mime_type" varchar,
|
||||||
|
"sizes_card_filesize" numeric,
|
||||||
|
"sizes_card_filename" varchar,
|
||||||
|
"sizes_hero_url" varchar,
|
||||||
|
"sizes_hero_width" numeric,
|
||||||
|
"sizes_hero_height" numeric,
|
||||||
|
"sizes_hero_mime_type" varchar,
|
||||||
|
"sizes_hero_filesize" numeric,
|
||||||
|
"sizes_hero_filename" varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "events" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar,
|
||||||
|
"slug" varchar,
|
||||||
|
"description" jsonb,
|
||||||
|
"date" timestamp(3) with time zone,
|
||||||
|
"start_time" timestamp(3) with time zone,
|
||||||
|
"end_time" timestamp(3) with time zone,
|
||||||
|
"location" varchar,
|
||||||
|
"category" "enum_events_category" DEFAULT 'sonstiges',
|
||||||
|
"image_id" integer,
|
||||||
|
"max_participants" numeric,
|
||||||
|
"registration_required" boolean DEFAULT false,
|
||||||
|
"registration_info" varchar,
|
||||||
|
"is_online" boolean DEFAULT false,
|
||||||
|
"meeting_password" varchar,
|
||||||
|
"regenerate_password" boolean DEFAULT false,
|
||||||
|
"reminder60_enabled" boolean DEFAULT true,
|
||||||
|
"reminder30_enabled" boolean DEFAULT true,
|
||||||
|
"host_reminder_enabled" boolean DEFAULT true,
|
||||||
|
"host_reminder60_sent" boolean DEFAULT false,
|
||||||
|
"host_reminder30_sent" boolean DEFAULT false,
|
||||||
|
"is_private_booking" boolean DEFAULT false,
|
||||||
|
"booking_request_id" integer,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"_status" "enum_events_status" DEFAULT 'draft'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "_events_v" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_slug" varchar,
|
||||||
|
"version_description" jsonb,
|
||||||
|
"version_date" timestamp(3) with time zone,
|
||||||
|
"version_start_time" timestamp(3) with time zone,
|
||||||
|
"version_end_time" timestamp(3) with time zone,
|
||||||
|
"version_location" varchar,
|
||||||
|
"version_category" "enum__events_v_version_category" DEFAULT 'sonstiges',
|
||||||
|
"version_image_id" integer,
|
||||||
|
"version_max_participants" numeric,
|
||||||
|
"version_registration_required" boolean DEFAULT false,
|
||||||
|
"version_registration_info" varchar,
|
||||||
|
"version_is_online" boolean DEFAULT false,
|
||||||
|
"version_meeting_password" varchar,
|
||||||
|
"version_regenerate_password" boolean DEFAULT false,
|
||||||
|
"version_reminder60_enabled" boolean DEFAULT true,
|
||||||
|
"version_reminder30_enabled" boolean DEFAULT true,
|
||||||
|
"version_host_reminder_enabled" boolean DEFAULT true,
|
||||||
|
"version_host_reminder60_sent" boolean DEFAULT false,
|
||||||
|
"version_host_reminder30_sent" boolean DEFAULT false,
|
||||||
|
"version_is_private_booking" boolean DEFAULT false,
|
||||||
|
"version_booking_request_id" integer,
|
||||||
|
"version_seo_title" varchar,
|
||||||
|
"version_seo_description" varchar,
|
||||||
|
"version_seo_keywords" varchar,
|
||||||
|
"version_seo_og_image_id" integer,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__events_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean,
|
||||||
|
"autosave" boolean
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "posts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar,
|
||||||
|
"slug" varchar,
|
||||||
|
"teaser" varchar,
|
||||||
|
"content" jsonb,
|
||||||
|
"cover_image_id" integer,
|
||||||
|
"publish_date" timestamp(3) with time zone,
|
||||||
|
"author" varchar DEFAULT 'Anouma',
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"_status" "enum_posts_status" DEFAULT 'draft'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "_posts_v" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_slug" varchar,
|
||||||
|
"version_teaser" varchar,
|
||||||
|
"version_content" jsonb,
|
||||||
|
"version_cover_image_id" integer,
|
||||||
|
"version_publish_date" timestamp(3) with time zone,
|
||||||
|
"version_author" varchar DEFAULT 'Anouma',
|
||||||
|
"version_seo_title" varchar,
|
||||||
|
"version_seo_description" varchar,
|
||||||
|
"version_seo_keywords" varchar,
|
||||||
|
"version_seo_og_image_id" integer,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean,
|
||||||
|
"autosave" boolean
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "offers" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar,
|
||||||
|
"slug" varchar,
|
||||||
|
"short_description" varchar,
|
||||||
|
"description" jsonb,
|
||||||
|
"image_id" integer,
|
||||||
|
"category" "enum_offers_category",
|
||||||
|
"order" numeric DEFAULT 0,
|
||||||
|
"visibility" "enum_offers_visibility" DEFAULT 'public',
|
||||||
|
"bookable" boolean DEFAULT false,
|
||||||
|
"price" varchar,
|
||||||
|
"duration_minutes" numeric,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"_status" "enum_offers_status" DEFAULT 'draft'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "_offers_v" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"version_title" varchar,
|
||||||
|
"version_slug" varchar,
|
||||||
|
"version_short_description" varchar,
|
||||||
|
"version_description" jsonb,
|
||||||
|
"version_image_id" integer,
|
||||||
|
"version_category" "enum__offers_v_version_category",
|
||||||
|
"version_order" numeric DEFAULT 0,
|
||||||
|
"version_visibility" "enum__offers_v_version_visibility" DEFAULT 'public',
|
||||||
|
"version_bookable" boolean DEFAULT false,
|
||||||
|
"version_price" varchar,
|
||||||
|
"version_duration_minutes" numeric,
|
||||||
|
"version_seo_title" varchar,
|
||||||
|
"version_seo_description" varchar,
|
||||||
|
"version_seo_keywords" varchar,
|
||||||
|
"version_seo_og_image_id" integer,
|
||||||
|
"version_updated_at" timestamp(3) with time zone,
|
||||||
|
"version_created_at" timestamp(3) with time zone,
|
||||||
|
"version__status" "enum__offers_v_version_status" DEFAULT 'draft',
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"latest" boolean
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "event_registrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"event_id" integer NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"status" "enum_event_registrations_status" DEFAULT 'registered' NOT NULL,
|
||||||
|
"registered_at" timestamp(3) with time zone,
|
||||||
|
"reminder60_sent" boolean DEFAULT false,
|
||||||
|
"reminder30_sent" boolean DEFAULT false,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "availability" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"weekday" "enum_availability_weekday" NOT NULL,
|
||||||
|
"start_time" timestamp(3) with time zone NOT NULL,
|
||||||
|
"end_time" timestamp(3) with time zone NOT NULL,
|
||||||
|
"active" boolean DEFAULT true,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "availability_overrides" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"date" timestamp(3) with time zone NOT NULL,
|
||||||
|
"type" "enum_availability_overrides_type" DEFAULT 'unavailable' NOT NULL,
|
||||||
|
"start_time" timestamp(3) with time zone,
|
||||||
|
"end_time" timestamp(3) with time zone,
|
||||||
|
"reason" "enum_availability_overrides_reason",
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "booking_requests" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"offer_id" integer NOT NULL,
|
||||||
|
"date" timestamp(3) with time zone NOT NULL,
|
||||||
|
"start_time" timestamp(3) with time zone NOT NULL,
|
||||||
|
"end_time" timestamp(3) with time zone NOT NULL,
|
||||||
|
"appointment_type" "enum_booking_requests_appointment_type" DEFAULT 'onsite' NOT NULL,
|
||||||
|
"status" "enum_booking_requests_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"user_message" varchar,
|
||||||
|
"internal_note" varchar,
|
||||||
|
"proposed_alternative_date" timestamp(3) with time zone,
|
||||||
|
"proposed_alternative_start_time" timestamp(3) with time zone,
|
||||||
|
"proposed_alternative_end_time" timestamp(3) with time zone,
|
||||||
|
"linked_event_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "redirects" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"from_path" varchar NOT NULL,
|
||||||
|
"to_path" varchar NOT NULL,
|
||||||
|
"type" "enum_redirects_type" DEFAULT 'permanent' NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_kv" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar NOT NULL,
|
||||||
|
"data" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"global_slug" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer,
|
||||||
|
"customers_id" integer,
|
||||||
|
"media_id" integer,
|
||||||
|
"events_id" integer,
|
||||||
|
"posts_id" integer,
|
||||||
|
"offers_id" integer,
|
||||||
|
"event_registrations_id" integer,
|
||||||
|
"availability_id" integer,
|
||||||
|
"availability_overrides_id" integer,
|
||||||
|
"booking_requests_id" integer,
|
||||||
|
"redirects_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar,
|
||||||
|
"value" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer,
|
||||||
|
"customers_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_migrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar,
|
||||||
|
"batch" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "home_values" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"quote" varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "home" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"hero_eyebrow" varchar,
|
||||||
|
"hero_title" varchar NOT NULL,
|
||||||
|
"hero_highlight" varchar,
|
||||||
|
"hero_supporting" varchar,
|
||||||
|
"hero_image_id" integer,
|
||||||
|
"about_eyebrow" varchar,
|
||||||
|
"about_title" varchar,
|
||||||
|
"about_text" jsonb,
|
||||||
|
"about_image_id" integer,
|
||||||
|
"values_eyebrow" varchar,
|
||||||
|
"values_title" varchar,
|
||||||
|
"cta_title" varchar,
|
||||||
|
"cta_lead" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "about" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"eyebrow" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"portrait_id" integer,
|
||||||
|
"body" jsonb NOT NULL,
|
||||||
|
"closing_lead" varchar,
|
||||||
|
"closing_highlight" varchar,
|
||||||
|
"closing_paragraph" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "angebote_intro" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"eyebrow" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"lead" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "aktuelles_intro" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"eyebrow" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"lead" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "contact" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"eyebrow" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"lead" varchar,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"phone" varchar,
|
||||||
|
"region" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "booking_steps" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"text" varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "booking" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"eyebrow" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"lead" varchar,
|
||||||
|
"form_note" varchar,
|
||||||
|
"seo_title" varchar,
|
||||||
|
"seo_description" varchar,
|
||||||
|
"seo_keywords" varchar,
|
||||||
|
"seo_og_image_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "meeting_settings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"participant_join_window_minutes" numeric DEFAULT 30 NOT NULL,
|
||||||
|
"host_join_window_minutes" numeric DEFAULT 60 NOT NULL,
|
||||||
|
"meeting_close_after_minutes" numeric DEFAULT 30 NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "booking_settings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"location_name" varchar DEFAULT 'Anouma',
|
||||||
|
"street" varchar,
|
||||||
|
"postal_code" varchar,
|
||||||
|
"city" varchar,
|
||||||
|
"region" varchar,
|
||||||
|
"country" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "seo_settings_opening_hours_days" (
|
||||||
|
"order" integer NOT NULL,
|
||||||
|
"parent_id" varchar NOT NULL,
|
||||||
|
"value" "enum_seo_settings_opening_hours_days",
|
||||||
|
"id" serial PRIMARY KEY NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "seo_settings_opening_hours" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"opens" varchar NOT NULL,
|
||||||
|
"closes" varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "seo_settings_social_links" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"platform" varchar NOT NULL,
|
||||||
|
"url" varchar NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "seo_settings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"site_url" varchar,
|
||||||
|
"default_description" varchar,
|
||||||
|
"default_og_image_id" integer,
|
||||||
|
"robots_indexable" boolean DEFAULT true,
|
||||||
|
"business_name" varchar DEFAULT 'Anouma',
|
||||||
|
"business_description" varchar,
|
||||||
|
"latitude" numeric,
|
||||||
|
"longitude" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone,
|
||||||
|
"created_at" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "customers_sessions" ADD CONSTRAINT "customers_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "events" ADD CONSTRAINT "events_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "events" ADD CONSTRAINT "events_booking_request_id_booking_requests_id_fk" FOREIGN KEY ("booking_request_id") REFERENCES "public"."booking_requests"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "events" ADD CONSTRAINT "events_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_parent_id_events_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_version_image_id_media_id_fk" FOREIGN KEY ("version_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_version_booking_request_id_booking_requests_id_fk" FOREIGN KEY ("version_booking_request_id") REFERENCES "public"."booking_requests"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_version_seo_og_image_id_media_id_fk" FOREIGN KEY ("version_seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "posts" ADD CONSTRAINT "posts_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "posts" ADD CONSTRAINT "posts_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_cover_image_id_media_id_fk" FOREIGN KEY ("version_cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_seo_og_image_id_media_id_fk" FOREIGN KEY ("version_seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "offers" ADD CONSTRAINT "offers_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "offers" ADD CONSTRAINT "offers_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_offers_v" ADD CONSTRAINT "_offers_v_parent_id_offers_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."offers"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_offers_v" ADD CONSTRAINT "_offers_v_version_image_id_media_id_fk" FOREIGN KEY ("version_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "_offers_v" ADD CONSTRAINT "_offers_v_version_seo_og_image_id_media_id_fk" FOREIGN KEY ("version_seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "event_registrations" ADD CONSTRAINT "event_registrations_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "booking_requests" ADD CONSTRAINT "booking_requests_user_id_customers_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "booking_requests" ADD CONSTRAINT "booking_requests_offer_id_offers_id_fk" FOREIGN KEY ("offer_id") REFERENCES "public"."offers"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "booking_requests" ADD CONSTRAINT "booking_requests_linked_event_id_events_id_fk" FOREIGN KEY ("linked_event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_customers_fk" FOREIGN KEY ("customers_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_events_fk" FOREIGN KEY ("events_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_offers_fk" FOREIGN KEY ("offers_id") REFERENCES "public"."offers"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_event_registrations_fk" FOREIGN KEY ("event_registrations_id") REFERENCES "public"."event_registrations"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_availability_fk" FOREIGN KEY ("availability_id") REFERENCES "public"."availability"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_availability_overrides_fk" FOREIGN KEY ("availability_overrides_id") REFERENCES "public"."availability_overrides"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_booking_requests_fk" FOREIGN KEY ("booking_requests_id") REFERENCES "public"."booking_requests"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_redirects_fk" FOREIGN KEY ("redirects_id") REFERENCES "public"."redirects"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_customers_fk" FOREIGN KEY ("customers_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "home_values" ADD CONSTRAINT "home_values_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."home"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "home" ADD CONSTRAINT "home_hero_image_id_media_id_fk" FOREIGN KEY ("hero_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "home" ADD CONSTRAINT "home_about_image_id_media_id_fk" FOREIGN KEY ("about_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "home" ADD CONSTRAINT "home_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "about" ADD CONSTRAINT "about_portrait_id_media_id_fk" FOREIGN KEY ("portrait_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "about" ADD CONSTRAINT "about_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "angebote_intro" ADD CONSTRAINT "angebote_intro_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "aktuelles_intro" ADD CONSTRAINT "aktuelles_intro_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "contact" ADD CONSTRAINT "contact_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "booking_steps" ADD CONSTRAINT "booking_steps_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."booking"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "booking" ADD CONSTRAINT "booking_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "seo_settings_opening_hours_days" ADD CONSTRAINT "seo_settings_opening_hours_days_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."seo_settings_opening_hours"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "seo_settings_opening_hours" ADD CONSTRAINT "seo_settings_opening_hours_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."seo_settings"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "seo_settings_social_links" ADD CONSTRAINT "seo_settings_social_links_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."seo_settings"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "seo_settings" ADD CONSTRAINT "seo_settings_default_og_image_id_media_id_fk" FOREIGN KEY ("default_og_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||||
|
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "users_calendar_feed_token_hash_idx" ON "users" USING btree ("calendar_feed_token_hash");
|
||||||
|
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||||
|
CREATE INDEX "customers_sessions_order_idx" ON "customers_sessions" USING btree ("_order");
|
||||||
|
CREATE INDEX "customers_sessions_parent_id_idx" ON "customers_sessions" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "customers_email_verification_token_hash_idx" ON "customers" USING btree ("email_verification_token_hash");
|
||||||
|
CREATE INDEX "customers_updated_at_idx" ON "customers" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "customers_created_at_idx" ON "customers" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "customers_email_idx" ON "customers" USING btree ("email");
|
||||||
|
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||||
|
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||||
|
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||||
|
CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "media" USING btree ("sizes_hero_filename");
|
||||||
|
CREATE UNIQUE INDEX "events_slug_idx" ON "events" USING btree ("slug");
|
||||||
|
CREATE INDEX "events_image_idx" ON "events" USING btree ("image_id");
|
||||||
|
CREATE INDEX "events_booking_request_idx" ON "events" USING btree ("booking_request_id");
|
||||||
|
CREATE INDEX "events_seo_seo_og_image_idx" ON "events" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "events_updated_at_idx" ON "events" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "events_created_at_idx" ON "events" USING btree ("created_at");
|
||||||
|
CREATE INDEX "events__status_idx" ON "events" USING btree ("_status");
|
||||||
|
CREATE INDEX "_events_v_parent_idx" ON "_events_v" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "_events_v_version_version_slug_idx" ON "_events_v" USING btree ("version_slug");
|
||||||
|
CREATE INDEX "_events_v_version_version_image_idx" ON "_events_v" USING btree ("version_image_id");
|
||||||
|
CREATE INDEX "_events_v_version_version_booking_request_idx" ON "_events_v" USING btree ("version_booking_request_id");
|
||||||
|
CREATE INDEX "_events_v_version_seo_version_seo_og_image_idx" ON "_events_v" USING btree ("version_seo_og_image_id");
|
||||||
|
CREATE INDEX "_events_v_version_version_updated_at_idx" ON "_events_v" USING btree ("version_updated_at");
|
||||||
|
CREATE INDEX "_events_v_version_version_created_at_idx" ON "_events_v" USING btree ("version_created_at");
|
||||||
|
CREATE INDEX "_events_v_version_version__status_idx" ON "_events_v" USING btree ("version__status");
|
||||||
|
CREATE INDEX "_events_v_created_at_idx" ON "_events_v" USING btree ("created_at");
|
||||||
|
CREATE INDEX "_events_v_updated_at_idx" ON "_events_v" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "_events_v_latest_idx" ON "_events_v" USING btree ("latest");
|
||||||
|
CREATE INDEX "_events_v_autosave_idx" ON "_events_v" USING btree ("autosave");
|
||||||
|
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
|
||||||
|
CREATE INDEX "posts_cover_image_idx" ON "posts" USING btree ("cover_image_id");
|
||||||
|
CREATE INDEX "posts_seo_seo_og_image_idx" ON "posts" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
|
||||||
|
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
|
||||||
|
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
|
||||||
|
CREATE INDEX "_posts_v_version_version_cover_image_idx" ON "_posts_v" USING btree ("version_cover_image_id");
|
||||||
|
CREATE INDEX "_posts_v_version_seo_version_seo_og_image_idx" ON "_posts_v" USING btree ("version_seo_og_image_id");
|
||||||
|
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
|
||||||
|
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
|
||||||
|
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
|
||||||
|
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
|
||||||
|
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
|
||||||
|
CREATE INDEX "_posts_v_autosave_idx" ON "_posts_v" USING btree ("autosave");
|
||||||
|
CREATE UNIQUE INDEX "offers_slug_idx" ON "offers" USING btree ("slug");
|
||||||
|
CREATE INDEX "offers_image_idx" ON "offers" USING btree ("image_id");
|
||||||
|
CREATE INDEX "offers_seo_seo_og_image_idx" ON "offers" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "offers_updated_at_idx" ON "offers" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "offers_created_at_idx" ON "offers" USING btree ("created_at");
|
||||||
|
CREATE INDEX "offers__status_idx" ON "offers" USING btree ("_status");
|
||||||
|
CREATE INDEX "_offers_v_parent_idx" ON "_offers_v" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "_offers_v_version_version_slug_idx" ON "_offers_v" USING btree ("version_slug");
|
||||||
|
CREATE INDEX "_offers_v_version_version_image_idx" ON "_offers_v" USING btree ("version_image_id");
|
||||||
|
CREATE INDEX "_offers_v_version_seo_version_seo_og_image_idx" ON "_offers_v" USING btree ("version_seo_og_image_id");
|
||||||
|
CREATE INDEX "_offers_v_version_version_updated_at_idx" ON "_offers_v" USING btree ("version_updated_at");
|
||||||
|
CREATE INDEX "_offers_v_version_version_created_at_idx" ON "_offers_v" USING btree ("version_created_at");
|
||||||
|
CREATE INDEX "_offers_v_version_version__status_idx" ON "_offers_v" USING btree ("version__status");
|
||||||
|
CREATE INDEX "_offers_v_created_at_idx" ON "_offers_v" USING btree ("created_at");
|
||||||
|
CREATE INDEX "_offers_v_updated_at_idx" ON "_offers_v" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "_offers_v_latest_idx" ON "_offers_v" USING btree ("latest");
|
||||||
|
CREATE INDEX "event_registrations_event_idx" ON "event_registrations" USING btree ("event_id");
|
||||||
|
CREATE INDEX "event_registrations_updated_at_idx" ON "event_registrations" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "event_registrations_created_at_idx" ON "event_registrations" USING btree ("created_at");
|
||||||
|
CREATE INDEX "availability_updated_at_idx" ON "availability" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "availability_created_at_idx" ON "availability" USING btree ("created_at");
|
||||||
|
CREATE INDEX "availability_overrides_updated_at_idx" ON "availability_overrides" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "availability_overrides_created_at_idx" ON "availability_overrides" USING btree ("created_at");
|
||||||
|
CREATE INDEX "booking_requests_user_idx" ON "booking_requests" USING btree ("user_id");
|
||||||
|
CREATE INDEX "booking_requests_offer_idx" ON "booking_requests" USING btree ("offer_id");
|
||||||
|
CREATE INDEX "booking_requests_linked_event_idx" ON "booking_requests" USING btree ("linked_event_id");
|
||||||
|
CREATE INDEX "booking_requests_updated_at_idx" ON "booking_requests" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "booking_requests_created_at_idx" ON "booking_requests" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "redirects_from_path_idx" ON "redirects" USING btree ("from_path");
|
||||||
|
CREATE INDEX "redirects_updated_at_idx" ON "redirects" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "redirects_created_at_idx" ON "redirects" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||||
|
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_customers_id_idx" ON "payload_locked_documents_rels" USING btree ("customers_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_events_id_idx" ON "payload_locked_documents_rels" USING btree ("events_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_offers_id_idx" ON "payload_locked_documents_rels" USING btree ("offers_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_event_registrations_id_idx" ON "payload_locked_documents_rels" USING btree ("event_registrations_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_availability_id_idx" ON "payload_locked_documents_rels" USING btree ("availability_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_availability_overrides_id_idx" ON "payload_locked_documents_rels" USING btree ("availability_overrides_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_booking_requests_id_idx" ON "payload_locked_documents_rels" USING btree ("booking_requests_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_redirects_id_idx" ON "payload_locked_documents_rels" USING btree ("redirects_id");
|
||||||
|
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_preferences_rels_customers_id_idx" ON "payload_preferences_rels" USING btree ("customers_id");
|
||||||
|
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");
|
||||||
|
CREATE INDEX "home_values_order_idx" ON "home_values" USING btree ("_order");
|
||||||
|
CREATE INDEX "home_values_parent_id_idx" ON "home_values" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "home_hero_image_idx" ON "home" USING btree ("hero_image_id");
|
||||||
|
CREATE INDEX "home_about_image_idx" ON "home" USING btree ("about_image_id");
|
||||||
|
CREATE INDEX "home_seo_seo_og_image_idx" ON "home" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "about_portrait_idx" ON "about" USING btree ("portrait_id");
|
||||||
|
CREATE INDEX "about_seo_seo_og_image_idx" ON "about" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "angebote_intro_seo_seo_og_image_idx" ON "angebote_intro" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "aktuelles_intro_seo_seo_og_image_idx" ON "aktuelles_intro" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "contact_seo_seo_og_image_idx" ON "contact" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "booking_steps_order_idx" ON "booking_steps" USING btree ("_order");
|
||||||
|
CREATE INDEX "booking_steps_parent_id_idx" ON "booking_steps" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "booking_seo_seo_og_image_idx" ON "booking" USING btree ("seo_og_image_id");
|
||||||
|
CREATE INDEX "seo_settings_opening_hours_days_order_idx" ON "seo_settings_opening_hours_days" USING btree ("order");
|
||||||
|
CREATE INDEX "seo_settings_opening_hours_days_parent_idx" ON "seo_settings_opening_hours_days" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "seo_settings_opening_hours_order_idx" ON "seo_settings_opening_hours" USING btree ("_order");
|
||||||
|
CREATE INDEX "seo_settings_opening_hours_parent_id_idx" ON "seo_settings_opening_hours" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "seo_settings_social_links_order_idx" ON "seo_settings_social_links" USING btree ("_order");
|
||||||
|
CREATE INDEX "seo_settings_social_links_parent_id_idx" ON "seo_settings_social_links" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "seo_settings_default_og_image_idx" ON "seo_settings" USING btree ("default_og_image_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP TABLE "users_sessions" CASCADE;
|
||||||
|
DROP TABLE "users" CASCADE;
|
||||||
|
DROP TABLE "customers_sessions" CASCADE;
|
||||||
|
DROP TABLE "customers" CASCADE;
|
||||||
|
DROP TABLE "media" CASCADE;
|
||||||
|
DROP TABLE "events" CASCADE;
|
||||||
|
DROP TABLE "_events_v" CASCADE;
|
||||||
|
DROP TABLE "posts" CASCADE;
|
||||||
|
DROP TABLE "_posts_v" CASCADE;
|
||||||
|
DROP TABLE "offers" CASCADE;
|
||||||
|
DROP TABLE "_offers_v" CASCADE;
|
||||||
|
DROP TABLE "event_registrations" CASCADE;
|
||||||
|
DROP TABLE "availability" CASCADE;
|
||||||
|
DROP TABLE "availability_overrides" CASCADE;
|
||||||
|
DROP TABLE "booking_requests" CASCADE;
|
||||||
|
DROP TABLE "redirects" CASCADE;
|
||||||
|
DROP TABLE "payload_kv" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_migrations" CASCADE;
|
||||||
|
DROP TABLE "home_values" CASCADE;
|
||||||
|
DROP TABLE "home" CASCADE;
|
||||||
|
DROP TABLE "about" CASCADE;
|
||||||
|
DROP TABLE "angebote_intro" CASCADE;
|
||||||
|
DROP TABLE "aktuelles_intro" CASCADE;
|
||||||
|
DROP TABLE "contact" CASCADE;
|
||||||
|
DROP TABLE "booking_steps" CASCADE;
|
||||||
|
DROP TABLE "booking" CASCADE;
|
||||||
|
DROP TABLE "meeting_settings" CASCADE;
|
||||||
|
DROP TABLE "booking_settings" CASCADE;
|
||||||
|
DROP TABLE "seo_settings_opening_hours_days" CASCADE;
|
||||||
|
DROP TABLE "seo_settings_opening_hours" CASCADE;
|
||||||
|
DROP TABLE "seo_settings_social_links" CASCADE;
|
||||||
|
DROP TABLE "seo_settings" CASCADE;
|
||||||
|
DROP TYPE "public"."enum_users_role";
|
||||||
|
DROP TYPE "public"."enum_events_category";
|
||||||
|
DROP TYPE "public"."enum_events_status";
|
||||||
|
DROP TYPE "public"."enum__events_v_version_category";
|
||||||
|
DROP TYPE "public"."enum__events_v_version_status";
|
||||||
|
DROP TYPE "public"."enum_posts_status";
|
||||||
|
DROP TYPE "public"."enum__posts_v_version_status";
|
||||||
|
DROP TYPE "public"."enum_offers_category";
|
||||||
|
DROP TYPE "public"."enum_offers_visibility";
|
||||||
|
DROP TYPE "public"."enum_offers_status";
|
||||||
|
DROP TYPE "public"."enum__offers_v_version_category";
|
||||||
|
DROP TYPE "public"."enum__offers_v_version_visibility";
|
||||||
|
DROP TYPE "public"."enum__offers_v_version_status";
|
||||||
|
DROP TYPE "public"."enum_event_registrations_status";
|
||||||
|
DROP TYPE "public"."enum_availability_weekday";
|
||||||
|
DROP TYPE "public"."enum_availability_overrides_type";
|
||||||
|
DROP TYPE "public"."enum_availability_overrides_reason";
|
||||||
|
DROP TYPE "public"."enum_booking_requests_appointment_type";
|
||||||
|
DROP TYPE "public"."enum_booking_requests_status";
|
||||||
|
DROP TYPE "public"."enum_redirects_type";
|
||||||
|
DROP TYPE "public"."enum_seo_settings_opening_hours_days";`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as migration_20260826_004425 from './20260826_004425';
|
||||||
|
|
||||||
|
export const migrations = [
|
||||||
|
{
|
||||||
|
up: migration_20260826_004425.up,
|
||||||
|
down: migration_20260826_004425.down,
|
||||||
|
name: '20260826_004425'
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -6,6 +6,47 @@ import { fileURLToPath } from "url";
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(__filename);
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
// NOT "standalone": this project uses a custom server (server.ts) for the
|
// NOT "standalone": this project uses a custom server (server.ts) for the
|
||||||
// WebRTC signaling WebSocket, and standalone output can't be combined with
|
// WebRTC signaling WebSocket, and standalone output can't be combined with
|
||||||
@@ -25,6 +66,12 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: path.resolve(dirname),
|
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 });
|
export default withPayload(nextConfig, { devBundleServerPackages: false });
|
||||||
|
|||||||
Generated
+383
@@ -20,6 +20,7 @@
|
|||||||
"payload": "^3.88.0",
|
"payload": "^3.88.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.23.12",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
@@ -3509,6 +3510,32 @@
|
|||||||
"url": "https://opencollective.com/preact"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3516,6 +3543,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
@@ -3848,6 +3887,69 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -3993,6 +4095,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
"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==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -6601,6 +6848,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/fast-copy": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||||
@@ -7278,6 +7531,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/immutable": {
|
||||||
"version": "4.3.9",
|
"version": "4.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
|
||||||
@@ -7325,6 +7588,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
||||||
@@ -10703,6 +10975,29 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-select": {
|
||||||
"version": "5.9.0",
|
"version": "5.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz",
|
||||||
@@ -10761,6 +11056,51 @@
|
|||||||
"node": ">= 12.13.0"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -10814,6 +11154,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -11591,6 +11937,12 @@
|
|||||||
"real-require": "^0.2.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"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": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
@@ -12612,6 +12973,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"payload": "^3.88.0",
|
"payload": "^3.88.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.23.12",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface Config {
|
|||||||
'availability-overrides': AvailabilityOverride;
|
'availability-overrides': AvailabilityOverride;
|
||||||
'booking-requests': BookingRequest;
|
'booking-requests': BookingRequest;
|
||||||
redirects: Redirect;
|
redirects: Redirect;
|
||||||
|
'analytics-events': AnalyticsEvent;
|
||||||
|
'analytics-daily-stats': AnalyticsDailyStat;
|
||||||
'payload-kv': PayloadKv;
|
'payload-kv': PayloadKv;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
@@ -97,6 +99,8 @@ export interface Config {
|
|||||||
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
|
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
|
||||||
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
|
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
|
||||||
redirects: RedirectsSelect<false> | RedirectsSelect<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-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
@@ -116,6 +120,8 @@ export interface Config {
|
|||||||
'meeting-settings': MeetingSetting;
|
'meeting-settings': MeetingSetting;
|
||||||
'booking-settings': BookingSetting;
|
'booking-settings': BookingSetting;
|
||||||
'seo-settings': SeoSetting;
|
'seo-settings': SeoSetting;
|
||||||
|
'cookie-settings': CookieSetting;
|
||||||
|
'analytics-settings': AnalyticsSetting;
|
||||||
};
|
};
|
||||||
globalsSelect: {
|
globalsSelect: {
|
||||||
home: HomeSelect<false> | HomeSelect<true>;
|
home: HomeSelect<false> | HomeSelect<true>;
|
||||||
@@ -127,6 +133,8 @@ export interface Config {
|
|||||||
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
|
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
|
||||||
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
|
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
|
||||||
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
|
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
|
||||||
|
'cookie-settings': CookieSettingsSelect<false> | CookieSettingsSelect<true>;
|
||||||
|
'analytics-settings': AnalyticsSettingsSelect<false> | AnalyticsSettingsSelect<true>;
|
||||||
};
|
};
|
||||||
locale: null;
|
locale: null;
|
||||||
widgets: {
|
widgets: {
|
||||||
@@ -184,6 +192,10 @@ export interface User {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
role: 'admin';
|
role: 'admin';
|
||||||
|
/**
|
||||||
|
* Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.
|
||||||
|
*/
|
||||||
|
canViewAnalytics?: boolean | null;
|
||||||
calendarFeedTokenHash?: string | null;
|
calendarFeedTokenHash?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -611,6 +623,89 @@ export interface Redirect {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-kv".
|
* via the `definition` "payload-kv".
|
||||||
@@ -678,6 +773,14 @@ export interface PayloadLockedDocument {
|
|||||||
| ({
|
| ({
|
||||||
relationTo: 'redirects';
|
relationTo: 'redirects';
|
||||||
value: number | Redirect;
|
value: number | Redirect;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'analytics-events';
|
||||||
|
value: number | AnalyticsEvent;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'analytics-daily-stats';
|
||||||
|
value: number | AnalyticsDailyStat;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user:
|
user:
|
||||||
@@ -738,6 +841,7 @@ export interface PayloadMigration {
|
|||||||
export interface UsersSelect<T extends boolean = true> {
|
export interface UsersSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
role?: T;
|
role?: T;
|
||||||
|
canViewAnalytics?: T;
|
||||||
calendarFeedTokenHash?: T;
|
calendarFeedTokenHash?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
@@ -1004,6 +1108,39 @@ export interface RedirectsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-kv_select".
|
* via the `definition` "payload-kv_select".
|
||||||
@@ -1364,6 +1501,63 @@ export interface SeoSetting {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "home_select".
|
* via the `definition` "home_select".
|
||||||
@@ -1575,6 +1769,47 @@ export interface SeoSettingsSelect<T extends boolean = true> {
|
|||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "collections_widget".
|
* via the `definition` "collections_widget".
|
||||||
|
|||||||
+29
-2
@@ -16,6 +16,8 @@ import { Availability } from "@/collections/Availability";
|
|||||||
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
|
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
|
||||||
import { BookingRequests } from "@/collections/BookingRequests";
|
import { BookingRequests } from "@/collections/BookingRequests";
|
||||||
import { Redirects } from "@/collections/Redirects";
|
import { Redirects } from "@/collections/Redirects";
|
||||||
|
import { AnalyticsEvents } from "@/collections/AnalyticsEvents";
|
||||||
|
import { AnalyticsDailyStats } from "@/collections/AnalyticsDailyStats";
|
||||||
|
|
||||||
import { Home } from "@/globals/Home";
|
import { Home } from "@/globals/Home";
|
||||||
import { About } from "@/globals/About";
|
import { About } from "@/globals/About";
|
||||||
@@ -26,6 +28,8 @@ import { Booking } from "@/globals/Booking";
|
|||||||
import { MeetingSettings } from "@/globals/MeetingSettings";
|
import { MeetingSettings } from "@/globals/MeetingSettings";
|
||||||
import { BookingSettings } from "@/globals/BookingSettings";
|
import { BookingSettings } from "@/globals/BookingSettings";
|
||||||
import { SEOSettings } from "@/globals/SEOSettings";
|
import { SEOSettings } from "@/globals/SEOSettings";
|
||||||
|
import { CookieSettings } from "@/globals/CookieSettings";
|
||||||
|
import { AnalyticsSettings } from "@/globals/AnalyticsSettings";
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
@@ -41,7 +45,10 @@ export default buildConfig({
|
|||||||
titleSuffix: "— Anouma Admin",
|
titleSuffix: "— Anouma Admin",
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
afterNavLinks: ["/components/admin/CalendarNavLink#CalendarNavLink"],
|
afterNavLinks: [
|
||||||
|
"/components/admin/CalendarNavLink#CalendarNavLink",
|
||||||
|
"/components/admin/AnalyticsNavLink#AnalyticsNavLink",
|
||||||
|
],
|
||||||
views: {
|
views: {
|
||||||
kalender: {
|
kalender: {
|
||||||
Component: "/components/admin/AdminCalendarView#AdminCalendarView",
|
Component: "/components/admin/AdminCalendarView#AdminCalendarView",
|
||||||
@@ -49,6 +56,12 @@ export default buildConfig({
|
|||||||
exact: true,
|
exact: true,
|
||||||
meta: { title: "Kalender" },
|
meta: { title: "Kalender" },
|
||||||
},
|
},
|
||||||
|
analytics: {
|
||||||
|
Component: "/components/admin/AdminAnalyticsView#AdminAnalyticsView",
|
||||||
|
path: "/analytics",
|
||||||
|
exact: true,
|
||||||
|
meta: { title: "Analytics" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,8 +77,22 @@ export default buildConfig({
|
|||||||
AvailabilityOverrides,
|
AvailabilityOverrides,
|
||||||
BookingRequests,
|
BookingRequests,
|
||||||
Redirects,
|
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(),
|
editor: lexicalEditor(),
|
||||||
secret: process.env.PAYLOAD_SECRET || "",
|
secret: process.env.PAYLOAD_SECRET || "",
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|||||||
+9
-9
@@ -95,7 +95,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
|
|||||||
bookable: true,
|
bookable: true,
|
||||||
price: "auf Anfrage",
|
price: "auf Anfrage",
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
shortDescription: "Persönliche Einzelbegleitung — vor Ort oder online.",
|
shortDescription: "Persönliche Einzelbegleitung – vor Ort oder online.",
|
||||||
description: lexicalFromParagraphs(prozessbegleitung.sections[0].paragraphs),
|
description: lexicalFromParagraphs(prozessbegleitung.sections[0].paragraphs),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -138,13 +138,13 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Verbindung",
|
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",
|
ctaTitle: "Kennenlernen & Termine vereinbaren",
|
||||||
ctaLead:
|
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");
|
console.log(" ↺ Global „Startseite“ gesetzt");
|
||||||
@@ -166,7 +166,7 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
|
|||||||
slug: "angebote-intro",
|
slug: "angebote-intro",
|
||||||
data: {
|
data: {
|
||||||
title: "Angebote",
|
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");
|
console.log(" ↺ Global „Angebote (Einleitung)“ gesetzt");
|
||||||
@@ -186,11 +186,11 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
|
|||||||
data: {
|
data: {
|
||||||
eyebrow: "Kontakt",
|
eyebrow: "Kontakt",
|
||||||
title: "Ich freue mich, von dir zu hören",
|
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.
|
// Placeholder contact details — replace with the real ones in the admin.
|
||||||
email: "hallo@anouma.org",
|
email: "hallo@anouma.org",
|
||||||
phone: "+49 000 00 00 00",
|
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)");
|
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: {
|
data: {
|
||||||
eyebrow: "Termin buchen",
|
eyebrow: "Termin buchen",
|
||||||
title: "Kennenlernen & Termine vereinbaren",
|
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: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: "Nachricht schreiben",
|
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",
|
title: "Kennenlernen",
|
||||||
|
|||||||
@@ -263,13 +263,23 @@ env_get() { $SUDO grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -n1 | cut -d'='
|
|||||||
# passing, never re-interpolated into a nested shell string) so arbitrary
|
# passing, never re-interpolated into a nested shell string) so arbitrary
|
||||||
# characters in $value — quotes, backslashes, anything a typed SMTP/registry
|
# characters in $value — quotes, backslashes, anything a typed SMTP/registry
|
||||||
# password might contain — can never break quoting or be misinterpreted.
|
# password might contain — can never break quoting or be misinterpreted.
|
||||||
|
#
|
||||||
|
# The value is written double-quoted, with backslashes and double quotes
|
||||||
|
# escaped: deploy.sh later `source`s this file as a bash script, so an
|
||||||
|
# unquoted value containing shell metacharacters (e.g. the default
|
||||||
|
# SMTP_FROM="ANOUMA <no-reply@...>", whose `<`/`>` are redirections) breaks
|
||||||
|
# with "syntax error near unexpected token `newline'". Quoting also works
|
||||||
|
# fine for Docker Compose's own env_file parsing, which strips matching
|
||||||
|
# surrounding quotes.
|
||||||
env_set() {
|
env_set() {
|
||||||
local key="$1" value="$2" tmp
|
local key="$1" value="$2" tmp escaped
|
||||||
|
escaped="${value//\\/\\\\}"
|
||||||
|
escaped="${escaped//\"/\\\"}"
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
if $SUDO test -f "$ENV_FILE"; then
|
if $SUDO test -f "$ENV_FILE"; then
|
||||||
$SUDO grep -vE "^${key}=" "$ENV_FILE" > "$tmp" 2>/dev/null || true
|
$SUDO grep -vE "^${key}=" "$ENV_FILE" > "$tmp" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
printf '%s="%s"\n' "$key" "$escaped" >> "$tmp"
|
||||||
$SUDO cp "$tmp" "$ENV_FILE"
|
$SUDO cp "$tmp" "$ENV_FILE"
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
@@ -291,6 +301,7 @@ POSTGRES_PASSWORD=
|
|||||||
POSTGRES_DB=anouma
|
POSTGRES_DB=anouma
|
||||||
PAYLOAD_SECRET=
|
PAYLOAD_SECRET=
|
||||||
MEETING_SESSION_SECRET=
|
MEETING_SESSION_SECRET=
|
||||||
|
ANALYTICS_SALT_SECRET=
|
||||||
CRON_SECRET=
|
CRON_SECRET=
|
||||||
NEXT_PUBLIC_SERVER_URL=
|
NEXT_PUBLIC_SERVER_URL=
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
@@ -319,6 +330,7 @@ fi
|
|||||||
# --- secrets --------------------------------------------------------------
|
# --- secrets --------------------------------------------------------------
|
||||||
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
|
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 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."
|
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
|
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
|
||||||
@@ -421,6 +433,7 @@ echo "POSTGRES_DB: $(env_get POSTGRES_DB)"
|
|||||||
echo "POSTGRES_PASSWORD: $(mask "$(env_get POSTGRES_PASSWORD)")"
|
echo "POSTGRES_PASSWORD: $(mask "$(env_get POSTGRES_PASSWORD)")"
|
||||||
echo "PAYLOAD_SECRET: $(mask "$(env_get PAYLOAD_SECRET)")"
|
echo "PAYLOAD_SECRET: $(mask "$(env_get PAYLOAD_SECRET)")"
|
||||||
echo "MEETING_SESSION_SECRET: $(mask "$(env_get MEETING_SESSION_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 "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")"
|
||||||
echo "SMTP_HOST: $(env_get SMTP_HOST)"
|
echo "SMTP_HOST: $(env_get SMTP_HOST)"
|
||||||
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"
|
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"
|
||||||
|
|||||||
Reference in New Issue
Block a user