Initial commit: ANOUMA website with Payload CMS, WebRTC meetings, and booking system

- Next.js 16 App Router site with the ANOUMA design system
- Payload CMS (PostgreSQL) for offers, events, posts and page content
- WebRTC video-call system with custom signaling server
- SMTP email reminders and booking-request notifications
- Customer accounts, calendar-based availability, and booking workflow
This commit is contained in:
maro
2026-08-25 16:40:51 +02:00
commit 45261a0461
138 changed files with 23263 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# PostgreSQL connection string used by Payload (see docker-compose.yml for a
# local database, or use a hosted Postgres such as Neon/Supabase).
DATABASE_URI=postgresql://postgres:postgres@127.0.0.1:5432/anouma
# Long random string used to sign Payload's auth tokens/cookies.
# Generate one with: openssl rand -base64 48
PAYLOAD_SECRET=replace-with-a-long-random-secret
# Public URL of this site — used by Payload for absolute admin/media links,
# and to build the meeting join link inside reminder emails.
# Set this to the real domain in production (e.g. https://anouma.org).
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Long random string used to sign meeting join tokens (separate from
# PAYLOAD_SECRET on purpose). Generate one with: openssl rand -base64 48
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
# Secret the external cron job must send as "Authorization: Bearer <value>"
# to trigger /api/cron/event-reminders. Generate with: openssl rand -hex 32
CRON_SECRET=replace-with-a-long-random-secret
# SMTP settings for the existing ANOUMA mail system (reminder emails).
# No external newsletter service — plain SMTP via nodemailer.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="ANOUMA <support@anouma.org>"
+46
View File
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# payload
/media
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+51
View File
@@ -0,0 +1,51 @@
# Runs a custom Node server (server.ts) for the WebRTC signaling WebSocket,
# so this can't use Next's "standalone" output — we ship the full app +
# node_modules instead. DATABASE_URI/PAYLOAD_SECRET etc. are only needed at
# runtime, not at build time (see docker-compose.yml and .env.example).
FROM node:22-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/payload.config.ts ./payload.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/collections ./collections
COPY --from=builder --chown=nextjs:nodejs /app/globals ./globals
COPY --from=builder --chown=nextjs:nodejs /app/access ./access
COPY --from=builder --chown=nextjs:nodejs /app/fields ./fields
COPY --from=builder --chown=nextjs:nodejs /app/components ./components
COPY --from=builder --chown=nextjs:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
RUN mkdir -p media && chown nextjs:nodejs media
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["npm", "start"]
+97
View File
@@ -0,0 +1,97 @@
# Anouma
Die Website von Anouma — Next.js (App Router) mit einem eingebauten [Payload CMS](https://payloadcms.com) (PostgreSQL) für Termine, Beiträge, Angebote und Seiteninhalte, plus Online-Termine mit P2P-Video-Call (WebRTC), Anmeldungen und E-Mail-Erinnerungen.
## Projektstruktur
- `app/(frontend)/` — die öffentliche Website (bestehendes ANOUMA-Design, eigener Root-Layout)
- `app/(call)/` — die Video-Call-Oberfläche (`/termine/[slug]/call`), eigenes minimalistisches dunkles Layout ohne Navbar/Footer
- `app/(payload)/` — der Admin-Bereich unter `/admin`, Payloads REST/GraphQL-API sowie die Meeting-/Cron-API-Routen (`api/meetings/...`, `api/cron/...`)
- `app/global-not-found.tsx` — statische 404-Seite (siehe unten, warum sie nötig ist)
- `collections/`, `globals/`, `access/`, `fields/`, `payload.config.ts` — die CMS-Konfiguration
- `lib/payload/` — Local-API-Zugriffe für die öffentliche Website (mit `React.cache` pro Request memoisiert)
- `lib/meeting/` — Meeting-Passwort, Zeitfenster/Status, signierte Beitritts-Tokens, WebSocket-Signaling, Reminder-Logik
- `lib/email/` — SMTP-Versand (nodemailer) und das E-Mail-Template für Reminder
- `lib/texte.ts` — die ursprünglichen Anouma-Texte, nur noch als Seed-Quelle verwendet
- `scripts/seed.ts` — überträgt die vorhandenen Inhalte ins CMS
- `server.ts` — eigener Node-Server (statt `next start`), weil daran der WebRTC-Signaling-WebSocket hängt
Zwei bzw. drei Root-Layouts (`(frontend)`, `(payload)`, `(call)`) bedeuten: Next.js kann daraus keine einzelne 404-Seite komponieren, deshalb gibt es `app/global-not-found.tsx` (siehe `experimental.globalNotFound` in `next.config.ts`).
## Online-Termine & Video-Call
- Ein Termin wird per Häkchen „Online-Termin“ zu einem Video-Call-Termin. Payload generiert dabei automatisch ein zufälliges Meeting-Passwort (nie aus der Meeting-ID abgeleitet); die Admin kann jederzeit ein neues erzeugen lassen.
- Öffentliche Beitrittsseite: `/termine/[slug]/beitreten` (Name + Passwort, beide Pflicht) → bei Erfolg `/termine/[slug]/call`.
- Ist die aufrufende Person im selben Browser als Admin eingeloggt, wird sie automatisch als **Host** erkannt (kein Passwort nötig, größeres Beitritts-Zeitfenster, exklusive Steuerung: Bildschirmfreigabe, Teilnehmer entfernen).
- Die Zeitfenster (wie früh Host/Teilnehmer beitreten dürfen, wie lange das Meeting nach Ende offen bleibt) sind zentral im CMS unter **Video-Termin-Einstellungen** konfigurierbar.
- Server-seitige Validierung: `/api/meetings/[slug]/join` prüft Termin, Zeitfenster und Passwort und stellt danach erst ein kurzlebiges, signiertes Sitzungs-Token aus (`MEETING_SESSION_SECRET`). Das WebSocket-Signaling (`/ws/signaling`, siehe `server.ts`) prüft dieses Token erneut, bevor jemand einem Raum beitreten darf.
- Reines P2P-WebRTC: Der Server relayt nur kleine Signaling-Nachrichten (Angebot/Antwort/ICE), niemals Audio/Video/Bildschirmfreigabe. Es wird nichts aufgezeichnet oder gespeichert.
- Teilnehmer-Anmeldungen laufen über die Collection **Anmeldungen** (`event-registrations`), öffentlich erreichbar über `/api/meetings/[slug]/register`.
### E-Mail-Erinnerungen
Erinnerungs-Mails (60/30 Minuten vorher, pro Termin im CMS einzeln an-/abschaltbar) werden **nicht** automatisch im Hintergrund verschickt, sondern müssen von einem externen Cron-Job ausgelöst werden, z. B. alle 5 Minuten:
```bash
curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
```
Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hostReminder60Sent`/`hostReminder30Sent` je Termin) — ein häufiger laufender Cron verschickt also nie doppelt.
## Setup
```bash
npm install
cp .env.example .env
# .env ausfüllen: DATABASE_URI, PAYLOAD_SECRET (z. B. mit `openssl rand -base64 48`)
```
### Datenbank
Lokal per Docker:
```bash
docker compose up -d postgres
```
(oder eine gehostete Postgres-Instanz, z. B. Neon/Supabase — einfach `DATABASE_URI` in `.env` entsprechend setzen).
### Entwicklung
```bash
npm run dev
```
- Website: http://localhost:3000
- Admin: http://localhost:3000/admin — beim ersten Aufruf legst du dort direkt deinen eigenen Admin-Zugang (E-Mail + Passwort) an. Es gibt kein Standardpasswort im Code.
### Vorhandene Inhalte ins CMS übertragen
```bash
npm run seed
```
Überträgt die 7 Angebote sowie die Texte für Startseite, Über mich, Angebote-Einleitung, Aktuelles-Einleitung, Kontakt und Termin buchen aus `lib/texte.ts` ins CMS. Kann gefahrlos mehrfach ausgeführt werden. Kontaktdaten (E-Mail/Telefon/Region) werden zunächst als Platzhalter gesetzt — bitte im Admin unter „Kontakt“ durch die echten Angaben ersetzen.
### Produktion
```bash
npm run build
npm run migrate # wendet Datenbank-Migrationen an
npm run start
```
Mit Docker: `docker build -t anouma .` (baut die App und startet sie über den eigenen `server.ts`-Server statt `next start`, wegen des WebSocket-Signalings), `.env` per `--env-file` oder `docker-compose.yml` (Service `app`, aktuell auskommentiert) bereitstellen.
## Weitere Skripte
| Skript | Zweck |
| --------------------------- | --------------------------------------------------- |
| `npm run generate:types` | `payload-types.ts` aus der Config neu erzeugen |
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
| `npm run lint` | ESLint |
## Design
Siehe `app/(frontend)/globals.css` für das Farbsystem und `AGENTS.md` für die Next.js-Version-16-Hinweise, die für jede Code-Änderung in `app/` gelten.
+26
View File
@@ -0,0 +1,26 @@
import type { Access, FieldAccess } from "payload";
/**
* Only the "users" auth collection (Anna/admins) may perform the action —
* and only with the "admin" role, so adding further roles to Users later
* only means extending this one check. Site visitors authenticate against
* the separate "customers" collection (see collections/Customers.ts) and
* must never be treated as admins here.
*/
export const isAdmin: Access = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
/**
* Admins can read every document (including drafts); everyone else may only
* read documents whose Payload draft/publish status is "published".
*/
export const publishedOrAdmin: Access = ({ req: { user } }) => {
if (user?.collection === "users" && user.role === "admin") return true;
return {
_status: { equals: "published" },
};
};
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Inter } from "next/font/google";
import "../(frontend)/globals.css";
// Its own root layout — the video-call UI is intentionally full-bleed and
// dark, without the marketing navbar/footer around it.
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Video-Call",
robots: { index: false, follow: false },
};
export default function CallLayout({ children }: { children: ReactNode }) {
return (
<html lang="de" className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full bg-neutral-900">{children}</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { CallRoom } from "@/components/meeting/CallRoom";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export default async function CallPage({ params }: Args) {
const { slug } = await params;
return <CallRoom slug={slug} />;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { getPostBySlug } from "@/lib/payload/content";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
return { title: post.title, description: post.teaser };
}
export default async function PostDetailPage({ params }: Args) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<>
<PageHeader
eyebrow={publishDate}
title={post.title}
lead={post.teaser}
crumbs={[{ title: "Aktuelles", href: "/aktuelles" }, { title: post.title }]}
/>
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
<div className="relative aspect-[21/9] w-full">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "hero")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
priority
className="h-full w-full"
/>
</div>
</div>
<Section tone="plain">
<div className="mx-auto max-w-2xl">
<RichText data={post.content} />
</div>
</Section>
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import { getPosts } from "@/lib/payload/content";
import { getAktuellesIntroGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const intro = await getAktuellesIntroGlobal();
return {
title: intro.title || "Aktuelles",
description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.",
};
}
export default async function AktuellesPage() {
const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]);
return (
<>
<PageHeader
eyebrow={intro.eyebrow || "Aktuelles"}
title={intro.title || "Neuigkeiten, Termine und Inspiration"}
lead={intro.lead}
crumbs={[{ title: "Aktuelles" }]}
/>
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten schau gerne wieder vorbei.
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={Math.min(i * 0.06, 0.3)}>
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h2 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
</div>
)}
</Section>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { getOfferBySlug } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { OFFER_CATEGORIES } from "@/collections/Offers";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
if (!offer) return {};
return {
title: offer.title,
description: offer.shortDescription,
};
}
export default async function OfferDetailPage({ params }: Args) {
const { slug } = await params;
const offer = await getOfferBySlug(slug);
if (!offer) notFound();
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
return (
<>
<PageHeader
eyebrow={categoryLabel}
title={offer.title}
lead={offer.shortDescription}
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: offer.title }]}
/>
<div className="mx-auto -mt-10 max-w-5xl px-6 sm:px-8 lg:px-12">
<div className="relative aspect-[21/9] w-full">
<ImagePlaceholder
mood={moodForOffer(offer)}
label={offer.title}
src={mediaUrl(offer.image, "hero")}
alt={mediaAlt(offer.image) ?? offer.title}
shape="soft"
priority
className="h-full w-full"
/>
</div>
</div>
<Section tone="plain">
<div className="mx-auto max-w-2xl">
<RichText data={offer.description} />
</div>
</Section>
<CTA
title="Interesse geweckt?"
lead={`Melde dich gerne für ein unverbindliches Gespräch zu „${offer.title}“.`}
/>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section, SectionHeading } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { AngebotCard } from "@/components/AngebotCard";
import { Button } from "@/components/Button";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { getAngeboteIntroGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const intro = await getAngeboteIntroGlobal();
return {
title: intro.title || "Angebote",
description:
intro.lead ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.",
};
}
export default async function AngebotePage() {
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
const groups = groupOffersByCategory(offers);
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
const kindergruppen = groups.find((g) => g.category === "kindergruppen");
const singkreise = groups.find((g) => g.category === "singkreise");
return (
<>
<PageHeader
title={intro.title || "Angebote"}
lead={intro.lead}
crumbs={[{ title: "Angebote" }]}
/>
{prozessbegleitung && (
<Section tone="plain">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal>
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none">
<ImagePlaceholder
mood={moodForOffer(prozessbegleitung)}
label={prozessbegleitung.title}
src={mediaUrl(prozessbegleitung.image, "hero")}
alt={mediaAlt(prozessbegleitung.image)}
className="h-full w-full"
/>
</div>
</Reveal>
<Reveal delay={0.1}>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Für Erwachsene
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{prozessbegleitung.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{prozessbegleitung.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${prozessbegleitung.slug}`}>
{prozessbegleitung.title} entdecken
</Button>
</div>
</Reveal>
</div>
</Section>
)}
{doula && (
<Section tone="cream">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal className="order-2 lg:order-1">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Kinderwunsch · Schwangerschaft · Geburt · Wochenbett
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{doula.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{doula.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${doula.slug}`}>{doula.title} entdecken</Button>
</div>
</Reveal>
<Reveal delay={0.1} className="order-1 lg:order-2">
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none lg:ml-auto">
<ImagePlaceholder
mood={moodForOffer(doula)}
label={doula.title}
src={mediaUrl(doula.image, "hero")}
alt={mediaAlt(doula.image)}
className="h-full w-full"
/>
</div>
</Reveal>
</div>
</Section>
)}
{kindergruppen && kindergruppen.offers.length > 0 && (
<Section id="kindergruppen" tone="plain">
<SectionHeading eyebrow="Für Kinder" title="Kindergruppen" lead={kindergruppen.label} />
<div className="mt-14 grid gap-6 sm:grid-cols-2">
{kindergruppen.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
size="large"
/>
</Reveal>
))}
</div>
</Section>
)}
{singkreise && singkreise.offers.length > 0 && (
<Section tone="warm">
<SectionHeading eyebrow="Für Herz und Seele" title="Singkreise" />
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{singkreise.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
/>
</Reveal>
))}
</div>
</Section>
)}
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
}
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Singkreise",
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
};
export default async function SingkreisePage() {
const offers = await getOffers();
const singkreise = offers
.filter((offer) => offer.category === "singkreise")
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<>
<PageHeader
eyebrow="Singkreise"
title="Singkreise miteinander singen, klingen und sein"
lead="Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele."
crumbs={[{ title: "Angebote", href: "/angebote" }, { title: "Singkreise" }]}
/>
<Section tone="plain">
<div className="grid gap-6 sm:grid-cols-3">
{singkreise.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
/>
</Reveal>
))}
</div>
</Section>
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Datenschutz",
description: "Datenschutzerklärung von Anouma.",
robots: { index: false, follow: true },
};
export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
Diese Seite ist eine strukturelle Vorlage. Bitte die Angaben zu verantwortlicher Stelle,
Hosting, eingesetzten Diensten und Cookies mit den tatsächlich genutzten Tools ergänzen
und rechtlich prüfen lassen, bevor die Seite live geht.
</PlaceholderNote>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname]
<br />
[Platzhalter Anschrift]
<br />
E-Mail: {contact.email}
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Erhebung und Speicherung personenbezogener Daten
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontaktformular</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Wenn du uns per Formular oder E-Mail Anfragen zukommen lässt, werden deine Angaben aus
dem Formular inklusive der von dir dort angegebenen Kontaktdaten zwecks Bearbeitung der
Anfrage bei uns gespeichert. Diese Daten geben wir nicht ohne deine Einwilligung weiter.
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Deine Rechte</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Du hast jederzeit das Recht auf Auskunft, Berichtigung, Löschung und Einschränkung der
Verarbeitung deiner gespeicherten personenbezogenen Daten sowie ein Recht auf
Datenübertragbarkeit und Widerspruch. Wende dich hierzu gerne an {contact.email}.
</p>
</div>
</div>
</Section>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
@import "tailwindcss";
@theme inline {
/* Anouma color palette */
--color-anouma-mauve-dark: #8b616d;
--color-anouma-rose: #d19ca1;
--color-anouma-rose-soft: #e0b4b1;
--color-anouma-rose-pale: #e5c4bc;
--color-anouma-peach-rose: #ecc8be;
--color-anouma-powder: #efd5c8;
--color-anouma-cream-warm: #efdbcd;
--color-anouma-cream-beige: #f0e0d3;
--color-anouma-cream-light: #f2e2d6;
--color-anouma-sage: #89937c;
--color-anouma-olive: #667052;
--color-anouma-moss: #4f5b45;
--color-anouma-walnut: #765a48;
--color-anouma-caramel: #a47c60;
--color-anouma-sand: #d4c1a5;
--color-anouma-cream-beige-2: #e8dcc8;
--color-anouma-taupe: #a89580;
--color-anouma-dustyrose: #a97070;
--color-anouma-mauve: #8b6f7d;
--color-anouma-plum: #66505f;
--color-background: #fbf6f1;
--color-foreground: #453238;
--font-serif: var(--font-cormorant), "Cormorant Garamond", ui-serif, Georgia, serif;
--font-sans: var(--font-inter), "Inter", ui-sans-serif, system-ui, sans-serif;
--animate-fade-up: fade-up 0.8s ease-out both;
--animate-fade-in: fade-in 1s ease-out both;
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
:root {
--background: #fbf6f1;
--foreground: #453238;
}
* {
scrollbar-color: var(--color-anouma-rose) var(--color-anouma-cream-light);
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
::selection {
background: var(--color-anouma-rose-soft);
color: var(--color-anouma-plum);
}
/* Visible, elegant focus state used across all interactive elements */
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--color-anouma-mauve-dark);
outline-offset: 3px;
border-radius: 2px;
}
.text-balance {
text-wrap: balance;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import type { ImageMood } from "@/lib/angebote";
export const metadata: Metadata = {
title: "Impressionen",
description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.",
};
const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" },
{ mood: "rose", label: "Gemeinschaft und Begegnung" },
{ mood: "peach", label: "Schwangerschaft und Wachsen" },
{ mood: "sand", label: "Singen und Klang" },
{ mood: "caramel", label: "Kreativität und Hände", span: "sm:row-span-2" },
{ mood: "dustyrose", label: "Mutter und Kind" },
{ mood: "plum", label: "Stille und Innehalten" },
{ mood: "mauve", label: "Räume der Verbindung" },
];
export default function ImpressionenPage() {
return (
<>
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
</Reveal>
))}
</div>
</Section>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Impressum",
description: "Impressum von Anouma gemäß § 5 TMG.",
robots: { index: false, follow: true },
};
export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
Dieses Impressum enthält noch Platzhalterangaben. Bitte vor Veröffentlichung durch die
vollständigen, rechtsverbindlichen Angaben gemäß § 5 TMG ersetzen (und im Zweifel
rechtlich prüfen lassen).
</PlaceholderNote>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname]
<br />
[Platzhalter Straße und Hausnummer]
<br />
[Platzhalter PLZ und Ort]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Kontakt</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
{contact.phone && (
<>
Telefon: {contact.phone}
<br />
</>
)}
E-Mail: {contact.email}
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter Vor- und Nachname, Anschrift wie oben]
</p>
</div>
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Haftungshinweis</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte
externer Links. Für den Inhalt der verlinkten Seiten sind ausschließlich deren
Betreiber verantwortlich.
</p>
</div>
</div>
</Section>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal";
import { getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const contact = await getContactGlobal();
return {
title: contact.title || "Kontakt",
description: contact.lead || "Ich freue mich, von dir zu hören.",
};
}
export default async function KontaktPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader
eyebrow={contact.eyebrow || "Kontakt"}
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<Reveal>
<ContactForm toEmail={contact.email} />
</Reveal>
<Reveal delay={0.1} className="space-y-8">
<div className="relative aspect-[4/3] w-full">
<ImagePlaceholder mood="mauve" label="Kontakt" className="h-full w-full" />
</div>
<div className="space-y-3 text-anouma-plum">
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
E-Mail
</span>
<a
href={`mailto:${contact.email}`}
className="text-lg text-anouma-plum hover:text-anouma-mauve-dark"
>
{contact.email}
</a>
</p>
{contact.phone && (
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Telefon
</span>
<span className="text-lg text-anouma-plum">{contact.phone}</span>
</p>
)}
{contact.region && (
<p>
<span className="block text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Region
</span>
<span className="text-lg text-anouma-plum">{contact.region}</span>
</p>
)}
</div>
</Reveal>
</div>
</Section>
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { PersonalCalendar } from "@/components/booking/PersonalCalendar";
export const metadata: Metadata = { title: "Mein Kalender" };
export default async function KontoKalenderPage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
return (
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Mein Kalender</h2>
<div className="mt-6">
<PersonalCalendar bookings={bookings} />
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Container } from "@/components/Section";
import { LogoutButton } from "@/components/auth/LogoutButton";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
const navItems = [
{ title: "Übersicht", href: "/konto" },
{ title: "Kalender", href: "/konto/kalender" },
{ title: "Meine Termine", href: "/konto/termine" },
{ title: "Profil", href: "/konto/profil" },
];
export default async function KontoLayout({ children }: { children: ReactNode }) {
const customer = await getCurrentCustomer();
if (!customer) redirect("/login?next=/konto");
return (
<section className="bg-anouma-cream-light py-16">
<Container>
<div className="mb-10">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">Mein Konto</p>
<h1 className="mt-2 font-serif text-3xl font-medium text-anouma-plum">Hallo, {customer.name}</h1>
</div>
<div className="grid gap-10 lg:grid-cols-[220px_1fr]">
<nav aria-label="Konto-Navigation" className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
</Container>
</section>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
export const metadata: Metadata = { title: "Mein Konto" };
export default async function KontoOverviewPage() {
const customer = await getCurrentCustomer();
if (!customer) return null; // Layout already redirects; keeps TS happy.
const bookings = await getCustomerBookings(customer.id);
const pending = bookings.filter((b) => b.status === "pending");
const confirmed = bookings.filter((b) => b.status === "confirmed" && new Date(b.date) >= new Date());
const past = bookings.filter((b) => new Date(b.date) < new Date() || b.status === "rejected" || b.status === "cancelled");
return (
<div className="space-y-10">
<div className="rounded-3xl bg-white p-6">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Deine Daten</h2>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">Name</dt>
<dd className="text-base text-anouma-plum">{customer.name}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-anouma-plum/60">E-Mail</dt>
<dd className="text-base text-anouma-plum">{customer.email}</dd>
</div>
</dl>
</div>
<div>
<div className="flex items-center justify-between">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Offene Buchungsanfragen</h2>
</div>
{pending.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">Keine offenen Anfragen.</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{pending.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Bestätigte Termine</h2>
{confirmed.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">Noch keine bestätigten Termine.</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{confirmed.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen
</Link>
</div>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { ProfileForm } from "@/components/auth/ProfileForm";
export const metadata: Metadata = { title: "Profil" };
export default async function KontoProfilPage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
return (
<div className="max-w-md">
<h2 className="font-serif text-xl font-medium text-anouma-plum">Profil</h2>
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
export const metadata: Metadata = { title: "Meine Termine" };
export default async function KontoTerminePage() {
const customer = await getCurrentCustomer();
if (!customer) return null;
const bookings = await getCustomerBookings(customer.id);
return (
<div>
<h2 className="font-serif text-xl font-medium text-anouma-plum">Meine Termine</h2>
{bookings.length === 0 ? (
<p className="mt-3 text-sm text-anouma-plum/70">
Du hast noch keine Terminanfragen gestellt.
</p>
) : (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{bookings.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/components/Footer";
import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
import "./globals.css";
// Every page under this layout can read live content from Payload, so the
// whole subtree is rendered dynamically rather than statically at build time.
export const dynamic = "force-dynamic";
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["normal", "italic"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: siteConfig.title,
template: `%s — ${siteConfig.name}`,
},
description: siteConfig.description,
openGraph: {
type: "website",
locale: siteConfig.locale,
url: siteConfig.domain,
siteName: siteConfig.name,
title: siteConfig.title,
description: siteConfig.description,
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
},
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const offers = await getOffers();
return (
<html
lang="de"
data-scroll-behavior="smooth"
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-background text-foreground">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-anouma-mauve-dark focus:px-5 focus:py-3 focus:text-anouma-cream-light"
>
Zum Inhalt springen
</a>
<Navbar offers={offers} />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { LoginForm } from "@/components/auth/LoginForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Anmelden",
};
export default async function LoginPage() {
const customer = await getCurrentCustomer();
if (customer) redirect("/konto");
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
<LoginForm />
</Suspense>
<p className="mt-6 text-center text-sm text-anouma-plum">
Noch kein Konto?{" "}
<Link href="/registrieren" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
Jetzt registrieren
</Link>
</p>
</div>
</Section>
</>
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content";
import { getHomeGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const home = await getHomeGlobal();
return {
title: { absolute: home.heroTitle || "Willkommen bei Anouma" },
description:
home.heroSupporting ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
};
}
export default async function Home() {
const [home, offers, posts, events] = await Promise.all([
getHomeGlobal(),
getOffers(),
getPosts(3),
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
return (
<>
<Hero
eyebrow={home.heroEyebrow ?? undefined}
title={home.heroTitle || "Willkommen bei Anouma"}
mood="rose"
imageSrc={mediaUrl(home.heroImage, "hero")}
imageAlt={mediaAlt(home.heroImage)}
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
Angebote entdecken
</Button>
</>
}
>
{home.heroHighlight && <p>{home.heroHighlight}</p>}
{home.heroSupporting && (
<p className="mt-3 text-base text-anouma-plum/90">{home.heroSupporting}</p>
)}
</Hero>
<Section tone="cream">
<SectionHeading
eyebrow="Angebote"
title="Räume, die zu deinem Weg passen"
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
/>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{groups.map((group, i) => {
const isSingle = group.offers.length === 1;
const first = group.offers[0];
return (
<Reveal key={group.category} delay={i * 0.08}>
<AngebotCard
href={isSingle ? `/angebote/${first.slug}` : group.href}
title={group.label}
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
mood={moodForOffer(first)}
imageSrc={mediaUrl(first.image, "card")}
/>
</Reveal>
);
})}
</div>
</Section>
{(home.aboutTitle || home.aboutText) && (
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-2 lg:items-center">
<Reveal>
<div className="relative mx-auto aspect-[4/5] w-full max-w-md lg:max-w-none">
<ImagePlaceholder
mood="mauve"
label="Anouma"
src={mediaUrl(home.aboutImage, "hero")}
alt={mediaAlt(home.aboutImage)}
className="h-full w-full"
/>
</div>
</Reveal>
<Reveal delay={0.1}>
{home.aboutEyebrow && (
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
{home.aboutEyebrow}
</p>
)}
{home.aboutTitle && (
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
{home.aboutTitle}
</h2>
)}
{home.aboutText && (
<div className="mt-6">
<RichText data={home.aboutText} />
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen
</Button>
</div>
</Reveal>
</div>
</Section>
)}
{home.values && home.values.length > 0 && (
<Section tone="mauve">
<SectionHeading
eyebrow={home.valuesEyebrow ?? undefined}
title={home.valuesTitle || "Philosophie"}
align="center"
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
/>
<div className="mt-16 grid gap-10 sm:grid-cols-3">
{home.values.map((wert, i) => (
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
{wert.title}
</p>
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
{wert.quote}
</p>
</Reveal>
))}
</div>
</Section>
)}
{events.length > 0 && (
<Section tone="warm">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
<Button href="/termine" variant="ghost">
Alle Termine
</Button>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
<Reveal key={event.slug} delay={i * 0.08}>
<EventTeaserCard event={event} />
</Reveal>
))}
</div>
</Section>
)}
{posts.length > 0 && (
<Section tone="cream">
<div className="flex flex-wrap items-end justify-between gap-6">
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
<Button href="/aktuelles" variant="ghost">
Alle Neuigkeiten
</Button>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{posts.map((post, i) => (
<Reveal key={post.slug} delay={i * 0.08}>
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
</Link>
</Reveal>
))}
</div>
</Section>
)}
<Section tone="plain">
<SectionHeading eyebrow="Impressionen" title="Einblicke in gemeinsame Räume" align="center" className="mx-auto" />
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
{(["moss", "rose", "peach", "sand"] as const).map((mood, i) => (
<Reveal key={mood} delay={i * 0.06}>
<ImagePlaceholder mood={mood} label="Impression" shape="soft" className="aspect-square" />
</Reveal>
))}
</div>
<div className="mt-10 text-center">
<Link
href="/impressionen"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Impressionen ansehen
</Link>
</div>
</Section>
<CTA
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
lead={
home.ctaLead ||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Konto erstellen",
};
export default async function RegisterPage() {
const customer = await getCurrentCustomer();
if (customer) redirect("/konto");
return (
<>
<PageHeader
eyebrow="Konto"
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
<RegisterForm />
<p className="mt-6 text-center text-sm text-anouma-plum">
Schon ein Konto?{" "}
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
Jetzt anmelden
</Link>
</p>
</div>
</Section>
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section, SectionHeading } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm";
import { Reveal } from "@/components/Reveal";
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const booking = await getBookingGlobal();
return {
title: booking.title || "Termin buchen",
description: booking.lead || "Kennenlernen & Termine vereinbaren.",
};
}
export default async function TerminBuchenPage() {
const [booking, contact] = await Promise.all([getBookingGlobal(), getContactGlobal()]);
return (
<>
<PageHeader
eyebrow={booking.eyebrow || "Termin buchen"}
title={booking.title || "Kennenlernen & Termine vereinbaren"}
lead={booking.lead}
crumbs={[{ title: "Termin buchen" }]}
/>
{booking.steps && booking.steps.length > 0 && (
<Section tone="cream">
<SectionHeading eyebrow="Ablauf" title="So findest du zu deinem Termin" />
<div className="mt-14 grid gap-8 sm:grid-cols-3">
{booking.steps.map((s, i) => (
<Reveal key={s.id ?? s.title} delay={i * 0.08}>
<p className="font-serif text-5xl font-medium text-anouma-rose">
{String(i + 1).padStart(2, "0")}
</p>
<h3 className="mt-4 font-serif text-xl font-medium text-anouma-plum">{s.title}</h3>
<p className="mt-3 text-base leading-relaxed text-anouma-plum">{s.text}</p>
</Reveal>
))}
</div>
</Section>
)}
<Section tone="plain">
<div className="mx-auto max-w-xl">
<SectionHeading title="Nachricht senden" align="left" />
<div className="mt-10">
<ContactForm
toEmail={contact.email}
subjectPrefix="Terminanfrage über anouma.org"
submitLabel="Terminanfrage senden"
/>
</div>
{booking.formNote && (
<p className="mt-8 text-sm leading-relaxed text-anouma-plum/80">{booking.formNote}</p>
)}
</div>
</Section>
</>
);
}
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { JoinForm } from "@/components/meeting/JoinForm";
import { OrganicBlob } from "@/components/OrganicBlob";
import { getEventForJoin } from "@/lib/payload/content";
import { combineDateAndTime } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Meeting beitreten",
};
type Args = { params: Promise<{ slug: string }> };
export default async function BeitretenPage({ params }: Args) {
const { slug } = await params;
const event = await getEventForJoin(slug);
if (!event || !event.isOnline) notFound();
const start = combineDateAndTime(event.date, event.startTime);
const dateLabel = start.toLocaleDateString("de-DE", { day: "2-digit", month: "long" });
const timeLabel = start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return (
<section className="relative flex min-h-[80vh] items-center overflow-hidden py-20">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
<div className="relative mx-auto w-full max-w-md px-6 text-center">
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.12em] text-anouma-mauve-dark">
ANOUMA
</Link>
<p className="mt-8 text-sm font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
Dein Termin beginnt bald
</p>
<h1 className="mt-3 font-serif text-3xl font-medium text-anouma-plum">{event.title}</h1>
<p className="mt-2 text-base text-anouma-plum/80">
{dateLabel} · {timeLabel} Uhr
</p>
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
<JoinForm slug={slug} />
</div>
</div>
</section>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers } from "next/headers";
import { getPayload } from "payload";
import config from "@payload-config";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { RegisterForm } from "@/components/meeting/RegisterForm";
import { getEventBySlug } from "@/lib/payload/content";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { formatFullDate, formatTimeRange } from "@/lib/format";
import { EVENT_CATEGORIES } from "@/collections/Events";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, statusLabel } from "@/lib/meeting/status";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params;
const event = await getEventBySlug(slug);
if (!event) return {};
return { title: event.title };
}
export default async function EventDetailPage({ params }: Args) {
const { slug } = await params;
const event = await getEventBySlug(slug);
if (!event) notFound();
const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label;
const time = formatTimeRange(event.startTime, event.endTime);
let meetingCard = null;
if (event.isOnline) {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: await headers() });
const isHost = Boolean(user);
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
const joinable = canJoinMeeting(status);
meetingCard = (
<div className="rounded-3xl bg-anouma-plum p-6 text-center text-anouma-cream-light">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-cream-light/80">
Online-Termin
</p>
{joinable ? (
<Link
href={`/termine/${slug}/beitreten`}
className="mt-4 inline-flex w-full items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light"
>
{statusLabel[status]}
</Link>
) : (
<p className="mt-4 text-base">{statusLabel[status]}</p>
)}
</div>
);
}
return (
<>
<PageHeader
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
<div className="order-2 lg:order-1 space-y-10">
{event.description && <RichText data={event.description} />}
{event.registrationRequired && (
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
<div className="mt-4 max-w-sm">
<RegisterForm slug={slug} />
</div>
</div>
)}
</div>
<div className="order-1 space-y-6 lg:order-2">
{meetingCard}
<div className="relative aspect-[4/3] w-full">
<ImagePlaceholder
mood="rose"
label={event.title}
src={mediaUrl(event.image, "card")}
alt={mediaAlt(event.image) ?? event.title}
className="h-full w-full"
/>
</div>
<dl className="space-y-3 rounded-3xl bg-anouma-cream-light p-6 text-sm">
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Datum</dt>
<dd className="text-base text-anouma-plum">{formatFullDate(event.date)}</dd>
</div>
{time && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Uhrzeit</dt>
<dd className="text-base text-anouma-plum">{time}</dd>
</div>
)}
{event.location && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">Ort</dt>
<dd className="text-base text-anouma-plum">{event.location}</dd>
</div>
)}
{event.maxParticipants && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
Teilnehmerzahl
</dt>
<dd className="text-base text-anouma-plum">max. {event.maxParticipants}</dd>
</div>
)}
{event.registrationRequired && event.registrationInfo && (
<div>
<dt className="font-medium uppercase tracking-wide text-anouma-plum/70">
Anmeldung
</dt>
<dd className="text-base text-anouma-plum">{event.registrationInfo}</dd>
</div>
)}
</dl>
</div>
</div>
</Section>
<CTA
title="Dabei sein?"
lead={`Melde dich gerne für „${event.title}“ an oder frag nach freien Plätzen.`}
/>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getAllEvents } from "@/lib/payload/content";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Termine",
description: "Aktuelle Termine und Veranstaltungen von Anouma.",
};
export default async function TerminePage() {
const events = await getAllEvents();
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
const past = events.filter((e) => new Date(e.date) < today);
return (
<>
<PageHeader
eyebrow="Termine"
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
<Reveal key={event.slug} delay={Math.min(i * 0.06, 0.3)}>
<EventTeaserCard event={event} />
</Reveal>
))}
</div>
)}
</Section>
{past.length > 0 && (
<Section tone="warm">
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Vergangene Termine</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{past.map((event) => (
<div key={event.slug} className="opacity-70">
<EventTeaserCard event={event} />
</div>
))}
</div>
</Section>
)}
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { OrganicBlob } from "@/components/OrganicBlob";
import { getAboutGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
const about = await getAboutGlobal();
return {
title: about.title || "Über mich",
description: "Mein Weg, meine Werte und was mich bewegt.",
};
}
export default async function UeberMichPage() {
const about = await getAboutGlobal();
const hasClosing = about.closingHighlight || about.closingParagraph;
return (
<>
<PageHeader
eyebrow={about.eyebrow || "Über mich"}
title={about.title || "Mein Weg, meine Werte und was mich bewegt"}
crumbs={[{ title: "Über mich" }]}
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[0.85fr_1.15fr] lg:items-start">
<Reveal className="lg:sticky lg:top-28">
<div className="relative mx-auto aspect-[4/5] w-full max-w-sm lg:max-w-none">
<ImagePlaceholder
mood="dustyrose"
label="Portrait"
src={mediaUrl(about.portrait, "hero")}
alt={mediaAlt(about.portrait)}
priority
className="h-full w-full"
/>
</div>
</Reveal>
<div className="max-w-2xl">
<Reveal>
<RichText data={about.body} />
</Reveal>
</div>
</div>
</Section>
{hasClosing && (
<section className="relative overflow-hidden bg-anouma-plum py-24 text-anouma-cream-light">
<OrganicBlob tone="mauve" className="-left-24 top-0 h-96 w-96 opacity-25" />
<div className="relative mx-auto max-w-3xl px-6 text-center sm:px-8">
<Reveal>
{about.closingLead && (
<p className="text-lg text-anouma-cream-light/90">{about.closingLead}</p>
)}
{about.closingHighlight && (
<p className="mt-6 text-balance font-serif text-3xl italic leading-snug sm:text-4xl">
{about.closingHighlight}
</p>
)}
{about.closingParagraph && (
<p className="mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-anouma-cream-light/90">
{about.closingParagraph}
</p>
)}
</Reveal>
</div>
</section>
)}
<CTA
title="Lust, dich kennenzulernen"
lead="Wenn dich mein Weg anspricht, freue ich mich, dich in einem persönlichen Gespräch kennenzulernen."
/>
</>
);
}
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from "next";
import config from "@payload-config";
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap.js";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap });
export default NotFound;
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from "next";
import config from "@payload-config";
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap.js";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap });
export default Page;
+52
View File
@@ -0,0 +1,52 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
const alternative = booking.proposedAlternative;
if (booking.status !== "pending" || !alternative?.date || !alternative.startTime || !alternative.endTime) {
return NextResponse.json({ error: "Für diese Buchung liegt kein alternativer Termin vor." }, { status: 409 });
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
collection: "booking-requests",
id,
data: {
status: "confirmed",
date: alternative.date,
startTime: alternative.startTime,
endTime: alternative.endTime,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof Error && "status" in error && (error as { status: number }).status === 409) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
console.error("accept-alternative failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht angenommen werden." }, { status: 500 });
}
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ id: string }> };
export async function POST(request: Request, { params }: Args) {
const { id } = await params;
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) {
return NextResponse.json({ error: "Buchung nicht gefunden." }, { status: 404 });
}
if (booking.status === "cancelled") {
return NextResponse.json({ ok: true });
}
await payload.update({ collection: "booking-requests", id, data: { status: "cancelled" } });
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking cancel failed", error);
return NextResponse.json({ error: "Der Termin konnte nicht storniert werden." }, { status: 500 });
}
}
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
type Body = {
offerSlug?: unknown;
start?: unknown;
appointmentType?: unknown;
message?: unknown;
};
export async function POST(request: Request) {
let body: Body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const offerSlug = typeof body.offerSlug === "string" ? body.offerSlug : "";
const startISO = typeof body.start === "string" ? body.start : "";
const appointmentType = body.appointmentType === "online" ? "online" : "onsite";
const message = typeof body.message === "string" ? body.message.trim().slice(0, 2000) : "";
if (!offerSlug || !startISO) {
return NextResponse.json({ error: "Angebot und Termin sind erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
}
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const start = new Date(startISO);
if (Number.isNaN(start.getTime()) || start < new Date()) {
return NextResponse.json({ error: "Bitte wähle einen gültigen, zukünftigen Termin." }, { status: 400 });
}
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(start);
dayEnd.setHours(23, 59, 59, 999);
const slotsByDay = await getAvailableSlots(payload, {
durationMinutes: offer.durationMinutes,
from: dayStart,
to: dayEnd,
});
const key = dayStart.toISOString().slice(0, 10);
const isStillFree = (slotsByDay.get(key) ?? []).some((s) => s.start.getTime() === start.getTime());
if (!isStillFree) {
return NextResponse.json({ error: "Dieser Termin ist leider nicht mehr verfügbar." }, { status: 409 });
}
await payload.create({
collection: "booking-requests",
data: {
user: user.id,
offer: offer.id,
date: start.toISOString(),
startTime: start.toISOString(),
endTime: end.toISOString(),
appointmentType,
status: "pending",
userMessage: message || undefined,
},
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("booking request failed", error);
return NextResponse.json({ error: "Die Anfrage konnte nicht gesendet werden. Bitte versuche es später erneut." }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { getAvailableSlots } from "@/lib/booking/slots";
export const dynamic = "force-dynamic";
/** GET /api/booking/slots?offer=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD */
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const offerSlug = searchParams.get("offer");
const fromParam = searchParams.get("from");
const toParam = searchParams.get("to");
if (!offerSlug) {
return NextResponse.json({ error: "offer ist erforderlich." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "offers",
where: { slug: { equals: offerSlug } },
limit: 1,
overrideAccess: false,
});
const offer = docs[0];
if (!offer || !offer.bookable || !offer.durationMinutes) {
return NextResponse.json({ error: "Dieses Angebot ist nicht buchbar." }, { status: 404 });
}
const from = fromParam ? new Date(fromParam) : new Date();
from.setHours(0, 0, 0, 0);
const to = toParam ? new Date(toParam) : new Date(from.getTime() + 21 * 24 * 60 * 60 * 1000);
to.setHours(23, 59, 59, 999);
// Cap the range so this can't be abused to run an expensive scan.
const maxRangeMs = 62 * 24 * 60 * 60 * 1000;
if (to.getTime() - from.getTime() > maxRangeMs) {
return NextResponse.json({ error: "Zeitraum zu groß." }, { status: 400 });
}
const slotsByDay = await getAvailableSlots(payload, { durationMinutes: offer.durationMinutes, from, to });
const result: Record<string, { start: string; end: string }[]> = {};
for (const [day, slots] of slotsByDay) {
result[day] = slots.map((s) => ({ start: s.start.toISOString(), end: s.end.toISOString() }));
}
return NextResponse.json({ durationMinutes: offer.durationMinutes, slots: result });
} catch (error) {
console.error("booking slots failed", error);
return NextResponse.json({ error: "Verfügbarkeiten konnten nicht geladen werden." }, { status: 500 });
}
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
/**
* 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
*/
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 !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await runEventReminders();
return NextResponse.json({ ok: true, ...result });
} catch (error) {
console.error("event-reminders cron failed", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config);
+8
View File
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,97 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { createMeetingToken } from "@/lib/meeting/token";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus } from "@/lib/meeting/status";
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);
}
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
if (!name) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event || !event.isOnline) {
return NextResponse.json({ error: "Dieser Online-Termin wurde nicht gefunden." }, { status: 404 });
}
const { user } = await payload.auth({ headers: request.headers });
const isHost = Boolean(user);
if (!isHost) {
if (!password) {
return NextResponse.json({ error: "Bitte gib das Meeting-Passwort ein." }, { status: 400 });
}
if (!event.meetingPassword || !safeEqual(password, event.meetingPassword)) {
return NextResponse.json({ error: "Das Meeting-Passwort ist nicht korrekt." }, { status: 401 });
}
}
const settings = await payload.findGlobal({ slug: "meeting-settings" });
const start = combineDateAndTime(event.date, event.startTime);
const end = event.endTime
? combineDateAndTime(event.date, event.endTime)
: new Date(start.getTime() + 60 * 60_000);
const status = getMeetingStatus({
start,
end,
joinWindowMinutes: isHost ? settings.hostJoinWindowMinutes : settings.participantJoinWindowMinutes,
closeAfterMinutes: settings.meetingCloseAfterMinutes,
});
if (!canJoinMeeting(status)) {
const message =
status === "scheduled"
? "Dieser Termin ist noch nicht offen. Bitte versuche es näher am Beginn erneut."
: "Dieser Termin ist bereits beendet.";
return NextResponse.json({ error: message }, { status: 403 });
}
const { token, participantId } = createMeetingToken({
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
});
return NextResponse.json({
token,
participantId,
role: isHost ? "host" : "participant",
eventTitle: event.title,
});
} catch (error) {
console.error("meeting join failed", error);
return NextResponse.json({ error: "Der Beitritt ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; email?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim() : "";
if (!name || !email || !email.includes("@")) {
return NextResponse.json({ error: "Bitte gib deinen Namen und eine gültige E-Mail-Adresse an." }, { status: 400 });
}
try {
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "events",
where: { slug: { equals: slug } },
limit: 1,
});
const event = docs[0];
if (!event) {
return NextResponse.json({ error: "Dieser Termin wurde nicht gefunden." }, { status: 404 });
}
const { docs: existingForEmail } = await payload.find({
collection: "event-registrations",
where: {
and: [
{ event: { equals: event.id } },
{ email: { equals: email } },
{ status: { equals: "registered" } },
],
},
limit: 1,
});
if (existingForEmail[0]) {
return NextResponse.json({ ok: true, alreadyRegistered: true });
}
if (event.maxParticipants) {
const { totalDocs } = await payload.count({
collection: "event-registrations",
where: { and: [{ event: { equals: event.id } }, { status: { equals: "registered" } }] },
});
if (totalDocs >= event.maxParticipants) {
return NextResponse.json({ error: "Dieser Termin ist bereits ausgebucht." }, { status: 409 });
}
}
await payload.create({
collection: "event-registrations",
data: { event: event.id, name, email, status: "registered" },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("event registration failed", error);
return NextResponse.json({ error: "Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut." }, { status: 500 });
}
}
+3
View File
@@ -0,0 +1,3 @@
/* Optional custom overrides for the Payload admin UI. Left empty intentionally —
the admin panel uses Payload's own default styling, only the public site
uses the ANOUMA design system (see app/(frontend)/globals.css). */
+31
View File
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "@payload-config";
import "@payloadcms/next/css";
import type { ServerFunctionClient } from "payload";
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import { importMap } from "./admin/importMap.js";
import "./custom.css";
type Args = {
children: React.ReactNode;
};
const serverFunction: ServerFunctionClient = async function (args) {
"use server";
return handleServerFunctions({
...args,
config,
importMap,
});
};
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
export default Layout;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+74
View File
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import { Navbar } from "@/components/Navbar";
import { Button } from "@/components/Button";
import { OrganicBlob } from "@/components/OrganicBlob";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { siteConfig } from "@/lib/site";
import "./(frontend)/globals.css";
// Required because the app has two root layouts — (frontend) and (payload) —
// so there is no single layout Next.js could compose a 404 page from.
// See node_modules/next/dist/docs/.../not-found.md ("global-not-found.js").
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["normal", "italic"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Seite nicht gefunden",
};
export default function GlobalNotFound() {
return (
<html
lang="de"
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="flex min-h-full flex-col bg-background text-foreground">
{/* This page is always statically prerendered, so it can't read live
CMS data — the mega menu is intentionally omitted here. */}
<Navbar offers={[]} />
<main className="flex-1">
<section className="relative overflow-hidden py-24 sm:py-32">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-96 w-96" />
<div className="mx-auto grid max-w-5xl items-center gap-12 px-6 sm:px-8 lg:grid-cols-2 lg:px-12">
<div>
<p className="text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">404</p>
<h1 className="mt-4 text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
Diesen Weg gibt es hier nicht
</h1>
<p className="mt-5 max-w-md text-lg leading-relaxed text-anouma-plum">
Die gesuchte Seite konnte nicht gefunden werden. Vielleicht findest du deinen Weg
über die Startseite oder die Angebote weiter.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Button href="/">Zur Startseite</Button>
<Button href="/angebote" variant="secondary">
Angebote ansehen
</Button>
</div>
</div>
<div className="relative mx-auto aspect-square w-full max-w-sm">
<ImagePlaceholder mood="sand" label="Seite nicht gefunden" className="h-full w-full" />
</div>
</div>
</section>
</main>
{/* Minimal static footer — the full Footer reads live CMS data,
which isn't available on this always-static 404 page. */}
<footer className="bg-anouma-plum py-8 text-center text-sm text-anouma-cream-light">
&copy; {new Date().getFullYear()} {siteConfig.name}
</footer>
</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/impressum", "/datenschutz"],
},
],
sitemap: `${siteConfig.domain}/sitemap.xml`,
};
}
+29
View File
@@ -0,0 +1,29 @@
import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site";
const routes = [
"",
"/ueber-mich",
"/angebote",
"/angebote/prozessbegleitung",
"/angebote/doula-begleitung",
"/angebote/erdenkinder",
"/angebote/maedchenkreis",
"/angebote/singkreise",
"/angebote/singkreise/singen-im-kreis",
"/angebote/singkreise/singen-fuer-schwangere",
"/angebote/singkreise/mama-baby-singkreis",
"/aktuelles",
"/termin-buchen",
"/kontakt",
"/impressionen",
];
export default function sitemap(): MetadataRoute.Sitemap {
return routes.map((route) => ({
url: `${siteConfig.domain}${route}`,
lastModified: new Date(),
changeFrequency: route === "" ? "weekly" : "monthly",
priority: route === "" ? 1 : 0.7,
}));
}
+69
View File
@@ -0,0 +1,69 @@
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
export const WEEKDAYS = [
{ label: "Montag", value: "monday" },
{ label: "Dienstag", value: "tuesday" },
{ label: "Mittwoch", value: "wednesday" },
{ label: "Donnerstag", value: "thursday" },
{ label: "Freitag", value: "friday" },
{ label: "Samstag", value: "saturday" },
{ label: "Sonntag", value: "sunday" },
] as const;
export const Availability: CollectionConfig = {
slug: "availability",
labels: {
singular: "Verfügbarkeit",
plural: "Verfügbarkeiten",
},
admin: {
useAsTitle: "weekday",
defaultColumns: ["weekday", "startTime", "endTime", "active"],
group: "Buchungen",
description: "Wiederkehrende wöchentliche Arbeitszeiten für Buchungsanfragen. Kein Eintrag = an diesem Tag nicht verfügbar.",
},
access: {
read: () => true,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "weekday",
type: "select",
label: "Wochentag",
required: true,
options: [...WEEKDAYS],
},
{
type: "row",
fields: [
{
name: "startTime",
type: "date",
label: "Start",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
},
{
name: "endTime",
type: "date",
label: "Ende",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
},
],
},
{
name: "active",
type: "checkbox",
label: "Aktiv",
defaultValue: true,
admin: {
description: "Deaktivieren statt löschen, um eine Zeitspanne vorübergehend auszusetzen.",
},
},
],
};
+72
View File
@@ -0,0 +1,72 @@
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
export const AvailabilityOverrides: CollectionConfig = {
slug: "availability-overrides",
labels: {
singular: "Ausnahme",
plural: "Ausnahmen",
},
admin: {
useAsTitle: "date",
defaultColumns: ["date", "type", "reason"],
group: "Buchungen",
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
},
access: {
read: () => true,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "date",
type: "date",
label: "Datum",
required: true,
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" } },
},
{
name: "type",
type: "select",
label: "Art",
required: true,
defaultValue: "unavailable",
options: [
{ label: "Nicht verfügbar (ganzer Tag)", value: "unavailable" },
{ label: "Abweichende Uhrzeiten", value: "custom-hours" },
],
},
{
type: "row",
admin: { condition: (data) => data?.type === "custom-hours" },
fields: [
{
name: "startTime",
type: "date",
label: "Start",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
},
{
name: "endTime",
type: "date",
label: "Ende",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "50%" },
},
],
},
{
name: "reason",
type: "select",
label: "Grund",
options: [
{ label: "Urlaub", value: "urlaub" },
{ label: "Feiertag", value: "feiertag" },
{ label: "Krankheit", value: "krankheit" },
{ label: "Sonderöffnungszeit", value: "sonderoeffnungszeit" },
{ label: "Sonstiges", value: "sonstiges" },
],
},
],
};
+403
View File
@@ -0,0 +1,403 @@
import { APIError } from "payload";
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { acquireBookingDayLock } from "@/lib/booking/lock";
import {
alternativeProposedEmail,
bookingCancelledAdminEmail,
bookingConfirmedEmail,
bookingRejectedEmail,
bookingRequestReceivedEmail,
newBookingRequestAdminEmail,
} from "@/lib/email/bookingTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer, Event, Offer } from "@/payload-types";
export const APPOINTMENT_TYPES = [
{ label: "Vor Ort", value: "onsite" },
{ label: "Online", value: "online" },
] as const;
export const BOOKING_STATUSES = [
{ label: "Ausstehend", value: "pending" },
{ label: "Bestätigt", value: "confirmed" },
{ label: "Abgelehnt", value: "rejected" },
{ label: "Storniert", value: "cancelled" },
] as const;
function dateKey(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
const appointmentTypeLabel = (value: string) =>
APPOINTMENT_TYPES.find((t) => t.value === value)?.label ?? value;
async function notifyAdmins(
req: Parameters<CollectionAfterChangeHook>[0]["req"],
template: { subject: string; html: string },
) {
const { docs: admins } = await req.payload.find({ collection: "users", limit: 50, req });
await Promise.all(admins.filter((admin) => admin.email).map((admin) => sendEmail(admin.email, template)));
}
// Runs whenever a booking is about to become "confirmed": locks the day
// (see lib/booking/lock.ts), re-checks for overlapping confirmed bookings
// inside that same lock/transaction, and — for online appointments — creates
// the linked video-call Event (reusing the existing meeting/password system)
// before the booking itself is written.
const handleConfirmation: CollectionBeforeChangeHook = async ({ data, originalDoc, req, operation }) => {
if (operation !== "update" || data.status !== "confirmed" || originalDoc?.status === "confirmed") {
return data;
}
const date = data.date ?? originalDoc.date;
const startTime = data.startTime ?? originalDoc.startTime;
const endTime = data.endTime ?? originalDoc.endTime;
const appointmentType = data.appointmentType ?? originalDoc.appointmentType;
await acquireBookingDayLock(req, dateKey(date));
const { docs: sameDayConfirmed } = await req.payload.find({
collection: "booking-requests",
where: {
and: [
{ status: { equals: "confirmed" } },
{ id: { not_equals: originalDoc.id } },
{ date: { greater_than_equal: new Date(new Date(date).setHours(0, 0, 0, 0)).toISOString() } },
{ date: { less_than_equal: new Date(new Date(date).setHours(23, 59, 59, 999)).toISOString() } },
],
},
req,
limit: 200,
});
const newStart = new Date(startTime).getTime();
const newEnd = new Date(endTime).getTime();
const conflict = sameDayConfirmed.some((doc) => {
const s = new Date(doc.startTime).getTime();
const e = new Date(doc.endTime).getTime();
return newStart < e && s < newEnd;
});
if (conflict) {
throw new APIError(
"Dieser Zeitraum ist bereits mit einem anderen bestätigten Termin belegt. Bitte wähle eine andere Zeit oder lehne den überschneidenden Termin zuerst ab.",
409,
undefined,
true,
);
}
if (appointmentType === "online" && !data.linkedEvent && !originalDoc.linkedEvent) {
const offer = (await req.payload.findByID({ collection: "offers", id: data.offer ?? originalDoc.offer, req })) as Offer;
const event = (await req.payload.create({
collection: "events",
req,
data: {
title: offer.title,
date,
startTime,
endTime,
category: "sonstiges",
isOnline: true, // Events' own beforeChange hook auto-generates the password.
isPrivateBooking: true,
bookingRequest: originalDoc.id,
},
// Created as a draft so it never appears in public /termine listings
// (see lib/payload/content.ts) — it's still reachable via its direct
// join link, which is exactly what a private 1:1 booking needs.
draft: true,
})) as Event;
data.linkedEvent = event.id;
}
return data;
};
const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, operation, req }) => {
try {
const user = (doc.user && typeof doc.user === "object" ? doc.user : await req.payload.findByID({
collection: "customers",
id: doc.user,
req,
})) as Customer;
const offer = (doc.offer && typeof doc.offer === "object" ? doc.offer : await req.payload.findByID({
collection: "offers",
id: doc.offer,
req,
})) as Offer;
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
const adminUrl = `${serverUrl}/admin/collections/booking-requests/${doc.id}`;
const accountUrl = `${serverUrl}/konto/termine`;
if (operation === "create") {
await sendEmail(
user.email,
bookingRequestReceivedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
}),
);
await notifyAdmins(
req,
newBookingRequestAdminEmail({
customerName: user.name,
customerEmail: user.email,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
adminUrl,
}),
);
return doc;
}
const statusChanged = previousDoc?.status !== doc.status;
if (statusChanged && doc.status === "confirmed") {
let online: { joinUrl: string; password: string } | undefined;
let onsite: { address: string; mapsUrl: string } | undefined;
if (doc.appointmentType === "online" && doc.linkedEvent) {
const event = (typeof doc.linkedEvent === "object" ? doc.linkedEvent : await req.payload.findByID({
collection: "events",
id: doc.linkedEvent,
req,
})) as Event;
online = {
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
password: event.meetingPassword || "—",
};
} else if (doc.appointmentType === "onsite") {
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
const address = addressParts.join(", ");
onsite = { address, mapsUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` };
}
await sendEmail(
user.email,
bookingConfirmedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: `${fmtTime(doc.startTime)} ${fmtTime(doc.endTime)}`,
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
online,
onsite,
}),
);
} else if (statusChanged && doc.status === "rejected") {
await sendEmail(
user.email,
bookingRejectedEmail({
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
}),
);
} else if (statusChanged && doc.status === "cancelled") {
await notifyAdmins(
req,
bookingCancelledAdminEmail({
customerName: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: fmtTime(doc.startTime),
adminUrl,
}),
);
}
const proposedNew = doc.proposedAlternative?.date && doc.proposedAlternative.date !== previousDoc?.proposedAlternative?.date;
if (proposedNew && doc.status === "pending") {
await sendEmail(
user.email,
alternativeProposedEmail({
name: user.name,
offerTitle: offer.title,
originalDateLabel: fmtDate(doc.date),
originalTimeLabel: fmtTime(doc.startTime),
altDateLabel: fmtDate(doc.proposedAlternative.date),
altTimeLabel: fmtTime(doc.proposedAlternative.startTime),
accountUrl,
}),
);
}
} catch (err) {
// Email delivery must never break the booking write itself.
req.payload.logger.error({ err, msg: "booking-requests notifyByEmail failed" });
}
return doc;
};
export const BookingRequests: CollectionConfig = {
slug: "booking-requests",
labels: {
singular: "Buchungsanfrage",
plural: "Buchungsanfragen",
},
admin: {
useAsTitle: "id",
defaultColumns: ["offer", "user", "date", "startTime", "status"],
group: "Buchungen",
description: "Terminanfragen von Nutzer:innen — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
},
access: {
// Customer-initiated writes always go through the vetted /api/booking/*
// routes (Local API, elevated access, after the route verifies
// ownership) — never directly through REST/GraphQL. This keeps a
// customer from ever reading/writing another customer's booking, or
// setting their own status to "confirmed" directly.
create: isAdmin,
read: ({ req }) => {
if (req.user?.collection === "users") return true;
if (req.user?.collection === "customers") return { user: { equals: req.user.id } };
return false;
},
update: isAdmin,
delete: isAdmin,
},
hooks: {
beforeChange: [handleConfirmation],
afterChange: [notifyByEmail],
},
fields: [
{
name: "user",
type: "relationship",
relationTo: "customers",
label: "Nutzer:in",
required: true,
index: true,
},
{
name: "offer",
type: "relationship",
relationTo: "offers",
label: "Angebot",
required: true,
},
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Datum",
required: true,
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
},
{
name: "startTime",
type: "date",
label: "Startzeit",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
{
name: "endTime",
type: "date",
label: "Endzeit",
required: true,
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
],
},
{
name: "appointmentType",
type: "select",
label: "Terminart",
required: true,
defaultValue: "onsite",
options: [...APPOINTMENT_TYPES],
},
{
name: "status",
type: "select",
label: "Status",
required: true,
defaultValue: "pending",
options: [...BOOKING_STATUSES],
admin: {
description: "Auf „Bestätigt“ setzen und speichern, um den Termin verbindlich zu machen (Konfliktprüfung läuft automatisch).",
},
},
{
name: "userMessage",
type: "textarea",
label: "Nachricht der Nutzerin/des Nutzers",
admin: { readOnly: true },
},
{
name: "internalNote",
type: "textarea",
label: "Interne Notiz",
access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
admin: { description: "Nur für Anna sichtbar." },
},
{
type: "collapsible",
label: "Alternativer Termin",
fields: [
{
type: "row",
fields: [
{
name: "proposedAlternative",
type: "group",
label: "",
fields: [
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Alternatives Datum",
admin: { date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, width: "34%" },
},
{
name: "startTime",
type: "date",
label: "Alternative Startzeit",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
{
name: "endTime",
type: "date",
label: "Alternative Endzeit",
admin: { date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" }, width: "33%" },
},
],
},
],
},
],
},
],
},
{
name: "linkedEvent",
type: "relationship",
relationTo: "events",
label: "Verknüpfter Video-Termin",
admin: { position: "sidebar", readOnly: true },
},
],
};
+53
View File
@@ -0,0 +1,53 @@
import type { CollectionConfig } from "payload";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
if (!user) return false;
if (user.collection === "users") return true;
if (user.collection === "customers") return { id: { equals: user.id } };
return false;
};
export const Customers: CollectionConfig = {
slug: "customers",
labels: {
singular: "Kundin/Kunde",
plural: "Kund:innen",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "phone"],
group: "Buchungen",
description: "Konten der Website-Besucher:innen (nicht die Admin-Zugänge unter „Benutzer:innen“).",
},
auth: {
maxLoginAttempts: 8,
lockTime: 10 * 60 * 1000,
},
access: {
// Anyone can create their own account (registration); reading/updating
// is restricted to the account owner or an admin.
create: () => true,
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
},
fields: [
{
name: "name",
type: "text",
label: "Name",
required: true,
},
{
name: "phone",
type: "text",
label: "Telefonnummer",
required: false,
admin: {
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
},
},
// "email" and "password" are added automatically by the auth config.
],
};
+79
View File
@@ -0,0 +1,79 @@
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
export const EventRegistrations: CollectionConfig = {
slug: "event-registrations",
labels: {
singular: "Anmeldung",
plural: "Anmeldungen",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
group: "Inhalte",
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
},
access: {
// Visitors register themselves through the public event page.
create: () => true,
read: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "event",
type: "relationship",
relationTo: "events",
label: "Termin",
required: true,
index: true,
},
{
type: "row",
fields: [
{ name: "name", type: "text", label: "Name", required: true, admin: { width: "50%" } },
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "50%" } },
],
},
{
name: "status",
type: "select",
label: "Status",
required: true,
defaultValue: "registered",
options: [
{ label: "Angemeldet", value: "registered" },
{ label: "Storniert", value: "cancelled" },
{ label: "Teilgenommen", value: "attended" },
],
},
{
name: "registeredAt",
type: "date",
label: "Registriert am",
defaultValue: () => new Date().toISOString(),
admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime" } },
},
{
type: "row",
admin: { position: "sidebar" },
fields: [
{
name: "reminder60Sent",
type: "checkbox",
label: "Reminder 60 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
{
name: "reminder30Sent",
type: "checkbox",
label: "Reminder 30 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
],
},
],
};
+247
View File
@@ -0,0 +1,247 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { generateMeetingPassword } from "@/lib/meeting/password";
export const EVENT_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
{ label: "Doula", value: "doula" },
{ label: "Erdenkinder", value: "erdenkinder" },
{ label: "Mädchenkreis", value: "maedchenkreis" },
{ label: "Singen im Kreis", value: "singen-im-kreis" },
{ label: "Singen für Schwangere", value: "singen-fuer-schwangere" },
{ label: "Singen für Mamas mit Baby", value: "singen-fuer-mamas-mit-baby" },
{ label: "Sonstiges", value: "sonstiges" },
] as const;
// Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
if (!data?.isOnline) return data;
if (!data.meetingPassword || data.regeneratePassword) {
data.meetingPassword = generateMeetingPassword();
}
data.regeneratePassword = false;
return data;
};
export const Events: CollectionConfig = {
slug: "events",
labels: {
singular: "Termin",
plural: "Termine",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "date", "category", "_status"],
group: "Inhalte",
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
},
versions: {
drafts: {
autosave: { interval: 1500 },
},
},
access: {
read: publishedOrAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
hooks: {
beforeChange: [setMeetingPassword],
},
fields: [
{
name: "title",
type: "text",
label: "Titel",
required: true,
},
slugField(),
{
name: "description",
type: "richText",
label: "Beschreibung",
},
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Datum",
required: true,
admin: {
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
width: "34%",
},
},
{
name: "startTime",
type: "date",
label: "Startzeit",
admin: {
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
width: "33%",
},
},
{
name: "endTime",
type: "date",
label: "Endzeit",
admin: {
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
width: "33%",
},
},
],
},
{
name: "location",
type: "text",
label: "Ort",
},
{
name: "category",
type: "select",
label: "Kategorie",
required: true,
defaultValue: "sonstiges",
options: [...EVENT_CATEGORIES],
},
{
name: "image",
type: "upload",
relationTo: "media",
label: "Bild",
},
{
type: "row",
fields: [
{
name: "maxParticipants",
type: "number",
label: "Maximale Teilnehmerzahl",
min: 1,
admin: { width: "50%" },
},
{
name: "registrationRequired",
type: "checkbox",
label: "Anmeldung erforderlich",
defaultValue: false,
admin: { width: "50%" },
},
],
},
{
name: "registrationInfo",
type: "text",
label: "Anmeldelink bzw. Kontaktmöglichkeit",
admin: {
description: "z. B. eine E-Mail-Adresse, ein Link oder ein Hinweistext.",
},
},
{
type: "collapsible",
label: "Online-Termin (Video-Call)",
fields: [
{
name: "isOnline",
type: "checkbox",
label: "Online-Termin",
defaultValue: false,
admin: {
description: "Aktiviert Meeting-Passwort, Beitrittsseite und Erinnerungs-E-Mails.",
},
},
{
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
description: "Wird automatisch erzeugt. Ist nicht Teil des Meeting-Links.",
},
},
{
name: "regeneratePassword",
type: "checkbox",
label: "Neues Passwort generieren (beim Speichern)",
defaultValue: false,
admin: {
condition: (data) => Boolean(data?.isOnline),
},
},
{
type: "row",
admin: { condition: (data) => Boolean(data?.isOnline) },
fields: [
{
name: "reminder60Enabled",
type: "checkbox",
label: "Erinnerung 60 Minuten vorher",
defaultValue: true,
admin: { width: "34%" },
},
{
name: "reminder30Enabled",
type: "checkbox",
label: "Erinnerung 30 Minuten vorher",
defaultValue: true,
admin: { width: "33%" },
},
{
name: "hostReminderEnabled",
type: "checkbox",
label: "Auch Host per E-Mail erinnern",
defaultValue: true,
admin: { width: "33%" },
},
],
},
{
type: "row",
admin: { condition: (data) => Boolean(data?.isOnline) },
fields: [
{
name: "hostReminder60Sent",
type: "checkbox",
label: "Host-Reminder 60 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
{
name: "hostReminder30Sent",
type: "checkbox",
label: "Host-Reminder 30 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
],
},
],
},
{
name: "isPrivateBooking",
type: "checkbox",
label: "Aus privater Buchungsanfrage erzeugt",
defaultValue: false,
admin: {
position: "sidebar",
readOnly: true,
description: "Wird automatisch gesetzt, wenn dieser Termin aus einer bestätigten Einzelbuchung entstanden ist. Solche Termine bleiben unveröffentlicht.",
},
},
{
name: "bookingRequest",
type: "relationship",
relationTo: "booking-requests",
label: "Zugehörige Buchungsanfrage",
admin: { position: "sidebar", readOnly: true },
},
],
};
+55
View File
@@ -0,0 +1,55 @@
import path from "path";
import { fileURLToPath } from "url";
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export const Media: CollectionConfig = {
slug: "media",
labels: {
singular: "Bild",
plural: "Bilder",
},
admin: {
useAsTitle: "alt",
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
},
access: {
// Images are public so the website can display them without a login.
read: () => true,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
upload: {
staticDir: path.resolve(dirname, "..", "media"),
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
{ name: "card", width: 800, position: "centre" },
{ name: "hero", width: 1600, position: "centre" },
],
adminThumbnail: "thumbnail",
formatOptions: { format: "webp", options: { quality: 82 } },
},
fields: [
{
name: "alt",
type: "text",
label: "Alt-Text",
required: true,
admin: {
description: "Kurze Beschreibung des Bildes für Screenreader und Suchmaschinen.",
},
},
{
name: "caption",
type: "textarea",
label: "Bildbeschreibung",
admin: {
description: "Optionale, längere Beschreibung (z. B. für eine Bildunterschrift).",
},
},
],
};
+127
View File
@@ -0,0 +1,127 @@
import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
// Groups the 7 individual offers for the mega menu and the /angebote
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
// bundles the three singing circles — matching the site's navigation).
export const OFFER_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
{ label: "Doula-Begleitung", value: "doula-begleitung" },
{ label: "Kindergruppen", value: "kindergruppen" },
{ label: "Singkreise", value: "singkreise" },
] as const;
export const Offers: CollectionConfig = {
slug: "offers",
labels: {
singular: "Angebot",
plural: "Angebote",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "category", "order", "_status"],
group: "Inhalte",
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
},
defaultSort: "order",
versions: {
drafts: true,
},
access: {
read: publishedOrAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "title",
type: "text",
label: "Titel",
required: true,
},
slugField(),
{
name: "shortDescription",
type: "textarea",
label: "Kurzbeschreibung",
required: true,
admin: {
description: "Kurzer Satz für Karten, Menü und Übersicht.",
},
},
{
name: "description",
type: "richText",
label: "Beschreibung",
required: true,
},
{
name: "image",
type: "upload",
relationTo: "media",
label: "Bild",
},
{
name: "category",
type: "select",
label: "Kategorie",
required: true,
options: [...OFFER_CATEGORIES],
},
{
name: "order",
type: "number",
label: "Reihenfolge",
defaultValue: 0,
admin: {
position: "sidebar",
description: "Kleinere Zahl erscheint zuerst.",
},
},
{
name: "visibility",
type: "select",
label: "Sichtbarkeit",
required: true,
defaultValue: "public",
options: [
{ label: "Öffentlich (Menü, Übersicht)", value: "public" },
{ label: "Privat (nur über direkten Link/Buchung)", value: "private" },
],
admin: {
position: "sidebar",
description: "Private Angebote wie „Einzelbegleitung“ erscheinen nicht im Menü oder auf /angebote.",
},
},
{
name: "bookable",
type: "checkbox",
label: "Buchbar",
defaultValue: false,
admin: {
position: "sidebar",
description: "Aktiviert die Terminbuchung mit Kalenderauswahl auf der Angebotsseite.",
},
},
{
type: "row",
fields: [
{
name: "price",
type: "text",
label: "Preis bzw. Preistext",
admin: { width: "50%", description: "z. B. „80 €“ oder „auf Anfrage“." },
},
{
name: "durationMinutes",
type: "number",
label: "Dauer (Minuten)",
min: 5,
admin: { width: "50%", condition: (data) => Boolean(data?.bookable) },
},
],
},
],
};
+68
View File
@@ -0,0 +1,68 @@
import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
export const Posts: CollectionConfig = {
slug: "posts",
labels: {
singular: "Beitrag",
plural: "Beiträge",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "publishDate", "_status"],
group: "Inhalte",
description: "Neuigkeiten und Beiträge für die Seite „Aktuelles“.",
},
versions: {
drafts: {
autosave: { interval: 1500 },
},
},
access: {
read: publishedOrAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "title",
type: "text",
label: "Titel",
required: true,
},
slugField(),
{
name: "teaser",
type: "textarea",
label: "Teaser",
required: true,
admin: {
description: "Kurzer Anrisstext für die Übersicht.",
},
},
{
name: "content",
type: "richText",
label: "Inhalt",
required: true,
},
{
name: "coverImage",
type: "upload",
relationTo: "media",
label: "Titelbild",
},
{
name: "publishDate",
type: "date",
label: "Veröffentlichungsdatum",
defaultValue: () => new Date().toISOString(),
admin: {
position: "sidebar",
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
},
},
],
};
+50
View File
@@ -0,0 +1,50 @@
import type { CollectionConfig } from "payload";
import { isAdmin, isAdminFieldLevel } from "@/access";
export const Users: CollectionConfig = {
slug: "users",
labels: {
singular: "Benutzer:in",
plural: "Benutzer:innen",
},
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "role"],
description: "Zugänge für den geschützten Admin-Bereich.",
},
auth: {
// Payload shows a "create first user" screen automatically when this
// collection is empty, bypassing normal access control just for that
// one-time setup — no hardcoded default password is ever needed.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000,
},
access: {
read: isAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "name",
type: "text",
label: "Name",
required: true,
},
{
name: "role",
type: "select",
label: "Rolle",
required: true,
defaultValue: "admin",
// Only "admin" exists today; add further roles here later and extend
// the checks in access/index.ts to match.
options: [{ label: "Administrator:in", value: "admin" }],
access: {
update: isAdminFieldLevel,
},
},
// "email" and "password" are added automatically by `auth: true`-style config.
],
};
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import type { ImageMood } from "@/lib/angebote";
type AngebotCardProps = {
href: string;
title: string;
tagline: string;
mood: ImageMood;
imageSrc?: string;
imageAlt?: string;
size?: "default" | "large";
};
export function AngebotCard({
href,
title,
tagline,
mood,
imageSrc,
imageAlt,
size = "default",
}: AngebotCardProps) {
return (
<Link href={href} className="group block">
<div
className={`relative overflow-hidden rounded-[2.5rem] ${
size === "large" ? "aspect-[4/3]" : "aspect-[4/5]"
}`}
>
<ImagePlaceholder
mood={mood}
label={title}
src={imageSrc}
alt={imageAlt}
shape="soft"
className="h-full w-full transition-transform duration-700 ease-out group-hover:scale-[1.04]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-anouma-plum/70 via-anouma-plum/0 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-7">
<h3 className="font-serif text-2xl font-medium text-white">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
Mehr erfahren
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</Link>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
export type Crumb = { title: string; href?: string };
export function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
<nav aria-label="Breadcrumb" className="mb-6">
<ol className="flex flex-wrap items-center gap-2 text-sm text-anouma-plum/80">
<li>
<Link href="/" className="hover:text-anouma-mauve-dark">
Startseite
</Link>
</li>
{items.map((item, i) => (
<li key={item.title} className="flex items-center gap-2">
<span aria-hidden="true" className="text-anouma-taupe">
/
</span>
{item.href && i !== items.length - 1 ? (
<Link href={item.href} className="hover:text-anouma-mauve-dark">
{item.title}
</Link>
) : (
<span aria-current="page" className="text-anouma-plum">
{item.title}
</span>
)}
</li>
))}
</ol>
</nav>
);
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "next/link";
import type { ReactNode } from "react";
type Variant = "primary" | "secondary" | "ghost" | "invert" | "invertOutline";
// Text colors are chosen to meet WCAG AA (4.5:1) against every background
// tone the button appears on — see components/Section.tsx for the tones.
const variants: Record<Variant, string> = {
primary: "bg-anouma-mauve-dark text-white hover:bg-anouma-plum",
secondary:
"border border-anouma-mauve-dark/40 text-anouma-plum hover:bg-anouma-mauve-dark hover:text-white",
ghost:
"text-anouma-plum underline underline-offset-4 decoration-anouma-mauve-dark/50 hover:decoration-anouma-mauve-dark",
// For use on dark (plum/mauve-dark) section backgrounds, e.g. CTA.
invert: "bg-anouma-cream-light text-anouma-plum hover:bg-white",
invertOutline: "border border-white/50 text-white hover:bg-white hover:text-anouma-plum",
};
type ButtonProps = {
href: string;
children: ReactNode;
variant?: Variant;
className?: string;
};
export function Button({ href, children, variant = "primary", className = "" }: ButtonProps) {
const isExternal = href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("tel:");
const base =
"inline-flex items-center justify-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium tracking-wide transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-4";
const classes = `${base} ${variants[variant]} ${className}`;
if (isExternal) {
return (
<a href={href} className={classes} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
return (
<Link href={href} className={classes}>
{children}
</Link>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Button } from "@/components/Button";
import { OrganicBlob } from "@/components/OrganicBlob";
import { Container } from "@/components/Section";
type CTAProps = {
title: string;
lead?: string;
primaryHref?: string;
primaryLabel?: string;
secondaryHref?: string;
secondaryLabel?: string;
};
export function CTA({
title,
lead,
primaryHref = "/termin-buchen",
primaryLabel = "Termin buchen",
secondaryHref = "/kontakt",
secondaryLabel = "Kontakt aufnehmen",
}: CTAProps) {
return (
<section className="relative overflow-hidden bg-anouma-plum py-20 text-anouma-cream-light sm:py-24">
<OrganicBlob tone="mauve" className="-left-20 -top-20 h-80 w-80 opacity-30" />
<OrganicBlob tone="rose" className="-bottom-24 -right-16 h-72 w-72 opacity-20" />
<Container className="relative text-center">
<h2 className="mx-auto max-w-2xl text-balance font-serif text-4xl font-medium leading-tight sm:text-5xl">
{title}
</h2>
{lead && (
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-anouma-cream-light/85">
{lead}
</p>
)}
<div className="mt-9 flex flex-wrap items-center justify-center gap-4">
<Button href={primaryHref} variant="invert">
{primaryLabel}
</Button>
<Button href={secondaryHref} variant="invertOutline">
{secondaryLabel}
</Button>
</div>
</Container>
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
type ContactFormProps = {
toEmail: string;
subjectPrefix?: string;
submitLabel?: string;
};
/**
* Opens the visitor's email client with a pre-filled message. There is no
* backend configured yet swap this for a Server Action once a mail
* provider is connected.
*/
export function ContactForm({
toEmail,
subjectPrefix = "Nachricht über anouma.org",
submitLabel = "Nachricht senden",
}: ContactFormProps) {
const [sent, setSent] = useState(false);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const name = String(data.get("name") ?? "");
const email = String(data.get("email") ?? "");
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
setSent(true);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
Deine Nachricht
</label>
<textarea id="message" name="message" required rows={5} className={fieldClass} />
</div>
<button
type="submit"
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
>
{submitLabel}
</button>
{sent && (
<p className="text-sm text-anouma-olive" role="status">
Dein E-Mail-Programm sollte sich jetzt geöffnet haben. Falls nicht, schreib gerne direkt an{" "}
{toEmail}.
</p>
)}
</form>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import type { Event } from "@/payload-types";
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
export function EventTeaserCard({ event }: { event: Event }) {
const { day, month } = formatEventDateParts(event.date);
const time = formatTimeRange(event.startTime, event.endTime);
return (
<Link
href={`/termine/${event.slug}`}
className="group flex gap-5 rounded-3xl bg-background p-6 transition-colors hover:bg-anouma-cream-light"
>
<div className="flex h-16 w-16 shrink-0 flex-col items-center justify-center rounded-2xl bg-anouma-mauve-dark text-white">
<span className="font-serif text-xl font-semibold leading-none">{day}</span>
<span className="mt-1 text-[10px] font-medium uppercase tracking-widest">{month}</span>
</div>
<div>
<h3 className="font-serif text-lg font-medium text-anouma-plum">{event.title}</h3>
{time && <p className="mt-1 text-sm text-anouma-plum">{time}</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">
Mehr erfahren
<svg
width="12"
height="9"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M1 5h11.5M8 1l4.5 4L8 9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
</Link>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Link from "next/link";
import { groupOffersByCategory } from "@/lib/angebote";
import { footerNav, mainNav, siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content";
export async function Footer() {
const offers = await getOffers();
const groups = groupOffersByCategory(offers);
return (
<footer className="bg-anouma-plum text-anouma-cream-light">
<div className="mx-auto max-w-6xl px-6 py-16 sm:px-8 lg:px-12">
<div className="grid gap-12 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Link href="/" className="font-serif text-2xl font-semibold tracking-[0.08em]">
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung mit dir selbst, miteinander und mit der Natur.
</p>
</div>
<div>
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
Navigation
</h3>
<ul className="mt-4 space-y-2.5">
{mainNav.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-sm text-anouma-cream-light/90 hover:text-white">
{item.title}
</Link>
</li>
))}
<li>
<Link href="/termin-buchen" className="text-sm text-anouma-cream-light/90 hover:text-white">
Termin buchen
</Link>
</li>
</ul>
</div>
<div>
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
Angebote
</h3>
<ul className="mt-4 space-y-2.5">
{groups.map((group) => (
<li key={group.category}>
<Link
href={group.offers.length === 1 ? `/angebote/${group.offers[0].slug}` : group.href}
className="text-sm text-anouma-cream-light/90 hover:text-white"
>
{group.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-xs font-medium uppercase tracking-[0.2em] text-anouma-cream-light/90">
Kontakt
</h3>
<p className="mt-4 text-sm leading-relaxed text-anouma-cream-light/90">
Ich freue mich, von dir zu hören.
</p>
<Link
href="/kontakt"
className="mt-3 inline-block text-sm font-medium underline underline-offset-4 hover:text-white"
>
Zum Kontaktformular
</Link>
</div>
</div>
<div className="mt-14 flex flex-col gap-4 border-t border-anouma-cream-light/20 pt-8 text-sm text-anouma-cream-light/90 sm:flex-row sm:items-center sm:justify-between">
<p>&copy; {new Date().getFullYear()} {siteConfig.name}</p>
<ul className="flex flex-wrap gap-x-6 gap-y-2">
{footerNav.map((item) => (
<li key={item.href}>
<Link href={item.href} className="hover:text-white">
{item.title}
</Link>
</li>
))}
</ul>
</div>
</div>
</footer>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { OrganicBlob } from "@/components/OrganicBlob";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import type { ImageMood } from "@/lib/angebote";
type HeroProps = {
eyebrow?: string;
title: string;
children?: ReactNode;
actions?: ReactNode;
mood?: ImageMood;
imageSrc?: string;
imageAlt?: string;
};
export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSrc, imageAlt }: HeroProps) {
return (
<section className="relative overflow-hidden pb-20 pt-16 sm:pb-28 sm:pt-24">
<OrganicBlob tone="rose" className="-right-24 -top-24 h-[28rem] w-[28rem]" />
<OrganicBlob tone="cream" className="-left-32 bottom-0 h-96 w-96" />
<div className="relative mx-auto grid max-w-6xl gap-14 px-6 sm:px-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:px-12">
<div className="animate-fade-up">
{eyebrow && (
<p className="mb-5 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
{eyebrow}
</p>
)}
<h1 className="text-balance font-serif text-5xl font-medium leading-[1.08] text-anouma-plum sm:text-6xl lg:text-7xl">
{title}
</h1>
{children && (
<div className="mt-7 max-w-xl text-lg leading-relaxed text-anouma-plum sm:text-xl">
{children}
</div>
)}
{actions && <div className="mt-9 flex flex-wrap gap-4">{actions}</div>}
</div>
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma — warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
className="h-full w-full"
/>
</div>
</div>
</section>
);
}
+114
View File
@@ -0,0 +1,114 @@
import Image from "next/image";
import type { ImageMood } from "@/lib/angebote";
/**
* Renders a photo when `src` is provided, otherwise falls back to a warm,
* organic gradient placeholder in the given mood. Pages should always pass
* `src` once real photography exists no other prop changes are needed.
*/
const moodGradients: Record<ImageMood, string> = {
plum: "linear-gradient(135deg, #a97070 0%, #66505f 55%, #453238 100%)",
dustyrose: "linear-gradient(135deg, #ecc8be 0%, #a97070 55%, #8b616d 100%)",
moss: "linear-gradient(135deg, #d4c1a5 0%, #89937c 55%, #4f5b45 100%)",
caramel: "linear-gradient(135deg, #efd5c8 0%, #d4c1a5 55%, #a47c60 100%)",
mauve: "linear-gradient(135deg, #e5c4bc 0%, #8b6f7d 55%, #66505f 100%)",
sand: "linear-gradient(135deg, #f2e2d6 0%, #e8dcc8 55%, #a89580 100%)",
rose: "linear-gradient(135deg, #f0e0d3 0%, #e0b4b1 55%, #d19ca1 100%)",
peach: "linear-gradient(135deg, #f2e2d6 0%, #ecc8be 55%, #e0b4b1 100%)",
};
const moodIcons: Record<ImageMood, React.ReactNode> = {
plum: (
<path d="M32 4c8 6 12 14 12 22 0 9-6.5 16-12 16S20 35 20 26c0-8 4-16 12-22Zm0 60V38" />
),
dustyrose: <path d="M32 8c9 8 18 18 18 30a18 18 0 1 1-36 0c0-12 9-22 18-30Z" />,
moss: (
<path d="M32 60V24M32 24c-10 0-18-8-18-18 10 0 18 8 18 18Zm0 0c0-10 8-18 18-18 0 10-8 18-18 18Z" />
),
caramel: (
<path d="M32 6c6 10 14 20 14 30a14 14 0 1 1-28 0c0-10 8-20 14-30Z" />
),
mauve: <path d="M8 32c8-14 16-20 24-20s16 6 24 20c-8 14-16 20-24 20S16 46 8 32Z" />,
sand: (
<path d="M6 24c8-6 12-6 20 0s12 6 20 0M6 40c8-6 12-6 20 0s12 6 20 0" />
),
rose: (
<path d="M32 34a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0 0a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm-14-10a10 10 0 1 1 14 10 10 10 0 0 1-14-10Zm28 0a10 10 0 1 0-14 10 10 10 0 0 0 14-10Z" />
),
peach: <path d="M8 40c6-20 18-32 24-32s18 12 24 32c-8 8-16 12-24 12s-16-4-24-12Z" />,
};
type ImagePlaceholderProps = {
mood: ImageMood;
label: string;
src?: string;
alt?: string;
className?: string;
shape?: "blob" | "soft";
priority?: boolean;
};
export function ImagePlaceholder({
mood,
label,
src,
alt,
className = "",
shape = "blob",
priority = false,
}: ImagePlaceholderProps) {
const radius =
shape === "blob"
? "rounded-[62%_38%_53%_47%/45%_42%_58%_55%]"
: "rounded-[2.5rem]";
if (src) {
return (
<div
className={`relative overflow-hidden ${radius} ${className}`}
aria-hidden={alt ? undefined : true}
>
<Image
src={src}
alt={alt ?? ""}
fill
priority={priority}
className="object-cover"
sizes="(min-width: 1024px) 50vw, 100vw"
/>
</div>
);
}
return (
<div
className={`relative overflow-hidden ${radius} ${className}`}
style={{ background: moodGradients[mood] }}
role="img"
aria-label={label}
>
<svg
className="absolute inset-0 h-full w-full opacity-[0.14] mix-blend-overlay"
aria-hidden="true"
>
<filter id={`grain-${mood}`}>
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
</filter>
<rect width="100%" height="100%" filter={`url(#grain-${mood})`} />
</svg>
<svg
viewBox="0 0 64 64"
className="absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 text-white/40 sm:h-20 sm:w-20"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{moodIcons[mood]}
</svg>
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import type { Offer } from "@/payload-types";
export function Navbar({ offers }: { offers: Offer[] }) {
const pathname = usePathname();
const groups = groupOffersByCategory(offers);
const [megaOpen, setMegaOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileAngeboteOpen, setMobileAngeboteOpen] = useState(false);
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const navRef = useRef<HTMLDivElement>(null);
// Close open menus when the route changes, adjusted during render rather
// than in an effect (see https://react.dev/learn/you-might-not-need-an-effect).
const [lastPathname, setLastPathname] = useState(pathname);
if (pathname !== lastPathname) {
setLastPathname(pathname);
setMobileOpen(false);
setMegaOpen(false);
}
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setMegaOpen(false);
setMobileOpen(false);
}
}
function onClick(e: MouseEvent) {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
setMegaOpen(false);
}
}
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClick);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClick);
};
}, []);
function openMega() {
if (closeTimeout.current) clearTimeout(closeTimeout.current);
setMegaOpen(true);
}
function scheduleCloseMega() {
closeTimeout.current = setTimeout(() => setMegaOpen(false), 150);
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
className="font-serif text-2xl font-semibold tracking-[0.08em] text-anouma-mauve-dark"
>
{siteConfig.name.toUpperCase()}
</Link>
<nav ref={navRef} className="hidden items-center gap-8 lg:flex" aria-label="Hauptnavigation">
{mainNav.map((item) =>
item.title === "Angebote" ? (
<div
key={item.href}
className="relative"
onMouseEnter={openMega}
onMouseLeave={scheduleCloseMega}
>
<button
type="button"
className="flex items-center gap-1 text-[15px] font-medium text-anouma-plum/90 transition-colors hover:text-anouma-mauve-dark"
aria-haspopup="true"
aria-expanded={megaOpen}
onClick={() => setMegaOpen((v) => !v)}
>
{item.title}
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
aria-hidden="true"
className={`transition-transform duration-300 ${megaOpen ? "rotate-180" : ""}`}
>
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence>
{megaOpen && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<Link
key={item.href}
href={item.href}
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
}`}
>
{item.title}
</Link>
)
)}
</nav>
<div className="hidden lg:block">
<Link
href={ctaNav.href}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
</Link>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full text-anouma-plum lg:hidden"
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((v) => !v)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<motion.path
animate={mobileOpen ? { d: "M5 5l14 14" } : { d: "M4 7h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.25 }}
/>
<motion.path
animate={mobileOpen ? { opacity: 0 } : { opacity: 1, d: "M4 12h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.2 }}
/>
<motion.path
animate={mobileOpen ? { d: "M5 19l14-14" } : { d: "M4 17h16" }}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
transition={{ duration: 0.25 }}
/>
</svg>
</button>
</div>
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-y-auto border-t border-anouma-taupe/15 bg-background lg:hidden"
style={{ maxHeight: "calc(100dvh - 5rem)" }}
>
<nav className="flex flex-col px-6 py-6" aria-label="Mobile Navigation">
{mainNav.map((item) =>
item.title === "Angebote" ? (
<div key={item.href} className="border-b border-anouma-taupe/10">
<button
type="button"
className="flex w-full items-center justify-between py-4 text-lg font-medium text-anouma-plum"
aria-expanded={mobileAngeboteOpen}
onClick={() => setMobileAngeboteOpen((v) => !v)}
>
{item.title}
<svg
width="12"
height="8"
viewBox="0 0 10 6"
fill="none"
aria-hidden="true"
className={`transition-transform duration-300 ${mobileAngeboteOpen ? "rotate-180" : ""}`}
>
<path d="M1 1l4 4 4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
<AnimatePresence>
{mobileAngeboteOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden pb-4"
>
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category} className="mb-4">
<Link
href={groupHref}
className="block py-2 text-base font-medium text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="ml-3 space-y-1 border-l border-anouma-taupe/20 pl-4">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="block py-2 text-sm text-anouma-plum"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<Link
key={item.href}
href={item.href}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
</Link>
)
)}
<Link
href={ctaNav.href}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
</Link>
</nav>
</motion.div>
)}
</AnimatePresence>
</header>
);
}
+21
View File
@@ -0,0 +1,21 @@
type OrganicBlobProps = {
className?: string;
tone?: "rose" | "sage" | "cream" | "mauve";
};
const tones: Record<NonNullable<OrganicBlobProps["tone"]>, string> = {
rose: "bg-anouma-rose-soft",
sage: "bg-anouma-sage",
cream: "bg-anouma-cream-beige-2",
mauve: "bg-anouma-mauve",
};
/** Purely decorative, blurred organic shape used to add warmth behind content. */
export function OrganicBlob({ className = "", tone = "rose" }: OrganicBlobProps) {
return (
<div
aria-hidden="true"
className={`pointer-events-none absolute rounded-[60%_40%_65%_35%/45%_55%_45%_55%] opacity-40 blur-3xl ${tones[tone]} ${className}`}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
import { OrganicBlob } from "@/components/OrganicBlob";
type PageHeaderProps = {
eyebrow?: string;
title: string;
lead?: ReactNode;
crumbs?: Crumb[];
};
export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
return (
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{crumbs && <Breadcrumbs items={crumbs} />}
{eyebrow && (
<p className="mb-4 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
{eyebrow}
</p>
)}
<h1 className="max-w-3xl text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl lg:text-6xl">
{title}
</h1>
{lead && (
<div className="mt-5 max-w-2xl text-lg leading-relaxed text-anouma-plum">{lead}</div>
)}
</div>
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
/** Visibly marks a section whose real copy is not yet available in texte.txt. */
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter noch zu ergänzen:</span> {children}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { motion, type Variants } from "framer-motion";
import type { ReactNode } from "react";
const variants: Variants = {
hidden: { opacity: 0, y: 28 },
visible: { opacity: 1, y: 0 },
};
type RevealProps = {
children: ReactNode;
delay?: number;
className?: string;
as?: "div" | "li";
};
/** Fades and lifts content into place once it scrolls into view. */
export function Reveal({ children, delay = 0, className, as = "div" }: RevealProps) {
const Component = motion[as];
return (
<Component
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-80px" }}
variants={variants}
transition={{ duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] }}
className={className}
>
{children}
</Component>
);
}
+31
View File
@@ -0,0 +1,31 @@
import {
RichText as LexicalRichText,
type JSXConvertersFunction,
} from "@payloadcms/richtext-lexical/react";
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
const converters: JSXConvertersFunction<DefaultNodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
paragraph: ({ node, nodesToJSX }) => {
const children = nodesToJSX({ nodes: node.children });
if (!children?.length) return null;
return <p className="text-lg leading-relaxed text-anouma-plum">{children}</p>;
},
heading: ({ node, nodesToJSX }) => {
const children = nodesToJSX({ nodes: node.children });
const Tag = node.tag;
return (
<Tag className="mt-4 font-serif text-3xl font-medium text-anouma-plum">{children}</Tag>
);
},
});
type RichTextProps = {
data: NonNullable<Parameters<typeof LexicalRichText>[0]["data"]>;
className?: string;
};
/** Renders a Payload Lexical richText field with the ANOUMA reading styles. */
export function RichText({ data, className = "space-y-6" }: RichTextProps) {
return <LexicalRichText data={data} converters={converters} className={className} />;
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
type ContainerProps = {
children: ReactNode;
className?: string;
};
export function Container({ children, className = "" }: ContainerProps) {
return <div className={`mx-auto w-full max-w-6xl px-6 sm:px-8 lg:px-12 ${className}`}>{children}</div>;
}
type SectionProps = {
children: ReactNode;
className?: string;
id?: string;
tone?: "cream" | "warm" | "plain" | "mauve";
};
const tones: Record<NonNullable<SectionProps["tone"]>, string> = {
cream: "bg-anouma-cream-light",
warm: "bg-anouma-cream-beige",
plain: "bg-background",
mauve: "bg-anouma-plum text-anouma-cream-light",
};
export function Section({ children, className = "", id, tone = "plain" }: SectionProps) {
return (
<section id={id} className={`py-20 sm:py-28 ${tones[tone]} ${className}`}>
<Container>{children}</Container>
</section>
);
}
type SectionHeadingProps = {
eyebrow?: string;
title: string;
lead?: string;
align?: "left" | "center";
className?: string;
};
export function SectionHeading({
eyebrow,
title,
lead,
align = "left",
className = "",
}: SectionHeadingProps) {
return (
<div
className={`max-w-2xl ${align === "center" ? "mx-auto text-center" : ""} ${className}`}
>
{eyebrow && (
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
{eyebrow}
</p>
)}
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
{title}
</h2>
{lead && (
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">{lead}</p>
)}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const data = new FormData(e.currentTarget);
const email = String(data.get("email") ?? "");
const password = String(data.get("password") ?? "");
try {
const res = await fetch("/api/customers/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
setError("E-Mail oder Passwort ist nicht korrekt.");
setLoading(false);
return;
}
router.push(searchParams.get("next") || "/konto");
router.refresh();
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full 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 disabled:opacity-60"
>
{loading ? "Wird geprüft …" : "Anmelden"}
</button>
</form>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton({ className }: { className?: string }) {
const router = useRouter();
async function handleLogout() {
await fetch("/api/customers/logout", { method: "POST", credentials: "include" });
router.push("/");
router.refresh();
}
return (
<button type="button" onClick={handleLogout} className={className}>
Abmelden
</button>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
const router = useRouter();
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("saving");
const data = new FormData(e.currentTarget);
const res = await fetch(`/api/customers/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: String(data.get("name") ?? ""),
phone: String(data.get("phone") ?? "") || null,
}),
});
if (res.ok) {
setStatus("saved");
router.refresh();
} else {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
Telefonnummer (optional)
</label>
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
</div>
<button
type="submit"
disabled={status === "saving"}
className="rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
>
{status === "saving" ? "Wird gespeichert …" : "Speichern"}
</button>
{status === "saved" && <p className="text-sm text-anouma-olive">Gespeichert.</p>}
{status === "error" && <p className="text-sm text-red-700">Speichern fehlgeschlagen.</p>}
</form>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
export function RegisterForm() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const data = new FormData(e.currentTarget);
const name = String(data.get("name") ?? "");
const email = String(data.get("email") ?? "");
const password = String(data.get("password") ?? "");
try {
const createRes = await fetch("/api/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!createRes.ok) {
const body = await createRes.json().catch(() => null);
setError(body?.errors?.[0]?.message || "Die Registrierung ist fehlgeschlagen. Ist die E-Mail-Adresse schon vergeben?");
setLoading(false);
return;
}
const loginRes = await fetch("/api/customers/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!loginRes.ok) {
router.push("/login");
return;
}
router.push("/konto");
router.refresh();
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className={fieldClass}
autoComplete="new-password"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full 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 disabled:opacity-60"
>
{loading ? "Wird erstellt …" : "Konto erstellen"}
</button>
</form>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest, Event, Offer } from "@/payload-types";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
export function BookingCard({ booking }: { booking: BookingRequest }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
const displayStatus = resolveDisplayStatus(booking.status, booking.date);
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
async function callAction(path: string) {
setBusy(true);
setError(null);
try {
const res = await fetch(path, { method: "POST", credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Aktion fehlgeschlagen.");
setBusy(false);
return;
}
router.refresh();
} catch {
setError("Verbindung fehlgeschlagen.");
setBusy(false);
}
}
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
<p className="mt-1 text-sm text-anouma-plum/80">
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} {fmtTime(booking.endTime)}
</p>
</div>
<BookingStatusBadge status={displayStatus} />
</div>
{booking.status === "pending" && !hasAlternative && (
<p className="mt-4 rounded-2xl bg-anouma-cream-light p-4 text-sm leading-relaxed text-anouma-plum">
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
</p>
)}
{hasAlternative && booking.proposedAlternative && (
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
<p className="mt-1 text-base text-anouma-plum">
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} {" "}
{fmtTime(booking.proposedAlternative.endTime!)}
</p>
<div className="mt-3 flex flex-wrap gap-3">
<button
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/accept-alternative`)}
className="rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
>
Termin annehmen
</button>
<button
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
className="rounded-full border border-anouma-mauve-dark/40 px-5 py-2.5 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light disabled:opacity-60"
>
Anderen Termin anfragen
</button>
</div>
</div>
)}
{booking.status === "confirmed" && (
<div className="mt-4 space-y-3">
{booking.appointmentType === "online" && event && (
<a
href={`/termine/${event.slug}/beitreten`}
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
>
Video-Call betreten
</a>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
{(booking.status === "pending" || booking.status === "confirmed") && displayStatus !== "past" && !hasAlternative && (
<button
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
>
Termin stornieren
</button>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-red-700">
{error}
</p>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
const statusStyles: Record<string, { label: string; className: string }> = {
pending: { label: "Vorgeschlagen", className: "bg-anouma-sand/60 text-anouma-plum" },
confirmed: { label: "Bestätigt", className: "bg-anouma-sage/30 text-anouma-moss" },
rejected: { label: "Abgelehnt", className: "bg-red-100 text-red-800" },
cancelled: { label: "Storniert", className: "bg-anouma-taupe/30 text-anouma-plum/70" },
past: { label: "Vergangen", className: "bg-anouma-taupe/20 text-anouma-plum/60" },
};
export function BookingStatusBadge({ status }: { status: string }) {
const style = statusStyles[status] ?? statusStyles.pending;
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${style.className}`}>
{style.label}
</span>
);
}
export function resolveDisplayStatus(status: string, dateISO: string): string {
if ((status === "pending" || status === "confirmed") && new Date(dateISO) < new Date()) {
return "past";
}
return status;
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
}
export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) {
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
useEffect(() => {
const from = new Date();
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
.then((res) => res.json())
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
.finally(() => setLoading(false));
}, [offerSlug]);
if (result?.ok) {
return (
<div className="rounded-3xl bg-anouma-cream-light p-8">
<p className="text-xs font-medium uppercase tracking-wide text-anouma-dustyrose">Deine Anfrage</p>
{selectedSlot && (
<p className="mt-2 font-serif text-2xl font-medium text-anouma-plum">
{fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
</p>
)}
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin vorgeschlagen</p>
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
</p>
</div>
);
}
if (!isLoggedIn) {
return (
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
<p className="text-base text-anouma-plum">
Melde dich an oder erstelle ein Konto, um einen Termin anzufragen.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3">
<a
href={`/login?next=/angebote/${offerSlug}`}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
>
Anmelden
</a>
<a
href="/registrieren"
className="rounded-full border border-anouma-mauve-dark/40 px-6 py-2.5 text-sm font-medium text-anouma-plum hover:bg-white"
>
Konto erstellen
</a>
</div>
</div>
);
}
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
async function submit() {
if (!selectedSlot) return;
setSubmitting(true);
setResult(null);
try {
const res = await fetch("/api/booking/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ offerSlug, start: selectedSlot, appointmentType, message }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setResult({ ok: false, error: data.error || "Anfrage fehlgeschlagen." });
setSubmitting(false);
return;
}
setResult({ ok: true });
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
setSubmitting(false);
}
}
if (loading) {
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen </p>;
}
return (
<div className="space-y-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
<div>
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
{selectedDay && daySlots.length === 0 && (
<p className="text-sm text-anouma-plum/70">An diesem Tag ist leider kein Termin frei.</p>
)}
{selectedDay && daySlots.length > 0 && (
<div>
<p className="text-sm font-medium text-anouma-plum">{fmtDate(daySlots[0].start)}</p>
<div className="mt-3 flex flex-wrap gap-2">
{daySlots.map((slot) => (
<button
key={slot.start}
type="button"
onClick={() => setSelectedSlot(slot.start)}
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
selectedSlot === slot.start
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
}`}
>
{fmtTime(slot.start)}
</button>
))}
</div>
</div>
)}
</div>
</div>
{selectedSlot && (
<div className="rounded-3xl bg-anouma-cream-light p-6">
<p className="text-sm font-medium text-anouma-plum">
Ausgewählt: {fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
</p>
<fieldset className="mt-4">
<legend className="text-sm font-medium text-anouma-plum">Terminart</legend>
<div className="mt-2 flex gap-4">
<label className="flex items-center gap-2 text-sm text-anouma-plum">
<input
type="radio"
name="appointmentType"
checked={appointmentType === "onsite"}
onChange={() => setAppointmentType("onsite")}
/>
Vor Ort
</label>
<label className="flex items-center gap-2 text-sm text-anouma-plum">
<input
type="radio"
name="appointmentType"
checked={appointmentType === "online"}
onChange={() => setAppointmentType("online")}
/>
Online
</label>
</div>
</fieldset>
<div className="mt-4">
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Nachricht (optional)
</label>
<textarea
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
/>
</div>
{result?.error && (
<p role="alert" className="mt-3 text-sm text-red-700">
{result.error}
</p>
)}
<button
type="button"
onClick={submit}
disabled={submitting}
className="mt-5 rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
>
{submitting ? "Wird gesendet …" : "Terminanfrage senden"}
</button>
</div>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useMemo, useState } from "react";
export type CalendarMarker = {
date: Date;
status: "pending" | "confirmed" | "rejected" | "cancelled" | "past" | "public" | "private";
};
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const markerColor: Record<CalendarMarker["status"], string> = {
pending: "bg-anouma-sand",
confirmed: "bg-anouma-sage",
rejected: "bg-red-400",
cancelled: "bg-anouma-taupe",
past: "bg-anouma-taupe/50",
public: "bg-anouma-rose",
private: "bg-anouma-mauve-dark",
};
function startOfMonth(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
}
export function MonthCalendar({
markers,
onSelectDay,
selectedDay,
}: {
markers: CalendarMarker[];
onSelectDay?: (day: Date) => void;
selectedDay?: Date | null;
}) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const markersByDay = useMemo(() => {
const map = new Map<string, CalendarMarker[]>();
for (const marker of markers) {
const key = dateKey(marker.date);
map.set(key, [...(map.get(key) ?? []), marker]);
}
return map;
}, [markers]);
const weeks = useMemo(() => {
const first = startOfMonth(month);
const firstWeekday = (first.getDay() + 6) % 7; // Monday = 0
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
const cells: (Date | null)[] = Array(firstWeekday).fill(null);
for (let d = 1; d <= daysInMonth; d++) {
cells.push(new Date(month.getFullYear(), month.getMonth(), d));
}
while (cells.length % 7 !== 0) cells.push(null);
const result: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) result.push(cells.slice(i, i + 7));
return result;
}, [month]);
const monthLabel = month.toLocaleDateString("de-DE", { month: "long", year: "numeric" });
const today = dateKey(new Date());
return (
<div className="rounded-3xl bg-white p-6">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
aria-label="Vorheriger Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
</button>
<p className="font-serif text-lg font-medium capitalize text-anouma-plum">{monthLabel}</p>
<button
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
aria-label="Nächster Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
</button>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs font-medium uppercase tracking-wide text-anouma-plum/50">
{WEEKDAY_LABELS.map((d) => (
<div key={d}>{d}</div>
))}
</div>
<div className="mt-1 space-y-1">
{weeks.map((week, i) => (
<div key={i} className="grid grid-cols-7 gap-1">
{week.map((day, j) => {
if (!day) return <div key={j} />;
const key = dateKey(day);
const dayMarkers = markersByDay.get(key) ?? [];
const isSelected = selectedDay && dateKey(selectedDay) === key;
return (
<button
key={j}
type="button"
onClick={() => onSelectDay?.(day)}
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
} ${key === today && !isSelected ? "font-semibold" : ""}`}
>
<span>{day.getDate()}</span>
{dayMarkers.length > 0 && (
<span className="mt-0.5 flex gap-0.5">
{dayMarkers.slice(0, 3).map((m, k) => (
<span
key={k}
className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : markerColor[m.status]}`}
/>
))}
</span>
)}
</button>
);
})}
</div>
))}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { BookingCard } from "./BookingCard";
import { resolveDisplayStatus } from "./BookingStatusBadge";
import type { BookingRequest } from "@/payload-types";
function dateKey(d: Date) {
return d.toISOString().slice(0, 10);
}
export function PersonalCalendar({ bookings }: { bookings: BookingRequest[] }) {
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const markers: CalendarMarker[] = bookings.map((b) => ({
date: new Date(b.date),
status: resolveDisplayStatus(b.status, b.date) as CalendarMarker["status"],
}));
const selectedBookings = selectedDay
? bookings.filter((b) => dateKey(new Date(b.date)) === dateKey(selectedDay))
: [];
return (
<div className="grid gap-8 lg:grid-cols-[minmax(0,360px)_1fr]">
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
<div>
{selectedDay ? (
selectedBookings.length > 0 ? (
<div className="space-y-4">
{selectedBookings.map((b) => (
<BookingCard key={b.id} booking={b} />
))}
</div>
) : (
<p className="text-sm text-anouma-plum/70">An diesem Tag ist kein Termin von dir eingetragen.</p>
)
) : (
<p className="text-sm text-anouma-plum/70">Wähle einen Tag mit Markierung, um deine Termine zu sehen.</p>
)}
</div>
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { VideoTile } from "./VideoTile";
import { MEETING_SESSION_KEY, type StoredMeetingSession } from "./JoinForm";
import type { ClientToServerMessage, ParticipantSummary, ServerToClientMessage } from "@/lib/meeting/protocol";
const ICE_SERVERS: RTCIceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
type RemoteEntry = ParticipantSummary & { stream: MediaStream | null };
type Phase = "loading" | "connecting" | "connected" | "kicked" | "ended" | "error";
export function CallRoom({ slug }: { slug: string }) {
const router = useRouter();
const [session, setSession] = useState<StoredMeetingSession | null>(null);
const [phase, setPhase] = useState<Phase>("loading");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [remotes, setRemotes] = useState<Map<string, RemoteEntry>>(new Map());
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [sharingScreen, setSharingScreen] = useState(false);
const [showParticipants, setShowParticipants] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const pcsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const sessionRef = useRef<StoredMeetingSession | null>(null);
const send = useCallback((message: ClientToServerMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
}, []);
const closePeer = useCallback((participantId: string) => {
pcsRef.current.get(participantId)?.close();
pcsRef.current.delete(participantId);
setRemotes((prev) => {
const next = new Map(prev);
next.delete(participantId);
return next;
});
}, []);
const createPeerConnection = useCallback(
(participantId: string, name: string, role: ParticipantSummary["role"], isInitiator: boolean) => {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
pcsRef.current.set(participantId, pc);
localStreamRef.current?.getTracks().forEach((track) => {
pc.addTrack(track, localStreamRef.current!);
});
pc.ontrack = (event) => {
setRemotes((prev) => {
const next = new Map(prev);
const existing = next.get(participantId);
next.set(participantId, { participantId, name, role, stream: event.streams[0] ?? existing?.stream ?? null });
return next;
});
};
pc.onicecandidate = (event) => {
if (event.candidate) {
send({ type: "ice-candidate", to: participantId, payload: event.candidate.toJSON() });
}
};
setRemotes((prev) => {
const next = new Map(prev);
next.set(participantId, { participantId, name, role, stream: next.get(participantId)?.stream ?? null });
return next;
});
if (isInitiator) {
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer).then(() => offer))
.then((offer) => send({ type: "offer", to: participantId, payload: offer }))
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
}
return pc;
},
[send],
);
const cleanup = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
pcsRef.current.forEach((pc) => pc.close());
pcsRef.current.clear();
localStreamRef.current?.getTracks().forEach((t) => t.stop());
localStreamRef.current = null;
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
}, []);
// Load session + acquire local media + open signaling connection. This
// reads sessionStorage and opens external connections, so it belongs in an
// effect; the early setState calls below are unavoidable for the
// missing/invalid-session error paths (no server-renderable equivalent).
useEffect(() => {
const raw = sessionStorage.getItem(MEETING_SESSION_KEY);
if (!raw) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
return;
}
let parsed: StoredMeetingSession;
try {
parsed = JSON.parse(raw);
} catch {
setPhase("error");
setErrorMessage("Keine aktive Meeting-Sitzung gefunden.");
return;
}
if (parsed.eventSlug !== slug) {
setPhase("error");
setErrorMessage("Diese Sitzung gehört zu einem anderen Termin.");
return;
}
setSession(parsed);
sessionRef.current = parsed;
setPhase("connecting");
let cancelled = false;
(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
localStreamRef.current = stream;
setLocalStream(stream);
} catch {
// Continue without local media — participant can still watch/listen to others.
setErrorMessage("Kamera/Mikrofon konnten nicht aktiviert werden. Du kannst trotzdem teilnehmen.");
}
if (cancelled) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
wsRef.current = ws;
ws.onmessage = (event) => {
const message: ServerToClientMessage = JSON.parse(event.data);
handleServerMessage(message);
};
ws.onclose = () => {
setPhase((p) => (p === "kicked" || p === "ended" ? p : "ended"));
};
ws.onerror = () => setErrorMessage("Verbindung zum Meeting-Server fehlgeschlagen.");
})();
function handleServerMessage(message: ServerToClientMessage) {
const me = sessionRef.current;
if (!me) return;
switch (message.type) {
case "welcome": {
setPhase("connected");
for (const p of message.participants) {
createPeerConnection(p.participantId, p.name, p.role, true);
}
break;
}
case "peer-joined": {
createPeerConnection(message.participantId, message.name, message.role, false);
break;
}
case "peer-left": {
closePeer(message.participantId);
break;
}
case "offer": {
const pc =
pcsRef.current.get(message.from) ??
createPeerConnection(message.from, remotesLookupName(message.from), "participant", false);
pc.setRemoteDescription(new RTCSessionDescription(message.payload))
.then(() => pc.createAnswer())
.then((answer) => pc.setLocalDescription(answer).then(() => answer))
.then((answer) => send({ type: "answer", to: message.from, payload: answer }))
.catch(() => setErrorMessage("Verbindung zu einem Teilnehmer ist fehlgeschlagen."));
break;
}
case "answer": {
pcsRef.current.get(message.from)?.setRemoteDescription(new RTCSessionDescription(message.payload));
break;
}
case "ice-candidate": {
pcsRef.current.get(message.from)?.addIceCandidate(new RTCIceCandidate(message.payload)).catch(() => {});
break;
}
case "kicked": {
setPhase("kicked");
cleanup();
sessionStorage.removeItem(MEETING_SESSION_KEY);
break;
}
}
}
function remotesLookupName(participantId: string): string {
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
}
return () => {
cancelled = true;
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug]);
function toggleMic() {
localStreamRef.current?.getAudioTracks().forEach((t) => (t.enabled = !micOn));
setMicOn((v) => !v);
}
function toggleCam() {
localStreamRef.current?.getVideoTracks().forEach((t) => (t.enabled = !camOn));
setCamOn((v) => !v);
}
async function toggleScreenShare() {
if (sharingScreen) {
stopScreenShare();
return;
}
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
screenStreamRef.current = screenStream;
const screenTrack = screenStream.getVideoTracks()[0];
screenTrack.onended = () => stopScreenShare();
pcsRef.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
sender?.replaceTrack(screenTrack);
});
setSharingScreen(true);
} catch {
// User cancelled the share dialog — nothing to do.
}
}
function stopScreenShare() {
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
const cameraTrack = localStreamRef.current?.getVideoTracks()[0] ?? null;
pcsRef.current.forEach((pc) => {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
sender?.replaceTrack(cameraTrack);
});
setSharingScreen(false);
}
function kickParticipant(participantId: string) {
send({ type: "kick", targetParticipantId: participantId });
}
function leaveMeeting() {
cleanup();
sessionStorage.removeItem(MEETING_SESSION_KEY);
router.push(`/termine/${slug}`);
}
if (phase === "loading" || phase === "connecting") {
return (
<div className="flex min-h-[70vh] items-center justify-center bg-neutral-900 text-white">
<p className="text-sm text-white/70">Verbindung wird aufgebaut </p>
</div>
);
}
if (phase === "error" || !session) {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">{errorMessage || "Dieses Meeting konnte nicht geöffnet werden."}</p>
<a
href={`/termine/${slug}/beitreten`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Zurück zur Beitrittsseite
</a>
</div>
);
}
if (phase === "kicked") {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">Du wurdest vom Meeting entfernt.</p>
<a
href={`/termine/${slug}/beitreten`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Erneut beitreten
</a>
</div>
);
}
if (phase === "ended") {
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center gap-4 bg-neutral-900 px-6 text-center text-white">
<p className="text-lg">Die Verbindung zum Meeting wurde beendet.</p>
<a
href={`/termine/${slug}`}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium hover:bg-anouma-plum"
>
Zurück zum Termin
</a>
</div>
);
}
const isHost = session.role === "host";
const remoteList = [...remotes.values()];
return (
<div className="flex min-h-[80vh] flex-col bg-neutral-900 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3">
<span className="text-sm font-medium">{session.eventTitle}</span>
<button
type="button"
onClick={() => setShowParticipants((v) => !v)}
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium hover:bg-white/20"
>
Teilnehmer ({remoteList.length + 1})
</button>
</div>
{errorMessage && (
<p className="bg-amber-900/50 px-5 py-2 text-center text-xs text-amber-100">{errorMessage}</p>
)}
<div className="flex flex-1">
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3">
<VideoTile stream={localStream} name={session.name} isLocal isHost={isHost} muted={!micOn} videoOff={!camOn} />
{remoteList.map((r) => (
<VideoTile key={r.participantId} stream={r.stream} name={r.name} isHost={r.role === "host"} />
))}
</div>
{showParticipants && (
<aside className="w-64 shrink-0 border-l border-white/10 p-4">
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-white/60">Teilnehmer</h2>
<ul className="space-y-2">
<li className="flex items-center justify-between text-sm">
<span> {session.name} (du)</span>
</li>
{remoteList.map((r) => (
<li key={r.participantId} className="flex items-center justify-between text-sm">
<span> {r.name}</span>
{isHost && (
<button
type="button"
onClick={() => kickParticipant(r.participantId)}
className="text-xs text-white/50 hover:text-red-300"
>
entfernen
</button>
)}
</li>
))}
</ul>
</aside>
)}
</div>
<div className="flex items-center justify-center gap-3 border-t border-white/10 px-5 py-4">
<ControlButton active={micOn} onClick={toggleMic} label={micOn ? "Mikrofon aus" : "Mikrofon an"} icon="mic" />
<ControlButton active={camOn} onClick={toggleCam} label={camOn ? "Kamera aus" : "Kamera an"} icon="cam" />
{isHost && (
<ControlButton
active={sharingScreen}
onClick={toggleScreenShare}
label={sharingScreen ? "Bildschirmfreigabe beenden" : "Bildschirm teilen"}
icon="screen"
/>
)}
<button
type="button"
onClick={leaveMeeting}
className="rounded-full bg-red-700 px-6 py-3 text-sm font-medium text-white hover:bg-red-800"
>
Meeting verlassen
</button>
</div>
</div>
);
}
function ControlButton({
active,
onClick,
label,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
icon: "mic" | "cam" | "screen";
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-pressed={active}
className={`flex h-12 w-12 items-center justify-center rounded-full transition-colors ${
active ? "bg-white/15 hover:bg-white/25" : "bg-red-700 hover:bg-red-800"
}`}
>
<span className="sr-only">{label}</span>
<Icon name={icon} off={!active} />
</button>
);
}
function Icon({ name, off }: { name: "mic" | "cam" | "screen"; off: boolean }) {
if (name === "mic") {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
<path
d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Zm5-3a5 5 0 0 1-10 0M12 18v3"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (name === "cam") {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
{off && <path d="M2 2l20 20" stroke="white" strokeWidth="1.8" strokeLinecap="round" />}
<rect x="2" y="6" width="14" height="12" rx="2" stroke="white" strokeWidth="1.8" />
<path d="M16 10l6-3v10l-6-3" stroke="white" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2" y="4" width="20" height="13" rx="2" stroke="white" strokeWidth="1.8" />
<path d="M8 21h8M12 17v4" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
export const MEETING_SESSION_KEY = "anouma-meeting-session";
export type StoredMeetingSession = {
token: string;
participantId: string;
role: "host" | "participant";
name: string;
eventTitle: string;
eventSlug: string;
};
export function JoinForm({ slug }: { slug: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`/api/meetings/${slug}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Beitritt leider nicht möglich.");
setLoading(false);
return;
}
const session: StoredMeetingSession = {
token: data.token,
participantId: data.participantId,
role: data.role,
name,
eventTitle: data.eventTitle,
eventSlug: slug,
};
sessionStorage.setItem(MEETING_SESSION_KEY, JSON.stringify(session));
router.push(`/termine/${slug}/call`);
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Dein Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name eingeben"
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Meeting-Passwort
</label>
<input
id="password"
type="text"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
className={fieldClass}
autoComplete="off"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full 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 disabled:opacity-60"
>
{loading ? "Wird geprüft …" : "Meeting betreten"}
</button>
</form>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
export function RegisterForm({ slug }: { slug: string }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
setError(null);
try {
const res = await fetch(`/api/meetings/${slug}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Anmeldung leider nicht möglich.");
setStatus("error");
return;
}
setStatus("done");
} catch {
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
setStatus("error");
}
}
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung wir freuen uns auf dich!
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Name
</label>
<input
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
autoComplete="email"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={status === "loading"}
className="rounded-full bg-anouma-mauve-dark px-6 py-3 text-sm font-medium text-white transition-colors duration-300 hover:bg-anouma-plum disabled:opacity-60"
>
{status === "loading" ? "Wird gesendet …" : "Zum Termin anmelden"}
</button>
</form>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef } from "react";
type VideoTileProps = {
stream: MediaStream | null;
name: string;
isLocal?: boolean;
isHost?: boolean;
muted?: boolean;
videoOff?: boolean;
};
export function VideoTile({ stream, name, isLocal, isHost, muted, videoOff }: VideoTileProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = stream;
}, [stream]);
return (
<div className="relative aspect-video overflow-hidden rounded-2xl bg-neutral-800">
{stream && !videoOff ? (
<video
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="h-full w-full object-cover [transform:scaleX(var(--flip,1))]"
style={isLocal ? ({ "--flip": -1 } as React.CSSProperties) : undefined}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-800">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-700 font-serif text-2xl text-white">
{name.charAt(0).toUpperCase()}
</div>
</div>
)}
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-3 py-2">
<span className="truncate text-sm font-medium text-white">
{name}
{isLocal && " (du)"}
</span>
<div className="flex items-center gap-1.5">
{isHost && (
<span className="rounded-full bg-anouma-mauve-dark/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
Host
</span>
)}
{muted && (
<span aria-label="Mikrofon stumm" className="text-white/80">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M1 1l22 22M9 9v3a3 3 0 0 0 4.6 2.55M15 9.34V5a3 3 0 0 0-5.94-.6M5 10v1a7 7 0 0 0 10.54 6.02M12 18v3m-4 0h8"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: anouma
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
# Optional: run the Next.js app in Docker too. For local development it's
# usually simpler to run `npm run dev` on the host against the Postgres
# container above (DATABASE_URI host: 127.0.0.1, as in .env.example).
# To run the whole stack in Docker instead, uncomment this service and set
# DATABASE_URI's host to `postgres` (the service name) in your .env file.
# app:
# build: .
# restart: unless-stopped
# ports:
# - "3000:3000"
# env_file:
# - .env
# depends_on:
# postgres:
# condition: service_healthy
volumes:
pgdata:
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+40
View File
@@ -0,0 +1,40 @@
import type { Field } from "payload";
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* A "Slug" field that auto-fills itself from `sourceField` (usually the
* title) whenever left empty, so the admin never has to think about URLs.
*/
export function slugField(sourceField = "title"): Field {
return {
name: "slug",
type: "text",
label: "Slug (URL)",
unique: true,
index: true,
admin: {
position: "sidebar",
description: "Wird automatisch aus dem Titel erzeugt, kann aber angepasst werden.",
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (value) return slugify(String(value));
const source = data?.[sourceField];
return typeof source === "string" ? slugify(source) : value;
},
],
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const About: GlobalConfig = {
slug: "about",
label: "Über mich",
admin: {
description: "Inhalt der Seite „Über mich“.",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "portrait", type: "upload", relationTo: "media", label: "Portraitbild" },
{ name: "body", type: "richText", label: "Haupttext", required: true },
{
type: "collapsible",
label: "Abschließendes Zitat",
fields: [
{ name: "closingLead", type: "text", label: "Einleitung" },
{ name: "closingHighlight", type: "text", label: "Hervorgehobenes Zitat" },
{ name: "closingParagraph", type: "textarea", label: "Abschlusstext" },
],
},
],
};
+20
View File
@@ -0,0 +1,20 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const AktuellesIntro: GlobalConfig = {
slug: "aktuelles-intro",
label: "Aktuelles (Einleitung)",
admin: {
description: "Kopftext der Aktuelles-Seite. Die Beiträge selbst werden in der Sammlung „Beiträge“ gepflegt.",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
],
};
+20
View File
@@ -0,0 +1,20 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const AngeboteIntro: GlobalConfig = {
slug: "angebote-intro",
label: "Angebote (Einleitung)",
admin: {
description: "Kopftext der Angebote-Übersichtsseite. Die Angebote selbst werden in der Sammlung „Angebote“ gepflegt.",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
],
};
+32
View File
@@ -0,0 +1,32 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const Booking: GlobalConfig = {
slug: "booking",
label: "Termin buchen",
admin: {
description: "Inhalt der Seite „Termin buchen“.",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
{
name: "steps",
type: "array",
label: "Ablaufschritte",
minRows: 1,
maxRows: 6,
fields: [
{ name: "title", type: "text", label: "Titel", required: true },
{ name: "text", type: "textarea", label: "Beschreibung", required: true },
],
},
{ name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" },
],
};
+21
View File
@@ -0,0 +1,21 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const BookingSettings: GlobalConfig = {
slug: "booking-settings",
label: "Buchungseinstellungen",
admin: {
description: "Adresse für Vor-Ort-Termine (wird für den Maps-Link verwendet).",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" },
{ name: "street", type: "text", label: "Straße und Hausnummer" },
{ name: "postalCode", type: "text", label: "PLZ" },
{ name: "city", type: "text", label: "Ort" },
],
};
+28
View File
@@ -0,0 +1,28 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
export const Contact: GlobalConfig = {
slug: "contact",
label: "Kontakt",
admin: {
description: "Inhalt und Kontaktdaten der Kontakt-Seite.",
group: "Seiteninhalte",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{ name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" },
{
type: "row",
fields: [
{ name: "email", type: "email", label: "E-Mail", required: true, admin: { width: "34%" } },
{ name: "phone", type: "text", label: "Telefon", admin: { width: "33%" } },
{ name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } },
],
},
],
};

Some files were not shown because too many files have changed in this diff Show More