From 45261a046143ef5a6e1b3439f57db71adb9e00b3 Mon Sep 17 00:00:00 2001 From: maro Date: Tue, 25 Aug 2026 16:40:51 +0200 Subject: [PATCH] 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 --- .env.example | 28 + .gitignore | 46 + AGENTS.md | 9 + CLAUDE.md | 1 + Dockerfile | 51 + README.md | 97 + access/index.ts | 26 + app/(call)/layout.tsx | 22 + app/(call)/termine/[slug]/call/page.tsx | 10 + app/(frontend)/aktuelles/[slug]/page.tsx | 60 + app/(frontend)/aktuelles/page.tsx | 70 + app/(frontend)/angebote/[slug]/page.tsx | 66 + app/(frontend)/angebote/page.tsx | 154 + app/(frontend)/angebote/singkreise/page.tsx | 55 + app/(frontend)/datenschutz/page.tsx | 71 + app/(frontend)/globals.css | 94 + app/(frontend)/impressionen/page.tsx | 44 + app/(frontend)/impressum/page.tsx | 82 + app/(frontend)/kontakt/page.tsx | 75 + app/(frontend)/konto/kalender/page.tsx | 22 + app/(frontend)/konto/layout.tsx | 46 + app/(frontend)/konto/page.tsx | 71 + app/(frontend)/konto/profil/page.tsx | 22 + app/(frontend)/konto/termine/page.tsx | 30 + app/(frontend)/layout.tsx | 71 + app/(frontend)/login/page.tsx | 38 + app/(frontend)/page.tsx | 223 + app/(frontend)/registrieren/page.tsx | 40 + app/(frontend)/termin-buchen/page.tsx | 64 + .../termine/[slug]/beitreten/page.tsx | 48 + app/(frontend)/termine/[slug]/page.tsx | 149 + app/(frontend)/termine/page.tsx | 62 + app/(frontend)/ueber-mich/page.tsx | 85 + .../admin/[[...segments]]/not-found.tsx | 24 + app/(payload)/admin/[[...segments]]/page.tsx | 24 + app/(payload)/admin/importMap.js | 52 + app/(payload)/api/[...slug]/route.ts | 19 + .../booking/[id]/accept-alternative/route.ts | 51 + .../api/booking/[id]/cancel/route.ts | 33 + app/(payload)/api/booking/request/route.ts | 93 + app/(payload)/api/booking/slots/route.ts | 55 + .../api/cron/event-reminders/route.ts | 28 + app/(payload)/api/graphql-playground/route.ts | 7 + app/(payload)/api/graphql/route.ts | 8 + .../api/meetings/[slug]/join/route.ts | 97 + .../api/meetings/[slug]/register/route.ts | 73 + app/(payload)/custom.css | 3 + app/(payload)/layout.tsx | 31 + app/favicon.ico | Bin 0 -> 25931 bytes app/global-not-found.tsx | 74 + app/robots.ts | 15 + app/sitemap.ts | 29 + collections/Availability.ts | 69 + collections/AvailabilityOverrides.ts | 72 + collections/BookingRequests.ts | 403 + collections/Customers.ts | 53 + collections/EventRegistrations.ts | 79 + collections/Events.ts | 247 + collections/Media.ts | 55 + collections/Offers.ts | 127 + collections/Posts.ts | 68 + collections/Users.ts | 50 + components/AngebotCard.tsx | 53 + components/Breadcrumbs.tsx | 33 + components/Button.tsx | 45 + components/CTA.tsx | 46 + components/ContactForm.tsx | 73 + components/EventTeaserCard.tsx | 44 + components/Footer.tsx | 92 + components/Hero.tsx | 53 + components/ImagePlaceholder.tsx | 114 + components/Navbar.tsx | 315 + components/OrganicBlob.tsx | 21 + components/PageHeader.tsx | 32 + components/PlaceholderNote.tsx | 10 + components/Reveal.tsx | 33 + components/RichText.tsx | 31 + components/Section.tsx | 66 + components/auth/LoginForm.tsx | 72 + components/auth/LogoutButton.tsx | 19 + components/auth/ProfileForm.tsx | 61 + components/auth/RegisterForm.tsx | 97 + components/booking/BookingCard.tsx | 123 + components/booking/BookingStatusBadge.tsx | 23 + components/booking/BookingWidget.tsx | 204 + components/booking/MonthCalendar.tsx | 132 + components/booking/PersonalCalendar.tsx | 45 + components/meeting/CallRoom.tsx | 458 + components/meeting/JoinForm.tsx | 107 + components/meeting/RegisterForm.tsx | 89 + components/meeting/VideoTile.tsx | 67 + docker-compose.yml | 36 + eslint.config.mjs | 18 + fields/slug.ts | 40 + globals/About.ts | 30 + globals/AktuellesIntro.ts | 20 + globals/AngeboteIntro.ts | 20 + globals/Booking.ts | 32 + globals/BookingSettings.ts | 21 + globals/Contact.ts | 28 + globals/Home.ts | 70 + globals/MeetingSettings.ts | 41 + lib/angebote.ts | 66 + lib/auth/customer.ts | 12 + lib/booking/lock.ts | 25 + lib/booking/queries.ts | 31 + lib/booking/slots.ts | 116 + lib/email/bookingTemplates.ts | 152 + lib/email/layout.ts | 80 + lib/email/reminderTemplate.ts | 114 + lib/email/sendBookingEmails.ts | 6 + lib/email/sendReminderEmail.ts | 13 + lib/email/transport.ts | 25 + lib/format.ts | 27 + lib/meeting/password.ts | 13 + lib/meeting/protocol.ts | 22 + lib/meeting/reminders.ts | 118 + lib/meeting/rooms.ts | 76 + lib/meeting/signalingServer.ts | 90 + lib/meeting/status.ts | 51 + lib/meeting/token.ts | 66 + lib/payload/content.ts | 124 + lib/payload/getPayload.ts | 11 + lib/payload/globals.ts | 33 + lib/payload/media.ts | 21 + lib/site.ts | 24 + lib/texte.ts | 183 + next.config.ts | 30 + package-lock.json | 12872 ++++++++++++++++ package.json | 47 + payload-types.ts | 1247 ++ payload.config.ts | 73 + postcss.config.mjs | 7 + scripts/lexical.ts | 92 + scripts/seed.ts | 241 + server.ts | 53 + texte.txt | 182 + tsconfig.json | 35 + 138 files changed, 23263 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 access/index.ts create mode 100644 app/(call)/layout.tsx create mode 100644 app/(call)/termine/[slug]/call/page.tsx create mode 100644 app/(frontend)/aktuelles/[slug]/page.tsx create mode 100644 app/(frontend)/aktuelles/page.tsx create mode 100644 app/(frontend)/angebote/[slug]/page.tsx create mode 100644 app/(frontend)/angebote/page.tsx create mode 100644 app/(frontend)/angebote/singkreise/page.tsx create mode 100644 app/(frontend)/datenschutz/page.tsx create mode 100644 app/(frontend)/globals.css create mode 100644 app/(frontend)/impressionen/page.tsx create mode 100644 app/(frontend)/impressum/page.tsx create mode 100644 app/(frontend)/kontakt/page.tsx create mode 100644 app/(frontend)/konto/kalender/page.tsx create mode 100644 app/(frontend)/konto/layout.tsx create mode 100644 app/(frontend)/konto/page.tsx create mode 100644 app/(frontend)/konto/profil/page.tsx create mode 100644 app/(frontend)/konto/termine/page.tsx create mode 100644 app/(frontend)/layout.tsx create mode 100644 app/(frontend)/login/page.tsx create mode 100644 app/(frontend)/page.tsx create mode 100644 app/(frontend)/registrieren/page.tsx create mode 100644 app/(frontend)/termin-buchen/page.tsx create mode 100644 app/(frontend)/termine/[slug]/beitreten/page.tsx create mode 100644 app/(frontend)/termine/[slug]/page.tsx create mode 100644 app/(frontend)/termine/page.tsx create mode 100644 app/(frontend)/ueber-mich/page.tsx create mode 100644 app/(payload)/admin/[[...segments]]/not-found.tsx create mode 100644 app/(payload)/admin/[[...segments]]/page.tsx create mode 100644 app/(payload)/admin/importMap.js create mode 100644 app/(payload)/api/[...slug]/route.ts create mode 100644 app/(payload)/api/booking/[id]/accept-alternative/route.ts create mode 100644 app/(payload)/api/booking/[id]/cancel/route.ts create mode 100644 app/(payload)/api/booking/request/route.ts create mode 100644 app/(payload)/api/booking/slots/route.ts create mode 100644 app/(payload)/api/cron/event-reminders/route.ts create mode 100644 app/(payload)/api/graphql-playground/route.ts create mode 100644 app/(payload)/api/graphql/route.ts create mode 100644 app/(payload)/api/meetings/[slug]/join/route.ts create mode 100644 app/(payload)/api/meetings/[slug]/register/route.ts create mode 100644 app/(payload)/custom.css create mode 100644 app/(payload)/layout.tsx create mode 100644 app/favicon.ico create mode 100644 app/global-not-found.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 collections/Availability.ts create mode 100644 collections/AvailabilityOverrides.ts create mode 100644 collections/BookingRequests.ts create mode 100644 collections/Customers.ts create mode 100644 collections/EventRegistrations.ts create mode 100644 collections/Events.ts create mode 100644 collections/Media.ts create mode 100644 collections/Offers.ts create mode 100644 collections/Posts.ts create mode 100644 collections/Users.ts create mode 100644 components/AngebotCard.tsx create mode 100644 components/Breadcrumbs.tsx create mode 100644 components/Button.tsx create mode 100644 components/CTA.tsx create mode 100644 components/ContactForm.tsx create mode 100644 components/EventTeaserCard.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Hero.tsx create mode 100644 components/ImagePlaceholder.tsx create mode 100644 components/Navbar.tsx create mode 100644 components/OrganicBlob.tsx create mode 100644 components/PageHeader.tsx create mode 100644 components/PlaceholderNote.tsx create mode 100644 components/Reveal.tsx create mode 100644 components/RichText.tsx create mode 100644 components/Section.tsx create mode 100644 components/auth/LoginForm.tsx create mode 100644 components/auth/LogoutButton.tsx create mode 100644 components/auth/ProfileForm.tsx create mode 100644 components/auth/RegisterForm.tsx create mode 100644 components/booking/BookingCard.tsx create mode 100644 components/booking/BookingStatusBadge.tsx create mode 100644 components/booking/BookingWidget.tsx create mode 100644 components/booking/MonthCalendar.tsx create mode 100644 components/booking/PersonalCalendar.tsx create mode 100644 components/meeting/CallRoom.tsx create mode 100644 components/meeting/JoinForm.tsx create mode 100644 components/meeting/RegisterForm.tsx create mode 100644 components/meeting/VideoTile.tsx create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 fields/slug.ts create mode 100644 globals/About.ts create mode 100644 globals/AktuellesIntro.ts create mode 100644 globals/AngeboteIntro.ts create mode 100644 globals/Booking.ts create mode 100644 globals/BookingSettings.ts create mode 100644 globals/Contact.ts create mode 100644 globals/Home.ts create mode 100644 globals/MeetingSettings.ts create mode 100644 lib/angebote.ts create mode 100644 lib/auth/customer.ts create mode 100644 lib/booking/lock.ts create mode 100644 lib/booking/queries.ts create mode 100644 lib/booking/slots.ts create mode 100644 lib/email/bookingTemplates.ts create mode 100644 lib/email/layout.ts create mode 100644 lib/email/reminderTemplate.ts create mode 100644 lib/email/sendBookingEmails.ts create mode 100644 lib/email/sendReminderEmail.ts create mode 100644 lib/email/transport.ts create mode 100644 lib/format.ts create mode 100644 lib/meeting/password.ts create mode 100644 lib/meeting/protocol.ts create mode 100644 lib/meeting/reminders.ts create mode 100644 lib/meeting/rooms.ts create mode 100644 lib/meeting/signalingServer.ts create mode 100644 lib/meeting/status.ts create mode 100644 lib/meeting/token.ts create mode 100644 lib/payload/content.ts create mode 100644 lib/payload/getPayload.ts create mode 100644 lib/payload/globals.ts create mode 100644 lib/payload/media.ts create mode 100644 lib/site.ts create mode 100644 lib/texte.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 payload-types.ts create mode 100644 payload.config.ts create mode 100644 postcss.config.mjs create mode 100644 scripts/lexical.ts create mode 100644 scripts/seed.ts create mode 100644 server.ts create mode 100644 texte.txt create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..24b4acd --- /dev/null +++ b/.env.example @@ -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 " +# 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 " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c72df72 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ebd4b6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f42e5 --- /dev/null +++ b/README.md @@ -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. diff --git a/access/index.ts b/access/index.ts new file mode 100644 index 0000000..bc9c522 --- /dev/null +++ b/access/index.ts @@ -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" }, + }; +}; diff --git a/app/(call)/layout.tsx b/app/(call)/layout.tsx new file mode 100644 index 0000000..478b01c --- /dev/null +++ b/app/(call)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/(call)/termine/[slug]/call/page.tsx b/app/(call)/termine/[slug]/call/page.tsx new file mode 100644 index 0000000..20656b6 --- /dev/null +++ b/app/(call)/termine/[slug]/call/page.tsx @@ -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 ; +} diff --git a/app/(frontend)/aktuelles/[slug]/page.tsx b/app/(frontend)/aktuelles/[slug]/page.tsx new file mode 100644 index 0000000..42ebbbc --- /dev/null +++ b/app/(frontend)/aktuelles/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+
+ +
+
+
+
+ +
+
+ + ); +} diff --git a/app/(frontend)/aktuelles/page.tsx b/app/(frontend)/aktuelles/page.tsx new file mode 100644 index 0000000..c51bee6 --- /dev/null +++ b/app/(frontend)/aktuelles/page.tsx @@ -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 { + 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 ( + <> + + +
+ {posts.length === 0 ? ( +

+ Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei. +

+ ) : ( +
+ {posts.map((post, i) => ( + + +
+ +
+

+ {new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", { + day: "2-digit", + month: "long", + year: "numeric", + })} +

+

{post.title}

+

{post.teaser}

+ +
+ ))} +
+ )} +
+ + ); +} diff --git a/app/(frontend)/angebote/[slug]/page.tsx b/app/(frontend)/angebote/[slug]/page.tsx new file mode 100644 index 0000000..47720f8 --- /dev/null +++ b/app/(frontend)/angebote/[slug]/page.tsx @@ -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 { + 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 ( + <> + +
+
+ +
+
+
+
+ +
+
+ + + ); +} diff --git a/app/(frontend)/angebote/page.tsx b/app/(frontend)/angebote/page.tsx new file mode 100644 index 0000000..5f00b57 --- /dev/null +++ b/app/(frontend)/angebote/page.tsx @@ -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 { + 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 ( + <> + + + {prozessbegleitung && ( +
+
+ +
+ +
+
+ +

+ Für Erwachsene +

+

+ {prozessbegleitung.title} +

+

+ {prozessbegleitung.shortDescription} +

+
+ +
+
+
+
+ )} + + {doula && ( +
+
+ +

+ Kinderwunsch · Schwangerschaft · Geburt · Wochenbett +

+

+ {doula.title} +

+

+ {doula.shortDescription} +

+
+ +
+
+ +
+ +
+
+
+
+ )} + + {kindergruppen && kindergruppen.offers.length > 0 && ( +
+ +
+ {kindergruppen.offers.map((offer, i) => ( + + + + ))} +
+
+ )} + + {singkreise && singkreise.offers.length > 0 && ( +
+ +
+ {singkreise.offers.map((offer, i) => ( + + + + ))} +
+
+ )} + + + + ); +} diff --git a/app/(frontend)/angebote/singkreise/page.tsx b/app/(frontend)/angebote/singkreise/page.tsx new file mode 100644 index 0000000..5ca5d82 --- /dev/null +++ b/app/(frontend)/angebote/singkreise/page.tsx @@ -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 ( + <> + + +
+
+ {singkreise.map((offer, i) => ( + + + + ))} +
+
+ + + + ); +} diff --git a/app/(frontend)/datenschutz/page.tsx b/app/(frontend)/datenschutz/page.tsx new file mode 100644 index 0000000..db185a7 --- /dev/null +++ b/app/(frontend)/datenschutz/page.tsx @@ -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 ( + <> + +
+
+ + 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. + + +
+

Verantwortliche Stelle

+

+ [Platzhalter — Vor- und Nachname] +
+ [Platzhalter — Anschrift] +
+ E-Mail: {contact.email} +

+
+ +
+

+ Erhebung und Speicherung personenbezogener Daten +

+

+ 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.] +

+
+ +
+

Kontaktformular

+

+ 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. +

+
+ +
+

Deine Rechte

+

+ 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}. +

+
+
+
+ + ); +} diff --git a/app/(frontend)/globals.css b/app/(frontend)/globals.css new file mode 100644 index 0000000..4c1bc24 --- /dev/null +++ b/app/(frontend)/globals.css @@ -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; +} diff --git a/app/(frontend)/impressionen/page.tsx b/app/(frontend)/impressionen/page.tsx new file mode 100644 index 0000000..df8589f --- /dev/null +++ b/app/(frontend)/impressionen/page.tsx @@ -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 ( + <> + +
+
+ {gallery.map((item, i) => ( + + + + ))} +
+
+ + ); +} diff --git a/app/(frontend)/impressum/page.tsx b/app/(frontend)/impressum/page.tsx new file mode 100644 index 0000000..49e0d34 --- /dev/null +++ b/app/(frontend)/impressum/page.tsx @@ -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 ( + <> + +
+
+ + 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). + + +
+

Angaben gemäß § 5 TMG

+

+ [Platzhalter — Vor- und Nachname] +
+ [Platzhalter — Straße und Hausnummer] +
+ [Platzhalter — PLZ und Ort] +

+
+ +
+

Kontakt

+

+ {contact.phone && ( + <> + Telefon: {contact.phone} +
+ + )} + E-Mail: {contact.email} +

+
+ +
+

+ Umsatzsteuer-Identifikationsnummer +

+

+ [Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz] +

+
+ +
+

+ Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV +

+

+ [Platzhalter — Vor- und Nachname, Anschrift wie oben] +

+
+ +
+

Haftungshinweis

+

+ 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. +

+
+
+
+ + ); +} diff --git a/app/(frontend)/kontakt/page.tsx b/app/(frontend)/kontakt/page.tsx new file mode 100644 index 0000000..aadf460 --- /dev/null +++ b/app/(frontend)/kontakt/page.tsx @@ -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 { + 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 ( + <> + + +
+
+ + + + + +
+ +
+
+

+ + E-Mail + + + {contact.email} + +

+ {contact.phone && ( +

+ + Telefon + + {contact.phone} +

+ )} + {contact.region && ( +

+ + Region + + {contact.region} +

+ )} +
+
+
+
+ + ); +} diff --git a/app/(frontend)/konto/kalender/page.tsx b/app/(frontend)/konto/kalender/page.tsx new file mode 100644 index 0000000..0e6b41f --- /dev/null +++ b/app/(frontend)/konto/kalender/page.tsx @@ -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 ( +
+

Mein Kalender

+
+ +
+
+ ); +} diff --git a/app/(frontend)/konto/layout.tsx b/app/(frontend)/konto/layout.tsx new file mode 100644 index 0000000..cbd1624 --- /dev/null +++ b/app/(frontend)/konto/layout.tsx @@ -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 ( +
+ +
+

Mein Konto

+

Hallo, {customer.name}

+
+
+ +
{children}
+
+
+
+ ); +} diff --git a/app/(frontend)/konto/page.tsx b/app/(frontend)/konto/page.tsx new file mode 100644 index 0000000..ae6ccd6 --- /dev/null +++ b/app/(frontend)/konto/page.tsx @@ -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 ( +
+
+

Deine Daten

+
+
+
Name
+
{customer.name}
+
+
+
E-Mail
+
{customer.email}
+
+
+
+ +
+
+

Offene Buchungsanfragen

+
+ {pending.length === 0 ? ( +

Keine offenen Anfragen.

+ ) : ( +
+ {pending.map((b) => ( + + ))} +
+ )} +
+ +
+

Bestätigte Termine

+ {confirmed.length === 0 ? ( +

Noch keine bestätigten Termine.

+ ) : ( +
+ {confirmed.map((b) => ( + + ))} +
+ )} +
+ + {past.length > 0 && ( +
+ + Alle Termine (inkl. vergangene) ansehen → + +
+ )} +
+ ); +} diff --git a/app/(frontend)/konto/profil/page.tsx b/app/(frontend)/konto/profil/page.tsx new file mode 100644 index 0000000..7df4662 --- /dev/null +++ b/app/(frontend)/konto/profil/page.tsx @@ -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 ( +
+

Profil

+
+ +

+ E-Mail: {customer.email} — für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna. +

+
+
+ ); +} diff --git a/app/(frontend)/konto/termine/page.tsx b/app/(frontend)/konto/termine/page.tsx new file mode 100644 index 0000000..7053119 --- /dev/null +++ b/app/(frontend)/konto/termine/page.tsx @@ -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 ( +
+

Meine Termine

+ {bookings.length === 0 ? ( +

+ Du hast noch keine Terminanfragen gestellt. +

+ ) : ( +
+ {bookings.map((b) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx new file mode 100644 index 0000000..9495d98 --- /dev/null +++ b/app/(frontend)/layout.tsx @@ -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 ( + + + + Zum Inhalt springen + + +
+ {children} +
+