This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
src/generated
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
media
|
||||
.mail-outbox
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
Ziel
|
||||
k8s
|
||||
docs
|
||||
e2e
|
||||
tests
|
||||
@@ -0,0 +1,55 @@
|
||||
# ARTISTA MODELCAMP 2027 — Konfigurationsvorlage
|
||||
# Kopieren nach .env (lokal) bzw. in ConfigMap/Secret (Kubernetes, k8s/).
|
||||
# Hier stehen KEINE echten Geheimnisse. Werte in <…> ersetzen.
|
||||
|
||||
# --- Umgebung ----------------------------------------------------------------
|
||||
# development | staging | production
|
||||
# Nur "production" wird indexiert, blendet Entwicklungs-Platzhalter aus und
|
||||
# erlaubt einen Stripe-Live-Schluessel.
|
||||
APP_ENV=development
|
||||
# Oeffentliche Adresse ohne abschliessenden Schraegstrich.
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# --- Datenbank ---------------------------------------------------------------
|
||||
# Lokal: pnpm db:dev (Port 54329) oder docker compose up -d db (Port 5432).
|
||||
DATABASE_URL=postgresql://artista:artista-dev@localhost:54329/artista
|
||||
DATABASE_POOL_SIZE=10
|
||||
|
||||
# --- Geheimnisse (je mind. 32 Zeichen, z. B. `openssl rand -base64 48`) -------
|
||||
# Leitet die Zugangstokens der Buchungsstatusseiten ab und hasht IPs fuer
|
||||
# Rate Limits. Wechsel macht alle bisherigen Statuslinks ungueltig.
|
||||
APP_SECRET=<zufaelliger-wert-mindestens-32-zeichen>
|
||||
# Signiert die Admin-Sitzungen (Better Auth).
|
||||
BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
|
||||
|
||||
# --- Stripe ------------------------------------------------------------------
|
||||
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
||||
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
||||
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
||||
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds.
|
||||
STRIPE_SECRET_KEY=<sk_test_…>
|
||||
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
||||
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
||||
|
||||
# --- E-Mail ------------------------------------------------------------------
|
||||
# file = sicherer Testausgang: Mails landen als .eml in MAIL_OUTBOX_DIR,
|
||||
# nichts wird verschickt (in production gesperrt).
|
||||
# smtp = Versand ueber den SMTP-Anbieter unten.
|
||||
MAIL_TRANSPORT=file
|
||||
MAIL_OUTBOX_DIR=.mail-outbox
|
||||
MAIL_FROM=ARTISTA MODELCAMP <[email protected]>
|
||||
# Empfaenger fuer "Pruefung noetig"-Hinweise (optional, empfohlen).
|
||||
ADMIN_NOTIFY_EMAIL=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# --- Medien ------------------------------------------------------------------
|
||||
# Persistentes Verzeichnis fuer freigegebene Bilder/Videos, ausgeliefert
|
||||
# unter /media/… (Kubernetes: eigenes PersistentVolume).
|
||||
MEDIA_DIR=./media
|
||||
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
||||
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
||||
EXTERNAL_VIDEO_PROVIDER=none
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Docker-Images bauen und veroeffentlichen
|
||||
|
||||
# Baut aus einem Dockerfile ZWEI Images mit identischen Tags:
|
||||
# <repo> Target "runner": der Next.js-Server
|
||||
# <repo>-tools Target "tools": Migration (Kubernetes-Job), Worker (CronJob), admin:create
|
||||
# Beide muessen immer mit demselben Tag ausgerollt werden (gleiches Datenbankschema).
|
||||
#
|
||||
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
||||
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: docker-builder
|
||||
env:
|
||||
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
||||
REGISTRY: git.encry.de
|
||||
# Push ueber den Cluster-internen Gitea-Service statt ueber git.encry.de:
|
||||
# Blob-Uploads scheitern am Cloudflare-Proxy ("413 Payload Too Large").
|
||||
# Gleiches Muster wie im Wabenhain-Repo; der Hostname ist nicht Teil des
|
||||
# gespeicherten Images, gezogen wird weiterhin ueber git.encry.de.
|
||||
REGISTRY_PUSH: gitea:80
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||
# OCI-Registries erlauben nur Kleinbuchstaben
|
||||
# ("Nick.Rosenloecher/Artista-Filmcamp" -> "nick.rosenloecher/artista-filmcamp").
|
||||
id: image
|
||||
run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# Der interne Gitea-Service spricht HTTP ohne TLS.
|
||||
config-inline: |
|
||||
[registry."gitea:80"]
|
||||
http = true
|
||||
|
||||
- name: Bei der Gitea-Container-Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_PUSH }}
|
||||
username: ${{ gitea.actor }}
|
||||
# GITEA_TOKEN darf keine Pakete schreiben: Personal Access Token mit
|
||||
# Scope "package" als Repo-Secret REGISTRY_TOKEN hinterlegen
|
||||
# (Repo-Einstellungen -> Actions -> Secrets).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Metadaten Anwendung
|
||||
id: meta-app
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||
# Gleiche Liste wie bei den Werkzeugen (keine YAML-Anker: nicht
|
||||
# jeder Gitea-Runner unterstuetzt sie).
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Metadaten Werkzeuge
|
||||
id: meta-tools
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}-tools
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
# Der Build braucht Internet (next/font laedt die Schriften), aber weder
|
||||
# Datenbank noch Secrets.
|
||||
- name: Anwendung bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: runner
|
||||
push: true
|
||||
tags: ${{ steps.meta-app.outputs.tags }}
|
||||
labels: ${{ steps.meta-app.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Nutzt die gemeinsamen Build-Stufen aus dem Cache des ersten Builds.
|
||||
- name: Werkzeuge bauen und pushen
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: tools
|
||||
push: true
|
||||
tags: ${{ steps.meta-tools.outputs.tags }}
|
||||
labels: ${{ steps.meta-tools.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Zusammenfassung
|
||||
run: |
|
||||
echo "Gebaut fuer ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}(-tools):"
|
||||
echo "${{ steps.meta-app.outputs.tags }}"
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/node_modules
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
/src/generated/
|
||||
/.dev-db/
|
||||
/.test-db/
|
||||
/.mail-outbox/
|
||||
/media/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Umgebungsdateien: nur die Vorlage ohne Geheimnisse ist versioniert.
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"detector": {
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [],
|
||||
"ignoreValues": [
|
||||
{
|
||||
"rule": "overused-font",
|
||||
"value": "arial",
|
||||
"createdAt": "2026-09-27T03:25:29.714Z",
|
||||
"reason": "Claude: E-Mail-Clients laden keine Webfonts; Arial ist dort bewusster websicherer Fallback (src/lib/email-templates.ts)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Zwei Images aus einem Build:
|
||||
# docker build --target runner -t <registry>/artista-modelcamp:<tag> .
|
||||
# docker build --target tools -t <registry>/artista-modelcamp-tools:<tag> .
|
||||
#
|
||||
# runner: der Next.js-Server (standalone), sonst nichts.
|
||||
# tools: Prisma-CLI + tsx fuer den kontrollierten Migrationsschritt
|
||||
# (Kubernetes-Job), den Worker (CronJob) und `admin:create`.
|
||||
# Beide IMMER mit demselben Tag bauen und ausrollen — sie teilen das
|
||||
# Datenbankschema.
|
||||
|
||||
FROM node:22.23.2-alpine3.24 AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# --- Abhaengigkeiten --------------------------------------------------------
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# postinstall (prisma generate) braucht das Schema, das erst im builder
|
||||
# vorliegt; generiert wird dort ausdruecklich.
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# --- Build ------------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Zur Build-Zeit wird keine Datenbank und kein Secret benoetigt.
|
||||
RUN mkdir -p public && pnpm prisma generate && pnpm next build
|
||||
|
||||
# --- Laufzeit: Anwendung ------------------------------------------------------
|
||||
FROM node:22.23.2-alpine3.24 AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
MEDIA_DIR=/app/media
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
# Schreibbar fuer den Bild-Cache; im Cluster ein emptyDir (reiner Cache).
|
||||
RUN mkdir -p /app/.next/cache /app/media && chown -R nextjs:nodejs /app/.next/cache /app/media
|
||||
USER 1001
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# --- Laufzeit: Werkzeuge (Migration, Worker, Admin-Anlage) --------------------
|
||||
FROM base AS tools
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./
|
||||
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||
USER 1001
|
||||
# Standard: Migrationen anwenden. Worker: ["tsx", "scripts/worker.ts"].
|
||||
CMD ["prisma", "migrate", "deploy"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
||||
|
||||
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
|
||||
|
||||
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design |
|
||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
||||
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
||||
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
||||
|
||||
## Stack
|
||||
|
||||
TypeScript 5.9 (strict) · Next.js 16.3 (App Router, standalone) · React 19.3 · Tailwind CSS 4.3 · Motion 13 · PostgreSQL 18 · Prisma 7.10 (versionierte Migrationen) · Stripe 22 (Checkout + PaymentIntents) · Zod 4 · Better Auth 1.7 (Admin, TOTP) · Nodemailer 10 · Vitest 4 · Playwright 1.63. Versionen sind exakt fixiert, Lockdatei: `pnpm-lock.yaml`.
|
||||
|
||||
## Lokal starten
|
||||
|
||||
Voraussetzungen: Node.js 22+, pnpm 12. Docker ist optional.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # APP_SECRET, BETTER_AUTH_SECRET setzen, Stripe-Testschlüssel eintragen
|
||||
pnpm db:dev # eingebettetes PostgreSQL (eigenes Terminal) – oder: docker compose up -d db mailpit
|
||||
pnpm db:deploy # Migrationen anwenden
|
||||
pnpm db:seed # nur Entwicklung: Early Bird simuliert, Test-Anreisetag, Gutschein TEST-STAMMMODEL
|
||||
pnpm admin:create --email [email protected] --name "Dein Name"
|
||||
pnpm dev # http://localhost:3000, Verwaltung unter /admin
|
||||
```
|
||||
|
||||
Stripe-Testbuchung mit Webhooks lokal:
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3000/api/stripe/webhook # gibt das whsec_… für STRIPE_WEBHOOK_SECRET aus
|
||||
pnpm worker # Reservierungen, Raten, E-Mails (im Cluster: CronJob)
|
||||
```
|
||||
|
||||
Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E-Mails landen mit `MAIL_TRANSPORT=file` als `.eml` in `.mail-outbox/` (oder in Mailpit unter http://localhost:8025).
|
||||
|
||||
## Befehle
|
||||
|
||||
| Befehl | Zweck |
|
||||
|---|---|
|
||||
| `pnpm dev` / `pnpm build` / `pnpm start` | Entwicklung, Produktionsbuild, Produktionsserver |
|
||||
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
||||
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
||||
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
||||
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
||||
| `pnpm worker` | Einmaliger Worker-Lauf |
|
||||
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
||||
|
||||
## Wo was gepflegt wird
|
||||
|
||||
- **Texte, Bilder, Dozentinnen, Videos, FAQ-Grundlagen:** [src/content/site.ts](src/content/site.ts). Einträge mit `confirmed: false` gelten als Entwurf.
|
||||
- **Termin, Ort, Zimmerbeschreibung, Veranstalter, Steuerhinweis, Bedingungen, Kapazität, Freigaben:** Adminbereich → „Einstellungen und Freigaben“.
|
||||
- **Preise und Preisphasen:** fest in [src/lib/pricing.ts](src/lib/pricing.ts), weil sie verbindlich vorgegeben sind.
|
||||
- **Gutscheine (Stammmodel-Codes):** Adminbereich → „Gutscheine“.
|
||||
- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
||||
@@ -0,0 +1,437 @@
|
||||
|
||||
Du bist ein erfahrener Creative Director, UX-Designer und Senior Fullstack-Entwickler. Entwickle eine hochwertige, produktionsfähige Buchungswebsite für das **ARTISTA MODELCAMP 2027**.
|
||||
|
||||
Erstelle eine tatsächlich funktionierende Anwendung mit vollständigem Quellcode, Stripe-Anbindung, Bestellverwaltung und Deployment für mein selbst gehostetes RKE2-Kubernetes-Cluster. Eine rein visuelle Demo reicht nicht.
|
||||
|
||||
## 1. Ziel und Arbeitsweise
|
||||
|
||||
Die Website soll eine deutschsprachige Onepage sein, die das Modelcamp emotional und hochwertig präsentiert und Besucherinnen verständlich bis zur verbindlichen Buchung führt.
|
||||
|
||||
Die öffentliche Verkaufsseite bleibt eine Onepage mit Ankernavigation. Separate technische Seiten für Zahlungsabschluss, Buchungsstatus, rechtliche Inhalte und einen geschützten Verwaltungsbereich sind erlaubt.
|
||||
|
||||
Arbeite selbstständig und setze sinnvolle technische Entscheidungen direkt um. Fehlende Inhalte sollen die Entwicklung nicht aufhalten. Verwende dafür zentral gepflegte, klar erkennbare Platzhalter.
|
||||
|
||||
Erfinde keine Veranstaltungsdaten, Leistungen, Qualifikationen, Kundenbewertungen oder rechtlichen Zusicherungen. Fehlende Angaben, die für einen echten Verkauf erforderlich sind, müssen den Live-Verkauf blockieren, aber nicht die Entwicklung oder Stripe-Testbuchungen.
|
||||
|
||||
Prüfe vor der Implementierung die aktuellen offiziellen Dokumentationen der eingesetzten Frameworks und Stripe. Dokumentiere relevante Architekturentscheidungen knapp.
|
||||
|
||||
## 2. Referenz und gestalterische Richtung
|
||||
|
||||
Orientiere dich am Aufbau und an der Besucherführung dieser Seite:
|
||||
|
||||
https://www.freedomfilmmaker.de/filmcamp
|
||||
|
||||
Analysiere, soweit erreichbar:
|
||||
|
||||
- Reihenfolge und Dramaturgie der Inhalte.
|
||||
- Hero, Bildwirkung und typografische Hierarchie.
|
||||
- Darstellung von Unterkunft und Dozenten.
|
||||
- Video-Feedback und Vertrauenselemente.
|
||||
- Preispräsentation und Buchungseinstieg.
|
||||
- Mobile Nutzerführung.
|
||||
|
||||
Übertrage geeignete Prinzipien in eine eigenständige Gestaltung für ARTISTA. Kopiere keine fremden Texte, Markenbestandteile oder nicht freigegebenen Medien.
|
||||
|
||||
Wenn die Referenz nicht erreichbar ist, benenne diese Einschränkung und entwickle anhand dieses Briefings weiter. Behaupte nicht, die Seite analysiert zu haben.
|
||||
|
||||
Gestalterische Richtung:
|
||||
|
||||
- Hochwertiges, modernes Editorial-Design mit Mode- und Fotografiecharakter.
|
||||
- Großzügige Bilder, ausdrucksstarke Typografie und bewusst gesetzter Weißraum.
|
||||
- Warme, mediterrane Atmosphäre passend zu einer Villa in Italien.
|
||||
- Als vorläufige Farbwelt: warmes Off-White, dunkles Anthrazit und ein zurückhaltender warmer Akzent.
|
||||
- Markante, individuelle Kompositionen statt einer austauschbaren Ansammlung identischer Karten.
|
||||
- Gute Lesbarkeit und klare Buchungsführung haben Vorrang vor Effekten.
|
||||
- Dezente Animationen für Hero, Bildwechsel, Scroll-Reveal, Navigation und Buchungsdialog.
|
||||
- Keine Scroll-Übernahme und keine Animationen, die Inhalte verzögern oder Bedienung erschweren.
|
||||
- `prefers-reduced-motion` respektieren.
|
||||
|
||||
Die Seite muss auf Smartphones ebenso sorgfältig gestaltet sein wie auf großen Bildschirmen.
|
||||
|
||||
## 3. Technischer Rahmen
|
||||
|
||||
Bevorzugter Stack:
|
||||
|
||||
- TypeScript mit strikter Typprüfung.
|
||||
- Next.js 16 mit App Router.
|
||||
- React 19.
|
||||
- Tailwind CSS v4.
|
||||
- Motion for React.
|
||||
- PostgreSQL für persistente Geschäftsdaten.
|
||||
- Ein geeignetes ORM mit versionierten Migrationen.
|
||||
- Stripe für Zahlungen.
|
||||
- Serverseitige Eingabevalidierung, beispielsweise mit Zod.
|
||||
|
||||
Prüfe die Versionskompatibilität. Passe den Stack nur bei einem nachvollziehbaren Vorteil an. Fixiere konkrete kompatible Versionen und liefere eine Lockdatei.
|
||||
|
||||
Nutze Server Components, wo sinnvoll. Beschränke Client Components auf tatsächlich interaktive Bereiche.
|
||||
|
||||
Die Anwendung muss ohne Vercel und ohne proprietäre Hosting-Abhängigkeiten als Docker-Container laufen.
|
||||
|
||||
Für ein einzelnes Camp ist kein klassischer Warenkorb erforderlich. Bevorzuge eine direkte, verständliche Buchungsstrecke.
|
||||
|
||||
## 4. Seitenstruktur
|
||||
|
||||
Entwickle eine schlüssige Dramaturgie mit folgenden Inhalten:
|
||||
|
||||
### Hero
|
||||
|
||||
- ARTISTA MODELCAMP 2027 als eindeutige Identität.
|
||||
- Prägnante deutsche Headline und verständliche Subline.
|
||||
- Hochwertiges Leitmotiv mit austauschbarem Bild oder Video.
|
||||
- Termin und Ort, sobald bestätigt.
|
||||
- Klarer Buchungs-CTA.
|
||||
- Preis abhängig von der tatsächlich gültigen Verkaufsphase.
|
||||
|
||||
### Das Camp
|
||||
|
||||
Erkläre Atmosphäre, Zielgruppe und bestätigte Inhalte. Formuliere überzeugend, aber ohne erfundene Versprechen.
|
||||
|
||||
Keine Aussagen über garantierte Agenturvermittlung, Karriereerfolg, Aufträge oder Verdienst.
|
||||
|
||||
### Erlebnisse und Leistungen
|
||||
|
||||
Zeige bestätigte Programmpunkte und enthaltene Leistungen übersichtlich. Nicht bestätigte Workshops, Shootings, Transfers, Verpflegungsleistungen oder Ergebnisse dürfen nicht als zugesichert erscheinen.
|
||||
|
||||
Ein beispielhafter Ablauf muss als solcher erkennbar sein. Ein verbindliches Programm darf erst mit bestätigten Angaben veröffentlicht werden.
|
||||
|
||||
### Unterkunft
|
||||
|
||||
- Bildstarke Galerie der Villa beziehungsweise Finca.
|
||||
- Informationen zu Zimmern und Ausstattung nur aus bestätigten Angaben.
|
||||
- Der Booking-Link und die Bilder werden nachgereicht.
|
||||
- Bilder erst nach bestätigter Nutzungsberechtigung übernehmen.
|
||||
- Kein dauerhaftes Hotlinking fremder Booking-Bilder.
|
||||
- Bis dahin hochwertige, klar erkennbare Entwicklungsplatzhalter.
|
||||
|
||||
### Dozentinnen
|
||||
|
||||
Zwei vorbereitete Profile mit austauschbaren Porträts, Namen und Kurzbeschreibungen.
|
||||
|
||||
Die tatsächlichen Namen und Bilder werden nachgereicht. Keine erfundenen Lebensläufe oder Referenzen.
|
||||
|
||||
### Über ARTISTA
|
||||
|
||||
Bereite einen eigenständigen Bereich vor. Inhaltliche Grundlage soll die bestehende Stammmodel-Seite von ARTISTA sein; der Link wird nachgereicht.
|
||||
|
||||
Bis dahin keine Unternehmensgeschichte oder Erfolgszahlen erfinden.
|
||||
|
||||
### Video-Feedback
|
||||
|
||||
Exakt drei Slots für echte Feedback-Videos:
|
||||
|
||||
- Austauschbares Vorschaubild.
|
||||
- Zugänglicher Play-Button.
|
||||
- Video bei Bedarf laden.
|
||||
- Keine automatische Tonwiedergabe.
|
||||
- Platz für echten Namen und freigegebenen Begleittext.
|
||||
|
||||
Fehlende Videos nicht durch erfundene Testimonials ersetzen. Leere Slots im Entwicklungsmodus darstellen und im öffentlichen Produktionsbetrieb ausblenden.
|
||||
|
||||
### Preis und Buchung
|
||||
|
||||
- Verständlicher Gesamtpreis pro Person.
|
||||
- Gültiger Frühbuchervorteil.
|
||||
- Einmalzahlung und gegebenenfalls freigegebene Ratenzahlung.
|
||||
- Neutrales Gutscheinfeld.
|
||||
- Klare Zusammenfassung von Unterkunft und Leistungen vor Zahlung.
|
||||
|
||||
### FAQ und Abschluss
|
||||
|
||||
FAQ zu Buchung, Unterkunft, Anreise, Zahlungsweise und bestätigten Bedingungen. Abschließender CTA und Footer mit Kontakt und rechtlichen Verweisen.
|
||||
|
||||
Eine mobile Buchungsleiste ist erwünscht, sofern sie keine Inhalte oder Bedienelemente verdeckt.
|
||||
|
||||
## 5. Verbindliche Preislogik
|
||||
|
||||
Alle Beträge in EUR. Intern ausschließlich mit ganzzahligen Cent-Beträgen rechnen.
|
||||
|
||||
| Preisphase | Ohne Stammmodel-Code | Mit gültigem Stammmodel-Code |
|
||||
|---|---:|---:|
|
||||
| Early Bird | 2.200,00 € | 1.980,00 € |
|
||||
| Regulär | 2.750,00 € | 2.475,00 € |
|
||||
|
||||
Regeln:
|
||||
|
||||
- Grundpreis: 2.750,00 € pro Person.
|
||||
- Early Bird: 20 % Rabatt auf den Grundpreis.
|
||||
- Early-Bird-Zeitraum: 01.11.2026 bis einschließlich 31.12.2026, Zeitzone Europe/Berlin.
|
||||
- Regulärer Preis ab 01.01.2027, 00:00 Uhr Europe/Berlin.
|
||||
- Vor dem 01.11.2026 standardmäßig Vorschau ohne Kaufmöglichkeit.
|
||||
- Innerhalb der Early-Bird-Phase 2.750 € als eindeutig bezeichneten regulären Preis durchstreichen und 2.200 € hervorheben.
|
||||
- Nach Ablauf keine Early-Bird-Darstellung und keine künstliche Verlängerung.
|
||||
|
||||
Ein gültiger Stammmodel-Code reduziert den jeweils gültigen Preis anschließend um weitere 10 %:
|
||||
|
||||
- 2.750 € × 0,80 × 0,90 = 1.980 €.
|
||||
- Der kombinierte Nachlass beträgt 28 %, nicht 30 %.
|
||||
- Regulär: 2.750 € × 0,90 = 2.475 €.
|
||||
|
||||
Preise und Berechtigung immer serverseitig berechnen. Niemals vom Browser übermittelte Endpreise übernehmen.
|
||||
|
||||
Definiere eine eindeutige Regel für Preisbindung bei begonnenem Checkout und beim Jahreswechsel. Kommuniziere sie verständlich und erzwinge sie auch in Stripe.
|
||||
|
||||
Die steuerliche Behandlung ist noch offen. Keine Umsatzsteuerbeträge oder Aussagen wie „inklusive 19 % MwSt.“ erfinden. Vor Live-Verkauf muss die korrekte Darstellung konfiguriert sein.
|
||||
|
||||
## 6. Individuelle Stammmodel-Codes
|
||||
|
||||
Beispiel:
|
||||
|
||||
`STAMMMODELLAURA1X0`
|
||||
|
||||
Dieser Beispielcode darf nicht automatisch als aktiver Produktionscode angelegt werden.
|
||||
|
||||
Der Nachlass wird persönlich vergeben und nicht öffentlich beworben. Auf der Seite erscheint nur ein neutrales Feld „Gutscheincode“.
|
||||
|
||||
Im geschützten Verwaltungsbereich müssen individuelle Codes anlegbar und bearbeitbar sein:
|
||||
|
||||
- Code.
|
||||
- Aktiv/inaktiv.
|
||||
- Gültigkeitszeitraum.
|
||||
- Maximale Einlösungen.
|
||||
- Optional Bindung an eine E-Mail-Adresse.
|
||||
- Interne Zuordnung.
|
||||
- Reservierte und abgeschlossene Einlösungen.
|
||||
|
||||
Codes sollen mit Early Bird kombinierbar sein, aber nicht mit weiteren Gutscheinen.
|
||||
|
||||
Verhindere Missbrauch durch serverseitige Prüfung, Rate Limiting und transaktionale Einlöselimits. Reservierungen bei abgebrochenem oder abgelaufenem Checkout korrekt freigeben.
|
||||
|
||||
Die Vorlage empfiehlt höchstens vier vergünstigte Stammmodel-Buchungen. Implementiere ein konfigurierbares Gesamtkontingent und hinterlege vier als vorläufigen, vor Live-Verkauf zu bestätigenden Wert.
|
||||
|
||||
Keine öffentlich sichtbare Liste gültiger Codes.
|
||||
|
||||
## 7. Zahlungsvarianten
|
||||
|
||||
Bereite folgende exakten Zahlungspläne vor:
|
||||
|
||||
| Variante | Einmalzahlung | Zwei Raten | Drei Raten |
|
||||
|---|---:|---|---|
|
||||
| Early Bird | 2.200,00 € | 1.100,00 € + 1.100,00 € | 800,00 € + 700,00 € + 700,00 € |
|
||||
| Early Bird Stammmodel | 1.980,00 € | 990,00 € + 990,00 € | 660,00 € + 660,00 € + 660,00 € |
|
||||
| Regulär | 2.750,00 € | 1.375,00 € + 1.375,00 € | 950,00 € + 900,00 € + 900,00 € |
|
||||
| Regulär Stammmodel | 2.475,00 € | 1.237,50 € + 1.237,50 € | 825,00 € + 825,00 € + 825,00 € |
|
||||
|
||||
- Maximal drei Raten.
|
||||
- Erste Rate bei Buchung.
|
||||
- Letzte Rate spätestens 30 Tage vor Anreise.
|
||||
- Konkrete weitere Fälligkeiten benötigen einen bestätigten Camp-Termin und eine freigegebene Terminregel.
|
||||
- Keine Termine erfinden.
|
||||
- Wenn die verbleibende Zeit eine Zahlungsvariante nicht mehr zulässt, diese serverseitig deaktivieren.
|
||||
|
||||
Die Raten aus der Vorlage sind fachliche Vorgaben, keine rechtliche Freigabe. Zahlungspläne technisch vorbereiten und im Testmodus prüfen; im Live-Modus erst nach Prüfung und ausdrücklicher Freigabe aktivieren.
|
||||
|
||||
Wähle eine anhand aktueller Stripe-Dokumentation geeignete Umsetzung für endliche Zahlungspläne mit teilweise unterschiedlichen Ratenbeträgen. Begründe die Entscheidung.
|
||||
|
||||
Kein unbegrenzt laufendes Abonnement und keine Gleichsetzung mit Klarna oder anderen externen Finanzierungsangeboten.
|
||||
|
||||
Falls spätere Raten automatisch eingezogen werden:
|
||||
|
||||
- Erforderliche Zustimmung und geeignete Zahlungsmethoden berücksichtigen.
|
||||
- Zusätzliche Authentifizierung ermöglichen.
|
||||
- Fehlgeschlagene Zahlungen und Wiederholungsversuche sauber abbilden.
|
||||
- Nach der letzten Rate weitere Abbuchungen zuverlässig ausschließen.
|
||||
|
||||
Zeige Gesamtpreis, heute fälligen Betrag, restliche Raten und Fälligkeiten vor dem Zahlungsabschluss transparent an.
|
||||
|
||||
## 8. Unterkunft und Kapazität
|
||||
|
||||
Die vorliegenden Angaben zur Zimmerart sind widersprüchlich:
|
||||
|
||||
- Es wird von einem einzelnen Platz in einem geteilten Zimmer gesprochen.
|
||||
- Die interne Kalkulation nennt fünf Doppelzimmer mit französischem Doppelbett für zehn Teilnehmerinnen.
|
||||
|
||||
Erfinde daraus keine Einzelzimmer oder getrennten Einzelbetten.
|
||||
|
||||
Bis zur Klärung gilt:
|
||||
|
||||
- Nur ein Buchungsprodukt: Teilnahme pro Person mit geteilter Unterbringung.
|
||||
- Keine buchbare Einzelbelegung.
|
||||
- Keine Zimmer-Upgrades.
|
||||
- Keine Auswahl zwischen mehreren Unterkunftskategorien.
|
||||
- Ein möglicherweise geteiltes französisches Doppelbett muss vor einem echten Kauf ausdrücklich und korrekt beschrieben werden.
|
||||
- Live-Verkauf bleibt bis zur Bestätigung der Unterbringung gesperrt.
|
||||
|
||||
Intern vorläufig mit zehn verkaufbaren Teilnehmerinnenplätzen rechnen. Den Wert konfigurierbar halten und vor Veröffentlichung bestätigen lassen.
|
||||
|
||||
Keine öffentliche Platzzahl, keinen Restplatz-Zähler und keine künstliche Verknappung anzeigen.
|
||||
|
||||
Verhindere Überbuchungen durch Datenbanktransaktionen und zeitlich begrenzte Reservierungen. Berücksichtige parallele Checkouts, verzögerte Zahlungen und verspätete Webhooks.
|
||||
|
||||
## 9. Stripe und Bestellprozess
|
||||
|
||||
Bevorzuge Stripe Checkout, sofern es den bestätigten Ablauf vollständig abbildet. Verwende eine eingebettete Zahlungsoberfläche nur bei begründetem Mehrwert.
|
||||
|
||||
Ablauf:
|
||||
|
||||
1. Person startet Buchung auf der Onepage.
|
||||
2. Buchungsdaten und Zahlungsvariante werden erfasst.
|
||||
3. Optionaler Gutschein wird serverseitig geprüft.
|
||||
4. Gesamtpreis, Unterkunft, Leistungen und Zahlungsplan werden zusammengefasst.
|
||||
5. Erforderliche Erklärungen werden eingeholt.
|
||||
6. Bestellung und Platzreservierung werden serverseitig angelegt.
|
||||
7. Stripe-Zahlung wird gestartet.
|
||||
8. Verifizierte Webhooks aktualisieren den Zahlungsstatus.
|
||||
9. Bestätigungsseite und E-Mail zeigen den tatsächlich bestätigten Status.
|
||||
|
||||
Ein Aufruf der Erfolgsseite darf niemals allein eine Bestellung als bezahlt markieren.
|
||||
|
||||
Erforderlich sind:
|
||||
|
||||
- Prüfung der Stripe-Webhook-Signatur anhand des unveränderten Request-Bodys.
|
||||
- Idempotente Checkout-Erstellung und Webhook-Verarbeitung.
|
||||
- Umgang mit mehrfachen, verzögerten und ungeordneten Events.
|
||||
- Getrennte Buchungs- und Zahlungszustände.
|
||||
- Behandlung von Abbruch, Ablauf, Zahlungsfehlern und Rückerstattungen.
|
||||
- Nachvollziehbare Zuordnung von Stripe-Objekten zu internen Bestellungen.
|
||||
- Speicherung des bei Buchung vereinbarten Preises und Zahlungsplans als unveränderlicher Snapshot.
|
||||
- Keine Zahlungsdaten wie Kartennummern in der eigenen Datenbank.
|
||||
|
||||
Bei Ratenzahlung bedeutet eine erfolgreiche erste Rate nicht „vollständig bezahlt“. Zeige Teilzahlung und offenen Betrag korrekt an.
|
||||
|
||||
Die Buchungsbestätigung erfolgt per konfigurierbarem E-Mail-Anbieter oder SMTP. Im Entwicklungsmodus einen sicheren Testausgang verwenden.
|
||||
|
||||
## 10. Verwaltung und Datenmodell
|
||||
|
||||
Erstelle einen schlanken, wirklich geschützten Adminbereich für:
|
||||
|
||||
- Buchungsübersicht und Buchungsdetails.
|
||||
- Zahlungsstatus, offene Beträge und Fälligkeiten.
|
||||
- Filter und CSV-Export.
|
||||
- Gutscheinverwaltung.
|
||||
- Interne Kapazität und Reservierungen.
|
||||
- Pflege der notwendigen Camp-Konfiguration.
|
||||
- Dokumentierten Umgang mit Stornierungen und Rückerstattungen.
|
||||
|
||||
Kein offener Adminbereich und keine fest eingebauten Standardpasswörter. Nutze eine etablierte Authentifizierungslösung und prüfe Berechtigungen auch serverseitig bei jeder Aktion.
|
||||
|
||||
Modelliere mindestens:
|
||||
|
||||
- Camp und Verkaufsfreigaben.
|
||||
- Buchung und Teilnehmerdaten.
|
||||
- Preis-Snapshot.
|
||||
- Zahlungsplan und Raten.
|
||||
- Gutschein und Einlösung.
|
||||
- Platzreservierung.
|
||||
- Verarbeitete Stripe-Events.
|
||||
|
||||
Bestandsänderungen und Zahlungsaktionen müssen nachvollziehbar sein.
|
||||
|
||||
## 11. Inhalte und Freigaben
|
||||
|
||||
Die beigefügte Kalkulation enthält interne Informationen. Folgendes darf nicht auf die öffentliche Seite oder in öffentlich ausgelieferte Daten gelangen:
|
||||
|
||||
- Kosten der Villa und Verpflegung.
|
||||
- Margen, Gewinn und Break-even.
|
||||
- Werbebudget.
|
||||
- Teambelegung.
|
||||
- Interne Argumentation zu Stammmodel-Rabatten.
|
||||
- Interne Empfehlungen zu Kontingenten.
|
||||
|
||||
Laut Vorlage nicht enthalten:
|
||||
|
||||
- Anreise nach Italien und zurück.
|
||||
- Persönliche Ausgaben vor Ort.
|
||||
- Reiserücktrittsversicherung.
|
||||
|
||||
Diese Ausschlüsse müssen verständlich dargestellt werden. Leite aus internen Kostenpositionen keine öffentlich zugesicherten Leistungen ab.
|
||||
|
||||
Vor Live-Verkauf sind insbesondere erforderlich:
|
||||
|
||||
- Bestätigter Termin und Ort.
|
||||
- Veranstalter- und Kontaktdaten.
|
||||
- Bestätigte Unterkunft und enthaltene Leistungen.
|
||||
- Steuerlich korrekte Preisangaben.
|
||||
- Freigegebene Buchungs-, Storno- und Zahlungsbedingungen.
|
||||
- Prüfung der reiserechtlichen Einordnung sowie gegebenenfalls erforderlicher Absicherung und Unterlagen.
|
||||
|
||||
Übernimm rechtliche Aussagen aus der Kalkulation nicht ungeprüft als geltendes Recht. Erfinde keine rechtssicheren AGB.
|
||||
|
||||
Implementiere eine serverseitige Verkaufsfreigabe, die auch direkt aufgerufene Checkout-Endpunkte schützt. Testmodus und Vorschau müssen ohne Live-Freigabe nutzbar bleiben.
|
||||
|
||||
## 12. Qualität und Datenschutz
|
||||
|
||||
- Tastaturbedienbarkeit, sichtbare Fokuszustände und ausreichende Kontraste.
|
||||
- Beschriftete Formulare und verständliche Fehlermeldungen.
|
||||
- Korrektes Fokusmanagement in Dialogen.
|
||||
- Responsive Bilder und performantes Laden.
|
||||
- Keine unnötig großen Client-Bundles.
|
||||
- Keine personenbezogenen Daten oder Secrets in Logs.
|
||||
- Serverseitige Validierung aller Eingaben.
|
||||
- Schutz schreibender Endpunkte und administrative Zugriffskontrollen.
|
||||
- Keine nicht erforderlichen Tracking-Dienste ohne passende Einwilligung.
|
||||
- Externe Videoanbieter erst gemäß konfiguriertem Datenschutzkonzept laden.
|
||||
|
||||
Bereite Titel, Beschreibung, Canonical und Social-Sharing-Vorschau vor. Strukturierte Veranstaltungsdaten erst mit bestätigten Angaben ausgeben. Vorschau, Admin und Buchungsstatusseiten nicht indexieren.
|
||||
|
||||
## 13. Deployment auf RKE2
|
||||
|
||||
Liefere:
|
||||
|
||||
- Produktionsfähiges Multi-Stage-Dockerfile.
|
||||
- Next.js-Standalone-Build.
|
||||
- Ausführung als Nicht-Root.
|
||||
- `.dockerignore`.
|
||||
- `.env.example` ohne echte Secrets.
|
||||
- Lokale Entwicklungsumgebung mit PostgreSQL.
|
||||
- Kubernetes-Manifeste oder eine übersichtliche Helm-Struktur.
|
||||
- Deployment, Service und Traefik-Ingress.
|
||||
- Konfigurierbare Domain, IngressClass und TLS-Einstellungen.
|
||||
- cert-manager-Anbindung mit konfigurierbarem ClusterIssuer; `letsencrypt-prod` als anpassbares Beispiel.
|
||||
- Readiness- und Liveness-Probes.
|
||||
- Ressourcenanforderungen und sinnvolle Limits.
|
||||
- Separaten, kontrollierten Datenbank-Migrationsschritt.
|
||||
- Dokumentation für Secrets, Stripe-Webhooks und E-Mail-Konfiguration.
|
||||
|
||||
Die Anwendung muss mehrere Replikate vertragen. Bestellungen, Kapazitäten, Gutscheine, Reservierungen und Zahlungsjobs dürfen nicht ausschließlich im Prozessspeicher liegen.
|
||||
|
||||
Keine In-Process-Timer für verbindliche Ratenfälligkeiten. Verwende Stripe-native Terminierung oder einen dauerhaften, idempotenten Worker beziehungsweise Kubernetes-CronJob.
|
||||
|
||||
PostgreSQL braucht geeigneten Datenbankspeicher. SMB-Freigaben nicht ungeprüft als PostgreSQL-Datenverzeichnis verwenden. Medien separat persistent speichern; kein `emptyDir` für dauerhaft benötigte Uploads.
|
||||
|
||||
Dokumentiere Backup und Wiederherstellung der Geschäftsdaten.
|
||||
|
||||
## 14. Tests und Abnahme
|
||||
|
||||
Teste die tatsächlichen Geschäftsrisiken:
|
||||
|
||||
- Alle vier Gesamtpreise.
|
||||
- Exakte Ratensummen einschließlich 1.237,50 €.
|
||||
- Zeitgrenzen am 01.11.2026 und 01.01.2027 in Europe/Berlin.
|
||||
- Ungültige, abgelaufene und ausgeschöpfte Gutscheine.
|
||||
- Gleichzeitige Einlösung begrenzter Codes.
|
||||
- Gleichzeitige Buchungen bei knapper Kapazität.
|
||||
- Abgelaufene Reservierungen und verspätete Zahlung.
|
||||
- Doppelte und ungeordnete Stripe-Webhooks.
|
||||
- Zahlungserfolg, Abbruch, Fehler und Teilzahlung.
|
||||
- Sperre sämtlicher Live-Checkout-Zugänge ohne Freigabe.
|
||||
- Verhinderung weiterer Abbuchungen nach der letzten Rate.
|
||||
- Zugriffsschutz für Admin und Buchungsdaten.
|
||||
|
||||
Führe außerdem Build, Typprüfung und eine visuelle Prüfung auf Smartphone, Tablet und Desktop durch.
|
||||
|
||||
## 15. Erwartetes Ergebnis
|
||||
|
||||
Liefere eine fertig implementierte Anwendung mit:
|
||||
|
||||
1. Hochwertiger Onepage.
|
||||
2. Funktionierender Stripe-Testbuchung.
|
||||
3. Persistenter Bestell- und Gutscheinverwaltung.
|
||||
4. Geschütztem Adminbereich.
|
||||
5. Docker- und RKE2-Deployment.
|
||||
6. Verständlicher README.
|
||||
7. Kurzer Liste der noch benötigten Inhalte und Freigaben.
|
||||
8. Übersicht der ausgeführten Tests und verbleibenden Einschränkungen.
|
||||
|
||||
Pflege offene Inhalte zentral, insbesondere:
|
||||
|
||||
- Camp-Termin und genauer Ort.
|
||||
- Booking-Link und freigegebene Unterkunftsbilder.
|
||||
- Verbindliche Zimmer- und Bettbeschreibung.
|
||||
- Namen und Porträts der beiden Dozentinnen.
|
||||
- Link zur ARTISTA-Stammmodel-Seite.
|
||||
- Drei echte Feedback-Videos.
|
||||
- Bestätigte Leistungen und Programm.
|
||||
- Veranstalterdaten, steuerliche Angaben und freigegebene Bedingungen.
|
||||
|
||||
Beginne mit einer knappen Architektur- und Designentscheidung und setze anschließend die Anwendung um. Stoppe nicht nach einem Konzept oder einer statischen Oberfläche.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Lokale Entwicklungsumgebung.
|
||||
#
|
||||
# docker compose up -d db mailpit nur Datenbank + Test-Postfach
|
||||
# docker compose --profile app up -d zusaetzlich die gebaute Anwendung
|
||||
#
|
||||
# Mailpit faengt alle Mails ab (Weboberflaeche http://localhost:8025):
|
||||
# MAIL_TRANSPORT=smtp, SMTP_HOST=localhost, SMTP_PORT=1025.
|
||||
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
||||
services:
|
||||
db:
|
||||
image: postgres:18.1-alpine
|
||||
environment:
|
||||
POSTGRES_USER: artista
|
||||
POSTGRES_PASSWORD: artista-dev
|
||||
POSTGRES_DB: artista
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U artista"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.27
|
||||
ports:
|
||||
- "127.0.0.1:8025:8025"
|
||||
- "127.0.0.1:1025:1025"
|
||||
|
||||
migrate:
|
||||
profiles: ["app"]
|
||||
build: { context: ., target: tools }
|
||||
environment:
|
||||
DATABASE_URL: postgresql://artista:artista-dev@db:5432/artista
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
|
||||
app:
|
||||
profiles: ["app"]
|
||||
build: { context: ., target: runner }
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://artista:artista-dev@db:5432/artista
|
||||
SMTP_HOST: mailpit
|
||||
SMTP_PORT: "1025"
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
- ./media:/app/media:ro
|
||||
depends_on:
|
||||
migrate: { condition: service_completed_successfully }
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
@@ -0,0 +1,86 @@
|
||||
# Architektur- und Designentscheidungen
|
||||
|
||||
## Referenzseite
|
||||
|
||||
`https://www.freedomfilmmaker.de/filmcamp` war erreichbar. Ausgewertet wurde eine automatisch erzeugte Zusammenfassung des ausgelieferten HTML, keine vollständige visuelle Analyse. Laut dieser Zusammenfassung ist die Reihenfolge: Hero mit Termin, Ort, Preis und CTA, dann Galerie, Tagesablauf, Unterkunftsgalerie, Platzoptionen mit Preisen, Team-Profile, Abschluss-CTA und Footer mit Rechtlichem. Video-Testimonials waren dort nicht erkennbar. Übernommen wurden nur Prinzipien: Fakten schon im Hero, Unterkunft über Bilder statt Text, Team mit Gesicht, früher und wiederholter Buchungseinstieg, eine persistente Buchungsaktion auf Mobilgeräten. Texte, Marken und Medien wurden nicht übernommen.
|
||||
|
||||
## Gestaltung
|
||||
|
||||
- **Idee:** Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild, Termin, Ort und Preis stehen als Cover-Lines daneben. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
|
||||
- **Farbe:** Putz-Weiß `#F2ECE3`, Papier `#FAF7F1`, Anthrazit `#23211F`, Stein `#676058`, Linie `#D8CEC0`. Der Akzent ist Pompeji-Rot `#8E3B2B` statt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1.
|
||||
- **Schrift:** Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über `next/font` selbst ausgeliefert, es gibt keine Verbindung zu Google.
|
||||
- **Layout:** asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
|
||||
- **Bewegung:**
|
||||
- eine orchestrierte Hero-Eröffnung in reinem CSS
|
||||
- Clip-Reveal nur auf Bildern
|
||||
- Nav-Indikator
|
||||
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
|
||||
- `prefers-reduced-motion` wird überall respektiert, es gibt keine Scroll-Übernahme.
|
||||
- **Platzhalter:** Fehlende Bilder erscheinen als gestaltete Flächen im Mittelmeer-Ton. In Entwicklung und Staging tragen sie das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
|
||||
|
||||
## Stack und Laufzeit
|
||||
|
||||
- Next.js 16 App Router mit Server Components. Clientseitig sind nur Header, Buchungsdialog (lazy geladen), mobile Leiste, Galerie, Video-Slots und Statusaktionen.
|
||||
- Die Ausgabe ist `standalone` und läuft als Docker-Container unter Nicht-Root. Es gibt keine Vercel-Abhängigkeit.
|
||||
- Der Zustand liegt vollständig in PostgreSQL: Buchungen, Reservierungen, Gutscheine, Rate Limits, E-Mail-Ausgang und Stripe-Events. Die App ist dadurch zustandslos und kann mehrere Replikate haben.
|
||||
- Die Content-Security-Policy arbeitet mit Nonce und `strict-dynamic` (src/proxy.ts). Stripe Checkout ist gehostet, deshalb braucht die Seite kein Stripe.js.
|
||||
|
||||
## ADR-1: Stripe Checkout statt eingebetteter Zahlung
|
||||
|
||||
Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, das Speichern der Zahlungsmethode für Folgeraten und dynamische Zahlungsarten. Kartendaten berühren die Anwendung nie, und die CSP bleibt streng. Eine eingebettete Oberfläche hätte keinen Mehrwert gebracht, der den zusätzlichen Integrations- und Sicherheitsaufwand rechtfertigt.
|
||||
|
||||
## ADR-2: Preisbindung
|
||||
|
||||
**Regel:** Maßgeblich ist der Zeitpunkt des Buchungsstarts. Der dabei serverseitig berechnete Preis gilt, wenn die Zahlung innerhalb der Reservierungszeit abgeschlossen wird (30 Minuten, konfigurierbar, Minimum laut Stripe). Danach verfällt die Reservierung, ein neuer Versuch wird zum dann gültigen Preis berechnet.
|
||||
|
||||
**Erzwingung:**
|
||||
- `expires_at` der Checkout-Session entspricht `priceLockedUntil`, danach ist die Session bei Stripe nicht mehr bezahlbar.
|
||||
- Der Platz bleibt 10 Minuten länger reserviert als die Session läuft, damit eine kurz vor Ablauf abgeschlossene Zahlung ihren Platz sicher behält.
|
||||
- Beim Jahreswechsel gilt: Wer am 31.12. um 23:50 Uhr startet, zahlt Early Bird, sofern die Zahlung bis zum angezeigten Ablauf gelingt. Eine Verlängerung gibt es nicht.
|
||||
- Kommuniziert wird die Regel im Dialog, in der FAQ und im Stripe-Checkout-Text.
|
||||
|
||||
## ADR-3: Endliche Ratenpläne
|
||||
|
||||
**Gewählt:** Die erste Rate läuft über Stripe Checkout mit `setup_future_usage=off_session`. Die Folgeraten sind einzelne Off-Session-PaymentIntents, die ein idempotenter Kubernetes-CronJob am Fälligkeitstag um 9:00 Uhr Berliner Zeit auslöst.
|
||||
|
||||
**Verworfen: Subscription Schedules.** Sie sind laut Stripe-Doku das Standardmuster für Ratenzahlung, rechnen aber in festen Intervallen (z. B. monatlich). Die geforderte Regel „letzte Rate spätestens 30 Tage vor Anreise“ und eine vom Buchungstag abhängige Mitte lassen sich damit nicht sauber abbilden. Außerdem wäre es formal ein Abonnement.
|
||||
|
||||
**Gründe für den gewählten Weg:**
|
||||
- Die Raten sind exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Beträge wie 800 + 700 + 700 € sind direkt abbildbar.
|
||||
- Endlichkeit ist dreifach gesichert: Es gibt maximal drei Ratenzeilen, jeder Einzug prüft `paid + Rate ≤ Gesamt`, und nach der letzten Rate wird die Zahlungsmethode bei Stripe getrennt.
|
||||
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
|
||||
- Verlangt die Bank eine Authentifizierung, geht die Rate auf `REQUIRES_ACTION`, und die Kundin bekommt per E-Mail einen Link und zahlt über eine eigene Checkout-Session.
|
||||
- Bei Fehlschlag gibt es bis zu drei Versuche im Abstand von drei Tagen, danach „Prüfung nötig“ im Admin.
|
||||
- Idempotenzschlüssel `offsession:<rate>:<versuch>` verhindern Doppelabbuchungen, auch nach Netzwerkfehlern oder Worker-Abbrüchen.
|
||||
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
|
||||
|
||||
**Aufteilung:** Ist der Betrag ohne Centrest teilbar, sind alle Raten gleich. Sonst werden die Folgeraten auf volle 50 € abgerundet, der Rest fällt in Rate 1. Das reproduziert alle zwölf Vorgabewerte (Tests).
|
||||
|
||||
**Termine:** Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
|
||||
|
||||
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
|
||||
|
||||
- Jede platzbelegende Transaktion sperrt zuerst die Camp-Zeile (`SELECT … FOR UPDATE`), dann gegebenenfalls die Gutscheinzeile. Durch die feste Reihenfolge entstehen keine Deadlocks. Zwei Personen bekommen nie denselben letzten Platz oder dieselbe letzte Einlösung (Tests mit 8 bzw. 7 parallelen Buchungen).
|
||||
- Belegt sind CONVERTED- und HELD-Reservierungen (verzögerte Zahlung wie SEPA) sowie ACTIVE-Reservierungen, die noch nicht abgelaufen sind. Ein abgelaufener Checkout gibt Platz und Gutschein automatisch frei.
|
||||
- Test- und Livebuchungen werden getrennt gezählt (`livemode`).
|
||||
- Öffentlich sind keine Platzzahl, kein Restplatz-Zähler und keine Liste gültiger Codes sichtbar. Abgelehnte Codes bekommen immer dieselbe neutrale Meldung.
|
||||
|
||||
## ADR-5: Webhooks und Zustände
|
||||
|
||||
- Die Signatur wird gegen den unveränderten Body geprüft (`request.text()`). Die Event-ID wird in `StripeEvent` gespeichert, wodurch jedes Event genau einmal verarbeitet wird.
|
||||
- Buchungsstatus und Zahlungsstatus sind getrennt. Die Übergänge sind monoton und laufen unter Buchungssperre, so dass ein spätes „expired“ keine bezahlte Buchung aufhebt.
|
||||
- Ist nach einer verspäteten Zahlung der Platz inzwischen vergeben, wird nicht überbucht. Die Buchung geht auf „Prüfung nötig“, und Admin und Kundin werden informiert.
|
||||
- Die Erfolgsseite zeigt nur den Datenbankstand. Bezahlt markieren darf nur der Webhook oder der Worker-Abgleich, der Stripe selbst abfragt.
|
||||
- E-Mails laufen über einen transaktionalen Postausgang: gleiche Transaktion wie die Statusänderung, Versand danach, Wiederholung durch den Worker.
|
||||
|
||||
## ADR-6: Verkaufsfreigabe
|
||||
|
||||
`src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen.
|
||||
|
||||
- **Livemodus (`sk_live_`):** Verkauf nur mit laufender echter Preisphase, eingeschaltetem Live-Verkauf und allen elf Bestätigungen.
|
||||
- **Testmodus:** Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
|
||||
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
|
||||
|
||||
## ADR-7: Adminbereich
|
||||
|
||||
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Betrieb auf RKE2
|
||||
|
||||
## 1. Images bauen
|
||||
|
||||
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“ beide Images und pusht sie in die Gitea-Registry:
|
||||
|
||||
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
||||
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
||||
|
||||
Einmalig einrichten:
|
||||
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
||||
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain)
|
||||
|
||||
Ein Release entsteht dann so: `git tag v0.1.0 && git push origin v0.1.0`, anschließend den Tag im Overlay setzen.
|
||||
|
||||
**Manuell:**
|
||||
|
||||
```bash
|
||||
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-filmcamp:0.1.0 .
|
||||
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0 .
|
||||
docker push … # beide Images, immer mit identischem Tag
|
||||
```
|
||||
|
||||
Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber weder Datenbank noch Secrets.
|
||||
|
||||
## 2. Einmalig einrichten
|
||||
|
||||
1. **Overlay anpassen** (`k8s/overlays/production/`): Image-Namen und Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name.
|
||||
2. **StorageClasses:**
|
||||
- PostgreSQL bekommt Blockspeicher mit ReadWriteOnce, auf keinen Fall eine SMB-Freigabe (Begründung in `k8s/base/postgres.yaml`).
|
||||
- Das Medienvolume braucht ReadWriteMany, sonst `replicas: 1`.
|
||||
- Das Backup-Volume darf NFS oder SMB sein.
|
||||
3. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator.
|
||||
4. **Anwenden:** `kubectl apply -k k8s/overlays/production`
|
||||
5. **Migration** (siehe Abschnitt 3), danach das erste Admin-Konto:
|
||||
```bash
|
||||
kubectl -n artista run admin-create --rm -it --restart=Never --labels=artista/db-client=true \
|
||||
--image=git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0 \
|
||||
--overrides='{"spec":{"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-filmcamp-tools:0.1.0","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-config"}},{"secretRef":{"name":"artista-secrets"}}]}]}}'
|
||||
```
|
||||
Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet.
|
||||
|
||||
## 3. Update ausrollen (kontrollierter Migrationsschritt)
|
||||
|
||||
```bash
|
||||
# 1. Neuen Tag im Overlay setzen, anwenden (App rollt noch NICHT aus, solange Tag gleich bleibt)
|
||||
kubectl apply -k k8s/overlays/production
|
||||
# 2. Migration bewusst starten und abwarten
|
||||
JOB=migrate-$(date +%Y%m%d%H%M)
|
||||
kubectl -n artista create job $JOB --from=cronjob/artista-migrate
|
||||
kubectl -n artista wait --for=condition=complete job/$JOB --timeout=5m
|
||||
# 3. Rollout beobachten
|
||||
kubectl -n artista rollout status deploy/artista-app
|
||||
```
|
||||
|
||||
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
|
||||
|
||||
## 4. Stripe
|
||||
|
||||
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
|
||||
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
|
||||
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
|
||||
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
|
||||
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
|
||||
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
|
||||
|
||||
## 5. E-Mail
|
||||
|
||||
`MAIL_TRANSPORT=smtp` mit `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER` und `SMTP_PASSWORD`. Den Absender (`MAIL_FROM`) per SPF, DKIM und DMARC für die Absenderdomain freischalten. `ADMIN_NOTIFY_EMAIL` erhält Hinweise zu „Prüfung nötig“. Nicht zustellbare Mails zeigt das Admin-Dashboard. Der Worker versucht jede Mail bis zu achtmal.
|
||||
|
||||
## 6. Worker
|
||||
|
||||
Der CronJob `artista-worker` läuft alle 10 Minuten und kümmert sich um:
|
||||
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
||||
- hängende Einzüge
|
||||
- fällige Raten
|
||||
- das Trennen der Zahlungsmethode nach der letzten Rate
|
||||
- den E-Mail-Versand
|
||||
- das Aufräumen der Rate Limits
|
||||
|
||||
Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artista get jobs`).
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
- **Sicherung:** Der CronJob `artista-db-backup` erstellt täglich um 3:15 Uhr einen Dump im `pg_dump`-Custom-Format nach `artista-db-backup` und bewahrt ihn 30 Tage auf.
|
||||
- **Aus dem Cluster sichern (regelmäßig):**
|
||||
```bash
|
||||
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-postgres -o name | head -1)
|
||||
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
||||
```
|
||||
- **Wiederherstellen:**
|
||||
1. App stoppen: `kubectl -n artista scale deploy/artista-app --replicas=0` und den Worker-CronJob pausieren: `kubectl -n artista patch cronjob artista-worker -p '{"spec":{"suspend":true}}'`
|
||||
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
||||
3. Migration laufen lassen (Abschnitt 3), App wieder hochskalieren, Worker fortsetzen.
|
||||
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
|
||||
- **Medien:** Das Volume `artista-media` separat sichern, z. B. mit Volume-Snapshots oder der Sicherung des Speichersystems.
|
||||
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
|
||||
|
||||
## 8. Stornierung und Erstattung
|
||||
|
||||
Die Software trifft keine Storno-Entscheidung, sie setzt sie nur nachvollziehbar um.
|
||||
|
||||
1. **Stornieren** (Admin → Buchung → „Stornieren“, Grund ist Pflicht): Offene Raten werden beendet, Platz und Gutschein freigegeben, die gespeicherte Zahlungsmethode getrennt und offene Checkout-Sessions geschlossen. Danach wird nichts mehr abgebucht.
|
||||
2. **Erstatten** (je bezahlter Rate, Betrag frei wählbar, höchstens bis zum gezahlten Betrag): Welcher Betrag erstattet wird, bestimmen die freigegebenen Stornobedingungen. Die Erstattung wird bei Stripe ausgelöst, der Zahlungsstatus aktualisiert sich per Webhook (`charge.refunded`). Ein doppeltes Abschicken wird über einen Idempotenzschlüssel abgefangen.
|
||||
3. **Prüfung nötig** entsteht bei verspäteter Zahlung ohne freien Platz, Doppelzahlung, Rückbuchung oder endgültig fehlgeschlagener Rate. Dann entweder „Buchung bestätigen“ (nur mit freiem Platz, sonst vorher Kapazität erhöhen) oder stornieren und erstatten.
|
||||
4. **Dokumentation:** Alle Schritte stehen im Protokoll der Buchung (AuditLog) mit handelnder Person.
|
||||
|
||||
## 9. Medien
|
||||
|
||||
Dateien (jpg, png, webp, avif, mp4, webm) in das Volume `artista-media` kopieren:
|
||||
|
||||
```bash
|
||||
kubectl -n artista cp villa-aussen.jpg <app-pod>:/app/media/villa-aussen.jpg # Volume ist im App-Pod read-only → einen Hilfs-Pod mit Schreibzugriff nutzen
|
||||
```
|
||||
|
||||
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder, etwa von Booking.com, ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Noch benötigte Inhalte und Freigaben
|
||||
|
||||
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
|
||||
|
||||
## Inhalte
|
||||
|
||||
| Punkt | Wo eintragen |
|
||||
|---|---|
|
||||
| Camp-Termin (Anreise/Abreise) **(Freigabe)** | Admin → Einstellungen |
|
||||
| Genauer Ort und Region **(Freigabe)** | Admin → Einstellungen |
|
||||
| Verbindliche Zimmer- und Bettbeschreibung, insbesondere ob ein französisches Doppelbett geteilt wird **(Freigabe)** | Admin → Einstellungen |
|
||||
| Booking-Link der Unterkunft | `src/content/site.ts` → `accommodation.bookingUrl` |
|
||||
| Unterkunftsbilder mit geklärter Nutzungsberechtigung | Medienvolume + `site.ts` |
|
||||
| Namen, Rollen, Kurzbeschreibungen und Porträts der beiden Dozentinnen | `site.ts` → `teachers` |
|
||||
| Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` |
|
||||
| Drei echte, freigegebene Feedback-Videos mit Namen und Begleittext | Medienvolume + `site.ts` → `videos` |
|
||||
| Hero-Leitbild oder -Video | `site.ts` → `hero.media` |
|
||||
| Bestätigte Leistungen (Workshops, Verpflegung, Programm) **(Freigabe)** | `site.ts` → `services`, danach Admin „Leistungen bestätigt“ |
|
||||
| Verbindliches Programm (bis dahin nur als Beispiel gekennzeichnet) | `site.ts` → `exampleSchedule` |
|
||||
| Veranstalter- und Kontaktdaten **(Freigabe)** | Admin → Einstellungen |
|
||||
| Freigabe der Entwurfstexte (Headline, Camp-Text, Zielgruppe) | `site.ts` |
|
||||
|
||||
## Recht, Steuer, Zahlung
|
||||
|
||||
| Punkt | Hinweis |
|
||||
|---|---|
|
||||
| Steuerliche Behandlung und korrekte Preisangabe **(Freigabe)** | Es wird bewusst keine USt-Aussage angezeigt, bis der Hinweis eingetragen und bestätigt ist |
|
||||
| Buchungs-, Storno- und Zahlungsbedingungen **(Freigabe)** | Als Dokument oder Seite hinterlegen (Link und Version im Admin). Es wurden keine AGB formuliert |
|
||||
| Reiserechtliche Einordnung (Pauschalreise?) samt gegebenenfalls nötiger Insolvenzabsicherung und Formblätter **(Freigabe)** | Rechtliche Aussagen aus der Kalkulation wurden nicht übernommen |
|
||||
| Vollständiges Impressum und Datenschutzerklärung | Technische Grundlage siehe `/datenschutz` (nur in Entwicklung sichtbar) |
|
||||
| Verkaufbare Platzzahl (vorläufig 10) **(Freigabe)** | Admin → Einstellungen |
|
||||
| Kontingent Stammmodel-Buchungen (vorläufig 4) **(Freigabe)** | Admin → Einstellungen |
|
||||
| Ratenzahlung: rechtliche Prüfung, Terminregel, Freigabe für den Livemodus | Admin → Einstellungen (zwei Häkchen) |
|
||||
| Teilnahme Minderjähriger | Die Buchung verlangt derzeit die Erklärung „Ich bin volljährig“. Bitte bestätigen oder die Regel vorgeben |
|
||||
|
||||
## Betrieb
|
||||
|
||||
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
|
||||
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
|
||||
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
|
||||
- Wiederherstellung aus der Sicherung einmal geübt.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Tests und Abnahme
|
||||
|
||||
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden.
|
||||
|
||||
## Automatisierte Tests
|
||||
|
||||
`pnpm test` umfasst 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
||||
|
||||
| Geschäftsrisiko | Datei |
|
||||
|---|---|
|
||||
| Alle vier Gesamtpreise, 28 % statt 30 % | `tests/pricing.test.ts` |
|
||||
| Alle zwölf Ratenbeträge exakt, einschließlich 1.237,50 € | `tests/payment-plan.test.ts` |
|
||||
| Zeitgrenzen 01.11.2026 und 01.01.2027 in Europe/Berlin, Sommerzeit | `tests/pricing.test.ts`, `booking.integration.test.ts` |
|
||||
| Ungültige, inaktive, abgelaufene, noch nicht gültige, fremd gebundene und ausgeschöpfte Gutscheine | `booking.integration.test.ts` |
|
||||
| Gleichzeitige Einlösung eines begrenzten Codes (7 parallel, 2 erlaubt) und Gesamtkontingent | `booking.integration.test.ts` |
|
||||
| Gleichzeitige Buchungen bei knapper Kapazität (8 parallel, 2 Plätze) | `booking.integration.test.ts` |
|
||||
| Abgelaufene Reservierungen, verzögerte Zahlung (SEPA), verspätete Zahlung mit und ohne freien Platz, verlorener Webhook | `payments.integration.test.ts` |
|
||||
| Doppelte und ungeordnete Stripe-Events, Livemodus-Event im Testbetrieb, Signaturprüfung | `payments.integration.test.ts` |
|
||||
| Zahlungserfolg, Abbruch, Fehler, Teilzahlung, Authentifizierung, Erstattung | `payments.integration.test.ts`, `admin.integration.test.ts` |
|
||||
| Sperre aller Live-Checkout-Zugänge ohne Freigabe, Vorschauphase | `sales-gate.test.ts`, `booking.integration.test.ts` |
|
||||
| Keine weitere Abbuchung nach der letzten Rate oder nach Stornierung, Zahlungsmethode getrennt | `payments.integration.test.ts`, `admin.integration.test.ts` |
|
||||
| Unveränderlicher Preis-Snapshot (DB-Trigger), Idempotenz bei Doppelklick | `booking.integration.test.ts` |
|
||||
|
||||
## End-to-End und Sicht
|
||||
|
||||
`pnpm test:e2e` umfasst 9 Tests auf den Profilen Smartphone (Pixel 7), Tablet (820 × 1180) und Desktop (1440 × 900):
|
||||
|
||||
- axe-Prüfung WCAG 2.1 AA ohne Verstöße
|
||||
- kein horizontales Scrollen
|
||||
- keine öffentliche Platzzahl
|
||||
- Buchungsdialog: Pflichtfelder, Gutschein, Ratenplan 3 × 660 €, Erklärungen, Fokus beim Öffnen und Schließen per Escape
|
||||
- Admin-Weiterleitung, Export mit gefälschtem Cookie ergibt 401, Statusseite mit `noindex`
|
||||
|
||||
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
||||
|
||||
## Manuell über HTTP geprüft
|
||||
|
||||
- Admin-Ablauf:
|
||||
- Öffentliche Registrierung wird mit 400 abgelehnt, ein falsches Passwort mit 401.
|
||||
- Ohne 2FA leitet `/admin` zur Einrichtung um, der Export antwortet 401.
|
||||
- Nach der TOTP-Aktivierung sind Dashboard und CSV mit 200 erreichbar.
|
||||
- CSRF: Anfragen ohne JSON bekommen 415, fremde Origins 403.
|
||||
- Preismanipulation: Ein mitgeschicktes `totalCents` wird ignoriert.
|
||||
- Sicherheitsheader (CSP mit Nonce, HSTS, X-Frame-Options) sind gesetzt.
|
||||
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
||||
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
||||
|
||||
## Nicht ausgeführt / Einschränkungen
|
||||
|
||||
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Off-Session-Ergebnisse), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
|
||||
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste.
|
||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
||||
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
||||
@@ -0,0 +1,77 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Voraussetzung: Stripe-Testschluessel und `pnpm db:seed` (simulierte
|
||||
* Early-Bird-Phase, Testgutschein TEST-STAMMMODEL). Die Zahlung selbst wird
|
||||
* nicht ausgeloest — der Test endet vor der Weiterleitung zu Stripe.
|
||||
*/
|
||||
|
||||
test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA");
|
||||
for (const id of ["camp", "leistungen", "unterkunft", "dozentinnen", "artista", "preis", "fragen"]) {
|
||||
await expect(page.locator(`#${id}`)).toBeAttached();
|
||||
}
|
||||
// Keine oeffentliche Platzzahl und kein Restplatz-Zaehler.
|
||||
await expect(page.locator("body")).not.toContainText(/Restpl|noch \d+ Pl|Plätze frei/);
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
|
||||
await page.waitForTimeout(1600); // Hero-Eroeffnung abwarten
|
||||
await page.screenshot({ path: testInfo.outputPath(`onepage-${testInfo.project.name}.png`), fullPage: true });
|
||||
|
||||
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
|
||||
expect(results.violations.map((v) => `${v.id}: ${v.nodes.length}`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Buchungsdialog: Daten, Gutschein, Ratenplan, Erklaerungen, Fokus", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
const trigger = page.locator("#top").getByRole("button", { name: "Platz buchen" });
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("heading", { name: "Deine Daten" })).toBeFocused();
|
||||
|
||||
// Pflichtfelder werden verstaendlich gemeldet.
|
||||
await dialog.getByRole("button", { name: "Weiter" }).click();
|
||||
await expect(dialog.getByText("Bitte gib deinen Vornamen an.")).toBeVisible();
|
||||
|
||||
await dialog.getByLabel("Vorname").fill("Test");
|
||||
await dialog.getByLabel("Nachname").fill("Person");
|
||||
await dialog.getByLabel("E-Mail").fill("[email protected]");
|
||||
await dialog.getByLabel("Telefon").fill("+49 30 123456");
|
||||
await dialog.getByRole("button", { name: "Weiter" }).click();
|
||||
|
||||
await expect(dialog.getByRole("heading", { name: "Zahlungsweise" })).toBeFocused();
|
||||
await dialog.getByRole("radio", { name: /Drei Raten/ }).check();
|
||||
await dialog.getByLabel("Gutscheincode (optional)").fill("test-stammmodel");
|
||||
await dialog.getByRole("button", { name: "Anwenden" }).click();
|
||||
await expect(dialog.getByText("Gutschein angewendet.")).toBeVisible();
|
||||
await expect(dialog.getByText("1.980,00 €").first()).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Weiter" }).click();
|
||||
await expect(dialog.getByText("Heute fällig: 660,00 €", { exact: false })).toBeVisible();
|
||||
await expect(dialog.getByText("Nicht enthalten:", { exact: false })).toBeVisible();
|
||||
|
||||
// Ohne Erklaerungen keine Buchung.
|
||||
await dialog.getByRole("button", { name: "Zahlungspflichtig buchen" }).click();
|
||||
await expect(dialog.getByText("Bitte stimme dem Einzug der weiteren Raten zu.")).toBeVisible();
|
||||
await dialog.screenshot({ path: testInfo.outputPath(`dialog-${testInfo.project.name}.png`) });
|
||||
|
||||
// Escape schliesst, Fokus kehrt zum ausloesenden Button zurueck.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("Verwaltung und Buchungsstatus sind geschuetzt und nicht indexierbar", async ({ page, request }) => {
|
||||
await page.goto("/admin/buchungen");
|
||||
await expect(page).toHaveURL(/\/admin\/anmelden$/);
|
||||
const exportResponse = await request.get("/admin/export", { maxRedirects: 0, headers: { cookie: "better-auth.session_token=gefaelscht" } });
|
||||
expect(exportResponse.status()).toBe(401);
|
||||
const status = await request.get("/buchung/status/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
expect(status.status()).toBe(404);
|
||||
expect(status.headers()["x-robots-tag"]).toContain("noindex");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "src/generated/**"]),
|
||||
]);
|
||||
@@ -0,0 +1,129 @@
|
||||
# Next.js-Anwendung. Zustandslos: Buchungen, Kapazitaet, Gutscheine,
|
||||
# Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in
|
||||
# PostgreSQL — beliebig viele Replikate moeglich.
|
||||
#
|
||||
# Migrationen laufen NICHT beim Start, sondern als eigener, kontrollierter
|
||||
# Schritt (jobs.yaml, CronJob "artista-migrate", siehe docs/BETRIEB.md).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: artista-app
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-app
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: artista-app
|
||||
artista/db-client: "true"
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-app
|
||||
containers:
|
||||
- name: app
|
||||
image: artista-modelcamp
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef: { name: artista-config }
|
||||
- secretRef: { name: artista-secrets }
|
||||
readinessProbe:
|
||||
httpGet: { path: /api/health/ready, port: http }
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
livenessProbe:
|
||||
# Bewusst ohne Datenbank: eine DB-Stoerung soll nicht zu
|
||||
# Neustartschleifen fuehren.
|
||||
httpGet: { path: /api/health, port: http }
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests: { cpu: 200m, memory: 256Mi }
|
||||
limits: { memory: 768Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: { drop: ["ALL"] }
|
||||
volumeMounts:
|
||||
- name: media
|
||||
mountPath: /app/media
|
||||
readOnly: true
|
||||
# Reiner Zwischenspeicher fuer optimierte Bilder: fluechtig ok.
|
||||
- name: next-cache
|
||||
mountPath: /app/.next/cache
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: media
|
||||
persistentVolumeClaim: { claimName: artista-media }
|
||||
- name: next-cache
|
||||
emptyDir: { sizeLimit: 512Mi }
|
||||
- name: tmp
|
||||
emptyDir: { sizeLimit: 64Mi }
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: artista-app
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: artista-app
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
---
|
||||
# Die App nimmt Verbindungen nur vom Ingress-Controller an. Ohne diese Regel
|
||||
# koennte jeder Pod im Cluster den Service direkt ansprechen und per
|
||||
# gefaelschtem X-Real-Ip-Header die Rate Limits (inkl. Admin-Login/2FA)
|
||||
# umgehen. Namespace des Traefik-Controllers anpassen
|
||||
# ("kubectl get pods -A | grep traefik").
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: artista-app-ingress
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-app
|
||||
policyTypes: ["Ingress"]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik
|
||||
ports:
|
||||
- port: 3000
|
||||
# Kubelet-Probes kommen vom Knoten und sind von NetworkPolicies je nach
|
||||
# CNI (Canal/Calico in RKE2) ausgenommen.
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: artista-app
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-app
|
||||
@@ -0,0 +1,61 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: artista
|
||||
labels:
|
||||
# Pod Security Admission: nur eingeschraenkte (non-root) Pods.
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
---
|
||||
# Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: artista-config
|
||||
data:
|
||||
APP_ENV: "production"
|
||||
APP_URL: "https://modelcamp.example.org"
|
||||
DATABASE_POOL_SIZE: "10"
|
||||
MAIL_TRANSPORT: "smtp"
|
||||
MAIL_FROM: "ARTISTA MODELCAMP <[email protected]>"
|
||||
ADMIN_NOTIFY_EMAIL: ""
|
||||
SMTP_PORT: "587"
|
||||
SMTP_SECURE: "false"
|
||||
MEDIA_DIR: "/app/media"
|
||||
EXTERNAL_VIDEO_PROVIDER: "none"
|
||||
---
|
||||
# Freigegebene Bilder und Videos (Unterkunft, Portraets, Feedback-Videos).
|
||||
# Dauerhaft, deshalb ein PersistentVolumeClaim und KEIN emptyDir.
|
||||
#
|
||||
# ReadWriteMany, weil mehrere App-Replikate dasselbe Verzeichnis lesen.
|
||||
# Geeignet: z. B. Longhorn (RWX), NFS oder eine SMB/CSI-Freigabe — fuer
|
||||
# Medien ist ein Netzwerkdateisystem unkritisch (anders als fuer PostgreSQL).
|
||||
# Steht nur ReadWriteOnce zur Verfuegung: replicas im Deployment auf 1 setzen.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: artista-media
|
||||
spec:
|
||||
accessModes: ["ReadWriteMany"]
|
||||
# storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
# Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar
|
||||
# (App, Migration, Worker, Sicherung).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: artista-postgres-access
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-postgres
|
||||
policyTypes: ["Ingress"]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
artista/db-client: "true"
|
||||
ports:
|
||||
- port: 5432
|
||||
@@ -0,0 +1,159 @@
|
||||
# --- Migration ----------------------------------------------------------------
|
||||
# Als SUSPENDIERTER CronJob definiert, damit Kustomize das Image mit der
|
||||
# App gleichzieht. Er laeuft nie von selbst, sondern bewusst vor jedem
|
||||
# Rollout:
|
||||
# kubectl -n artista create job migrate-$(date +%Y%m%d%H%M) --from=cronjob/artista-migrate
|
||||
# kubectl -n artista wait --for=condition=complete job/migrate-… --timeout=5m
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: artista-migrate
|
||||
spec:
|
||||
suspend: true
|
||||
schedule: "0 0 1 1 *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
artista/db-client: "true"
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: migrate
|
||||
image: artista-modelcamp-tools
|
||||
command: ["prisma", "migrate", "deploy"]
|
||||
envFrom:
|
||||
- secretRef: { name: artista-secrets }
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 128Mi }
|
||||
limits: { memory: 512Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
---
|
||||
# --- Worker -------------------------------------------------------------------
|
||||
# Gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe), zieht
|
||||
# faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate,
|
||||
# versendet den E-Mail-Ausgang. Idempotent; Forbid verhindert
|
||||
# ueberlappende Laeufe zusaetzlich.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: artista-worker
|
||||
spec:
|
||||
schedule: "*/10 * * * *"
|
||||
timeZone: "Europe/Berlin"
|
||||
concurrencyPolicy: Forbid
|
||||
startingDeadlineSeconds: 300
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 5
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 900
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
artista/db-client: "true"
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: worker
|
||||
image: artista-modelcamp-tools
|
||||
command: ["tsx", "scripts/worker.ts"]
|
||||
envFrom:
|
||||
- configMapRef: { name: artista-config }
|
||||
- secretRef: { name: artista-secrets }
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 192Mi }
|
||||
limits: { memory: 512Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
---
|
||||
# --- Sicherung ----------------------------------------------------------------
|
||||
# Taeglicher pg_dump mit 30 Tagen Aufbewahrung. Das Backup-Volume darf ein
|
||||
# Netzwerkspeicher (NFS/SMB) sein. Zusaetzlich regelmaessig aus dem Cluster
|
||||
# kopieren (docs/BETRIEB.md, "Sicherung und Wiederherstellung").
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: artista-db-backup
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
# storageClassName: smb-backup
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: artista-db-backup
|
||||
spec:
|
||||
schedule: "15 3 * * *"
|
||||
timeZone: "Europe/Berlin"
|
||||
concurrencyPolicy: Forbid
|
||||
failedJobsHistoryLimit: 5
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
artista/db-client: "true"
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 70
|
||||
runAsGroup: 70
|
||||
fsGroup: 70
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: pg-dump
|
||||
# Gleiche Hauptversion wie der Server.
|
||||
image: postgres:18.1-alpine
|
||||
env:
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: artista-secrets, key: DB_PASSWORD }
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eu
|
||||
set -o pipefail
|
||||
target="/backup/artista-$(date -u +%Y-%m-%d_%H%M).dump"
|
||||
# Custom-Format: komprimiert, selektiv wiederherstellbar.
|
||||
pg_dump --host=artista-postgres --username=artista --dbname=artista \
|
||||
--format=custom --file="${target}.partial"
|
||||
mv "${target}.partial" "${target}"
|
||||
find /backup -name 'artista-*.dump.partial' -mtime +1 -delete
|
||||
find /backup -name 'artista-*.dump' -mtime +30 -delete
|
||||
ls -lh /backup
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 128Mi }
|
||||
limits: { memory: 256Mi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
volumeMounts:
|
||||
- name: backup
|
||||
mountPath: /backup
|
||||
volumes:
|
||||
- name: backup
|
||||
persistentVolumeClaim: { claimName: artista-db-backup }
|
||||
@@ -0,0 +1,14 @@
|
||||
# Basis fuer RKE2. Nicht direkt anwenden — ueber ein Overlay
|
||||
# (k8s/overlays/production), das Domain, Images und TLS setzt.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: artista
|
||||
resources:
|
||||
- config.yaml
|
||||
- postgres.yaml
|
||||
- app.yaml
|
||||
- jobs.yaml
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/part-of: artista-modelcamp
|
||||
includeSelectors: false
|
||||
@@ -0,0 +1,82 @@
|
||||
# PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll.
|
||||
#
|
||||
# SPEICHER: Blockspeicher mit ReadWriteOnce verwenden (z. B. Longhorn,
|
||||
# local-path, Ceph RBD). KEINE SMB/CIFS-Freigabe als Datenverzeichnis:
|
||||
# SMB bildet POSIX-Sperren, fsync-Garantien und Dateirechte nicht so ab,
|
||||
# wie PostgreSQL sie fuer Absturzsicherheit voraussetzt — Datenverlust bei
|
||||
# Stromausfall oder Neustart ist die Folge. Sicherungen (backup-CronJob)
|
||||
# duerfen dagegen auf SMB/NFS liegen.
|
||||
#
|
||||
# Alternative fuer hoehere Verfuegbarkeit: CloudNativePG-Operator; dann
|
||||
# dieses Manifest weglassen und DATABASE_URL auf dessen Service zeigen lassen.
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: artista-postgres
|
||||
spec:
|
||||
serviceName: artista-postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: artista-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: artista-postgres
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 70
|
||||
runAsGroup: 70
|
||||
fsGroup: 70
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:18.1-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: artista
|
||||
- name: POSTGRES_DB
|
||||
value: artista
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: artista-secrets, key: DB_PASSWORD }
|
||||
volumeMounts:
|
||||
# Ab PostgreSQL 18 liegt PGDATA unter /var/lib/postgresql/18/docker.
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql
|
||||
readinessProbe:
|
||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests: { cpu: 100m, memory: 256Mi }
|
||||
limits: { memory: 1Gi }
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
# storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: artista-postgres
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: artista-postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
@@ -0,0 +1,64 @@
|
||||
# Ingress fuer Traefik mit cert-manager.
|
||||
# Anpassen: Host (auch in APP_URL, kustomization.yaml), ingressClassName
|
||||
# ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name.
|
||||
#
|
||||
# X-Forwarded-For/X-Real-Ip: Rate Limits verlassen sich darauf, dass Traefik
|
||||
# vom Client mitgeschickte Werte verwirft. Dafuer im Traefik-EntryPoint
|
||||
# forwardedHeaders.trustedIPs nur auf echte vorgelagerte Proxys setzen.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: artista
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- modelcamp.example.org
|
||||
secretName: artista-tls
|
||||
rules:
|
||||
- host: modelcamp.example.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: artista-app
|
||||
port:
|
||||
name: http
|
||||
---
|
||||
# HTTP -> HTTPS. Entfaellt, wenn Traefik das bereits EntryPoint-weit tut
|
||||
# (Helm: ports.web.redirections.entryPoint.to=websecure).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: artista-http-redirect
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
traefik.ingress.kubernetes.io/router.middlewares: artista-redirect-https@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: modelcamp.example.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: artista-app
|
||||
port:
|
||||
name: http
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
@@ -0,0 +1,27 @@
|
||||
# Produktions-Overlay. Anpassen: Images/Tags, Domain, IngressClass,
|
||||
# ClusterIssuer und TLS-Secret-Name. Anwenden:
|
||||
# kubectl apply -k k8s/overlays/production
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: artista
|
||||
resources:
|
||||
- ../../base
|
||||
- ingress.yaml
|
||||
|
||||
images:
|
||||
# Gebaut von .gitea/workflows/docker-build.yml: Git-Tag "v0.1.0" pushen
|
||||
# ergibt Image-Tag "0.1.0" (Alternativen: "sha-<kurz>", "latest").
|
||||
# Beide Images immer mit identischem Tag ausrollen.
|
||||
- name: artista-modelcamp
|
||||
newName: git.encry.de/nick.rosenloecher/artista-filmcamp
|
||||
newTag: "0.1.0"
|
||||
- name: artista-modelcamp-tools
|
||||
newName: git.encry.de/nick.rosenloecher/artista-filmcamp-tools
|
||||
newTag: "0.1.0"
|
||||
|
||||
patches:
|
||||
- target: { kind: ConfigMap, name: artista-config }
|
||||
patch: |-
|
||||
- op: replace
|
||||
path: /data/APP_URL
|
||||
value: https://modelcamp.example.org
|
||||
@@ -0,0 +1,28 @@
|
||||
# VORLAGE — nicht mit echten Werten ins Repository einchecken.
|
||||
# Anlegen z. B. per:
|
||||
# kubectl -n artista create secret generic artista-secrets \
|
||||
# --from-literal=DB_PASSWORD='…' \
|
||||
# --from-literal=DATABASE_URL='postgresql://artista:…@artista-postgres:5432/artista' \
|
||||
# --from-literal=APP_SECRET="$(openssl rand -base64 48)" \
|
||||
# --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 48)" \
|
||||
# --from-literal=STRIPE_SECRET_KEY='sk_test_…' \
|
||||
# --from-literal=STRIPE_WEBHOOK_SECRET='whsec_…' \
|
||||
# --from-literal=SMTP_HOST='…' --from-literal=SMTP_USER='…' --from-literal=SMTP_PASSWORD='…'
|
||||
# Besser noch: Sealed Secrets oder External Secrets Operator.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: artista-secrets
|
||||
namespace: artista
|
||||
type: Opaque
|
||||
stringData:
|
||||
DB_PASSWORD: "<starkes-passwort>"
|
||||
# Passwort URL-kodiert einsetzen, falls Sonderzeichen enthalten sind.
|
||||
DATABASE_URL: "postgresql://artista:<starkes-passwort>@artista-postgres:5432/artista"
|
||||
APP_SECRET: "<mind-32-zeichen-zufall>"
|
||||
BETTER_AUTH_SECRET: "<anderer-mind-32-zeichen-zufall>"
|
||||
STRIPE_SECRET_KEY: "<sk_test_… oder sk_live_…>"
|
||||
STRIPE_WEBHOOK_SECRET: "<whsec_…>"
|
||||
SMTP_HOST: "<smtp.example.org>"
|
||||
SMTP_USER: "<benutzer>"
|
||||
SMTP_PASSWORD: "<passwort>"
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
// Minimaler Node-Server fuer das Docker-Image (siehe Dockerfile).
|
||||
output: "standalone",
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
qualities: [75, 85],
|
||||
// Freigegebene Bilder liegen im persistenten Medienverzeichnis und
|
||||
// werden unter /media/… ausgeliefert (src/app/media/[...path]/route.ts).
|
||||
localPatterns: [{ pathname: "/media/**" }],
|
||||
dangerouslyAllowSVG: false,
|
||||
},
|
||||
// Turbopack-Dateicaches kollidieren im OneDrive-synchronisierten
|
||||
// Arbeitsverzeichnis mit der Cloud-Synchronisierung (Erfahrung aus dem
|
||||
// Nachbarprojekt Wabenhain). Im Container spielt das keine Rolle.
|
||||
experimental: {
|
||||
turbopackFileSystemCacheForDev: false,
|
||||
turbopackFileSystemCacheForBuild: false,
|
||||
},
|
||||
async headers() {
|
||||
// Die Content-Security-Policy setzt src/proxy.ts pro Request mit Nonce.
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Statusseiten enthalten ein Zugangstoken in der URL: nie als
|
||||
// Referrer weitergeben, nie indexieren, nie cachen.
|
||||
source: "/buchung/:path*",
|
||||
headers: [
|
||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
|
||||
{ key: "Cache-Control", value: "no-store" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/admin/:path*",
|
||||
headers: [
|
||||
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
|
||||
{ key: "Cache-Control", value: "no-store" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "artista-modelcamp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "prisma generate && tsc --noEmit",
|
||||
"postinstall": "prisma generate",
|
||||
"db:dev": "node scripts/dev-db.mjs",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx scripts/seed-dev.ts",
|
||||
"admin:create": "tsx scripts/create-admin.ts",
|
||||
"worker": "tsx scripts/worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"better-auth": "1.7.6",
|
||||
"motion": "13.4.4",
|
||||
"next": "16.3.6",
|
||||
"nodemailer": "10.0.10",
|
||||
"pg": "8.23.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"server-only": "0.0.1",
|
||||
"stripe": "22.6.2",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/node": "22.20.4",
|
||||
"@types/nodemailer": "8.0.2",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "18.0.4",
|
||||
"embedded-postgres": "18.4.0-beta.17",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"prisma": "7.10.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Sichtpruefung und Buchungsstrecke gegen eine laufende Instanz
|
||||
* (pnpm build && pnpm start, Datenbank mit pnpm db:seed).
|
||||
* Bildschirmfotos landen unter test-results/.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{ name: "smartphone", use: { ...devices["Pixel 7"] } },
|
||||
{ name: "tablet", use: { ...devices["Desktop Chrome"], viewport: { width: 820, height: 1180 }, hasTouch: true } },
|
||||
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
|
||||
],
|
||||
});
|
||||
Generated
+7177
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
allowBuilds:
|
||||
'@prisma/engines': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
embedded-postgres: true
|
||||
'@embedded-postgres/windows-x64': true
|
||||
'@embedded-postgres/linux-x64': true
|
||||
unrs-resolver: false
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,15 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "tsx scripts/seed-dev.ts",
|
||||
},
|
||||
datasource: {
|
||||
// Zur Build-Zeit reicht ein Platzhalter (prisma generate verbindet sich
|
||||
// nicht); migrate/seed brauchen die echte URL aus der Umgebung.
|
||||
url: process.env.DATABASE_URL ?? "postgresql://placeholder@localhost:5432/placeholder",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PricePhase" AS ENUM ('PREVIEW', 'EARLY_BIRD', 'REGULAR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELED', 'EXPIRED', 'NEEDS_REVIEW');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PROCESSING', 'PARTIALLY_PAID', 'PAID', 'FAILED', 'PARTIALLY_REFUNDED', 'REFUNDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PlanType" AS ENUM ('FULL', 'TWO_INSTALLMENTS', 'THREE_INSTALLMENTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InstallmentStatus" AS ENUM ('PENDING', 'PROCESSING', 'PAID', 'FAILED', 'REQUIRES_ACTION', 'CANCELED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReservationStatus" AS ENUM ('ACTIVE', 'HELD', 'CONVERTED', 'RELEASED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RedemptionStatus" AS ENUM ('RESERVED', 'COMPLETED', 'RELEASED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StripeEventStatus" AS ENUM ('PROCESSING', 'PROCESSED', 'IGNORED', 'FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmailStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActorType" AS ENUM ('ADMIN', 'CUSTOMER', 'STRIPE', 'WORKER', 'SYSTEM');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Camp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"salesEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"capacity" INTEGER NOT NULL DEFAULT 10,
|
||||
"capacityConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"stammmodelQuota" INTEGER NOT NULL DEFAULT 4,
|
||||
"stammmodelQuotaConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"campStartDate" DATE,
|
||||
"campEndDate" DATE,
|
||||
"dateConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"locationName" TEXT,
|
||||
"locationRegion" TEXT,
|
||||
"locationConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"accommodationText" TEXT,
|
||||
"accommodationConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"servicesConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"organizerName" TEXT,
|
||||
"organizerAddress" TEXT,
|
||||
"organizerEmail" TEXT,
|
||||
"organizerPhone" TEXT,
|
||||
"organizerConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"taxNote" TEXT,
|
||||
"taxConfirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"termsVersion" TEXT,
|
||||
"termsUrl" TEXT,
|
||||
"termsApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"travelLawChecked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"installmentsApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"installmentRuleApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"reservationMinutes" INTEGER NOT NULL DEFAULT 30,
|
||||
"finalInstallmentLeadDays" INTEGER NOT NULL DEFAULT 30,
|
||||
"minInstallmentGapDays" INTEGER NOT NULL DEFAULT 14,
|
||||
"testPhaseOverride" "PricePhase",
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
|
||||
CONSTRAINT "Camp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Booking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"campId" TEXT NOT NULL,
|
||||
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
|
||||
"livemode" BOOLEAN NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"statusTokenHash" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"country" TEXT NOT NULL,
|
||||
"notes" TEXT,
|
||||
"adultConfirmedAt" TIMESTAMP(3) NOT NULL,
|
||||
"termsAcceptedAt" TIMESTAMP(3) NOT NULL,
|
||||
"termsVersion" TEXT NOT NULL,
|
||||
"accommodationAcknowledgedAt" TIMESTAMP(3) NOT NULL,
|
||||
"privacyAcknowledgedAt" TIMESTAMP(3) NOT NULL,
|
||||
"installmentMandateAt" TIMESTAMP(3),
|
||||
"pricePhase" "PricePhase" NOT NULL,
|
||||
"basePriceCents" INTEGER NOT NULL,
|
||||
"phaseDiscountCents" INTEGER NOT NULL,
|
||||
"voucherDiscountCents" INTEGER NOT NULL,
|
||||
"totalCents" INTEGER NOT NULL,
|
||||
"planType" "PlanType" NOT NULL,
|
||||
"priceSnapshot" JSONB NOT NULL,
|
||||
"priceLockedUntil" TIMESTAMP(3) NOT NULL,
|
||||
"voucherId" TEXT,
|
||||
"stripeCustomerId" TEXT,
|
||||
"stripeCheckoutSessionId" TEXT,
|
||||
"stripeCheckoutUrl" TEXT,
|
||||
"stripePaymentMethodId" TEXT,
|
||||
"paymentMethodDetachedAt" TIMESTAMP(3),
|
||||
"paidCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"refundedCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"confirmedAt" TIMESTAMP(3),
|
||||
"canceledAt" TIMESTAMP(3),
|
||||
"cancelReason" TEXT,
|
||||
"reviewReason" TEXT,
|
||||
|
||||
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Installment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"seq" INTEGER NOT NULL,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"status" "InstallmentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastAttemptAt" TIMESTAMP(3),
|
||||
"nextAttemptAt" TIMESTAMP(3),
|
||||
"failureCode" TEXT,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"refundedCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"stripePaymentIntentId" TEXT,
|
||||
"stripeChargeId" TEXT,
|
||||
"checkoutSessionId" TEXT,
|
||||
"checkoutExpiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Installment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Refund" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"installmentId" TEXT,
|
||||
"stripeRefundId" TEXT NOT NULL,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Refund_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeatReservation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"campId" TEXT NOT NULL,
|
||||
"livemode" BOOLEAN NOT NULL,
|
||||
"status" "ReservationStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"releaseReason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeatReservation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Voucher" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"validFrom" TIMESTAMP(3),
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"maxRedemptions" INTEGER NOT NULL DEFAULT 1,
|
||||
"boundEmail" TEXT,
|
||||
"internalNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Voucher_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoucherRedemption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"voucherId" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"livemode" BOOLEAN NOT NULL,
|
||||
"status" "RedemptionStatus" NOT NULL DEFAULT 'RESERVED',
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VoucherRedemption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StripeEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"livemode" BOOLEAN NOT NULL,
|
||||
"status" "StripeEventStatus" NOT NULL DEFAULT 'PROCESSING',
|
||||
"error" TEXT,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "StripeEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EmailMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"dedupeKey" TEXT NOT NULL,
|
||||
"bookingId" TEXT,
|
||||
"toAddress" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"textBody" TEXT NOT NULL,
|
||||
"htmlBody" TEXT NOT NULL,
|
||||
"status" "EmailStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EmailMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"actorType" "ActorType" NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"details" JSONB,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RateLimitBucket" (
|
||||
"key" TEXT NOT NULL,
|
||||
"windowStart" TIMESTAMP(3) NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "RateLimitBucket_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"twoFactorEnabled" BOOLEAN DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "twoFactor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"backupCodes" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "twoFactor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rateLimit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"lastRequest" BIGINT NOT NULL,
|
||||
|
||||
CONSTRAINT "rateLimit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_reference_key" ON "Booking"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_idempotencyKey_key" ON "Booking"("idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_statusTokenHash_key" ON "Booking"("statusTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_stripeCheckoutSessionId_key" ON "Booking"("stripeCheckoutSessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_livemode_status_idx" ON "Booking"("livemode", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_email_idx" ON "Booking"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_createdAt_idx" ON "Booking"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Installment_stripePaymentIntentId_key" ON "Installment"("stripePaymentIntentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Installment_stripeChargeId_key" ON "Installment"("stripeChargeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Installment_checkoutSessionId_key" ON "Installment"("checkoutSessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Installment_status_dueAt_idx" ON "Installment"("status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Installment_bookingId_seq_key" ON "Installment"("bookingId", "seq");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Refund_stripeRefundId_key" ON "Refund"("stripeRefundId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SeatReservation_bookingId_key" ON "SeatReservation"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatReservation_campId_livemode_status_expiresAt_idx" ON "SeatReservation"("campId", "livemode", "status", "expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Voucher_code_key" ON "Voucher"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VoucherRedemption_bookingId_key" ON "VoucherRedemption"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoucherRedemption_voucherId_livemode_status_idx" ON "VoucherRedemption"("voucherId", "livemode", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EmailMessage_dedupeKey_key" ON "EmailMessage"("dedupeKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EmailMessage_status_createdAt_idx" ON "EmailMessage"("status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "twoFactor_secret_idx" ON "twoFactor"("secret");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "twoFactor_userId_idx" ON "twoFactor"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "rateLimit_key_key" ON "rateLimit"("key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_campId_fkey" FOREIGN KEY ("campId") REFERENCES "Camp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_voucherId_fkey" FOREIGN KEY ("voucherId") REFERENCES "Voucher"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Installment" ADD CONSTRAINT "Installment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Refund" ADD CONSTRAINT "Refund_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "Installment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatReservation" ADD CONSTRAINT "SeatReservation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatReservation" ADD CONSTRAINT "SeatReservation_campId_fkey" FOREIGN KEY ("campId") REFERENCES "Camp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoucherRedemption" ADD CONSTRAINT "VoucherRedemption_voucherId_fkey" FOREIGN KEY ("voucherId") REFERENCES "Voucher"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoucherRedemption" ADD CONSTRAINT "VoucherRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EmailMessage" ADD CONSTRAINT "EmailMessage_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "twoFactor" ADD CONSTRAINT "twoFactor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Unveraenderlicher Preis-Snapshot und Zahlungsplan (Briefing Abschnitt 9).
|
||||
-- Der bei Buchung vereinbarte Preis darf auch durch einen Programmfehler oder
|
||||
-- einen direkten Datenbankzugriff nicht nachtraeglich veraendert werden.
|
||||
-- Eine Vertragsaenderung heisst: stornieren und neu buchen.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION booking_snapshot_immutable() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."reference" IS DISTINCT FROM OLD."reference"
|
||||
OR NEW."livemode" IS DISTINCT FROM OLD."livemode"
|
||||
OR NEW."idempotencyKey" IS DISTINCT FROM OLD."idempotencyKey"
|
||||
OR NEW."pricePhase" IS DISTINCT FROM OLD."pricePhase"
|
||||
OR NEW."basePriceCents" IS DISTINCT FROM OLD."basePriceCents"
|
||||
OR NEW."phaseDiscountCents" IS DISTINCT FROM OLD."phaseDiscountCents"
|
||||
OR NEW."voucherDiscountCents" IS DISTINCT FROM OLD."voucherDiscountCents"
|
||||
OR NEW."totalCents" IS DISTINCT FROM OLD."totalCents"
|
||||
OR NEW."planType" IS DISTINCT FROM OLD."planType"
|
||||
OR NEW."priceSnapshot" IS DISTINCT FROM OLD."priceSnapshot"
|
||||
OR NEW."priceLockedUntil" IS DISTINCT FROM OLD."priceLockedUntil"
|
||||
OR NEW."voucherId" IS DISTINCT FROM OLD."voucherId"
|
||||
THEN
|
||||
RAISE EXCEPTION 'Preis-Snapshot der Buchung % ist unveraenderlich', OLD."id";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER booking_snapshot_immutable
|
||||
BEFORE UPDATE ON "Booking"
|
||||
FOR EACH ROW EXECUTE FUNCTION booking_snapshot_immutable();
|
||||
|
||||
CREATE OR REPLACE FUNCTION installment_plan_immutable() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."bookingId" IS DISTINCT FROM OLD."bookingId"
|
||||
OR NEW."seq" IS DISTINCT FROM OLD."seq"
|
||||
OR NEW."amountCents" IS DISTINCT FROM OLD."amountCents"
|
||||
OR NEW."dueAt" IS DISTINCT FROM OLD."dueAt"
|
||||
THEN
|
||||
RAISE EXCEPTION 'Rate % ist Teil des vereinbarten Zahlungsplans und unveraenderlich', OLD."id";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER installment_plan_immutable
|
||||
BEFORE UPDATE ON "Installment"
|
||||
FOR EACH ROW EXECUTE FUNCTION installment_plan_immutable();
|
||||
|
||||
-- Plausibilitaet auf Datenbankebene.
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_amounts_check"
|
||||
CHECK ("totalCents" > 0 AND "basePriceCents" > 0 AND "phaseDiscountCents" >= 0
|
||||
AND "voucherDiscountCents" >= 0 AND "paidCents" >= 0 AND "refundedCents" >= 0
|
||||
AND "totalCents" = "basePriceCents" - "phaseDiscountCents" - "voucherDiscountCents");
|
||||
ALTER TABLE "Installment" ADD CONSTRAINT "Installment_amount_check"
|
||||
CHECK ("amountCents" > 0 AND "seq" BETWEEN 1 AND 3 AND "refundedCents" >= 0);
|
||||
ALTER TABLE "Camp" ADD CONSTRAINT "Camp_limits_check"
|
||||
CHECK ("capacity" >= 0 AND "stammmodelQuota" >= 0 AND "reservationMinutes" BETWEEN 30 AND 1440
|
||||
AND "finalInstallmentLeadDays" >= 0 AND "minInstallmentGapDays" >= 1);
|
||||
ALTER TABLE "Voucher" ADD CONSTRAINT "Voucher_limits_check"
|
||||
CHECK ("maxRedemptions" >= 1 AND "code" = upper("code"));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "twoFactor" ADD COLUMN "failedVerificationCount" INTEGER DEFAULT 0,
|
||||
ADD COLUMN "lockedUntil" TIMESTAMP(3),
|
||||
ADD COLUMN "verified" BOOLEAN DEFAULT true;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,529 @@
|
||||
// ARTISTA MODELCAMP 2027 — Datenmodell
|
||||
//
|
||||
// Geld: ausschliesslich ganzzahlige Cent-Betraege (Int), Waehrung immer EUR.
|
||||
// Zeit: Zeitstempel in UTC; fachliche Grenzen (Preisphasen, Faelligkeiten)
|
||||
// werden in src/lib/time.ts gegen Europe/Berlin berechnet.
|
||||
//
|
||||
// Test- und Livebuchungen liegen in denselben Tabellen, sind aber ueber
|
||||
// "livemode" strikt getrennt: Kapazitaet und Gutscheinkontingente zaehlen nur
|
||||
// Buchungen desselben Modus, damit Stripe-Testbuchungen nie echte Plaetze
|
||||
// blockieren.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camp und Verkaufsfreigaben
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum PricePhase {
|
||||
PREVIEW
|
||||
EARLY_BIRD
|
||||
REGULAR
|
||||
}
|
||||
|
||||
/// Genau eine Zeile (id = "modelcamp-2027"). Enthaelt die betrieblichen
|
||||
/// Angaben, die den Live-Verkauf freischalten. Die Preise selbst sind
|
||||
/// verbindlich vorgegeben und stehen in src/lib/pricing.ts, nicht hier.
|
||||
model Camp {
|
||||
id String @id
|
||||
|
||||
/// Hauptschalter fuer den Live-Verkauf. Wirkt nur zusammen mit einer
|
||||
/// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts).
|
||||
salesEnabled Boolean @default(false)
|
||||
|
||||
capacity Int @default(10)
|
||||
capacityConfirmed Boolean @default(false)
|
||||
stammmodelQuota Int @default(4)
|
||||
stammmodelQuotaConfirmed Boolean @default(false)
|
||||
|
||||
campStartDate DateTime? @db.Date
|
||||
campEndDate DateTime? @db.Date
|
||||
dateConfirmed Boolean @default(false)
|
||||
|
||||
locationName String?
|
||||
locationRegion String?
|
||||
locationConfirmed Boolean @default(false)
|
||||
|
||||
/// Verbindliche Zimmer- und Bettbeschreibung, wird vor dem Kauf angezeigt
|
||||
/// und muss ausdruecklich bestaetigt werden.
|
||||
accommodationText String?
|
||||
accommodationConfirmed Boolean @default(false)
|
||||
|
||||
/// Bestaetigt, dass die in src/content/site.ts als "confirmed" markierten
|
||||
/// Leistungen verbindlich sind.
|
||||
servicesConfirmed Boolean @default(false)
|
||||
|
||||
organizerName String?
|
||||
organizerAddress String?
|
||||
organizerEmail String?
|
||||
organizerPhone String?
|
||||
organizerConfirmed Boolean @default(false)
|
||||
|
||||
/// Steuerlich gepruefter Preis-Hinweis, z. B. wie die Umsatzsteuer
|
||||
/// ausgewiesen wird. Ohne Freigabe erscheint KEINE Steueraussage.
|
||||
taxNote String?
|
||||
taxConfirmed Boolean @default(false)
|
||||
|
||||
/// Version der freigegebenen Buchungs-, Storno- und Zahlungsbedingungen.
|
||||
termsVersion String?
|
||||
termsUrl String?
|
||||
termsApproved Boolean @default(false)
|
||||
|
||||
travelLawChecked Boolean @default(false)
|
||||
|
||||
installmentsApproved Boolean @default(false)
|
||||
installmentRuleApproved Boolean @default(false)
|
||||
|
||||
/// Reservierungsdauer fuer einen begonnenen Checkout. Stripe verlangt fuer
|
||||
/// expires_at mindestens 30 Minuten.
|
||||
reservationMinutes Int @default(30)
|
||||
finalInstallmentLeadDays Int @default(30)
|
||||
minInstallmentGapDays Int @default(14)
|
||||
|
||||
/// Nur im Stripe-Testmodus wirksam: simuliert eine Preisphase, damit
|
||||
/// Testbuchungen auch vor dem 01.11.2026 moeglich sind.
|
||||
testPhaseOverride PricePhase?
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
|
||||
bookings Booking[]
|
||||
reservations SeatReservation[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buchung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum BookingStatus {
|
||||
/// Platz reserviert, Zahlung der ersten Rate steht aus.
|
||||
PENDING
|
||||
CONFIRMED
|
||||
CANCELED
|
||||
/// Checkout abgebrochen oder abgelaufen, Platz wieder frei.
|
||||
EXPIRED
|
||||
/// Zahlung eingegangen, aber ein Konflikt (z. B. Platz inzwischen
|
||||
/// vergeben) verlangt eine manuelle Entscheidung.
|
||||
NEEDS_REVIEW
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
UNPAID
|
||||
/// Verzoegerte Zahlungsart (z. B. SEPA) ist unterwegs.
|
||||
PROCESSING
|
||||
PARTIALLY_PAID
|
||||
PAID
|
||||
FAILED
|
||||
PARTIALLY_REFUNDED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum PlanType {
|
||||
FULL
|
||||
TWO_INSTALLMENTS
|
||||
THREE_INSTALLMENTS
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(cuid())
|
||||
/// Oeffentliche Buchungsnummer, z. B. AMC27-7K3Q9P.
|
||||
reference String @unique
|
||||
|
||||
campId String
|
||||
camp Camp @relation(fields: [campId], references: [id])
|
||||
|
||||
status BookingStatus @default(PENDING)
|
||||
paymentStatus PaymentStatus @default(UNPAID)
|
||||
livemode Boolean
|
||||
|
||||
/// Vom Browser erzeugter Schluessel je Buchungsversuch: ein doppelt
|
||||
/// abgeschicktes Formular fuehrt zur selben Buchung statt zu zweien.
|
||||
idempotencyKey String @unique
|
||||
/// SHA-256 des Zugangstokens fuer die Statusseite. Das Token selbst wird
|
||||
/// nie gespeichert.
|
||||
statusTokenHash String @unique
|
||||
|
||||
firstName String
|
||||
lastName String
|
||||
email String
|
||||
phone String
|
||||
country String
|
||||
notes String?
|
||||
|
||||
adultConfirmedAt DateTime
|
||||
termsAcceptedAt DateTime
|
||||
termsVersion String
|
||||
accommodationAcknowledgedAt DateTime
|
||||
privacyAcknowledgedAt DateTime
|
||||
/// Zustimmung zum Einzug spaeterer Raten (nur bei Ratenzahlung).
|
||||
installmentMandateAt DateTime?
|
||||
|
||||
// --- Preis-Snapshot (per Datenbank-Trigger unveraenderlich) -------------
|
||||
pricePhase PricePhase
|
||||
basePriceCents Int
|
||||
phaseDiscountCents Int
|
||||
voucherDiscountCents Int
|
||||
totalCents Int
|
||||
planType PlanType
|
||||
priceSnapshot Json
|
||||
/// Bis hierhin gilt der gebundene Preis (= Ablauf der Stripe-Session).
|
||||
priceLockedUntil DateTime
|
||||
|
||||
voucherId String?
|
||||
voucher Voucher? @relation(fields: [voucherId], references: [id])
|
||||
|
||||
stripeCustomerId String?
|
||||
stripeCheckoutSessionId String? @unique
|
||||
stripeCheckoutUrl String?
|
||||
/// Referenz (keine Kartendaten) auf die fuer Folgeraten gespeicherte
|
||||
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
||||
stripePaymentMethodId String?
|
||||
paymentMethodDetachedAt DateTime?
|
||||
|
||||
paidCents Int @default(0)
|
||||
refundedCents Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
confirmedAt DateTime?
|
||||
canceledAt DateTime?
|
||||
cancelReason String?
|
||||
reviewReason String?
|
||||
|
||||
installments Installment[]
|
||||
reservation SeatReservation?
|
||||
redemption VoucherRedemption?
|
||||
refunds Refund[]
|
||||
emails EmailMessage[]
|
||||
|
||||
@@index([livemode, status])
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
enum InstallmentStatus {
|
||||
/// Noch nicht faellig bzw. erste Rate vor dem Checkout.
|
||||
PENDING
|
||||
/// Einzug laeuft (Worker hat die Rate beansprucht).
|
||||
PROCESSING
|
||||
PAID
|
||||
FAILED
|
||||
/// Bank verlangt zusaetzliche Authentifizierung: Kundin zahlt ueber einen
|
||||
/// Link auf der Statusseite selbst.
|
||||
REQUIRES_ACTION
|
||||
CANCELED
|
||||
}
|
||||
|
||||
model Installment {
|
||||
id String @id @default(cuid())
|
||||
bookingId String
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
|
||||
|
||||
seq Int
|
||||
amountCents Int
|
||||
/// null = faellig bei Buchung (erste Rate).
|
||||
dueAt DateTime?
|
||||
|
||||
status InstallmentStatus @default(PENDING)
|
||||
attemptCount Int @default(0)
|
||||
lastAttemptAt DateTime?
|
||||
nextAttemptAt DateTime?
|
||||
failureCode String?
|
||||
paidAt DateTime?
|
||||
refundedCents Int @default(0)
|
||||
|
||||
stripePaymentIntentId String? @unique
|
||||
stripeChargeId String? @unique
|
||||
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
||||
checkoutSessionId String? @unique
|
||||
checkoutExpiresAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
refunds Refund[]
|
||||
|
||||
@@unique([bookingId, seq])
|
||||
@@index([status, dueAt])
|
||||
}
|
||||
|
||||
model Refund {
|
||||
id String @id @default(cuid())
|
||||
bookingId String
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
|
||||
installmentId String?
|
||||
installment Installment? @relation(fields: [installmentId], references: [id])
|
||||
|
||||
stripeRefundId String @unique
|
||||
amountCents Int
|
||||
status String
|
||||
reason String?
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kapazitaet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum ReservationStatus {
|
||||
/// Zeitlich begrenzt, verfaellt mit expiresAt.
|
||||
ACTIVE
|
||||
/// Verzoegerte Zahlung unterwegs: Platz bleibt bis zum Ergebnis gehalten.
|
||||
HELD
|
||||
CONVERTED
|
||||
RELEASED
|
||||
}
|
||||
|
||||
model SeatReservation {
|
||||
id String @id @default(cuid())
|
||||
bookingId String @unique
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
|
||||
campId String
|
||||
camp Camp @relation(fields: [campId], references: [id])
|
||||
livemode Boolean
|
||||
|
||||
status ReservationStatus @default(ACTIVE)
|
||||
expiresAt DateTime
|
||||
releasedAt DateTime?
|
||||
releaseReason String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([campId, livemode, status, expiresAt])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutscheine (Stammmodel-Codes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Voucher {
|
||||
id String @id @default(cuid())
|
||||
/// Immer in Grossbuchstaben normalisiert gespeichert.
|
||||
code String @unique
|
||||
|
||||
active Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxRedemptions Int @default(1)
|
||||
/// Optional: nur fuer diese E-Mail-Adresse (klein geschrieben) einloesbar.
|
||||
boundEmail String?
|
||||
/// Interne Zuordnung, z. B. Name des Stammmodels. Nie oeffentlich.
|
||||
internalNote String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
redemptions VoucherRedemption[]
|
||||
bookings Booking[]
|
||||
}
|
||||
|
||||
enum RedemptionStatus {
|
||||
RESERVED
|
||||
COMPLETED
|
||||
RELEASED
|
||||
}
|
||||
|
||||
model VoucherRedemption {
|
||||
id String @id @default(cuid())
|
||||
voucherId String
|
||||
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Restrict)
|
||||
bookingId String @unique
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Restrict)
|
||||
livemode Boolean
|
||||
|
||||
status RedemptionStatus @default(RESERVED)
|
||||
releasedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([voucherId, livemode, status])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe-Ereignisse, E-Mail-Ausgang, Protokoll, Rate Limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum StripeEventStatus {
|
||||
PROCESSING
|
||||
PROCESSED
|
||||
IGNORED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model StripeEvent {
|
||||
/// Stripe-Event-ID (evt_...) — Primaerschluessel macht die Verarbeitung
|
||||
/// idempotent.
|
||||
id String @id
|
||||
type String
|
||||
livemode Boolean
|
||||
status StripeEventStatus @default(PROCESSING)
|
||||
error String?
|
||||
attempts Int @default(1)
|
||||
receivedAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
}
|
||||
|
||||
enum EmailStatus {
|
||||
PENDING
|
||||
SENT
|
||||
FAILED
|
||||
}
|
||||
|
||||
/// Transaktionaler Postausgang: E-Mails entstehen in derselben Transaktion
|
||||
/// wie die Statusaenderung und werden danach (bzw. vom Worker) versendet.
|
||||
model EmailMessage {
|
||||
id String @id @default(cuid())
|
||||
/// Verhindert doppelte Mails bei wiederholten Webhooks.
|
||||
dedupeKey String @unique
|
||||
bookingId String?
|
||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||
|
||||
toAddress String
|
||||
subject String
|
||||
textBody String
|
||||
htmlBody String
|
||||
|
||||
status EmailStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
sentAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
enum ActorType {
|
||||
ADMIN
|
||||
CUSTOMER
|
||||
STRIPE
|
||||
WORKER
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/// Nachvollziehbarkeit fuer Bestands-, Buchungs- und Zahlungsaktionen.
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
actorType ActorType
|
||||
actorId String?
|
||||
action String
|
||||
entityType String
|
||||
entityId String
|
||||
details Json?
|
||||
|
||||
@@index([entityType, entityId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// Gemeinsamer Zaehler fuer Rate Limits — funktioniert ueber mehrere
|
||||
/// Replikate hinweg. Der Schluessel enthaelt nur einen Hash der IP.
|
||||
model RateLimitBucket {
|
||||
key String @id
|
||||
windowStart DateTime
|
||||
count Int
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Better Auth (Adminbereich)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
twoFactorEnabled Boolean? @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
twofactors TwoFactor[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([identifier])
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
model TwoFactor {
|
||||
id String @id
|
||||
secret String
|
||||
backupCodes String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
verified Boolean? @default(true)
|
||||
failedVerificationCount Int? @default(0)
|
||||
lockedUntil DateTime?
|
||||
|
||||
@@index([secret])
|
||||
@@index([userId])
|
||||
@@map("twoFactor")
|
||||
}
|
||||
|
||||
/// Rate Limits der Anmeldung (Better Auth, Modellname vom Adapter vorgegeben).
|
||||
model RateLimit {
|
||||
id String @id
|
||||
key String @unique
|
||||
count Int
|
||||
lastRequest BigInt
|
||||
|
||||
@@map("rateLimit")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Legt ein Konto fuer den Adminbereich an. Es gibt keine Selbstregistrierung
|
||||
* und kein Standardpasswort.
|
||||
*
|
||||
* pnpm admin:create --email admin@example.org --name "Vorname Nachname"
|
||||
*
|
||||
* Passwort: aus ADMIN_PASSWORD (mind. 12 Zeichen) oder zufaellig erzeugt und
|
||||
* EINMAL ausgegeben. Nach der ersten Anmeldung erzwingt der Adminbereich die
|
||||
* Einrichtung der Zwei-Faktor-Anmeldung.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createProvisioningAuth } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// Umgebung kommt aus dem Container.
|
||||
}
|
||||
|
||||
const { values } = parseArgs({ options: { email: { type: "string" }, name: { type: "string" } } });
|
||||
const email = values.email?.trim().toLowerCase();
|
||||
const name = values.name?.trim();
|
||||
if (!email || !/^\S+@\S+\.\S+$/.test(email) || !name) {
|
||||
console.error('Aufruf: pnpm admin:create --email <adresse> --name "<Name>"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provided = process.env.ADMIN_PASSWORD;
|
||||
if (provided !== undefined && provided.length < 12) {
|
||||
console.error("ADMIN_PASSWORD muss mindestens 12 Zeichen haben.");
|
||||
process.exit(1);
|
||||
}
|
||||
const password = provided ?? randomBytes(18).toString("base64url");
|
||||
|
||||
const db = getDb();
|
||||
try {
|
||||
if (await db.user.findUnique({ where: { email } })) {
|
||||
console.error("Für diese E-Mail-Adresse existiert bereits ein Konto.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
// Offizieller Weg ueber Better Auth: Passwortregeln und Leak-Pruefung
|
||||
// greifen dabei automatisch.
|
||||
const result = await createProvisioningAuth().api.signUpEmail({ body: { email, name, password } });
|
||||
await db.user.update({ where: { id: result.user.id }, data: { emailVerified: true } });
|
||||
await db.auditLog.create({ data: { actorType: "SYSTEM", action: "admin.created", entityType: "User", entityId: result.user.id } });
|
||||
console.log(`Konto angelegt: ${email}`);
|
||||
if (!provided) console.log(`Einmaliges Passwort (jetzt sicher ablegen, wird nicht erneut angezeigt): ${password}`);
|
||||
console.log("Bei der ersten Anmeldung wird die Zwei-Faktor-Anmeldung eingerichtet.");
|
||||
}
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Lokale PostgreSQL-Instanz fuer die Entwicklung ohne Docker.
|
||||
//
|
||||
// pnpm db:dev startet PostgreSQL auf Port 54329 (Strg+C beendet)
|
||||
//
|
||||
// Mit Docker stattdessen: docker compose up -d db (siehe docker-compose.yml).
|
||||
//
|
||||
// Das Datenverzeichnis liegt bewusst NICHT im Projektordner: dieser liegt
|
||||
// hier in einem OneDrive-synchronisierten Verzeichnis, und ein
|
||||
// PostgreSQL-Datenverzeichnis in einer Cloud-Synchronisierung fuehrt zu
|
||||
// gesperrten oder beschaedigten Dateien.
|
||||
import EmbeddedPostgres from "embedded-postgres";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const baseDir = process.env.LOCALAPPDATA ?? path.join(os.homedir(), ".local", "share");
|
||||
const databaseDir = path.join(baseDir, "artista-modelcamp", "dev-db");
|
||||
const port = Number(process.env.DEV_DB_PORT ?? 54329);
|
||||
|
||||
const pg = new EmbeddedPostgres({
|
||||
databaseDir,
|
||||
user: "artista",
|
||||
password: "artista-dev",
|
||||
port,
|
||||
persistent: true,
|
||||
onLog: () => {},
|
||||
});
|
||||
|
||||
if (!existsSync(path.join(databaseDir, "PG_VERSION"))) {
|
||||
await pg.initialise();
|
||||
}
|
||||
await pg.start();
|
||||
try {
|
||||
await pg.createDatabase("artista");
|
||||
} catch {
|
||||
// existiert bereits
|
||||
}
|
||||
|
||||
console.log(`PostgreSQL laeuft: postgresql://artista:artista-dev@localhost:${port}/artista`);
|
||||
console.log(`Daten: ${databaseDir}`);
|
||||
|
||||
const shutdown = async () => {
|
||||
await pg.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
// Prozess am Leben halten.
|
||||
setInterval(() => {}, 1 << 30);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Testdaten NUR fuer Entwicklung und Staging, damit eine Stripe-Testbuchung
|
||||
* sofort moeglich ist:
|
||||
* - Preisphase "Early Bird" simuliert (wirkt nur mit Stripe-Testschluessel)
|
||||
* - ein TEST-Anreisetag, ausdruecklich NICHT bestaetigt (erscheint deshalb
|
||||
* nicht oeffentlich, erlaubt aber Ratenplaene im Testmodus)
|
||||
* - ein Test-Gutschein TEST-STAMMMODEL (nicht der Beispielcode aus dem Briefing)
|
||||
*
|
||||
* Verweigert den Lauf mit APP_ENV=production.
|
||||
*/
|
||||
import { getDb, CAMP_ID } from "@/lib/db";
|
||||
import { isoToDbDate } from "@/lib/time";
|
||||
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// Umgebung kommt aus dem Container.
|
||||
}
|
||||
|
||||
if ((process.env.APP_ENV ?? "development") === "production") {
|
||||
console.error("seed-dev laeuft nicht mit APP_ENV=production.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
try {
|
||||
await db.camp.upsert({
|
||||
where: { id: CAMP_ID },
|
||||
create: { id: CAMP_ID, testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
||||
update: { testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
||||
});
|
||||
await db.voucher.upsert({
|
||||
where: { code: "TEST-STAMMMODEL" },
|
||||
create: { code: "TEST-STAMMMODEL", maxRedemptions: 3, internalNote: "Entwicklung: Testgutschein" },
|
||||
update: {},
|
||||
});
|
||||
console.log("Entwicklungsdaten gesetzt: Early Bird simuliert, Test-Anreisetag 05.06.2027 (unbestätigt), Gutschein TEST-STAMMMODEL.");
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Hintergrundlauf fuer Reservierungen, Folgeraten, Zahlungsmethoden und
|
||||
* E-Mails. Im Cluster als Kubernetes-CronJob (k8s/base/cronjob-worker.yaml),
|
||||
* lokal per `pnpm worker`.
|
||||
*
|
||||
* Endet mit Exit-Code 1, wenn etwas fehlschlaegt — der CronJob-Lauf wird
|
||||
* dann als fehlgeschlagen sichtbar statt still im Log zu verschwinden.
|
||||
*/
|
||||
import { getDb } from "@/lib/db";
|
||||
import { env, requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { runWorker } from "@/lib/worker-jobs";
|
||||
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
} catch {
|
||||
// Im Container kommen die Variablen aus der Umgebung.
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
try {
|
||||
const result = await runWorker(db, getGateway(), {
|
||||
now: new Date(),
|
||||
secret: requireAppSecret(),
|
||||
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
||||
});
|
||||
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
|
||||
process.exitCode = result.emails.failed > 0 ? 1 : 0;
|
||||
} catch (error) {
|
||||
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function SignOutButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-ink/30 px-3 py-1 font-semibold"
|
||||
onClick={async () => {
|
||||
await authClient.signOut();
|
||||
router.replace("/admin/anmelden");
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { confirmReviewAction, cancelBookingAction, refundAction, resendConfirmationAction } from "@/app/admin/actions";
|
||||
import { Flash, KeyValue, PageTitle, Panel } from "@/components/admin/AdminUi";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, INSTALLMENT_STATUS_LABEL, PAYMENT_STATUS_LABEL, REDEMPTION_STATUS_LABEL, RESERVATION_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Buchung" };
|
||||
|
||||
export default async function BookingDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ ok?: string; fehler?: string }>;
|
||||
}) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
const flash = await searchParams;
|
||||
const db = getDb();
|
||||
const booking = await db.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
installments: { orderBy: { seq: "asc" }, include: { refunds: true } },
|
||||
reservation: true,
|
||||
redemption: true,
|
||||
voucher: true,
|
||||
emails: { orderBy: { createdAt: "desc" }, take: 20 },
|
||||
},
|
||||
});
|
||||
if (!booking) notFound();
|
||||
const log = await db.auditLog.findMany({
|
||||
where: { OR: [{ entityType: "Booking", entityId: id }, { entityType: "Installment", entityId: { in: booking.installments.map((i) => i.id) } }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link href="/admin/buchungen" className="text-sm underline">Alle Buchungen</Link>
|
||||
<PageTitle>{booking.reference}</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
{booking.reviewReason && booking.status === "NEEDS_REVIEW" && (
|
||||
<p className="mb-6 rounded-lg border border-rosso/40 p-4">
|
||||
<strong>Prüfung nötig:</strong> {booking.reviewReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<Panel title="Teilnehmerin">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Name", `${booking.firstName} ${booking.lastName}`],
|
||||
["E-Mail", booking.email],
|
||||
["Telefon", booking.phone],
|
||||
["Land", booking.country],
|
||||
["Anmerkungen", booking.notes ?? "–"],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Status">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Modus", booking.livemode ? "Live" : "Test"],
|
||||
["Buchung", BOOKING_STATUS_LABEL[booking.status]],
|
||||
["Zahlung", PAYMENT_STATUS_LABEL[booking.paymentStatus]],
|
||||
["Platz", booking.reservation ? RESERVATION_STATUS_LABEL[booking.reservation.status] : "–"],
|
||||
["Gutschein", booking.voucher ? `${booking.voucher.code} (${booking.redemption ? REDEMPTION_STATUS_LABEL[booking.redemption.status] : "–"})` : "–"],
|
||||
["Angelegt", formatDateTime(booking.createdAt)],
|
||||
["Bestätigt", booking.confirmedAt ? formatDateTime(booking.confirmedAt) : "–"],
|
||||
["Storniert", booking.canceledAt ? `${formatDateTime(booking.canceledAt)}: ${booking.cancelReason ?? ""}` : "–"],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Preis-Snapshot (unveränderlich)">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Phase", PHASE_LABEL[booking.pricePhase]],
|
||||
["Grundpreis", formatEuro(booking.basePriceCents)],
|
||||
["Early Bird", `−${formatEuro(booking.phaseDiscountCents)}`],
|
||||
["Gutschein", `−${formatEuro(booking.voucherDiscountCents)}`],
|
||||
["Gesamt", formatEuro(booking.totalCents)],
|
||||
["Plan", PLAN_LABEL[booking.planType]],
|
||||
["Bezahlt", formatEuro(booking.paidCents)],
|
||||
["Erstattet", formatEuro(booking.refundedCents)],
|
||||
["Bedingungen", booking.termsVersion],
|
||||
["Preis gebunden bis", formatDateTime(booking.priceLockedUntil)],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-xl font-semibold">Raten und Erstattungen</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Rate</th>
|
||||
<th className="font-normal">Fällig</th>
|
||||
<th className="text-right font-normal">Betrag</th>
|
||||
<th className="pl-4 font-normal">Status</th>
|
||||
<th className="font-normal">Versuche</th>
|
||||
<th className="font-normal">Stripe</th>
|
||||
<th className="font-normal">Erstattung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{booking.installments.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line align-top">
|
||||
<td className="py-2">{i.seq}</td>
|
||||
<td>{i.dueAt ? formatDate(i.dueAt) : "bei Buchung"}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="pl-4">
|
||||
{INSTALLMENT_STATUS_LABEL[i.status]}
|
||||
{i.failureCode && <span className="block text-xs text-stone">{i.failureCode}</span>}
|
||||
{i.nextAttemptAt && <span className="block text-xs text-stone">nächster Versuch {formatDateTime(i.nextAttemptAt)}</span>}
|
||||
</td>
|
||||
<td>{i.attemptCount}</td>
|
||||
<td className="break-all text-xs">{i.stripePaymentIntentId ?? "–"}</td>
|
||||
<td className="text-sm">
|
||||
{i.refunds.map((r) => (
|
||||
<span key={r.id} className="block">{formatEuro(r.amountCents)} ({r.status})</span>
|
||||
))}
|
||||
{i.status === "PAID" && i.stripePaymentIntentId && (
|
||||
<form action={refundAction} className="mt-1 flex flex-wrap gap-2">
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<input type="hidden" name="installmentId" value={i.id} />
|
||||
<input type="hidden" name="requestKey" value={crypto.randomUUID()} />
|
||||
<label className="sr-only" htmlFor={`amount-${i.id}`}>Betrag in Euro</label>
|
||||
<input id={`amount-${i.id}`} name="amount" inputMode="decimal" placeholder="Betrag €" className="field min-h-9 w-28 py-1" required />
|
||||
<label className="sr-only" htmlFor={`reason-${i.id}`}>Grund</label>
|
||||
<input id={`reason-${i.id}`} name="reason" placeholder="Grund" className="field min-h-9 w-40 py-1" />
|
||||
<button type="submit" className="btn btn-quiet min-h-9 px-3 py-1 text-sm">Erstatten</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-3">
|
||||
{booking.status === "NEEDS_REVIEW" && (
|
||||
<Panel title="Prüfung abschließen">
|
||||
<form action={confirmReviewAction}>
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<p className="mb-3 text-sm text-stone">Bestätigt die Buchung, wenn ein Platz frei ist. Andernfalls stornieren und erstatten.</p>
|
||||
<button type="submit" className="btn btn-primary">Buchung bestätigen</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{booking.status !== "CANCELED" && booking.status !== "EXPIRED" && (
|
||||
<Panel title="Stornieren">
|
||||
<form action={cancelBookingAction} className="space-y-3">
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<label className="field-label" htmlFor="cancel-reason">Grund (Pflicht)</label>
|
||||
<input id="cancel-reason" name="reason" className="field" required />
|
||||
<p className="text-sm text-stone">
|
||||
Beendet alle offenen Raten, gibt Platz und Gutschein frei und trennt die gespeicherte Zahlungsmethode. Erstattungen werden danach gemäß den
|
||||
freigegebenen Stornobedingungen oben je Rate ausgelöst.
|
||||
</p>
|
||||
<button type="submit" className="btn btn-quiet">Buchung stornieren</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{booking.status === "CONFIRMED" && (
|
||||
<Panel title="E-Mail">
|
||||
<form action={resendConfirmationAction}>
|
||||
<input type="hidden" name="bookingId" value={booking.id} />
|
||||
<button type="submit" className="btn btn-quiet">Bestätigung erneut senden</button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Protokoll">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{log.map((l) => (
|
||||
<li key={l.id}>
|
||||
<span className="text-stone">{formatDateTime(l.createdAt)}</span> {l.action} <span className="text-stone">({l.actorType})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
<Panel title="E-Mails">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{booking.emails.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="text-stone">{formatDateTime(m.createdAt)}</span> {m.subject} – {m.status === "SENT" ? "gesendet" : m.status === "FAILED" ? "fehlgeschlagen" : "wartet"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { PageTitle } from "@/components/admin/AdminUi";
|
||||
import { BOOKING_STATUSES, bookingWhere, filterQuery, parseBookingFilter, PAYMENT_STATUSES } from "@/lib/admin-queries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { BOOKING_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { formatDateTime } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Buchungen" };
|
||||
|
||||
export default async function BookingsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
await connection();
|
||||
const filter = parseBookingFilter(await searchParams, stripeModeOrNull() === "live" ? "live" : "test");
|
||||
const bookings = await getDb().booking.findMany({
|
||||
where: bookingWhere(filter),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 500,
|
||||
include: { voucher: { select: { code: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle action={<a href={`/admin/export?${filterQuery(filter)}`} className="btn btn-quiet">CSV exportieren</a>}>Buchungen</PageTitle>
|
||||
<form className="mb-6 grid gap-3 rounded-xl border border-line p-4 sm:grid-cols-5" method="get">
|
||||
<label className="text-sm">
|
||||
Modus
|
||||
<select name="modus" defaultValue={filter.modus} className="field mt-1">
|
||||
<option value="test">Test</option>
|
||||
<option value="live">Live</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Buchung
|
||||
<select name="status" defaultValue={filter.status ?? ""} className="field mt-1">
|
||||
<option value="">alle</option>
|
||||
{BOOKING_STATUSES.map((s) => <option key={s} value={s}>{BOOKING_STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Zahlung
|
||||
<select name="zahlung" defaultValue={filter.zahlung ?? ""} className="field mt-1">
|
||||
<option value="">alle</option>
|
||||
{PAYMENT_STATUSES.map((s) => <option key={s} value={s}>{PAYMENT_STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
Suche
|
||||
<input name="q" defaultValue={filter.q ?? ""} placeholder="Nummer, Name, E-Mail" className="field mt-1" />
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary self-end">Filtern</button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[56rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Nummer</th>
|
||||
<th className="font-normal">Name</th>
|
||||
<th className="font-normal">Buchung</th>
|
||||
<th className="font-normal">Zahlung</th>
|
||||
<th className="font-normal">Plan</th>
|
||||
<th className="text-right font-normal">Gesamt</th>
|
||||
<th className="text-right font-normal">Offen</th>
|
||||
<th className="pl-4 font-normal">Angelegt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id} className="border-t border-line">
|
||||
<td className="py-2"><Link href={`/admin/buchungen/${b.id}`} className="font-semibold underline">{b.reference}</Link></td>
|
||||
<td>{b.firstName} {b.lastName}{b.voucher && <span className="ml-2 text-xs text-stone">Gutschein</span>}</td>
|
||||
<td>{BOOKING_STATUS_LABEL[b.status]}</td>
|
||||
<td>{PAYMENT_STATUS_LABEL[b.paymentStatus]}</td>
|
||||
<td>{PLAN_LABEL[b.planType]}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(b.totalCents)}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(Math.max(0, b.totalCents - b.paidCents))}</td>
|
||||
<td className="pl-4 text-sm">{formatDateTime(b.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{bookings.length === 0 && <p className="py-6 text-stone">Keine Buchungen für diesen Filter.</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { connection } from "next/server";
|
||||
import { updateCampAction } from "@/app/admin/actions";
|
||||
import { Checkbox, Field, Flash, PageTitle, Panel } from "@/components/admin/AdminUi";
|
||||
import { getCamp } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { dbDateToIso } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Einstellungen und Freigaben" };
|
||||
|
||||
export default async function SettingsPage({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const flash = await searchParams;
|
||||
const camp = await getCamp(getDb());
|
||||
const mode = stripeModeOrNull();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Einstellungen und Freigaben</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<p className="mb-6 max-w-3xl text-stone">
|
||||
Der Live-Verkauf öffnet erst, wenn „Live-Verkauf einschalten“ gesetzt ist UND alle Bestätigungen vorliegen. Setze eine Bestätigung nur, wenn die
|
||||
Angabe verbindlich geprüft ist. Jede Änderung wird protokolliert.
|
||||
</p>
|
||||
<form action={updateCampAction} className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Termin und Ort">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="campStartDate" label="Anreisetag" type="date" defaultValue={camp.campStartDate ? dbDateToIso(camp.campStartDate) : ""} />
|
||||
<Field name="campEndDate" label="Abreisetag" type="date" defaultValue={camp.campEndDate ? dbDateToIso(camp.campEndDate) : ""} />
|
||||
</div>
|
||||
<Checkbox name="dateConfirmed" label="Termin bestätigt" defaultChecked={camp.dateConfirmed} hint="Erst dann wird der Termin öffentlich angezeigt." />
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<Field name="locationName" label="Ort" defaultValue={camp.locationName} />
|
||||
<Field name="locationRegion" label="Region" defaultValue={camp.locationRegion} />
|
||||
</div>
|
||||
<Checkbox name="locationConfirmed" label="Ort bestätigt" defaultChecked={camp.locationConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Unterkunft und Leistungen">
|
||||
<Field
|
||||
name="accommodationText"
|
||||
label="Verbindliche Zimmer- und Bettbeschreibung"
|
||||
defaultValue={camp.accommodationText}
|
||||
multiline
|
||||
hint="Wird vor dem Kauf angezeigt und muss bestätigt werden, z. B. ob ein französisches Doppelbett geteilt wird."
|
||||
/>
|
||||
<Checkbox name="accommodationConfirmed" label="Unterbringung bestätigt" defaultChecked={camp.accommodationConfirmed} />
|
||||
<Checkbox
|
||||
name="servicesConfirmed"
|
||||
label="Enthaltene Leistungen bestätigt"
|
||||
defaultChecked={camp.servicesConfirmed}
|
||||
hint="Bezieht sich auf die als bestätigt markierten Leistungen in src/content/site.ts."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Veranstalter und Kontakt">
|
||||
<div className="grid gap-4">
|
||||
<Field name="organizerName" label="Veranstalter" defaultValue={camp.organizerName} />
|
||||
<Field name="organizerAddress" label="Anschrift" defaultValue={camp.organizerAddress} multiline />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="organizerEmail" label="E-Mail" type="email" defaultValue={camp.organizerEmail} />
|
||||
<Field name="organizerPhone" label="Telefon" defaultValue={camp.organizerPhone} />
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox name="organizerConfirmed" label="Veranstalter- und Kontaktdaten bestätigt" defaultChecked={camp.organizerConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Preisangabe, Bedingungen, Recht">
|
||||
<Field name="taxNote" label="Steuerlicher Preishinweis" defaultValue={camp.taxNote} hint="Nur nach steuerlicher Prüfung, z. B. zur Umsatzsteuer." />
|
||||
<Checkbox name="taxConfirmed" label="Steuerliche Preisangabe geprüft" defaultChecked={camp.taxConfirmed} />
|
||||
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<Field name="termsVersion" label="Version der Bedingungen" defaultValue={camp.termsVersion} />
|
||||
<Field name="termsUrl" label="Link zu den Bedingungen" defaultValue={camp.termsUrl} hint="https://… oder /pfad" />
|
||||
</div>
|
||||
<Checkbox name="termsApproved" label="Buchungs-, Storno- und Zahlungsbedingungen freigegeben" defaultChecked={camp.termsApproved} />
|
||||
<Checkbox
|
||||
name="travelLawChecked"
|
||||
label="Reiserechtliche Einordnung geprüft"
|
||||
defaultChecked={camp.travelLawChecked}
|
||||
hint="Inklusive ggf. erforderlicher Absicherung und Unterlagen."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Kapazität und Kontingent (intern)">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="capacity" label="Verkaufbare Plätze" type="number" defaultValue={camp.capacity} />
|
||||
<Field name="stammmodelQuota" label="Max. Stammmodel-Buchungen" type="number" defaultValue={camp.stammmodelQuota} />
|
||||
</div>
|
||||
<Checkbox name="capacityConfirmed" label="Platzzahl bestätigt" defaultChecked={camp.capacityConfirmed} />
|
||||
<Checkbox name="stammmodelQuotaConfirmed" label="Kontingent bestätigt" defaultChecked={camp.stammmodelQuotaConfirmed} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Zahlung und Reservierung">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field name="reservationMinutes" label="Reservierung (Min.)" type="number" defaultValue={camp.reservationMinutes} hint="mind. 30" />
|
||||
<Field name="finalInstallmentLeadDays" label="Letzte Rate vor Anreise (Tage)" type="number" defaultValue={camp.finalInstallmentLeadDays} />
|
||||
<Field name="minInstallmentGapDays" label="Mindestabstand Raten (Tage)" type="number" defaultValue={camp.minInstallmentGapDays} />
|
||||
</div>
|
||||
<Checkbox
|
||||
name="installmentRuleApproved"
|
||||
label="Terminregel für Raten freigegeben"
|
||||
defaultChecked={camp.installmentRuleApproved}
|
||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
|
||||
/>
|
||||
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
||||
<label className="mt-3 block">
|
||||
<span className="field-label">Preisphase simulieren (nur Stripe-Testmodus)</span>
|
||||
<select name="testPhaseOverride" defaultValue={camp.testPhaseOverride ?? ""} className="field">
|
||||
<option value="">keine Simulation</option>
|
||||
<option value="EARLY_BIRD">Early Bird</option>
|
||||
<option value="REGULAR">Regulär</option>
|
||||
</select>
|
||||
</label>
|
||||
{mode === "live" && <p className="mt-2 text-sm text-stone">Im Livemodus wirkungslos.</p>}
|
||||
</Panel>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title="Live-Verkauf">
|
||||
<Checkbox
|
||||
name="salesEnabled"
|
||||
label="Live-Verkauf einschalten"
|
||||
defaultChecked={camp.salesEnabled}
|
||||
hint="Wirkt nur mit Stripe-Live-Schlüssel und vollständigen Freigaben."
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary mt-4">Speichern</button>
|
||||
</Panel>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||
|
||||
export const metadata = { title: "Gutschein" };
|
||||
|
||||
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
const flash = await searchParams;
|
||||
const voucher = await getDb().voucher.findUnique({
|
||||
where: { id },
|
||||
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
||||
});
|
||||
if (!voucher) notFound();
|
||||
return (
|
||||
<>
|
||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||
<PageTitle>{voucher.code}</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<VoucherForm voucher={voucher} />
|
||||
<section>
|
||||
<h2 className="mb-3 text-xl font-semibold">Einlösungen</h2>
|
||||
<ul className="space-y-2">
|
||||
{voucher.redemptions.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link href={`/admin/buchungen/${r.booking.id}`} className="underline">{r.booking.reference}</Link> – {REDEMPTION_STATUS_LABEL[r.status]}, Buchung{" "}
|
||||
{BOOKING_STATUS_LABEL[r.booking.status]} {r.booking.livemode ? "" : "(Test)"}
|
||||
</li>
|
||||
))}
|
||||
{voucher.redemptions.length === 0 && <li className="text-stone">Noch keine Einlösungen.</li>}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { formatDate } from "@/lib/time";
|
||||
|
||||
export const metadata = { title: "Gutscheine" };
|
||||
|
||||
export default async function VouchersPage({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||
await connection();
|
||||
const flash = await searchParams;
|
||||
const livemode = stripeModeOrNull() === "live";
|
||||
const now = new Date();
|
||||
const vouchers = await getDb().voucher.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { redemptions: { where: { livemode }, include: { booking: { select: { reservation: { select: { status: true, expiresAt: true } } } } } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Gutscheine (Stammmodel-Codes)</PageTitle>
|
||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||
<p className="mb-6 max-w-3xl text-stone">
|
||||
Codes werden persönlich vergeben und nirgends öffentlich gelistet. Ein Code gibt 10 % auf den jeweils gültigen Preis und ist mit Early Bird
|
||||
kombinierbar, aber nicht mit einem weiteren Gutschein. Das Gesamtkontingent steht unter Einstellungen.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th className="py-2 font-normal">Code</th>
|
||||
<th className="font-normal">Aktiv</th>
|
||||
<th className="font-normal">Gültig</th>
|
||||
<th className="font-normal">Einlösungen</th>
|
||||
<th className="font-normal">Gebunden an</th>
|
||||
<th className="font-normal">Interne Zuordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vouchers.map((v) => {
|
||||
const completed = v.redemptions.filter((r) => r.status === "COMPLETED").length;
|
||||
const reserved = v.redemptions.filter(
|
||||
(r) =>
|
||||
r.status === "RESERVED" &&
|
||||
(r.booking.reservation?.status === "HELD" ||
|
||||
r.booking.reservation?.status === "CONVERTED" ||
|
||||
(r.booking.reservation?.status === "ACTIVE" && r.booking.reservation.expiresAt > now)),
|
||||
).length;
|
||||
return (
|
||||
<tr key={v.id} className="border-t border-line">
|
||||
<td className="py-2"><Link href={`/admin/gutscheine/${v.id}`} className="font-semibold underline">{v.code}</Link></td>
|
||||
<td>{v.active ? "ja" : "nein"}</td>
|
||||
<td className="text-sm">
|
||||
{v.validFrom ? formatDate(v.validFrom) : "sofort"} – {v.validUntil ? formatDate(v.validUntil) : "unbegrenzt"}
|
||||
</td>
|
||||
<td>{completed} abgeschlossen, {reserved} reserviert, max. {v.maxRedemptions}</td>
|
||||
<td className="text-sm">{v.boundEmail ?? "–"}</td>
|
||||
<td className="text-sm">{v.internalNote ?? "–"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{vouchers.length === 0 && <p className="py-6 text-stone">Noch keine Codes angelegt.</p>}
|
||||
</div>
|
||||
<section className="mt-10 max-w-2xl">
|
||||
<h2 className="mb-4 text-xl font-semibold">Neuen Code anlegen</h2>
|
||||
<VoucherForm voucher={null} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdminPage } from "@/lib/admin-auth";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { SignOutButton } from "./SignOutButton";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/admin", label: "Übersicht" },
|
||||
{ href: "/admin/buchungen", label: "Buchungen" },
|
||||
{ href: "/admin/gutscheine", label: "Gutscheine" },
|
||||
{ href: "/admin/einstellungen", label: "Einstellungen und Freigaben" },
|
||||
];
|
||||
|
||||
export default async function ProtectedAdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const admin = await requireAdminPage();
|
||||
const mode = stripeModeOrNull();
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-line bg-plaster">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="display text-2xl">ARTISTA</Link>
|
||||
<span className={`rounded-full px-3 py-0.5 text-xs font-semibold ${mode === "live" ? "bg-rosso text-paper" : "bg-[#efe0b8] text-[#5c4712]"}`}>
|
||||
{mode === "live" ? "Stripe: Livemodus" : mode === "test" ? "Stripe: Testmodus" : "Stripe: nicht konfiguriert"}
|
||||
</span>
|
||||
</div>
|
||||
<nav aria-label="Verwaltung">
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{NAV.map((n) => (
|
||||
<li key={n.href}>
|
||||
<Link href={n.href} className="hover:underline">{n.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-stone">{admin.email}</span>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { Panel, PageTitle, KeyValue } from "@/components/admin/AdminUi";
|
||||
import { countOccupiedSeats } from "@/lib/booking-service";
|
||||
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { BLOCKER_TEXT } from "@/lib/sales-gate";
|
||||
import { formatDate } from "@/lib/time";
|
||||
import { countUsedRedemptions } from "@/lib/vouchers";
|
||||
|
||||
export const metadata = { title: "Übersicht" };
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
await connection();
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const camp = await getCamp(db);
|
||||
const mode = stripeModeOrNull();
|
||||
const livemode = mode === "live";
|
||||
const state = mode ? salesStateFor(camp, mode, now) : null;
|
||||
|
||||
const [occupied, quotaUsed, byStatus, money, reviews, failedEvents, failedMails, upcoming] = await Promise.all([
|
||||
countOccupiedSeats(db, livemode, now),
|
||||
countUsedRedemptions(db, { livemode, now }),
|
||||
db.booking.groupBy({ by: ["status"], where: { livemode }, _count: true }),
|
||||
db.booking.aggregate({ where: { livemode, status: { in: ["CONFIRMED", "NEEDS_REVIEW"] } }, _sum: { totalCents: true, paidCents: true, refundedCents: true } }),
|
||||
db.booking.findMany({ where: { livemode, status: "NEEDS_REVIEW" }, select: { id: true, reference: true, reviewReason: true }, take: 20 }),
|
||||
db.stripeEvent.count({ where: { status: "FAILED" } }),
|
||||
db.emailMessage.count({ where: { status: "FAILED" } }),
|
||||
db.installment.findMany({
|
||||
where: { booking: { livemode, status: "CONFIRMED" }, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] }, seq: { gt: 1 } },
|
||||
include: { booking: { select: { id: true, reference: true } } },
|
||||
orderBy: { dueAt: "asc" },
|
||||
take: 15,
|
||||
}),
|
||||
]);
|
||||
const count = (s: string) => byStatus.find((b) => b.status === s)?._count ?? 0;
|
||||
const total = money._sum.totalCents ?? 0;
|
||||
const paid = money._sum.paidCents ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>Übersicht</PageTitle>
|
||||
<p className="mb-6 text-stone">Alle Zahlen beziehen sich auf den aktuellen Stripe-Modus ({mode === "live" ? "Live" : "Test"}). Test- und Livebuchungen sind getrennt.</p>
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<Panel title="Verkauf">
|
||||
{state ? (
|
||||
<>
|
||||
<KeyValue
|
||||
items={[
|
||||
["Preisphase", `${PHASE_LABEL[state.effectivePhase]}${state.phaseSimulated ? " (simuliert)" : ""}`],
|
||||
["Checkout", state.checkoutOpen ? "offen" : "gesperrt"],
|
||||
["Ratenzahlung", state.installmentsAllowed ? "möglich" : "nicht möglich"],
|
||||
]}
|
||||
/>
|
||||
{state.releaseBlockers.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="font-semibold">Fehlt für den Live-Verkauf:</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm">
|
||||
{state.releaseBlockers.map((b) => (
|
||||
<li key={b}>{BLOCKER_TEXT[b]}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/admin/einstellungen" className="mt-2 inline-block text-sm underline">Freigaben bearbeiten</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-rosso">STRIPE_SECRET_KEY fehlt oder ist ungültig.</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Kapazität (intern, nicht öffentlich)">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Belegt oder reserviert", `${occupied} von ${camp.capacity}${camp.capacityConfirmed ? "" : " (vorläufig)"}`],
|
||||
["Stammmodel-Buchungen", `${quotaUsed} von ${camp.stammmodelQuota}${camp.stammmodelQuotaConfirmed ? "" : " (vorläufig)"}`],
|
||||
["Bestätigt", count("CONFIRMED")],
|
||||
["Zahlung ausstehend", count("PENDING")],
|
||||
["In Prüfung", count("NEEDS_REVIEW")],
|
||||
["Storniert / verfallen", count("CANCELED") + count("EXPIRED")],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Zahlungen">
|
||||
<KeyValue
|
||||
items={[
|
||||
["Vereinbart", formatEuro(total)],
|
||||
["Eingegangen", formatEuro(paid)],
|
||||
["Offen", formatEuro(Math.max(0, total - paid))],
|
||||
["Erstattet", formatEuro(money._sum.refundedCents ?? 0)],
|
||||
["Fehlgeschlagene Webhooks", failedEvents],
|
||||
["Nicht zustellbare E-Mails", failedMails],
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="Prüfung nötig">
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-stone">Keine offenen Prüfungen.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{reviews.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link href={`/admin/buchungen/${r.id}`} className="font-semibold underline">{r.reference}</Link>
|
||||
<span className="block text-sm text-stone">{r.reviewReason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Nächste offene Raten">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-stone">Keine offenen Folgeraten.</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<tbody>
|
||||
{upcoming.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line">
|
||||
<td className="py-1.5"><Link href={`/admin/buchungen/${i.booking.id}`} className="underline">{i.booking.reference}</Link></td>
|
||||
<td>Rate {i.seq}</td>
|
||||
<td>{i.dueAt ? formatDate(i.dueAt) : "–"}</td>
|
||||
<td className="text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="pl-3">{i.status === "PENDING" ? "" : i.status === "FAILED" ? "fehlgeschlagen" : "Aktion nötig"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* Server-Actions des Adminbereichs. JEDE Action prueft zuerst selbst die
|
||||
* Anmeldung inkl. 2FA (requireAdminAction) — unabhaengig davon, ob die
|
||||
* Seite geschuetzt ist, denn Actions sind eigenstaendig aufrufbar.
|
||||
*/
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { adminActor, requireAdminAction } from "@/lib/admin-auth";
|
||||
import {
|
||||
campInputSchema,
|
||||
cancelBooking,
|
||||
confirmReviewedBooking,
|
||||
refundInstallment,
|
||||
resendConfirmation,
|
||||
saveVoucher,
|
||||
updateCamp,
|
||||
voucherInputSchema,
|
||||
type ActionResult,
|
||||
} from "@/lib/admin-service";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
|
||||
function back(path: string, result: ActionResult): never {
|
||||
const param = result.ok ? "ok" : "fehler";
|
||||
redirect(`${path}?${param}=${encodeURIComponent(result.message)}`);
|
||||
}
|
||||
|
||||
function str(form: FormData, key: string): string {
|
||||
const value = form.get(key);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function bool(form: FormData, key: string): boolean {
|
||||
return form.get(key) === "on";
|
||||
}
|
||||
|
||||
function firstIssue(error: { issues: { message: string }[] }): string {
|
||||
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
}
|
||||
|
||||
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
|
||||
export async function cancelBookingAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await cancelBooking(getDb(), getGateway(), { bookingId: id, reason: str(form, "reason") }, adminActor(admin));
|
||||
revalidatePath(`/admin/buchungen/${id}`);
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function refundAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const bookingId = str(form, "bookingId");
|
||||
const installmentId = str(form, "installmentId");
|
||||
const requestKey = str(form, "requestKey");
|
||||
if (!ID.test(bookingId) || !ID.test(installmentId) || !/^[0-9a-f-]{36}$/.test(requestKey)) {
|
||||
back("/admin/buchungen", { ok: false, message: "Ungültige Anfrage." });
|
||||
}
|
||||
const euros = Number(str(form, "amount").replace(",", "."));
|
||||
const amountCents = Math.round(euros * 100);
|
||||
const result = await refundInstallment(
|
||||
getDb(),
|
||||
getGateway(),
|
||||
{ installmentId, amountCents, requestKey, reason: str(form, "reason") },
|
||||
adminActor(admin),
|
||||
);
|
||||
revalidatePath(`/admin/buchungen/${bookingId}`);
|
||||
back(`/admin/buchungen/${bookingId}`, result);
|
||||
}
|
||||
|
||||
export async function confirmReviewAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await confirmReviewedBooking(getDb(), id, adminActor(admin));
|
||||
revalidatePath(`/admin/buchungen/${id}`);
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function resendConfirmationAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "bookingId");
|
||||
if (!ID.test(id)) back("/admin/buchungen", { ok: false, message: "Ungültige Buchung." });
|
||||
const result = await resendConfirmation(getDb(), id, requireAppSecret(), adminActor(admin));
|
||||
back(`/admin/buchungen/${id}`, result);
|
||||
}
|
||||
|
||||
export async function saveVoucherAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const id = str(form, "voucherId") || null;
|
||||
if (id && !ID.test(id)) back("/admin/gutscheine", { ok: false, message: "Ungültiger Gutschein." });
|
||||
const parsed = voucherInputSchema.safeParse({
|
||||
code: str(form, "code"),
|
||||
active: bool(form, "active"),
|
||||
validFrom: str(form, "validFrom"),
|
||||
validUntil: str(form, "validUntil"),
|
||||
maxRedemptions: str(form, "maxRedemptions"),
|
||||
boundEmail: str(form, "boundEmail"),
|
||||
internalNote: str(form, "internalNote"),
|
||||
});
|
||||
const target = id ? `/admin/gutscheine/${id}` : "/admin/gutscheine";
|
||||
if (!parsed.success) back(target, { ok: false, message: firstIssue(parsed.error) });
|
||||
const result = await saveVoucher(getDb(), id, parsed.data, adminActor(admin));
|
||||
revalidatePath("/admin/gutscheine");
|
||||
back(result.ok && result.id ? `/admin/gutscheine/${result.id}` : target, result);
|
||||
}
|
||||
|
||||
export async function updateCampAction(form: FormData) {
|
||||
const admin = await requireAdminAction();
|
||||
const parsed = campInputSchema.safeParse({
|
||||
salesEnabled: bool(form, "salesEnabled"),
|
||||
capacity: str(form, "capacity"),
|
||||
capacityConfirmed: bool(form, "capacityConfirmed"),
|
||||
stammmodelQuota: str(form, "stammmodelQuota"),
|
||||
stammmodelQuotaConfirmed: bool(form, "stammmodelQuotaConfirmed"),
|
||||
campStartDate: str(form, "campStartDate"),
|
||||
campEndDate: str(form, "campEndDate"),
|
||||
dateConfirmed: bool(form, "dateConfirmed"),
|
||||
locationName: str(form, "locationName"),
|
||||
locationRegion: str(form, "locationRegion"),
|
||||
locationConfirmed: bool(form, "locationConfirmed"),
|
||||
accommodationText: str(form, "accommodationText"),
|
||||
accommodationConfirmed: bool(form, "accommodationConfirmed"),
|
||||
servicesConfirmed: bool(form, "servicesConfirmed"),
|
||||
organizerName: str(form, "organizerName"),
|
||||
organizerAddress: str(form, "organizerAddress"),
|
||||
organizerEmail: str(form, "organizerEmail"),
|
||||
organizerPhone: str(form, "organizerPhone"),
|
||||
organizerConfirmed: bool(form, "organizerConfirmed"),
|
||||
taxNote: str(form, "taxNote"),
|
||||
taxConfirmed: bool(form, "taxConfirmed"),
|
||||
termsVersion: str(form, "termsVersion"),
|
||||
termsUrl: str(form, "termsUrl"),
|
||||
termsApproved: bool(form, "termsApproved"),
|
||||
travelLawChecked: bool(form, "travelLawChecked"),
|
||||
installmentsApproved: bool(form, "installmentsApproved"),
|
||||
installmentRuleApproved: bool(form, "installmentRuleApproved"),
|
||||
reservationMinutes: str(form, "reservationMinutes"),
|
||||
finalInstallmentLeadDays: str(form, "finalInstallmentLeadDays"),
|
||||
minInstallmentGapDays: str(form, "minInstallmentGapDays"),
|
||||
testPhaseOverride: str(form, "testPhaseOverride"),
|
||||
});
|
||||
if (!parsed.success) back("/admin/einstellungen", { ok: false, message: firstIssue(parsed.error) });
|
||||
const result = await updateCamp(getDb(), parsed.data, adminActor(admin));
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/");
|
||||
back("/admin/einstellungen", result);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function LoginForm({ initialStep }: { initialStep: "password" | "totp" }) {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(initialStep);
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function signIn(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await authClient.signIn.email({
|
||||
email: String(form.get("email") ?? ""),
|
||||
password: String(form.get("password") ?? ""),
|
||||
});
|
||||
setBusy(false);
|
||||
// Bewusst ohne Unterscheidung zwischen falscher E-Mail und falschem Passwort.
|
||||
if (err) {
|
||||
setError(err.status === 429 ? "Zu viele Versuche. Bitte warte einige Minuten." : "Anmeldung fehlgeschlagen. Bitte prüfe E-Mail und Passwort.");
|
||||
return;
|
||||
}
|
||||
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
|
||||
setStep("totp");
|
||||
return;
|
||||
}
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function verify(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const code = String(form.get("code") ?? "").replace(/\s+/g, "");
|
||||
const result = useBackup
|
||||
? await authClient.twoFactor.verifyBackupCode({ code })
|
||||
: await authClient.twoFactor.verifyTotp({ code });
|
||||
setBusy(false);
|
||||
if (result.error) {
|
||||
setError(result.error.status === 429 ? "Zu viele Versuche. Bitte warte einige Minuten." : "Der Code ist ungültig oder abgelaufen.");
|
||||
return;
|
||||
}
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (step === "totp") {
|
||||
return (
|
||||
<form action={verify} className="mt-8 space-y-5">
|
||||
<div>
|
||||
<label htmlFor="code" className="field-label">
|
||||
{useBackup ? "Backup-Code" : "Code aus der Authenticator-App"}
|
||||
</label>
|
||||
<input id="code" name="code" className="field" inputMode={useBackup ? "text" : "numeric"} autoComplete="one-time-code" required autoFocus />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
|
||||
Bestätigen
|
||||
</button>
|
||||
<button type="button" className="text-sm underline" onClick={() => setUseBackup((v) => !v)}>
|
||||
{useBackup ? "Code aus der App verwenden" : "Stattdessen einen Backup-Code verwenden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={signIn} className="mt-8 space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="field-label">E-Mail</label>
|
||||
<input id="email" name="email" type="email" className="field" autoComplete="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="field-label">Passwort</label>
|
||||
<input id="password" name="password" type="password" className="field" autoComplete="current-password" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
export const metadata = { title: "Anmelden" };
|
||||
|
||||
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ schritt?: string }> }) {
|
||||
const { schritt } = await searchParams;
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-4 py-16">
|
||||
<p className="display text-3xl">ARTISTA</p>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Verwaltung anmelden</h1>
|
||||
<LoginForm initialStep={schritt === "2fa" ? "totp" : "password"} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* CSV-Export der Buchungen (Semikolon, UTF-8 mit BOM fuer Excel).
|
||||
* Nur mit gueltiger Admin-Sitzung inkl. 2FA.
|
||||
*/
|
||||
import { adminOrNull } from "@/lib/admin-auth";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { bookingWhere, csvCell, parseBookingFilter } from "@/lib/admin-queries";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { BOOKING_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
|
||||
const euro = (cents: number) => (cents / 100).toFixed(2).replace(".", ",");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const admin = await adminOrNull();
|
||||
if (!admin) return new Response("Nicht angemeldet.", { status: 401 });
|
||||
|
||||
const params = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const filter = parseBookingFilter(params, stripeModeOrNull() === "live" ? "live" : "test");
|
||||
const db = getDb();
|
||||
const bookings = await db.booking.findMany({
|
||||
where: bookingWhere(filter),
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { installments: { orderBy: { seq: "asc" } }, voucher: { select: { code: true } } },
|
||||
});
|
||||
|
||||
const header = [
|
||||
"Buchungsnummer", "Modus", "Buchungsstatus", "Zahlungsstatus", "Vorname", "Nachname", "E-Mail", "Telefon", "Land",
|
||||
"Preisphase", "Zahlungsweise", "Gesamt EUR", "Bezahlt EUR", "Offen EUR", "Erstattet EUR", "Gutschein",
|
||||
"Nächste offene Rate", "Angelegt", "Bestätigt", "Anmerkungen",
|
||||
];
|
||||
const rows = bookings.map((b) => {
|
||||
const next = b.installments.find((i) => i.status !== "PAID" && i.status !== "CANCELED");
|
||||
return [
|
||||
b.reference, b.livemode ? "Live" : "Test", BOOKING_STATUS_LABEL[b.status], PAYMENT_STATUS_LABEL[b.paymentStatus],
|
||||
b.firstName, b.lastName, b.email, b.phone, b.country, PHASE_LABEL[b.pricePhase], PLAN_LABEL[b.planType],
|
||||
euro(b.totalCents), euro(b.paidCents), euro(Math.max(0, b.totalCents - b.paidCents)), euro(b.refundedCents),
|
||||
b.voucher?.code ?? "", next ? `${euro(next.amountCents)} ${next.dueAt ? `am ${formatDate(next.dueAt)}` : "bei Buchung"}` : "",
|
||||
formatDateTime(b.createdAt), b.confirmedAt ? formatDateTime(b.confirmedAt) : "", b.notes ?? "",
|
||||
].map(csvCell).join(";");
|
||||
});
|
||||
|
||||
await audit(db, { type: "ADMIN", id: admin.id }, "bookings.exported", "Booking", "*", { count: bookings.length, mode: filter.modus });
|
||||
const csv = `${[header.map(csvCell).join(";"), ...rows].join("\r\n")}\r\n`;
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="buchungen-${filter.modus}-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "Verwaltung", template: "%s – Verwaltung ARTISTA Modelcamp" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-dvh bg-paper text-[0.9375rem]">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import QRCode from "qrcode";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const [stage, setStage] = useState<"password" | "verify" | "done">("password");
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function start(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await authClient.twoFactor.enable({ password: String(form.get("password") ?? "") });
|
||||
setBusy(false);
|
||||
if (err || !data || data.method !== "totp") {
|
||||
setError("Passwort falsch oder Einrichtung nicht möglich.");
|
||||
return;
|
||||
}
|
||||
setQr(await QRCode.toDataURL(data.totpURI, { margin: 1, width: 240 }));
|
||||
setSecret(new URL(data.totpURI).searchParams.get("secret"));
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStage("verify");
|
||||
}
|
||||
|
||||
async function verify(form: FormData) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { error: err } = await authClient.twoFactor.verifyTotp({ code: String(form.get("code") ?? "").replace(/\s+/g, "") });
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError("Der Code stimmt nicht. Bitte prüfe die Uhrzeit deines Geräts und versuche es erneut.");
|
||||
return;
|
||||
}
|
||||
setStage("done");
|
||||
}
|
||||
|
||||
if (stage === "password") {
|
||||
return (
|
||||
<form action={start} className="mt-8 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="field-label">Passwort bestätigen</label>
|
||||
<input id="password" name="password" type="password" className="field" autoComplete="current-password" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>Einrichtung starten</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage === "verify") {
|
||||
return (
|
||||
<div className="mt-8 space-y-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- lokal erzeugte data:-URL */}
|
||||
{qr && <img src={qr} alt="QR-Code für die Authenticator-App" width={240} height={240} />}
|
||||
{secret && (
|
||||
<p>
|
||||
Manuelle Eingabe: <code className="break-all rounded bg-plaster px-2 py-1">{secret}</code>
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="font-semibold">Backup-Codes</h2>
|
||||
<p className="text-stone">Jetzt sicher ablegen (z. B. im Passwortmanager). Sie werden nicht erneut angezeigt.</p>
|
||||
<ul className="mt-2 grid grid-cols-2 gap-1 font-mono text-sm">
|
||||
{backupCodes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<form action={verify} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="code" className="field-label">Code aus der App</label>
|
||||
<input id="code" name="code" className="field" inputMode="numeric" autoComplete="one-time-code" required />
|
||||
</div>
|
||||
{error && <p role="alert" className="font-medium text-rosso">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>Aktivieren</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<p className="font-semibold">Zwei-Faktor-Anmeldung ist aktiv.</p>
|
||||
<a href="/admin" className="btn btn-primary mt-4">Zur Übersicht</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdminPage } from "@/lib/admin-auth";
|
||||
import { TwoFactorSetup } from "./TwoFactorSetup";
|
||||
|
||||
export const metadata = { title: "Sicherheit" };
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const admin = await requireAdminPage({ allowWithout2fa: true });
|
||||
return (
|
||||
<main className="mx-auto max-w-xl px-4 py-16">
|
||||
<p className="display text-3xl">ARTISTA</p>
|
||||
<h1 className="mt-6 text-2xl font-semibold">Zwei-Faktor-Anmeldung</h1>
|
||||
{admin.twoFactorEnabled ? (
|
||||
<>
|
||||
<p className="mt-4">Für {admin.email} ist die Zwei-Faktor-Anmeldung aktiv.</p>
|
||||
<Link href="/admin" className="btn btn-primary mt-6">Zur Übersicht</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-4">
|
||||
Der Adminbereich ist nur mit Zwei-Faktor-Anmeldung nutzbar. Richte sie jetzt mit einer Authenticator-App ein (z. B. Aegis, 2FAS oder die App deines
|
||||
Passwortmanagers).
|
||||
</p>
|
||||
<TwoFactorSetup />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return toNextJsHandler(getAuth()).GET(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return toNextJsHandler(getAuth()).POST(request);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Startet eine Buchung. Preis, Gutschein, Zahlungsplan und Verkaufsfreigabe
|
||||
* werden ausschliesslich hier auf dem Server bestimmt — vom Browser kommen
|
||||
* nur Teilnehmerdaten, gewaehlte Zahlungsweise, Gutscheincode und
|
||||
* Erklaerungen, nie ein Betrag.
|
||||
*/
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson, type ApiError } from "@/lib/api";
|
||||
import { bookingRequestSchema } from "@/lib/booking-service";
|
||||
import { startCheckout, type CheckoutError } from "@/lib/checkout";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import { PUBLIC_VOUCHER_REJECTION } from "@/lib/vouchers";
|
||||
|
||||
function mapError(error: CheckoutError): { status: number; body: ApiError } {
|
||||
switch (error.code) {
|
||||
case "SALES_CLOSED":
|
||||
return { status: 409, body: { code: error.code, message: "Die Buchung ist derzeit nicht geöffnet." } };
|
||||
case "SOLD_OUT":
|
||||
return { status: 409, body: { code: error.code, message: "Derzeit ist leider kein Platz frei." } };
|
||||
case "VOUCHER_REJECTED":
|
||||
return { status: 422, body: { code: error.code, message: PUBLIC_VOUCHER_REJECTION, fields: { voucherCode: PUBLIC_VOUCHER_REJECTION } } };
|
||||
case "PLAN_UNAVAILABLE":
|
||||
return { status: 409, body: { code: error.code, message: "Diese Zahlungsweise ist nicht mehr verfügbar. Bitte wähle eine andere." } };
|
||||
case "IDEMPOTENCY_CONFLICT":
|
||||
case "ATTEMPT_EXPIRED":
|
||||
return { status: 409, body: { code: error.code, message: "Die Reservierung ist abgelaufen. Bitte starte die Buchung erneut." } };
|
||||
case "ALREADY_COMPLETED":
|
||||
return { status: 409, body: { code: error.code, message: "Diese Buchung wurde bereits abgeschlossen. Den Stand findest du in deiner Bestätigungs-E-Mail." } };
|
||||
case "PAYMENT_PROVIDER":
|
||||
return { status: 502, body: { code: error.code, message: "Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuche es in einigen Minuten erneut." } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const limited = await rateLimited(request, RATE_LIMITS.checkout);
|
||||
if (limited) return limited;
|
||||
const body = await readJson(request, bookingRequestSchema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
let gateway;
|
||||
try {
|
||||
gateway = getGateway();
|
||||
} catch {
|
||||
return fail(503, { code: "NOT_CONFIGURED", message: "Die Buchung ist derzeit nicht verfügbar." });
|
||||
}
|
||||
|
||||
const result = await startCheckout(getDb(), gateway, body.data, { now: new Date(), secret: requireAppSecret() });
|
||||
if (result.ok) return ok({ url: result.url, reference: result.reference });
|
||||
const mapped = mapError(result.error);
|
||||
return fail(mapped.status, mapped.body);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { stripeModeOrNull } from "@/lib/env";
|
||||
import { computeQuote } from "@/lib/quote";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({
|
||||
voucherCode: z.string().trim().max(60).optional(),
|
||||
email: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const body = await readJson(request, schema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const limited = await rateLimited(request, RATE_LIMITS.quote);
|
||||
if (limited) return limited;
|
||||
// Gutscheinpruefungen zusaetzlich eng begrenzen: gegen Durchprobieren.
|
||||
if (body.data.voucherCode) {
|
||||
const voucherLimited = await rateLimited(request, RATE_LIMITS.voucher);
|
||||
if (voucherLimited) return voucherLimited;
|
||||
}
|
||||
|
||||
const mode = stripeModeOrNull();
|
||||
if (!mode) return fail(503, { code: "NOT_CONFIGURED", message: "Die Buchung ist derzeit nicht verfügbar." });
|
||||
|
||||
const email = body.data.email && z.email().safeParse(body.data.email).success ? body.data.email : undefined;
|
||||
const quote = await computeQuote(getDb(), { voucherCode: body.data.voucherCode || undefined, email }, { now: new Date(), mode });
|
||||
return ok(quote);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Aktionen der Kundin auf ihrer Statusseite. Zugang nur ueber das
|
||||
* Statustoken aus Bestaetigungsmail bzw. Stripe-Ruecksprung.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { requireAppSecret } from "@/lib/env";
|
||||
import { getGateway } from "@/lib/payment-gateway";
|
||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("pay_installment"), token: z.string(), installmentId: z.string().min(1).max(64) }),
|
||||
z.object({ action: z.literal("resume_checkout"), token: z.string() }),
|
||||
z.object({ action: z.literal("abandon"), token: z.string() }),
|
||||
]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const blocked = checkSameOrigin(request);
|
||||
if (blocked) return blocked;
|
||||
const limited = await rateLimited(request, RATE_LIMITS.statusAction);
|
||||
if (limited) return limited;
|
||||
const body = await readJson(request, schema);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const notFound = fail(404, { code: "NOT_FOUND", message: "Diese Buchung wurde nicht gefunden." });
|
||||
if (!isWellFormedStatusToken(body.data.token)) return notFound;
|
||||
const db = getDb();
|
||||
const booking = await db.booking.findUnique({ where: { statusTokenHash: sha256Hex(body.data.token) } });
|
||||
if (!booking) return notFound;
|
||||
|
||||
const gateway = getGateway();
|
||||
if (booking.livemode !== (gateway.mode === "live")) return notFound;
|
||||
const now = new Date();
|
||||
|
||||
switch (body.data.action) {
|
||||
case "pay_installment": {
|
||||
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
|
||||
if (result.ok) return ok({ url: result.url });
|
||||
return result.error === "NOT_PAYABLE"
|
||||
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
||||
: fail(502, { code: "PAYMENT_PROVIDER", message: "Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuche es später erneut." });
|
||||
}
|
||||
case "resume_checkout": {
|
||||
if (booking.status === "PENDING" && booking.paymentStatus === "UNPAID" && booking.stripeCheckoutUrl && booking.priceLockedUntil > now) {
|
||||
return ok({ url: booking.stripeCheckoutUrl });
|
||||
}
|
||||
return fail(409, { code: "EXPIRED", message: "Die Reservierung ist abgelaufen. Bitte starte eine neue Buchung." });
|
||||
}
|
||||
case "abandon": {
|
||||
await abandonCheckout(db, gateway, booking, { now });
|
||||
return ok({ abandoned: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
/** Readiness: nimmt den Pod aus dem Load-Balancing, wenn die Datenbank fehlt. */
|
||||
export async function GET() {
|
||||
try {
|
||||
await getDb().$queryRaw`SELECT 1`;
|
||||
return Response.json({ status: "ready" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch {
|
||||
return Response.json({ status: "database_unavailable" }, { status: 503, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Liveness: bewusst ohne Datenbank — eine DB-Stoerung soll den Pod nicht neu starten. */
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok" }, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Stripe-Webhook. Einzige Stelle (neben dem Worker-Abgleich), die Zahlungen
|
||||
* als eingegangen verbucht — der Aufruf der Erfolgsseite tut das nie.
|
||||
*
|
||||
* Signaturpruefung gegen den unveraenderten Body: deshalb request.text()
|
||||
* statt request.json().
|
||||
*/
|
||||
import { getDb } from "@/lib/db";
|
||||
import { flushEmails } from "@/lib/email";
|
||||
import { env, requireAppSecret } from "@/lib/env";
|
||||
import { getGateway, verifyStripeWebhook } from "@/lib/payment-gateway";
|
||||
import { handleStripeEvent } from "@/lib/webhook";
|
||||
import { detachFinishedPaymentMethods } from "@/lib/worker-jobs";
|
||||
|
||||
const MAX_WEBHOOK_BYTES = 512 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = env().STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) return new Response("Webhook nicht konfiguriert", { status: 503 });
|
||||
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!signature) return new Response("Signatur fehlt", { status: 400 });
|
||||
// Stripe-Events sind wenige Kilobyte. Groessere Koerper werden abgewiesen,
|
||||
// bevor sie (unsigniert) vollstaendig verarbeitet werden.
|
||||
const declared = Number(request.headers.get("content-length") ?? "0");
|
||||
if (declared > MAX_WEBHOOK_BYTES) return new Response("Zu gross", { status: 413 });
|
||||
const raw = await request.text();
|
||||
if (raw.length > MAX_WEBHOOK_BYTES) return new Response("Zu gross", { status: 413 });
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = verifyStripeWebhook(raw, signature, secret);
|
||||
} catch {
|
||||
return new Response("Ungueltige Signatur", { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const gateway = getGateway();
|
||||
let bookingIds: string[];
|
||||
try {
|
||||
const result = await handleStripeEvent(db, gateway, event, {
|
||||
now: new Date(),
|
||||
secret: requireAppSecret(),
|
||||
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
||||
});
|
||||
bookingIds = result.bookingIds;
|
||||
} catch (error) {
|
||||
// 500 -> Stripe stellt das Event spaeter erneut zu.
|
||||
console.error(`[webhook] Verarbeitung von ${event.id} (${event.type}) fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
return new Response("Verarbeitung fehlgeschlagen", { status: 500 });
|
||||
}
|
||||
|
||||
// Nachgelagert und fehlertolerant: der Worker holt beides nach.
|
||||
if (bookingIds.length > 0) {
|
||||
try {
|
||||
await detachFinishedPaymentMethods(db, gateway, bookingIds);
|
||||
for (const id of bookingIds) await flushEmails(db, { bookingId: id });
|
||||
} catch (error) {
|
||||
console.error(`[webhook] Nacharbeit fuer ${event.id} verschoben: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
}
|
||||
}
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
async function action(body: Record<string, string>): Promise<{ url?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/booking/status-action", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) return { url: json.data.url };
|
||||
return { error: json.error?.message ?? "Das hat nicht geklappt. Bitte lade die Seite neu." };
|
||||
} catch {
|
||||
return { error: "Keine Verbindung. Bitte versuche es erneut." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Fragt nach der Rueckkehr von Stripe einige Male den bestaetigten Stand ab. */
|
||||
export function AutoRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
let count = 0;
|
||||
const timer = setInterval(() => {
|
||||
count++;
|
||||
router.refresh();
|
||||
if (count >= 20) clearInterval(timer);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PayInstallmentButton({ token, installmentId }: { token: string; installmentId: string }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary min-h-10 px-4 py-1.5 text-sm"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "pay_installment", token, installmentId });
|
||||
if (result.url) window.location.assign(result.url);
|
||||
else {
|
||||
setError(result.error ?? null);
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "Einen Moment …" : "Jetzt bezahlen"}
|
||||
</button>
|
||||
{error && <p role="alert" className="mt-1 text-sm text-rosso">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingCheckoutActions({ token, canResume, lockedUntil }: { token: string; canResume: boolean; lockedUntil: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mt-6 rounded-2xl border border-line p-6">
|
||||
<p className="font-semibold">Die Zahlung ist noch nicht abgeschlossen.</p>
|
||||
<p className="mt-2 text-ink/80">
|
||||
{canResume
|
||||
? `Dein Platz und der Preis sind bis ${lockedUntil} reserviert.`
|
||||
: "Die Reservierung ist abgelaufen. Du kannst jederzeit eine neue Buchung starten."}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{canResume && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "resume_checkout", token });
|
||||
if (result.url) window.location.assign(result.url);
|
||||
else {
|
||||
setError(result.error ?? null);
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Zahlung fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{canResume ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-quiet"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
const result = await action({ action: "abandon", token });
|
||||
setBusy(false);
|
||||
if (result.error) setError(result.error);
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
Buchung verwerfen
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/#preis" className="btn btn-quiet">
|
||||
Neue Buchung starten
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-rosso">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Buchungsstatus fuer die Kundin. Zeigt ausschliesslich den in der
|
||||
* Datenbank bestaetigten Stand. Der Ruecksprung von Stripe (?checkout=success)
|
||||
* aendert nichts — bezahlt ist eine Buchung erst, wenn der Webhook es
|
||||
* bestaetigt hat.
|
||||
*/
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { BOOKING_STATUS_LABEL, INSTALLMENT_STATUS_LABEL, PAYMENT_STATUS_LABEL } from "@/lib/labels";
|
||||
import { formatEuro } from "@/lib/money";
|
||||
import { PLAN_LABEL } from "@/lib/payment-plan";
|
||||
import { PHASE_LABEL } from "@/lib/pricing";
|
||||
import { formatDate, formatDateTime } from "@/lib/time";
|
||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||
import { AutoRefresh, PayInstallmentButton, PendingCheckoutActions } from "./StatusActions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Deine Buchung",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function StatusPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
searchParams: Promise<{ checkout?: string }>;
|
||||
}) {
|
||||
await connection();
|
||||
const { token } = await params;
|
||||
const { checkout } = await searchParams;
|
||||
if (!isWellFormedStatusToken(token)) notFound();
|
||||
|
||||
const booking = await getDb().booking.findUnique({
|
||||
where: { statusTokenHash: sha256Hex(token) },
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
if (!booking) notFound();
|
||||
|
||||
const now = new Date();
|
||||
const open = Math.max(0, booking.totalCents - booking.paidCents);
|
||||
const waitingForConfirmation = checkout === "success" && booking.status === "PENDING" && booking.paymentStatus === "UNPAID";
|
||||
const pendingUnpaid = booking.status === "PENDING" && booking.paymentStatus === "UNPAID";
|
||||
|
||||
return (
|
||||
<main className="container-edge max-w-3xl py-12 md:py-20">
|
||||
<Link href="/" className="display text-2xl">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</Link>
|
||||
<p className="mt-10 text-stone">Buchung {booking.reference}</p>
|
||||
<h1 className="section-title mt-2">Hallo {booking.firstName}</h1>
|
||||
|
||||
{!booking.livemode && (
|
||||
<p className="mt-6">
|
||||
<span className="draft-flag">Testbuchung</span> Diese Buchung wurde im Stripe-Testmodus angelegt; es wurde kein Geld eingezogen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div role="status" className="mt-8 rounded-2xl border border-line bg-paper p-6">
|
||||
{waitingForConfirmation ? (
|
||||
<>
|
||||
<p className="text-lg font-semibold">Wir warten auf die Bestätigung deiner Zahlung.</p>
|
||||
<p className="mt-2 text-ink/80">
|
||||
Das dauert meist nur wenige Sekunden. Diese Seite aktualisiert sich selbst; zusätzlich bekommst du eine E-Mail, sobald die Zahlung bestätigt ist.
|
||||
</p>
|
||||
<AutoRefresh />
|
||||
</>
|
||||
) : (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-stone">Buchung</dt>
|
||||
<dd className="text-lg font-semibold">{BOOKING_STATUS_LABEL[booking.status]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-stone">Zahlung</dt>
|
||||
<dd className="text-lg font-semibold">{PAYMENT_STATUS_LABEL[booking.paymentStatus]}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
{booking.status === "NEEDS_REVIEW" && (
|
||||
<p className="mt-4 text-ink/80">Deine Zahlung ist eingegangen. Wir prüfen die Buchung kurz manuell und melden uns bei dir.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingUnpaid && checkout !== "success" && (
|
||||
<PendingCheckoutActions token={token} canResume={Boolean(booking.stripeCheckoutUrl) && booking.priceLockedUntil > now} lockedUntil={formatDateTime(booking.priceLockedUntil)} />
|
||||
)}
|
||||
|
||||
<section aria-labelledby="summary" className="mt-12">
|
||||
<h2 id="summary" className="display text-3xl">Übersicht</h2>
|
||||
<dl className="mt-4 divide-y divide-line border-y border-line">
|
||||
<Row label="Preisphase">{PHASE_LABEL[booking.pricePhase]}</Row>
|
||||
<Row label="Gesamtpreis">{formatEuro(booking.totalCents)}</Row>
|
||||
{booking.voucherDiscountCents > 0 && <Row label="Darin enthaltener Gutschein">−{formatEuro(booking.voucherDiscountCents)}</Row>}
|
||||
<Row label="Zahlungsweise">{PLAN_LABEL[booking.planType]}</Row>
|
||||
<Row label="Bezahlt">{formatEuro(booking.paidCents)}</Row>
|
||||
<Row label="Offen">{formatEuro(open)}</Row>
|
||||
{booking.refundedCents > 0 && <Row label="Erstattet">{formatEuro(booking.refundedCents)}</Row>}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="plan" className="mt-12">
|
||||
<h2 id="plan" className="display text-3xl">Zahlungsplan</h2>
|
||||
<table className="mt-4 w-full text-left">
|
||||
<thead className="text-sm text-stone">
|
||||
<tr>
|
||||
<th scope="col" className="py-2 font-normal">Rate</th>
|
||||
<th scope="col" className="py-2 font-normal">Fällig</th>
|
||||
<th scope="col" className="py-2 text-right font-normal">Betrag</th>
|
||||
<th scope="col" className="py-2 pl-4 font-normal">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{booking.installments.map((i) => (
|
||||
<tr key={i.id} className="border-t border-line align-top">
|
||||
<td className="py-3">{booking.installments.length > 1 ? i.seq : "–"}</td>
|
||||
<td className="py-3">{i.dueAt ? formatDate(i.dueAt) : "bei Buchung"}</td>
|
||||
<td className="py-3 text-right tabular-nums">{formatEuro(i.amountCents)}</td>
|
||||
<td className="py-3 pl-4">
|
||||
{INSTALLMENT_STATUS_LABEL[i.status]}
|
||||
{booking.status === "CONFIRMED" && i.seq > 1 && (i.status === "FAILED" || i.status === "REQUIRES_ACTION") && (
|
||||
<PayInstallmentButton token={token} installmentId={i.id} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{booking.installments.length > 1 && (
|
||||
<p className="mt-4 text-[0.9375rem] text-stone">
|
||||
Weitere Raten werden am Fälligkeitstag über die gespeicherte Zahlungsmethode eingezogen. Nach der letzten Rate wird die Zahlungsmethode gelöst.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">{label}</dt>
|
||||
<dd className="text-right tabular-nums">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import { connection } from "next/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { getCamp, publicCampFacts } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Buchungsbedingungen", alternates: { canonical: "/buchungsbedingungen" } };
|
||||
|
||||
/**
|
||||
* Die freigegebenen Buchungs-, Storno- und Zahlungsbedingungen werden als
|
||||
* Dokument (termsUrl) im Adminbereich hinterlegt. Hier werden keine
|
||||
* Bedingungen formuliert oder erfunden.
|
||||
*/
|
||||
export default async function TermsPage() {
|
||||
await connection();
|
||||
const facts = publicCampFacts(await getCamp(getDb()));
|
||||
if (facts.termsUrl && facts.termsUrl !== "/buchungsbedingungen") redirect(facts.termsUrl);
|
||||
return (
|
||||
<LegalPage title="Buchungs-, Storno- und Zahlungsbedingungen">
|
||||
<PendingLegalNotice what="Die freigegebenen Buchungs-, Storno- und Zahlungsbedingungen" />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { showDevPlaceholders } from "@/lib/env";
|
||||
|
||||
export const metadata: Metadata = { title: "Datenschutz", alternates: { canonical: "/datenschutz" } };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Die Datenschutzerklaerung wird rechtlich geprueft ergaenzt. Die Liste
|
||||
* unten beschreibt nur, was diese Anwendung technisch tatsaechlich tut —
|
||||
* als Grundlage fuer die Erstellung, sichtbar nur ausserhalb der Produktion.
|
||||
*/
|
||||
export default function DatenschutzPage() {
|
||||
return (
|
||||
<LegalPage title="Datenschutz">
|
||||
<PendingLegalNotice what="Die vollständige Datenschutzerklärung" />
|
||||
{showDevPlaceholders() && (
|
||||
<section className="rounded-xl border border-dashed border-stone/60 p-5 text-[0.9375rem]">
|
||||
<p className="font-semibold">Technische Grundlage für die Erstellung (nur in Entwicklung sichtbar)</p>
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5">
|
||||
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
||||
<li>Zahlungen über Stripe (Stripe Payments Europe Ltd.); Kartendaten gelangen nicht auf unsere Server, gespeichert werden nur Stripe-Objekt-IDs.</li>
|
||||
<li>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
||||
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</li>
|
||||
<li>Keine Analyse- oder Tracking-Dienste, keine Cookies auf der öffentlichen Seite; Schriften werden selbst ausgeliefert.</li>
|
||||
<li>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
||||
<li>Adminbereich: Sitzungscookie für angemeldete Administratorinnen.</li>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Gestaltung: Modemagazin trifft italienischen Sommer.
|
||||
* Putz-Weiss (plaster) als Grund, Anthrazit (ink) fuer Schrift und dunkle
|
||||
* Flaechen, Pompeji-Rot (rosso) als sparsam eingesetzter Akzent.
|
||||
* Kontraste (gegen plaster): ink 14.6:1, stone 5.3:1, rosso 6.7:1.
|
||||
*/
|
||||
@theme {
|
||||
--color-plaster: #f2ece3;
|
||||
--color-paper: #faf7f1;
|
||||
--color-ink: #23211f;
|
||||
--color-stone: #676058;
|
||||
--color-line: #d8cec0;
|
||||
--color-rosso: #8e3b2b;
|
||||
--color-rosso-deep: #6f2c20;
|
||||
--color-sand: #c9b79c;
|
||||
|
||||
--font-display: var(--font-bodoni), "Bodoni 72", "Didot", Georgia, serif;
|
||||
--font-sans: var(--font-hanken), "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-plaster);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.6;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-rosso);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokus fuer Tastaturbedienung, nie nur ueber Farbe. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-rosso);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.on-dark :focus-visible {
|
||||
outline-color: var(--color-paper);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container-edge {
|
||||
width: 100%;
|
||||
max-width: 88rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.container-edge {
|
||||
padding-inline: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4.25rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.prose-body {
|
||||
max-width: 38rem;
|
||||
}
|
||||
.prose-body p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
background-color 180ms var(--ease-editorial),
|
||||
color 180ms var(--ease-editorial),
|
||||
transform 180ms var(--ease-editorial);
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-rosso);
|
||||
color: var(--color-paper);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-rosso-deep);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: var(--color-stone);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-quiet {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.btn-quiet:hover {
|
||||
background: var(--color-ink);
|
||||
color: var(--color-paper);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.field {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--color-paper);
|
||||
border: 1px solid #b3a794;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.field:focus-visible {
|
||||
outline-offset: 0;
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
.field[aria-invalid="true"] {
|
||||
border-color: var(--color-rosso);
|
||||
}
|
||||
|
||||
.draft-flag {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #efe0b8;
|
||||
color: #5c4712;
|
||||
}
|
||||
}
|
||||
|
||||
/* Masthead: der eine laute Moment der Seite. */
|
||||
.masthead {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: clamp(4.25rem, 21vw, 19rem);
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.035em;
|
||||
font-variation-settings: "opsz" 96;
|
||||
}
|
||||
|
||||
/*
|
||||
* Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt
|
||||
* sich, die Cover-Lines folgen. Reines CSS, kein JavaScript noetig; bei
|
||||
* reduzierter Bewegung greift die Regel in @layer base.
|
||||
*/
|
||||
@keyframes hero-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.18em);
|
||||
clip-path: inset(0 0 100% 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
@keyframes hero-settle {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.035);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes hero-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.hero-rise {
|
||||
animation: hero-rise 1.1s var(--ease-editorial) both;
|
||||
}
|
||||
.hero-image {
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-image > * {
|
||||
animation: hero-settle 1.5s var(--ease-editorial) 0.15s both;
|
||||
}
|
||||
.hero-fade {
|
||||
animation: hero-fade 0.9s var(--ease-editorial) 0.5s both;
|
||||
}
|
||||
|
||||
/* Mobile Buchungsleiste: Inhalt am Seitenende nie verdecken. */
|
||||
body:has(.mobile-book-bar[data-visible="true"]) footer {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
body:has(.mobile-book-bar[data-visible="true"]) footer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
dialog.booking-dialog::backdrop {
|
||||
background: rgb(35 33 31 / 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { connection } from "next/server";
|
||||
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
|
||||
import { getCamp, publicCampFacts } from "@/lib/camp";
|
||||
import { getDb } from "@/lib/db";
|
||||
|
||||
export const metadata: Metadata = { title: "Impressum", alternates: { canonical: "/impressum" } };
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
await connection();
|
||||
const { organizer } = publicCampFacts(await getCamp(getDb()));
|
||||
return (
|
||||
<LegalPage title="Impressum">
|
||||
{organizer ? (
|
||||
<>
|
||||
<p>Angaben gemäß § 5 DDG</p>
|
||||
<address className="not-italic">
|
||||
{organizer.name}
|
||||
<br />
|
||||
<span className="whitespace-pre-line">{organizer.address}</span>
|
||||
</address>
|
||||
<p>
|
||||
E-Mail: <a href={`mailto:${organizer.email}`} className="underline">{organizer.email}</a>
|
||||
{organizer.phone && (
|
||||
<>
|
||||
<br />
|
||||
Telefon: {organizer.phone}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-stone">Weitere Pflichtangaben (z. B. Vertretungsberechtigte, Register, USt-IdNr.) werden nach rechtlicher Prüfung ergänzt.</p>
|
||||
</>
|
||||
) : (
|
||||
<PendingLegalNotice what="Das vollständige Impressum" />
|
||||
)}
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Bodoni_Moda, Hanken_Grotesk } from "next/font/google";
|
||||
import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
// next/font laedt die Schriften zur Build-Zeit herunter und liefert sie
|
||||
// selbst aus: Besucherinnen stellen keine Verbindung zu Google her.
|
||||
const bodoni = Bodoni_Moda({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-bodoni",
|
||||
display: "swap",
|
||||
axes: ["opsz"],
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const hanken = Hanken_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-hanken",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const TITLE = "ARTISTA MODELCAMP 2027";
|
||||
const DESCRIPTION =
|
||||
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Dozentinnen, Preis und Buchung.";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const { APP_URL, APP_ENV } = env();
|
||||
return {
|
||||
metadataBase: new URL(APP_URL),
|
||||
title: { default: TITLE, template: `%s – ${TITLE}` },
|
||||
description: DESCRIPTION,
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
url: "/",
|
||||
siteName: TITLE,
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION },
|
||||
// Nur die Produktivumgebung darf indexiert werden.
|
||||
robots: APP_ENV === "production" ? { index: true, follow: true } : { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#f2ece3",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de" className={`${bodoni.variable} ${hanken.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Auslieferung freigegebener Medien aus dem persistenten Verzeichnis
|
||||
* MEDIA_DIR (Kubernetes: eigenes PersistentVolume, kein emptyDir).
|
||||
*
|
||||
* Nur lesend, nur erlaubte Dateiendungen, kein Verlassen des Verzeichnisses.
|
||||
* Unterstuetzt Range-Anfragen, damit Videos spulbar sind.
|
||||
*/
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".vtt": "text/vtt",
|
||||
};
|
||||
|
||||
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path: segments } = await params;
|
||||
const notFound = new Response("Nicht gefunden", { status: 404 });
|
||||
if (segments.length === 0 || segments.length > 4 || !segments.every((s) => SEGMENT.test(s) && !s.includes(".."))) return notFound;
|
||||
|
||||
const base = path.resolve(env().MEDIA_DIR);
|
||||
const file = path.resolve(base, ...segments);
|
||||
if (!file.startsWith(base + path.sep)) return notFound;
|
||||
const type = TYPES[path.extname(file).toLowerCase()];
|
||||
if (!type) return notFound;
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await stat(file);
|
||||
} catch {
|
||||
return notFound;
|
||||
}
|
||||
if (!info.isFile()) return notFound;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": type,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
|
||||
if (range && (range[1] || range[2])) {
|
||||
const size = info.size;
|
||||
let start = range[1] ? Number(range[1]) : size - Number(range[2]);
|
||||
let end = range[1] && range[2] ? Number(range[2]) : size - 1;
|
||||
start = Math.max(0, start);
|
||||
end = Math.min(end, size - 1);
|
||||
if (start > end) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
|
||||
const stream = Readable.toWeb(createReadStream(file, { start, end })) as ReadableStream;
|
||||
return new Response(stream, {
|
||||
status: 206,
|
||||
headers: { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1) },
|
||||
});
|
||||
}
|
||||
|
||||
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
|
||||
return new Response(stream, { headers: { ...headers, "Content-Length": String(info.size) } });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { connection } from "next/server";
|
||||
import { BookingProvider } from "@/components/booking/BookingProvider";
|
||||
import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
|
||||
import { Hero, priceLine } from "@/components/Hero";
|
||||
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
|
||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
||||
import { PricingSection } from "@/components/sections/PricingSection";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { EventJsonLd } from "@/components/EventJsonLd";
|
||||
import { loadPublicPage } from "@/lib/public-page";
|
||||
|
||||
export default async function HomePage() {
|
||||
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
||||
await connection();
|
||||
const { facts, pricing, showDrafts, externalVideoProvider } = await loadPublicPage();
|
||||
|
||||
return (
|
||||
<BookingProvider pricing={pricing} facts={facts}>
|
||||
<SiteHeader />
|
||||
<main id="inhalt">
|
||||
<Hero facts={facts} pricing={pricing} showDrafts={showDrafts} />
|
||||
<CampSection showDrafts={showDrafts} />
|
||||
<ServicesSection showDrafts={showDrafts} />
|
||||
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
||||
<TeachersSection showDrafts={showDrafts} />
|
||||
<AboutSection showDrafts={showDrafts} />
|
||||
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
||||
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
||||
<FaqSection facts={facts} pricing={pricing} />
|
||||
<ClosingSection pricing={pricing} />
|
||||
</main>
|
||||
<SiteFooter facts={facts} showDrafts={showDrafts} />
|
||||
<MobileBookingBar priceLabel={priceLine(pricing)} />
|
||||
<EventJsonLd facts={facts} pricing={pricing} />
|
||||
</BookingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const { APP_ENV, APP_URL } = env();
|
||||
if (APP_ENV !== "production") return { rules: { userAgent: "*", disallow: "/" } };
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/", disallow: ["/admin", "/buchung", "/api"] },
|
||||
host: new URL(APP_URL).host,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { appUrl } from "@/lib/env";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
/**
|
||||
* Strukturierte Veranstaltungsdaten (schema.org/Event) — nur, wenn Termin,
|
||||
* Ort und Veranstalter bestaetigt sind und der Live-Verkauf laeuft.
|
||||
* Unbestaetigte Angaben erscheinen nie in Suchmaschinen.
|
||||
*/
|
||||
export function EventJsonLd({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
if (!facts.startDate || !facts.location || !facts.organizer || pricing.mode !== "live" || !pricing.checkoutOpen || pricing.currentCents === null) {
|
||||
return null;
|
||||
}
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: "ARTISTA MODELCAMP 2027",
|
||||
startDate: facts.startDate,
|
||||
...(facts.endDate ? { endDate: facts.endDate } : {}),
|
||||
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||
eventStatus: "https://schema.org/EventScheduled",
|
||||
location: { "@type": "Place", name: facts.location, address: { "@type": "PostalAddress", addressCountry: "IT", ...(facts.region ? { addressRegion: facts.region } : {}) } },
|
||||
organizer: { "@type": "Organization", name: facts.organizer.name, email: facts.organizer.email },
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: (pricing.currentCents / 100).toFixed(2),
|
||||
priceCurrency: "EUR",
|
||||
availability: "https://schema.org/InStock",
|
||||
url: appUrl("/#preis"),
|
||||
},
|
||||
};
|
||||
// JSON.stringify + Ersetzen von "<" verhindert, dass Inhalte das
|
||||
// Script-Element vorzeitig schliessen.
|
||||
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
function describe(media: Media): string {
|
||||
return media.kind === "image" ? media.alt : `Platzhalter: ${media.label}`;
|
||||
}
|
||||
|
||||
/** Bildmosaik der Unterkunft mit Vollbildansicht (Pfeiltasten, Esc). */
|
||||
export function Gallery({ images, showLabels }: { images: readonly Media[]; showLabels: boolean }) {
|
||||
const [current, setCurrent] = useState<number | null>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggers = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (current !== null && !dialog.open) dialog.showModal();
|
||||
if (current === null && dialog.open) dialog.close();
|
||||
}, [current]);
|
||||
|
||||
const close = () => {
|
||||
const index = current;
|
||||
setCurrent(null);
|
||||
if (index !== null) requestAnimationFrame(() => triggers.current[index]?.focus());
|
||||
};
|
||||
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
||||
|
||||
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
|
||||
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
|
||||
const aspect = (index: number) =>
|
||||
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[4/3] md:aspect-[21/8]" : "aspect-[4/3]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
|
||||
{images.map((media, index) => (
|
||||
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
||||
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
||||
<button
|
||||
ref={(el) => {
|
||||
triggers.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setCurrent(index)}
|
||||
aria-label={`${describe(media)} vergrößern`}
|
||||
className={`block w-full ${index === 0 ? "md:h-full" : ""}`}
|
||||
>
|
||||
<MediaFrame
|
||||
media={media}
|
||||
aspect={aspect(index)}
|
||||
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
|
||||
showLabel={showLabels}
|
||||
className="w-full"
|
||||
/>
|
||||
</button>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Bildansicht Unterkunft"
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight") step(1);
|
||||
if (e.key === "ArrowLeft") step(-1);
|
||||
}}
|
||||
className="booking-dialog m-auto w-full max-w-5xl bg-transparent p-4"
|
||||
>
|
||||
{current !== null && (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={current}
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<MediaFrame media={images[current]!} aspect="aspect-[3/2]" sizes="90vw" showLabel={showLabels} />
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-paper">
|
||||
<p aria-live="polite" className="text-sm">
|
||||
Bild {current + 1} von {images.length}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(-1)}>
|
||||
Vorheriges
|
||||
</button>
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(1)}>
|
||||
Nächstes
|
||||
</button>
|
||||
<button type="button" className="btn bg-paper min-h-11 py-2 text-ink" onClick={close} autoFocus>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { formatIsoDateLong } from "@/lib/time";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
import { BookButton } from "./booking/BookingProvider";
|
||||
|
||||
export function dateLine(facts: PublicCampFacts): string {
|
||||
if (!facts.startDate) return "Termin wird bekanntgegeben";
|
||||
if (!facts.endDate) return `ab ${formatIsoDateLong(facts.startDate)}`;
|
||||
return `${formatIsoDateLong(facts.startDate)} bis ${formatIsoDateLong(facts.endDate)}`;
|
||||
}
|
||||
|
||||
export function priceLine(pricing: PublicPricing): string {
|
||||
if (pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null) {
|
||||
return `${formatEuroShort(pricing.currentCents)} Early Bird statt ${formatEuroShort(pricing.regularCents)}`;
|
||||
}
|
||||
if (pricing.phase === "REGULAR" && pricing.currentCents !== null) return `${formatEuroShort(pricing.currentCents)} pro Person`;
|
||||
return `Early Bird ab 1. November 2026: ${formatEuroShort(pricing.earlyBirdCents)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
||||
* Termin, Ort und Preis stehen als Cover-Lines daneben.
|
||||
*/
|
||||
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
||||
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
|
||||
return (
|
||||
<section id="top" aria-labelledby="hero-title" className="relative pb-16 pt-4 md:pb-24">
|
||||
<div className="container-edge">
|
||||
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
|
||||
<span>Modelcamp 2027</span>
|
||||
<span>{place}</span>
|
||||
</p>
|
||||
<h1 id="hero-title" className="relative z-10 mt-3">
|
||||
<span className="masthead hero-rise block" aria-hidden>
|
||||
ARTISTA
|
||||
</span>
|
||||
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
|
||||
</h1>
|
||||
|
||||
<div className="mt-[-3.2vw] grid gap-8 md:grid-cols-12 md:gap-10">
|
||||
<div className="hero-image md:col-span-7 lg:col-span-8">
|
||||
<MediaFrame
|
||||
media={site.hero.media}
|
||||
aspect="aspect-[4/5] md:aspect-[5/4] lg:aspect-[16/11]"
|
||||
sizes="(min-width: 1024px) 66vw, (min-width: 768px) 58vw, 100vw"
|
||||
showLabel={showDrafts}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hero-fade flex flex-col justify-end md:col-span-5 lg:col-span-4">
|
||||
<p className="display text-[2.25rem] leading-[1.05] md:text-[2.75rem]">{site.hero.headline}</p>
|
||||
<p className="mt-5 max-w-md text-lg text-ink/85">{site.hero.subline}</p>
|
||||
<dl className="mt-8 divide-y divide-line border-y border-line text-[0.9375rem]">
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Termin</dt>
|
||||
<dd className="text-right">{dateLine(facts)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Ort</dt>
|
||||
<dd className="text-right">{facts.location ?? "Italien, genauer Ort folgt"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Preis</dt>
|
||||
<dd className="text-right">{priceLine(pricing)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-primary">
|
||||
Preis und Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#camp" className="btn btn-quiet">
|
||||
Mehr über das Camp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** Rahmen fuer die rechtlichen Seiten. Inhalte ausschliesslich freigegeben. */
|
||||
export function LegalPage({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="container-edge max-w-3xl py-12 md:py-20">
|
||||
<Link href="/" className="display text-2xl">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</Link>
|
||||
<h1 className="section-title mt-12">{title}</h1>
|
||||
<div className="prose-body mt-8 space-y-4 text-ink/90">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingLegalNotice({ what }: { what: string }) {
|
||||
return (
|
||||
<p className="rounded-xl border border-line bg-paper p-5">
|
||||
{what} veröffentlichen wir vor dem Verkaufsstart. Bis dahin ist keine verbindliche Buchung möglich.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Image from "next/image";
|
||||
import type { Media, PlaceholderTone } from "@/content/site";
|
||||
|
||||
const TONES: Record<PlaceholderTone, { background: string; shape: "arch" | "cypress" | "window" | "figure" }> = {
|
||||
villa: { background: "linear-gradient(172deg, #efe2cc 0%, #e0c7a2 52%, #c49a6c 100%)", shape: "arch" },
|
||||
terrace: { background: "linear-gradient(185deg, #e9dcc6 0%, #d7c29d 60%, #9f8b62 100%)", shape: "cypress" },
|
||||
room: { background: "linear-gradient(160deg, #f3ebdf 0%, #e3d3bb 60%, #cdb592 100%)", shape: "window" },
|
||||
garden: { background: "linear-gradient(190deg, #e6e0cc 0%, #c9c3a0 55%, #8c8a64 100%)", shape: "cypress" },
|
||||
portrait: { background: "linear-gradient(170deg, #eadccb 0%, #d4b99c 60%, #a8805f 100%)", shape: "figure" },
|
||||
studio: { background: "linear-gradient(175deg, #e4ddd3 0%, #cabdac 60%, #8f8273 100%)", shape: "figure" },
|
||||
};
|
||||
|
||||
function Shape({ shape }: { shape: (typeof TONES)[PlaceholderTone]["shape"] }) {
|
||||
const common = { fill: "currentColor", className: "absolute inset-0 h-full w-full text-ink/10" } as const;
|
||||
switch (shape) {
|
||||
case "arch":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M120 500V250a80 80 0 0 1 160 0v250z" />
|
||||
<path d="M20 500V330a40 40 0 0 1 80 0v170zM300 500V330a40 40 0 0 1 80 0v170z" opacity=".6" />
|
||||
</svg>
|
||||
);
|
||||
case "cypress":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M90 500c-22-90-18-230 12-330 26 100 30 240 10 330z" />
|
||||
<path d="M290 500c-18-70-15-180 10-260 21 80 24 190 8 260z" opacity=".7" />
|
||||
<rect x="0" y="440" width="400" height="60" opacity=".5" />
|
||||
</svg>
|
||||
);
|
||||
case "window":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMid slice" aria-hidden {...common}>
|
||||
<path d="M150 90h100v190H150z" opacity=".7" />
|
||||
<rect x="60" y="360" width="280" height="70" rx="6" />
|
||||
</svg>
|
||||
);
|
||||
case "figure":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<circle cx="200" cy="185" r="62" />
|
||||
<path d="M80 500c0-120 54-200 120-200s120 80 120 200z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
media: Media;
|
||||
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
|
||||
aspect: string;
|
||||
sizes: string;
|
||||
showLabel: boolean;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
||||
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
||||
* uebersehen werden.
|
||||
*/
|
||||
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
|
||||
if (media.kind === "image") {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
|
||||
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tone = TONES[media.tone];
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Platzhalter: ${media.label}`}
|
||||
className={`relative overflow-hidden ${aspect} ${className}`}
|
||||
style={{ background: tone.background }}
|
||||
>
|
||||
<Shape shape={tone.shape} />
|
||||
{showLabel && (
|
||||
<span className="absolute bottom-3 left-3 rounded-full bg-paper/85 px-3 py-1 text-xs font-semibold text-ink">
|
||||
Platzhalter: {media.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Einmaliges Aufdecken eines Bildes beim Hineinscrollen (Clip von unten).
|
||||
* Nur fuer Bildflaechen — Text erscheint sofort, damit nichts auf eine
|
||||
* Animation warten muss.
|
||||
*/
|
||||
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
||||
const reduce = useReducedMotion();
|
||||
if (reduce) return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className={className}
|
||||
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
||||
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
|
||||
export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const organizer = facts.organizer;
|
||||
return (
|
||||
<footer id="site-footer" className="border-t border-line bg-plaster">
|
||||
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
|
||||
<div className="md:col-span-5">
|
||||
<p className="display text-4xl">ARTISTA</p>
|
||||
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<h2 className="font-semibold">Kontakt</h2>
|
||||
{organizer ? (
|
||||
<address className="mt-3 not-italic leading-relaxed">
|
||||
{organizer.name}
|
||||
<br />
|
||||
<span className="whitespace-pre-line">{organizer.address}</span>
|
||||
<br />
|
||||
<a href={`mailto:${organizer.email}`} className="underline underline-offset-4">
|
||||
{organizer.email}
|
||||
</a>
|
||||
{organizer.phone && (
|
||||
<>
|
||||
<br />
|
||||
<a href={`tel:${organizer.phone.replace(/\s+/g, "")}`}>{organizer.phone}</a>
|
||||
</>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p className="mt-3 text-stone">
|
||||
{showDrafts ? <span className="draft-flag">Veranstalter- und Kontaktdaten folgen</span> : "Kontaktdaten folgen in Kürze."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<nav aria-label="Rechtliches" className="md:col-span-3">
|
||||
<h2 className="font-semibold">Rechtliches</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<li><a href="/impressum" className="hover:underline">Impressum</a></li>
|
||||
<li><a href="/datenschutz" className="hover:underline">Datenschutz</a></li>
|
||||
<li><a href={facts.termsUrl ?? "/buchungsbedingungen"} className="hover:underline">Buchungsbedingungen</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NAV_ITEMS } from "@/content/site";
|
||||
|
||||
export function SiteHeader() {
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuButton = useRef<HTMLButtonElement>(null);
|
||||
const firstLink = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Markiert den Abschnitt, der gerade die Bildschirmmitte kreuzt.
|
||||
useEffect(() => {
|
||||
const sections = NAV_ITEMS.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id);
|
||||
},
|
||||
{ rootMargin: "-45% 0px -50% 0px" },
|
||||
);
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
firstLink.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
menuButton.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-40 transition-colors duration-300 ${scrolled ? "bg-plaster/92 shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "bg-transparent"}`}
|
||||
>
|
||||
<a href="#inhalt" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:bg-paper focus:px-3 focus:py-2">
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<div className="container-edge flex h-16 items-center justify-between gap-6">
|
||||
<a href="#top" className="display text-xl tracking-tight" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</a>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<nav aria-label="Abschnitte" className="hidden lg:block">
|
||||
<ul className="flex items-center gap-6 text-[0.9375rem]">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.id} className="relative">
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={active === item.id ? "location" : undefined}
|
||||
className="block py-2 text-ink/80 hover:text-ink aria-[current]:text-ink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{active === item.id && (
|
||||
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-px bg-rosso" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</LazyMotion>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
|
||||
Platz buchen
|
||||
</a>
|
||||
<button
|
||||
ref={menuButton}
|
||||
type="button"
|
||||
className="inline-flex h-11 items-center rounded-full border border-ink/30 px-4 text-sm font-semibold lg:hidden"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
{menuOpen ? "Schließen" : "Menü"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{menuOpen && (
|
||||
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster lg:hidden">
|
||||
<ul className="container-edge py-4">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
ref={index === 0 ? firstLink : undefined}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="display block border-b border-line/70 py-3 text-2xl"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
|
||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null; externalId: string | null };
|
||||
|
||||
const EXTERNAL = {
|
||||
"youtube-nocookie": { label: "YouTube", url: (id: string) => `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0` },
|
||||
vimeo: { label: "Vimeo", url: (id: string) => `https://player.vimeo.com/video/${encodeURIComponent(id)}?autoplay=1&dnt=1` },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Ein Feedback-Video. Geladen wird erst nach Klick (preload="none"); es
|
||||
* startet nie von selbst mit Ton. Externe Player (YouTube/Vimeo) werden nur
|
||||
* geladen, wenn das Datenschutzkonzept sie erlaubt (EXTERNAL_VIDEO_PROVIDER)
|
||||
* und die Besucherin dem Laden ausdruecklich zustimmt.
|
||||
*/
|
||||
export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; index: number; provider: "none" | "youtube-nocookie" | "vimeo"; showDrafts: boolean }) {
|
||||
const [stage, setStage] = useState<"poster" | "consent" | "playing">("poster");
|
||||
const label = slot.name ? `Video von ${slot.name} abspielen` : `Feedback-Video ${index + 1} abspielen`;
|
||||
const external = slot.externalId && provider !== "none" ? EXTERNAL[provider] : null;
|
||||
const playable = Boolean(slot.src) || Boolean(external);
|
||||
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Feedback-Video ${index + 1} folgt`, tone: "studio" };
|
||||
|
||||
return (
|
||||
<figure>
|
||||
<div className="relative aspect-[4/5] overflow-hidden bg-ink">
|
||||
{stage === "playing" && slot.src && (
|
||||
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
{stage === "playing" && !slot.src && external && slot.externalId && (
|
||||
<iframe
|
||||
src={external.url(slot.externalId)}
|
||||
title={slot.name ? `Video von ${slot.name}` : `Feedback-Video ${index + 1}`}
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="h-full w-full"
|
||||
/>
|
||||
)}
|
||||
{stage !== "playing" && (
|
||||
<>
|
||||
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 768px) 33vw, 100vw" showLabel={showDrafts} />
|
||||
{playable && stage === "poster" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStage(external && !slot.src ? "consent" : "playing")}
|
||||
aria-label={label}
|
||||
className="absolute inset-0 m-auto flex h-20 w-20 items-center justify-center rounded-full bg-paper/90 text-ink transition-transform hover:scale-105"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
|
||||
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{stage === "consent" && external && (
|
||||
<div className="absolute inset-0 flex flex-col justify-end gap-3 bg-ink/85 p-5 text-paper">
|
||||
<p className="text-[0.9375rem]">
|
||||
Beim Abspielen wird eine Verbindung zu {external.label} hergestellt und Daten werden dorthin übertragen. Details in der{" "}
|
||||
<a href="/datenschutz" className="underline">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn bg-paper text-ink" onClick={() => setStage("playing")}>
|
||||
Video laden
|
||||
</button>
|
||||
<button type="button" className="btn btn-quiet text-paper" onClick={() => setStage("poster")}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="mt-4">
|
||||
{slot.name ? (
|
||||
<span className="block font-semibold">{slot.name}</span>
|
||||
) : (
|
||||
showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>
|
||||
)}
|
||||
{slot.caption && <span className="mt-1 block text-ink/80">{slot.caption}</span>}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Kleine, gemeinsame Bausteine fuer die Admin-Seiten. */
|
||||
|
||||
export function Flash({ ok, fehler }: { ok?: string; fehler?: string }) {
|
||||
if (!ok && !fehler) return null;
|
||||
return (
|
||||
<p role={fehler ? "alert" : "status"} className={`mb-6 rounded-lg border p-3 font-medium ${fehler ? "border-rosso/40 text-rosso" : "border-line bg-plaster"}`}>
|
||||
{fehler ?? ok}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<h1 className="display text-4xl">{children}</h1>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-line bg-plaster/60 p-5">
|
||||
<h2 className="mb-3 font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValue({ items }: { items: [string, React.ReactNode][] }) {
|
||||
return (
|
||||
<dl className="divide-y divide-line">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-4 py-1.5">
|
||||
<dt className="text-stone">{k}</dt>
|
||||
<dd className="text-right tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({ name, label, defaultChecked, hint }: { name: string; label: string; defaultChecked: boolean; hint?: string }) {
|
||||
return (
|
||||
<label className="flex gap-3 py-1.5">
|
||||
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]" />
|
||||
<span>
|
||||
{label}
|
||||
{hint && <span className="block text-sm text-stone">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
type = "text",
|
||||
hint,
|
||||
multiline,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string | number | null | undefined;
|
||||
type?: string;
|
||||
hint?: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`f-${name}`} className="field-label">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea id={`f-${name}`} name={name} defaultValue={defaultValue ?? ""} rows={4} className="field" />
|
||||
) : (
|
||||
<input id={`f-${name}`} name={name} type={type} defaultValue={defaultValue ?? ""} className="field" />
|
||||
)}
|
||||
{hint && <p className="mt-1 text-sm text-stone">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Voucher } from "@/generated/prisma/client";
|
||||
import { saveVoucherAction } from "@/app/admin/actions";
|
||||
import { berlinDateString } from "@/lib/time";
|
||||
import { Checkbox, Field } from "./AdminUi";
|
||||
|
||||
/** Anlegen und Bearbeiten eines Stammmodel-Codes. Alle Felder bleiben aenderbar. */
|
||||
export function VoucherForm({ voucher }: { voucher: Voucher | null }) {
|
||||
return (
|
||||
<form action={saveVoucherAction} className="space-y-4">
|
||||
{voucher && <input type="hidden" name="voucherId" value={voucher.id} />}
|
||||
<Field name="code" label="Code" defaultValue={voucher?.code} hint="Wird in Großbuchstaben gespeichert. Keine öffentlich erratbaren Codes verwenden." />
|
||||
<Checkbox name="active" label="Aktiv" defaultChecked={voucher?.active ?? true} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="validFrom" label="Gültig ab (optional)" type="date" defaultValue={voucher?.validFrom ? berlinDateString(voucher.validFrom) : ""} />
|
||||
<Field
|
||||
name="validUntil"
|
||||
label="Gültig bis einschließlich (optional)"
|
||||
type="date"
|
||||
defaultValue={voucher?.validUntil ? berlinDateString(new Date(voucher.validUntil.getTime() - 1)) : ""}
|
||||
/>
|
||||
</div>
|
||||
<Field name="maxRedemptions" label="Maximale Einlösungen" type="number" defaultValue={voucher?.maxRedemptions ?? 1} />
|
||||
<Field name="boundEmail" label="Nur für diese E-Mail-Adresse (optional)" type="email" defaultValue={voucher?.boundEmail} />
|
||||
<Field name="internalNote" label="Interne Zuordnung" defaultValue={voucher?.internalNote} hint="Nur hier sichtbar, z. B. Name des Stammmodels." />
|
||||
<button type="submit" className="btn btn-primary">{voucher ? "Speichern" : "Code anlegen"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import type { QuoteData } from "@/lib/quote";
|
||||
|
||||
type PlanType = "FULL" | "TWO_INSTALLMENTS" | "THREE_INSTALLMENTS";
|
||||
|
||||
const eur = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
const money = (cents: number) => eur.format(cents / 100);
|
||||
const dateFmt = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" });
|
||||
const isoDate = (iso: string) => dateFmt.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
const STEPS = ["Deine Daten", "Zahlungsweise", "Prüfen und bezahlen"] as const;
|
||||
|
||||
type Participant = { firstName: string; lastName: string; email: string; phone: string; country: string; notes: string };
|
||||
type Consents = { adult: boolean; terms: boolean; accommodation: boolean; privacy: boolean; installmentMandate: boolean };
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
const EMPTY: Participant = { firstName: "", lastName: "", email: "", phone: "", country: "Deutschland", notes: "" };
|
||||
|
||||
type ApiResult<T> = { success: true; data: T } | { success: false; error: { code: string; message: string; fields?: FieldErrors } };
|
||||
|
||||
async function post<T>(url: string, body: unknown): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
return (await res.json()) as ApiResult<T>;
|
||||
} catch {
|
||||
return { success: false, error: { code: "NETWORK", message: "Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut." } };
|
||||
}
|
||||
}
|
||||
|
||||
function validateParticipant(p: Participant): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!p.firstName.trim()) errors["participant.firstName"] = "Bitte gib deinen Vornamen an.";
|
||||
if (!p.lastName.trim()) errors["participant.lastName"] = "Bitte gib deinen Nachnamen an.";
|
||||
if (!/^\S+@\S+\.\S+$/.test(p.email.trim())) errors["participant.email"] = "Bitte gib eine gültige E-Mail-Adresse an.";
|
||||
if (!/^[+0-9 ()/-]{6,40}$/.test(p.phone.trim())) errors["participant.phone"] = "Bitte gib eine Telefonnummer mit mindestens sechs Ziffern an.";
|
||||
if (p.country.trim().length < 2) errors["participant.country"] = "Bitte gib dein Land an.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function BookingDialog({
|
||||
open,
|
||||
onClose,
|
||||
pricing,
|
||||
facts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
const titleId = useId();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
||||
const [voucherInput, setVoucherInput] = useState("");
|
||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||
const [consents, setConsents] = useState<Consents>({ adult: false, terms: false, accommodation: false, privacy: false, installmentMandate: false });
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Idempotenz: identische Absendungen (Doppelklick, Wiederholung nach
|
||||
// Netzfehler) tragen denselben Schluessel, jede geaenderte Angabe einen neuen.
|
||||
const lastSubmission = useRef<{ fingerprint: string; key: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadQuote = useCallback(
|
||||
async (voucherCode: string | null) => {
|
||||
const result = await post<QuoteData>("/api/booking/quote", {
|
||||
voucherCode: voucherCode ?? undefined,
|
||||
email: participant.email.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setQuote(result.data);
|
||||
return result.data;
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
return null;
|
||||
},
|
||||
[participant.email],
|
||||
);
|
||||
|
||||
// Erste Preisvorschau beim Oeffnen.
|
||||
useEffect(() => {
|
||||
if (!open || quote) return;
|
||||
let cancelled = false;
|
||||
post<QuoteData>("/api/booking/quote", {}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success) setQuote(result.data);
|
||||
else setFormError(result.error.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, quote]);
|
||||
|
||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
||||
|
||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
||||
|
||||
function updateParticipant(field: keyof Participant, value: string) {
|
||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`participant.${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
setFormError(null);
|
||||
if (step === 0) {
|
||||
const found = validateParticipant(participant);
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
requestAnimationFrame(() => (dialogRef.current?.querySelector('[aria-invalid="true"]') as HTMLElement | null)?.focus());
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
function back() {
|
||||
setFormError(null);
|
||||
setStep((s) => Math.max(s - 1, 0));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
async function applyVoucher() {
|
||||
const code = voucherInput.trim();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
const data = await loadQuote(code);
|
||||
setBusy(false);
|
||||
if (!data) return;
|
||||
if (data.voucher.state === "applied") {
|
||||
setAppliedVoucher(code);
|
||||
setVoucherMessage("Gutschein angewendet.");
|
||||
} else {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherMessage(data.voucher.message ?? "Dieser Gutscheincode kann leider nicht angewendet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeVoucher() {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherInput("");
|
||||
setVoucherMessage(null);
|
||||
await loadQuote(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setFormError(null);
|
||||
const missing: FieldErrors = {};
|
||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||
if (!consents.accommodation) missing["consents.accommodation"] = "Bitte bestätige die Unterbringung.";
|
||||
if (!consents.privacy) missing["consents.privacy"] = "Bitte bestätige die Datenschutzhinweise.";
|
||||
if (planType !== "FULL" && !consents.installmentMandate) missing["consents.installmentMandate"] = "Bitte stimme dem Einzug der weiteren Raten zu.";
|
||||
setErrors(missing);
|
||||
if (Object.keys(missing).length > 0) return;
|
||||
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
participant: { ...participant, notes: participant.notes || undefined },
|
||||
planType,
|
||||
voucherCode: appliedVoucher ?? undefined,
|
||||
consents,
|
||||
};
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
if (lastSubmission.current?.fingerprint !== fingerprint) {
|
||||
lastSubmission.current = { fingerprint, key: crypto.randomUUID() };
|
||||
}
|
||||
const result = await post<{ url: string }>("/api/booking/checkout", { idempotencyKey: lastSubmission.current.key, ...payload });
|
||||
if (result.success) {
|
||||
window.location.assign(result.data.url);
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
if (result.error.fields) {
|
||||
setErrors(result.error.fields);
|
||||
if (Object.keys(result.error.fields).some((k) => k.startsWith("participant."))) setStep(0);
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
if (result.error.code === "VOUCHER_REJECTED") {
|
||||
setAppliedVoucher(null);
|
||||
void loadQuote(null);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = (field: keyof Participant) => ({
|
||||
id: `bk-${field}`,
|
||||
value: participant[field],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => updateParticipant(field, e.target.value),
|
||||
"aria-invalid": errors[`participant.${field}`] ? true : undefined,
|
||||
"aria-describedby": errors[`participant.${field}`] ? `bk-${field}-error` : undefined,
|
||||
className: "field",
|
||||
});
|
||||
|
||||
const fieldError = (key: string, id: string) =>
|
||||
errors[key] ? (
|
||||
<p id={id} className="mt-1.5 text-sm font-medium text-rosso">
|
||||
{errors[key]}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const today = selectedPlan?.installments[0]?.amountCents ?? null;
|
||||
const organizer = facts.organizer?.name ?? "den Veranstalter";
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
className="booking-dialog m-0 h-dvh max-h-dvh w-full max-w-full bg-plaster p-0 text-ink backdrop:bg-transparent md:m-auto md:h-auto md:max-h-[92dvh] md:max-w-3xl md:rounded-2xl"
|
||||
>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
initial={reduce ? false : { opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex h-full max-h-[inherit] flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line px-5 pb-4 pt-5 md:px-8">
|
||||
<div>
|
||||
<p id={titleId} className="text-sm text-stone">
|
||||
Buchung ARTISTA Modelcamp 2027
|
||||
{pricing.mode === "test" && <span className="ml-2 draft-flag">Testmodus</span>}
|
||||
</p>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="display mt-1 text-3xl outline-none md:text-4xl">
|
||||
{STEPS[step]}
|
||||
</h2>
|
||||
<ol className="mt-3 flex gap-2" aria-label="Fortschritt">
|
||||
{STEPS.map((label, index) => (
|
||||
<li key={label} aria-current={index === step ? "step" : undefined} className={`h-1 w-10 rounded-full ${index <= step ? "bg-rosso" : "bg-line"}`}>
|
||||
<span className="sr-only">
|
||||
Schritt {index + 1} von {STEPS.length}: {label}
|
||||
{index < step ? " (erledigt)" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-ink/30 px-4 py-2 text-sm font-semibold">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6 md:px-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={step}
|
||||
initial={reduce ? false : { opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{step === 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-firstName">Vorname</label>
|
||||
<input {...fieldProps("firstName")} autoComplete="given-name" required />
|
||||
{fieldError("participant.firstName", "bk-firstName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-lastName">Nachname</label>
|
||||
<input {...fieldProps("lastName")} autoComplete="family-name" required />
|
||||
{fieldError("participant.lastName", "bk-lastName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-email">E-Mail</label>
|
||||
<input {...fieldProps("email")} type="email" autoComplete="email" inputMode="email" required />
|
||||
{fieldError("participant.email", "bk-email-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-phone">Telefon</label>
|
||||
<input {...fieldProps("phone")} type="tel" autoComplete="tel" required />
|
||||
{fieldError("participant.phone", "bk-phone-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="bk-country">Land</label>
|
||||
<input {...fieldProps("country")} autoComplete="country-name" required />
|
||||
{fieldError("participant.country", "bk-country-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="bk-notes">Anmerkungen (optional)</label>
|
||||
<textarea {...fieldProps("notes")} rows={3} maxLength={1000} aria-describedby="bk-notes-hint" />
|
||||
<p id="bk-notes-hint" className="mt-1.5 text-sm text-stone">
|
||||
Bitte hier keine Gesundheitsdaten eintragen – dazu sprechen wir bei Bedarf persönlich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-8">
|
||||
<fieldset>
|
||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||
<div className="mt-2 space-y-3">
|
||||
{quote?.plans.map((plan) => (
|
||||
<label
|
||||
key={plan.type}
|
||||
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="planType"
|
||||
value={plan.type}
|
||||
checked={planType === plan.type}
|
||||
disabled={!plan.available}
|
||||
onChange={() => {
|
||||
setPlanType(plan.type);
|
||||
}}
|
||||
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block font-semibold">{plan.label}</span>
|
||||
{plan.available ? (
|
||||
<span className="mt-1 block text-[0.9375rem] text-ink/80">
|
||||
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-voucher">Gutscheincode (optional)</label>
|
||||
{appliedVoucher ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full bg-paper px-4 py-2 font-semibold">{appliedVoucher.toUpperCase()}</span>
|
||||
<button type="button" className="text-sm font-semibold underline" onClick={removeVoucher}>
|
||||
Gutschein entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="bk-voucher"
|
||||
className="field"
|
||||
value={voucherInput}
|
||||
onChange={(e) => setVoucherInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
aria-describedby={voucherMessage ? "bk-voucher-msg" : undefined}
|
||||
/>
|
||||
<button type="button" className="btn btn-quiet shrink-0" onClick={applyVoucher} disabled={busy || !voucherInput.trim()}>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p id="bk-voucher-msg" role="status" className={`mt-1.5 text-sm ${appliedVoucher ? "text-ink" : "text-rosso"}`}>
|
||||
{voucherMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && quote?.price && selectedPlan && (
|
||||
<div className="space-y-8">
|
||||
<section aria-labelledby="sum-price">
|
||||
<h3 id="sum-price" className="font-semibold">Preis und Zahlungsplan</h3>
|
||||
<PriceBreakdown quote={quote} />
|
||||
<table className="mt-4 w-full text-[0.9375rem]">
|
||||
<caption className="sr-only">Zahlungsplan</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-stone">
|
||||
<th scope="col" className="py-1 font-normal">Rate</th>
|
||||
<th scope="col" className="py-1 font-normal">Fällig</th>
|
||||
<th scope="col" className="py-1 text-right font-normal">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedPlan.installments.map((i) => (
|
||||
<tr key={i.seq} className="border-t border-line">
|
||||
<td className="py-2">{selectedPlan.installments.length > 1 ? `Rate ${i.seq}` : "Einmalzahlung"}</td>
|
||||
<td className="py-2">{i.dueDate ? isoDate(i.dueDate) : "heute, bei Buchung"}</td>
|
||||
<td className="py-2 text-right tabular-nums">{money(i.amountCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{today !== null && (
|
||||
<p className="mt-3 font-semibold">
|
||||
Heute fällig: {money(today)}
|
||||
{selectedPlan.installments.length > 1 && `, danach noch ${money(quote.price.totalCents - today)}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">
|
||||
Der Preis ist ab Klick auf „Zahlungspflichtig buchen“ für {quote.priceLockMinutes} Minuten reserviert. Wird die Zahlung in dieser Zeit
|
||||
nicht abgeschlossen, verfällt die Reservierung.
|
||||
</p>
|
||||
{quote.taxNote ? <p className="mt-2 text-sm text-stone">{quote.taxNote}</p> : quote.mode === "test" && <p className="mt-2"><span className="draft-flag">Steuerlicher Hinweis folgt vor Verkaufsstart</span></p>}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="sum-stay">
|
||||
<h3 id="sum-stay" className="font-semibold">Unterkunft und Leistungen</h3>
|
||||
<p className="mt-2">Teilnahme pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-2 whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-2">
|
||||
<span className="draft-flag">Testmodus</span>{" "}
|
||||
<span className="text-stone">Die verbindliche Zimmer- und Bettbeschreibung wird vor dem Live-Verkauf ergänzt.</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">Nicht enthalten: Anreise nach Italien und zurück, persönliche Ausgaben vor Ort, Reiserücktrittsversicherung.</p>
|
||||
</section>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="font-semibold">Erklärungen</legend>
|
||||
<Consent id="adult" checked={consents.adult} error={errors["consents.adult"]} onChange={(v) => setConsents((c) => ({ ...c, adult: v }))}>
|
||||
Ich bin volljährig.
|
||||
</Consent>
|
||||
<Consent id="accommodation" checked={consents.accommodation} error={errors["consents.accommodation"]} onChange={(v) => setConsents((c) => ({ ...c, accommodation: v }))}>
|
||||
Ich habe die Beschreibung der Unterbringung gelesen und buche einen Platz mit geteilter Unterbringung.
|
||||
</Consent>
|
||||
<Consent id="terms" checked={consents.terms} error={errors["consents.terms"]} onChange={(v) => setConsents((c) => ({ ...c, terms: v }))}>
|
||||
Ich akzeptiere die{" "}
|
||||
<a href={facts.termsUrl ?? "/buchungsbedingungen"} target="_blank" rel="noopener" className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
.
|
||||
</Consent>
|
||||
<Consent id="privacy" checked={consents.privacy} error={errors["consents.privacy"]} onChange={(v) => setConsents((c) => ({ ...c, privacy: v }))}>
|
||||
Ich habe die{" "}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||
Datenschutzhinweise
|
||||
</a>{" "}
|
||||
gelesen.
|
||||
</Consent>
|
||||
{planType !== "FULL" && (
|
||||
<Consent
|
||||
id="installmentMandate"
|
||||
checked={consents.installmentMandate}
|
||||
error={errors["consents.installmentMandate"]}
|
||||
onChange={(v) => setConsents((c) => ({ ...c, installmentMandate: v }))}
|
||||
>
|
||||
Ich ermächtige {organizer}, die weiteren Raten zu den oben genannten Terminen über die bei der Zahlung gespeicherte Zahlungsmethode
|
||||
einzuziehen. Nach der letzten Rate wird die Zahlungsmethode gelöst; weitere Abbuchungen finden nicht statt.
|
||||
</Consent>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
{formError && (
|
||||
<p role="alert" className="mt-6 rounded-lg border border-rosso/40 bg-paper p-4 text-[0.9375rem] font-medium text-rosso">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-line px-5 py-4 md:px-8">
|
||||
{step > 0 ? (
|
||||
<button type="button" className="btn btn-quiet" onClick={back} disabled={busy}>
|
||||
Zurück
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy || !quote?.checkoutOpen}>
|
||||
{busy ? "Einen Moment …" : "Zahlungspflichtig buchen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||
const p = quote.price!;
|
||||
return (
|
||||
<dl className="mt-3 space-y-1.5 text-[0.9375rem]">
|
||||
<div className="flex justify-between">
|
||||
<dt>Regulärer Preis</dt>
|
||||
<dd className="tabular-nums">{money(p.basePriceCents)}</dd>
|
||||
</div>
|
||||
{p.phaseDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Early-Bird-Vorteil (20 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.phaseDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{p.voucherDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Gutschein (10 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.voucherDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t border-ink pt-2 text-lg font-semibold">
|
||||
<dt>Gesamtpreis pro Person</dt>
|
||||
<dd className="tabular-nums">{money(p.totalCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Consent({
|
||||
id,
|
||||
checked,
|
||||
error,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
error?: string;
|
||||
onChange: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`bk-c-${id}`} className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||
<input
|
||||
id={`bk-c-${id}`}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `bk-c-${id}-error` : undefined}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p id={`bk-c-${id}-error`} className="ml-8 mt-1 text-sm font-medium text-rosso">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
// Der Dialog (Formular, Motion, Preislogik-Anzeige) wird erst beim ersten
|
||||
// Oeffnen geladen und belastet den ersten Seitenaufbau nicht.
|
||||
const BookingDialog = dynamic(() => import("./BookingDialog").then((m) => m.BookingDialog), { ssr: false });
|
||||
|
||||
type BookingContextValue = { open: () => void; pricing: PublicPricing };
|
||||
const BookingContext = createContext<BookingContextValue | null>(null);
|
||||
|
||||
export function useBooking(): BookingContextValue {
|
||||
const ctx = useContext(BookingContext);
|
||||
if (!ctx) throw new Error("useBooking ausserhalb von BookingProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function BookingProvider({
|
||||
pricing,
|
||||
facts,
|
||||
children,
|
||||
}: {
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const trigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
trigger.current = document.activeElement as HTMLElement | null;
|
||||
setMounted(true);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
// Fokus zurueck auf den ausloesenden Button.
|
||||
requestAnimationFrame(() => trigger.current?.focus());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookingContext.Provider value={{ open, pricing }}>
|
||||
{children}
|
||||
{mounted && <BookingDialog open={isOpen} onClose={close} pricing={pricing} facts={facts} />}
|
||||
</BookingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookButton({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const { open, pricing } = useBooking();
|
||||
return (
|
||||
<button type="button" className={className} onClick={open} disabled={!pricing.checkoutOpen} aria-haspopup="dialog">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookButton, useBooking } from "./BookingProvider";
|
||||
|
||||
/**
|
||||
* Buchungsleiste fuer kleine Bildschirme. Erscheint erst, wenn der Hero
|
||||
* verlassen ist, und verschwindet im Preisbereich und am Seitenende, damit
|
||||
* sie dort keine Inhalte oder Bedienelemente verdeckt.
|
||||
*/
|
||||
export function MobileBookingBar({ priceLabel }: { priceLabel: string }) {
|
||||
const { pricing } = useBooking();
|
||||
const [heroVisible, setHeroVisible] = useState(true);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hero = document.getElementById("top");
|
||||
const blockers = ["preis", "site-footer"].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
|
||||
const visible = new Set<string>();
|
||||
const heroObserver = new IntersectionObserver(([entry]) => setHeroVisible(entry?.isIntersecting ?? false));
|
||||
const blockObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) visible.add(entry.target.id);
|
||||
else visible.delete(entry.target.id);
|
||||
}
|
||||
setBlocked(visible.size > 0);
|
||||
});
|
||||
if (hero) heroObserver.observe(hero);
|
||||
blockers.forEach((b) => blockObserver.observe(b));
|
||||
return () => {
|
||||
heroObserver.disconnect();
|
||||
blockObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const show = !heroVisible && !blocked;
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<m.div
|
||||
className="mobile-book-bar fixed inset-x-0 bottom-0 z-30 border-t border-line bg-plaster/95 backdrop-blur lg:hidden"
|
||||
data-visible="true"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="container-edge flex items-center justify-between gap-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<p className="text-sm leading-tight">
|
||||
<span className="block font-semibold">Modelcamp 2027</span>
|
||||
<span className="text-stone">{priceLabel}</span>
|
||||
</p>
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary min-h-11 px-5 py-2 text-sm">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-quiet min-h-11 px-5 py-2 text-sm">
|
||||
Zum Preis
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { Gallery } from "../Gallery";
|
||||
|
||||
function DraftFlag({ show }: { show: boolean }) {
|
||||
return show ? <span className="draft-flag ml-2 align-middle">Entwurf, nicht bestätigt</span> : null;
|
||||
}
|
||||
|
||||
export function CampSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { camp } = site;
|
||||
const showAudience = camp.audience.confirmed || showDrafts;
|
||||
return (
|
||||
<section id="camp" aria-labelledby="camp-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
||||
<div className="md:col-span-6 lg:col-span-5">
|
||||
<h2 id="camp-title" className="section-title">{camp.title}</h2>
|
||||
<p className="display mt-8 text-[1.75rem] leading-[1.2] text-ink md:text-[2.125rem]">{camp.lead}</p>
|
||||
</div>
|
||||
<div className="md:col-span-6 md:col-start-7 lg:col-span-5 lg:col-start-8 md:pt-24">
|
||||
<div className="prose-body text-ink/90">
|
||||
{camp.paragraphs.map((p) => (
|
||||
<p key={p}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
{showAudience && (
|
||||
<div className="mt-12 border-t border-ink pt-6">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{camp.audience.title}
|
||||
<DraftFlag show={!camp.audience.confirmed} />
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{camp.audience.items.map((item) => (
|
||||
<li key={item} className="flex gap-3">
|
||||
<span aria-hidden className="mt-[0.7em] h-px w-5 shrink-0 bg-rosso" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { services } = site;
|
||||
const included = services.included.filter((s) => s.confirmed || showDrafts);
|
||||
return (
|
||||
<section id="leistungen" aria-labelledby="leistungen-title" className="on-dark bg-ink py-20 text-paper md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="leistungen-title" className="section-title max-w-3xl">{services.title}</h2>
|
||||
<div className="mt-14 grid gap-12 md:grid-cols-2 md:gap-16">
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Im Preis enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{included.map((item) => (
|
||||
<li key={item.text} className="py-4 text-lg">
|
||||
{item.text}
|
||||
{!item.confirmed && <DraftFlag show />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!showDrafts && included.length < services.included.length && (
|
||||
<p className="mt-4 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Nicht enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{services.excluded.map((item) => (
|
||||
<li key={item} className="py-4 text-lg text-paper/85">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 border-t border-paper/30 pt-10">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h3 className="display text-3xl">So könnte ein Tag im Camp aussehen</h3>
|
||||
<p className="max-w-md text-[0.9375rem] text-paper/75">{services.exampleSchedule.note}</p>
|
||||
</div>
|
||||
{/* Nummeriert, weil es eine Abfolge ist — ausdruecklich als Beispiel gekennzeichnet. */}
|
||||
<ol className="mt-10 grid gap-8 md:grid-cols-3">
|
||||
{services.exampleSchedule.steps.map((step, index) => (
|
||||
<li key={step.title} className="border-t border-paper/30 pt-5">
|
||||
<span className="display text-5xl text-paper/40">{index + 1}</span>
|
||||
<p className="mt-3 text-lg font-semibold">{step.title}</p>
|
||||
<p className="mt-2 text-paper/80">{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const { accommodation } = site;
|
||||
return (
|
||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<h2 id="unterkunft-title" className="section-title md:col-span-6">{accommodation.title}</h2>
|
||||
<p className="text-lg text-ink/85 md:col-span-5 md:col-start-8 md:self-end">{accommodation.lead}</p>
|
||||
</div>
|
||||
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
||||
<p className="mt-3 max-w-2xl">
|
||||
Gebucht wird ein Platz pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.
|
||||
</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-3 max-w-2xl text-stone">
|
||||
Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-4 md:col-start-9">
|
||||
{accommodation.bookingUrl ? (
|
||||
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
|
||||
Unterkunft ansehen (externe Seite)
|
||||
</a>
|
||||
) : (
|
||||
showDrafts && <span className="draft-flag">Link zur Unterkunft folgt</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
type Faq = { q: string; a: React.ReactNode };
|
||||
|
||||
function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
||||
return [
|
||||
{
|
||||
q: "Wie läuft die Buchung ab?",
|
||||
a: "Du gibst deine Daten ein, wählst die Zahlungsweise und prüfst alles in einer Übersicht. Bezahlt wird auf einer sicheren Zahlungsseite unseres Zahlungsdienstleisters Stripe. Verbindlich bestätigt ist deine Buchung, sobald die Zahlung bei uns eingegangen ist – dann bekommst du eine E-Mail.",
|
||||
},
|
||||
{
|
||||
q: "Wie lange gilt der angezeigte Preis?",
|
||||
a: `Es gilt der Preis zum Zeitpunkt, an dem du die Buchung startest. Er bleibt ${pricing.reservationMinutes} Minuten für dich reserviert. Schließt du die Zahlung in dieser Zeit nicht ab, verfällt die Reservierung, und ein neuer Buchungsversuch wird zum dann gültigen Preis berechnet. Der Early-Bird-Preis endet am 31. Dezember 2026 um 24 Uhr und wird nicht verlängert.`,
|
||||
},
|
||||
{
|
||||
q: "Kann ich in Raten zahlen?",
|
||||
a: pricing.installmentsAllowed
|
||||
? "Ja, in zwei oder drei Raten. Die erste Rate zahlst du bei der Buchung, die weiteren werden zu festen Terminen über deine hinterlegte Zahlungsmethode eingezogen – die letzte spätestens 30 Tage vor Anreise. Alle Beträge und Termine siehst du vor der Zahlung. Nach der letzten Rate finden keine weiteren Abbuchungen statt."
|
||||
: "Ob eine Ratenzahlung möglich ist, geben wir bekannt, sobald der Camp-Termin feststeht.",
|
||||
},
|
||||
{
|
||||
q: "Welche Zahlungsarten gibt es?",
|
||||
a: "Die verfügbaren Zahlungsarten siehst du auf der Zahlungsseite von Stripe. Kartendaten gibst du nur dort ein; wir speichern sie nicht.",
|
||||
},
|
||||
{
|
||||
q: "Wie bin ich untergebracht?",
|
||||
a: facts.accommodationText
|
||||
? `Du buchst einen Platz pro Person mit geteilter Unterbringung. ${facts.accommodationText}`
|
||||
: "Du buchst einen Platz pro Person mit geteilter Unterbringung. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.",
|
||||
},
|
||||
{
|
||||
q: "Wie reise ich an?",
|
||||
a: `Die Anreise nach Italien und zurück organisierst du selbst; sie ist nicht im Preis enthalten.${facts.location ? ` Das Camp findet in ${facts.location} statt.` : " Den genauen Ort geben wir bekannt, sobald er feststeht."}`,
|
||||
},
|
||||
{
|
||||
q: "Was ist nicht im Preis enthalten?",
|
||||
a: `${site.services.excluded.join(", ")}.`,
|
||||
},
|
||||
{
|
||||
q: "Was gilt, wenn ich nicht teilnehmen kann?",
|
||||
a: facts.termsUrl ? (
|
||||
<>
|
||||
Das regeln unsere{" "}
|
||||
<a href={facts.termsUrl} className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
. Eine Reiserücktrittsversicherung ist nicht enthalten.
|
||||
</>
|
||||
) : (
|
||||
"Die Buchungs-, Storno- und Zahlungsbedingungen veröffentlichen wir vor dem Verkaufsstart. Eine Reiserücktrittsversicherung ist nicht enthalten."
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Ich habe einen Gutscheincode. Wo gebe ich ihn ein?",
|
||||
a: "Im zweiten Schritt der Buchung. Pro Buchung lässt sich ein Gutschein einlösen.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
return (
|
||||
<section id="fragen" aria-labelledby="fragen-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
||||
<h2 id="fragen-title" className="section-title md:col-span-4">{site.faq.title}</h2>
|
||||
<div className="md:col-span-8">
|
||||
{faqs(facts, pricing).map((item) => (
|
||||
<details key={item.q} className="group border-b border-line py-1 first:border-t">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-6 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden">
|
||||
{item.q}
|
||||
<span aria-hidden className="text-2xl font-normal text-rosso transition-transform duration-200 group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClosingSection({ pricing }: { pricing: PublicPricing }) {
|
||||
return (
|
||||
<section aria-labelledby="schluss-title" className="on-dark bg-ink py-24 text-paper md:py-36">
|
||||
<div className="container-edge">
|
||||
<h2 id="schluss-title" className="display max-w-4xl text-[clamp(2.75rem,7vw,6rem)] leading-[0.98]">
|
||||
Wir freuen uns auf dich in Italien.
|
||||
</h2>
|
||||
<div className="mt-10 flex flex-wrap gap-3">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">
|
||||
Zum Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#fragen" className="btn btn-quiet text-paper">
|
||||
Fragen ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { site } from "@/content/site";
|
||||
import { MediaFrame } from "../MediaFrame";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { VideoSlot } from "../VideoSlot";
|
||||
|
||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { teachers } = site;
|
||||
return (
|
||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
||||
{teachers.people.map((person, index) => (
|
||||
<article
|
||||
key={index}
|
||||
aria-label={person.name ?? `Dozentin ${index + 1}`}
|
||||
className={index === 0 ? "md:col-span-5" : "md:col-span-5 md:col-start-8 md:mt-40"}
|
||||
>
|
||||
<Reveal>
|
||||
<MediaFrame media={person.portrait} aspect="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
||||
</Reveal>
|
||||
<h3 className="display mt-6 text-3xl">
|
||||
{person.name ?? (showDrafts ? `Dozentin ${index + 1}: Name folgt` : "Wird in Kürze vorgestellt")}
|
||||
</h3>
|
||||
{person.role && <p className="mt-1 text-stone">{person.role}</p>}
|
||||
{person.bio ? (
|
||||
<p className="mt-4 max-w-md">{person.bio}</p>
|
||||
) : (
|
||||
showDrafts && <p className="mt-4"><span className="draft-flag">Kurzbeschreibung folgt</span></p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { about } = site;
|
||||
return (
|
||||
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-8 md:grid-cols-12">
|
||||
<h2 id="artista-title" className="section-title md:col-span-5">{about.title}</h2>
|
||||
<div className="prose-body md:col-span-6 md:col-start-7">
|
||||
{about.paragraphs.length > 0 ? (
|
||||
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
||||
) : (
|
||||
<p className="text-stone">
|
||||
{showDrafts ? (
|
||||
<>
|
||||
<span className="draft-flag">Inhalt folgt</span> Grundlage wird die ARTISTA-Stammmodel-Seite (Link folgt).
|
||||
</>
|
||||
) : (
|
||||
"Mehr über ARTISTA erfährst du in Kürze hier."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{about.sourceUrl && (
|
||||
<p className="mt-6">
|
||||
<a href={about.sourceUrl} target="_blank" rel="noopener noreferrer" className="font-semibold underline underline-offset-4">
|
||||
Zur ARTISTA-Stammmodel-Seite
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoicesSection({ showDrafts, provider }: { showDrafts: boolean; provider: "none" | "youtube-nocookie" | "vimeo" }) {
|
||||
const { videos } = site;
|
||||
const filled = videos.slots.filter((s) => s.src || s.externalId);
|
||||
// Leere Slots nur in Entwicklung/Staging zeigen; oeffentlich nie ein
|
||||
// erfundenes Testimonial als Ersatz.
|
||||
const slots = showDrafts ? videos.slots : filled;
|
||||
if (slots.length === 0) return null;
|
||||
return (
|
||||
<section id="stimmen" aria-labelledby="stimmen-title" className="border-t border-line py-20 md:py-28">
|
||||
<div className="container-edge">
|
||||
<h2 id="stimmen-title" className="section-title">{videos.title}</h2>
|
||||
<ul className="mt-12 grid gap-10 md:grid-cols-3">
|
||||
{slots.map((slot, index) => (
|
||||
<li key={index}>
|
||||
<VideoSlot slot={slot} index={index} provider={provider} showDrafts={showDrafts} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
const CLOSED_TEXT: Record<Exclude<PublicPricing["closedReason"], null>, string> = {
|
||||
PREVIEW: "Die Buchung öffnet am 1. November 2026.",
|
||||
NOT_RELEASED: "Die Buchung ist noch nicht freigeschaltet. Wir veröffentlichen alle verbindlichen Angaben vor dem Verkaufsstart.",
|
||||
CAMP_STARTED: "Die Buchung ist geschlossen.",
|
||||
NOT_CONFIGURED: "Die Buchung ist derzeit nicht verfügbar.",
|
||||
};
|
||||
|
||||
export function PricingSection({ pricing, facts, showDrafts }: { pricing: PublicPricing; facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const included = site.services.included.filter((s) => s.confirmed);
|
||||
return (
|
||||
<section id="preis" aria-labelledby="preis-title" className="bg-paper py-20 md:py-32">
|
||||
<div className="container-edge grid gap-14 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<h2 id="preis-title" className="section-title">Preis und Buchung</h2>
|
||||
|
||||
{pricing.mode === "test" && (
|
||||
<p className="mt-6 rounded-lg border border-line bg-plaster p-4 text-[0.9375rem]">
|
||||
<span className="draft-flag">Testmodus</span> Zahlungen laufen über Stripe-Testdaten, es wird kein Geld eingezogen.
|
||||
{pricing.phaseSimulated && " Die Preisphase ist für Tests simuliert."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-10">
|
||||
{pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null && (
|
||||
<>
|
||||
<p className="text-stone">
|
||||
Regulärer Preis <s className="tabular-nums">{formatEuroShort(pricing.regularCents)}</s>
|
||||
</p>
|
||||
<p className="display mt-2 text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
|
||||
<p className="mt-3 text-lg">Early-Bird-Preis pro Person, gültig bis einschließlich 31. Dezember 2026.</p>
|
||||
</>
|
||||
)}
|
||||
{pricing.phase === "REGULAR" && pricing.currentCents !== null && (
|
||||
<>
|
||||
<p className="display text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
|
||||
<p className="mt-3 text-lg">pro Person</p>
|
||||
</>
|
||||
)}
|
||||
{pricing.phase === "PREVIEW" && (
|
||||
<dl className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="border-t border-ink pt-4">
|
||||
<dt className="text-stone">Early Bird, 1. November bis 31. Dezember 2026</dt>
|
||||
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.earlyBirdCents)}</dd>
|
||||
</div>
|
||||
<div className="border-t border-line pt-4">
|
||||
<dt className="text-stone">Regulär, ab 1. Januar 2027</dt>
|
||||
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.regularCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 max-w-xl space-y-2 text-[0.9375rem] text-ink/85">
|
||||
<li>Gesamtpreis pro Person für die Teilnahme mit geteilter Unterbringung.</li>
|
||||
<li>
|
||||
{pricing.installmentsAllowed
|
||||
? "Bezahlen kannst du auf einmal oder in bis zu drei Raten. Die erste Rate ist bei Buchung fällig, die letzte spätestens 30 Tage vor Anreise."
|
||||
: "Bezahlt wird bei Buchung. Ob zusätzlich Ratenzahlung möglich ist, geben wir bekannt, sobald der Termin feststeht."}
|
||||
</li>
|
||||
<li>Einen Gutscheincode kannst du im Buchungsschritt eingeben.</li>
|
||||
{facts.taxNote ? (
|
||||
<li>{facts.taxNote}</li>
|
||||
) : (
|
||||
showDrafts && (
|
||||
<li>
|
||||
<span className="draft-flag">Steuerlicher Preishinweis folgt vor Verkaufsstart</span>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="mt-10">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary px-8 text-lg">Platz buchen</BookButton>
|
||||
) : (
|
||||
<p className="max-w-xl text-lg font-semibold">{CLOSED_TEXT[pricing.closedReason ?? "NOT_CONFIGURED"]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside aria-labelledby="preis-umfang" className="md:col-span-4 md:col-start-9 md:pt-24">
|
||||
<h3 id="preis-umfang" className="text-lg font-semibold">Was du buchst</h3>
|
||||
<ul className="mt-4 divide-y divide-line border-y border-line">
|
||||
{included.map((item) => (
|
||||
<li key={item.text} className="py-3">{item.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-6 text-[0.9375rem] text-stone">
|
||||
Nicht enthalten: {site.services.excluded.join(", ")}.
|
||||
</p>
|
||||
<p className="mt-4 text-[0.9375rem] text-stone">
|
||||
Vor der Zahlung siehst du Gesamtpreis, heute fälligen Betrag, alle weiteren Raten mit Terminen und die Beschreibung der Unterbringung auf einen Blick.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Zentrale Inhalte der Onepage.
|
||||
*
|
||||
* REGELN FUER DIESE DATEI
|
||||
* - Nichts erfinden: Termine, Leistungen, Qualifikationen, Bewertungen und
|
||||
* Rechtliches nur aus bestaetigten Angaben.
|
||||
* - "confirmed: false" heisst Entwurf. Entwuerfe erscheinen in Entwicklung
|
||||
* und Staging sichtbar als solche markiert und werden im Produktivbetrieb
|
||||
* (APP_ENV=production) ausgeblendet oder durch einen neutralen Hinweis
|
||||
* ersetzt.
|
||||
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
|
||||
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
|
||||
* Live-Verkauf freischalten. Hier steht alles Uebrige.
|
||||
* - Bilder: nur mit bestaetigter Nutzungsberechtigung, als lokale Datei im
|
||||
* Medienverzeichnis (MEDIA_DIR, ausgeliefert unter /media/…). Kein
|
||||
* Hotlinking fremder Bilder (z. B. von Booking.com).
|
||||
*
|
||||
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
||||
*/
|
||||
|
||||
export type PlaceholderTone = "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
||||
|
||||
export type Media =
|
||||
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
||||
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
||||
|
||||
export type Confirmable<T> = T & { confirmed: boolean };
|
||||
|
||||
const placeholder = (label: string, tone: PlaceholderTone): Media => ({ kind: "placeholder", label, tone });
|
||||
|
||||
export const site = {
|
||||
hero: {
|
||||
// Entwurf zur Freigabe: bewusst ohne Versprechen zu Shootings,
|
||||
// Vermittlung oder Ergebnissen.
|
||||
headline: "Gemeinsam wachsen. In Italien.",
|
||||
subline:
|
||||
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
||||
media: placeholder("Leitmotiv: Bild oder Video folgt", "villa"),
|
||||
},
|
||||
|
||||
camp: {
|
||||
title: "Das Camp",
|
||||
lead: "Ein paar Tage, an denen sich alles um deine Arbeit als Model dreht – in Ruhe, in einer kleinen Gruppe und mit Zeit füreinander.",
|
||||
paragraphs: [
|
||||
"Das Modelcamp verbindet gemeinsames Arbeiten mit dem Leben unter einem Dach. Du wohnst mit den anderen Teilnehmerinnen in einer Unterkunft in Italien, lernst von zwei Dozentinnen und tauschst dich mit Menschen aus, die dieselbe Leidenschaft teilen.",
|
||||
"Das Programm wird gerade zusammengestellt. Was bereits feststeht, findest du unten unter Leistungen – alles andere ergänzen wir, sobald es verbindlich ist.",
|
||||
],
|
||||
audience: {
|
||||
title: "Für wen das Camp gedacht ist",
|
||||
items: [
|
||||
"Models, die bereits mit ARTISTA arbeiten",
|
||||
"Models, die ARTISTA und die Arbeit vor der Kamera näher kennenlernen möchten",
|
||||
"Alle, die Lust auf eine konzentrierte Zeit in einer kleinen Gruppe haben",
|
||||
],
|
||||
confirmed: false,
|
||||
},
|
||||
// Ausdruecklich KEINE Aussagen zu Agenturvermittlung, Auftraegen,
|
||||
// Verdienst oder Karriereerfolg (Briefing Abschnitt 4).
|
||||
},
|
||||
|
||||
services: {
|
||||
title: "Erlebnisse und Leistungen",
|
||||
included: [
|
||||
// Bestaetigt durch das Buchungsprodukt selbst:
|
||||
{ text: "Teilnahme am Modelcamp 2027", confirmed: true },
|
||||
{ text: "Unterbringung in einer geteilten Unterkunft in Italien", confirmed: true },
|
||||
// Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt")
|
||||
// auf confirmed: true setzen.
|
||||
{ text: "Workshops mit den beiden Dozentinnen", confirmed: false },
|
||||
{ text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false },
|
||||
] satisfies Confirmable<{ text: string }>[],
|
||||
excluded: [
|
||||
// Laut Vorlage ausdruecklich nicht enthalten.
|
||||
"Anreise nach Italien und zurück",
|
||||
"Persönliche Ausgaben vor Ort",
|
||||
"Reiserücktrittsversicherung",
|
||||
],
|
||||
exampleSchedule: {
|
||||
// Beispiel, KEIN verbindliches Programm.
|
||||
note: "Beispielhafter Ablauf – das verbindliche Programm veröffentlichen wir, sobald alle Punkte bestätigt sind.",
|
||||
steps: [
|
||||
{ title: "Ankommen", text: "Anreise, Zimmer beziehen, erstes Kennenlernen der Gruppe." },
|
||||
{ title: "Arbeiten", text: "Gemeinsame Einheiten mit den Dozentinnen und Zeit für Fragen." },
|
||||
{ title: "Abschied", text: "Rückblick, Austausch und individuelle Abreise." },
|
||||
],
|
||||
confirmed: false,
|
||||
},
|
||||
},
|
||||
|
||||
accommodation: {
|
||||
title: "Die Unterkunft",
|
||||
lead: "Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
|
||||
// Link zur Unterkunft (z. B. Booking.com), nachzureichen.
|
||||
bookingUrl: null as string | null,
|
||||
images: [
|
||||
placeholder("Unterkunft: Außenansicht", "villa"),
|
||||
placeholder("Unterkunft: Garten oder Terrasse", "terrace"),
|
||||
placeholder("Unterkunft: Zimmer", "room"),
|
||||
placeholder("Unterkunft: Gemeinschaftsbereich", "garden"),
|
||||
] as Media[],
|
||||
},
|
||||
|
||||
teachers: {
|
||||
title: "Die Dozentinnen",
|
||||
people: [
|
||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "portrait") as Media },
|
||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "studio") as Media },
|
||||
],
|
||||
},
|
||||
|
||||
about: {
|
||||
title: "Über ARTISTA",
|
||||
// Grundlage soll die ARTISTA-Stammmodel-Seite sein (Link folgt). Bis
|
||||
// dahin keine Unternehmensgeschichte und keine Zahlen.
|
||||
sourceUrl: null as string | null,
|
||||
paragraphs: [] as string[],
|
||||
},
|
||||
|
||||
videos: {
|
||||
title: "Stimmen",
|
||||
// Genau drei Slots. Nur echte, freigegebene Videos eintragen.
|
||||
slots: [
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
|
||||
],
|
||||
},
|
||||
|
||||
faq: {
|
||||
title: "Häufige Fragen",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ id: "camp", label: "Das Camp" },
|
||||
{ id: "leistungen", label: "Leistungen" },
|
||||
{ id: "unterkunft", label: "Unterkunft" },
|
||||
{ id: "dozentinnen", label: "Dozentinnen" },
|
||||
{ id: "artista", label: "Über ARTISTA" },
|
||||
{ id: "preis", label: "Preis" },
|
||||
{ id: "fragen", label: "Fragen" },
|
||||
] as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Serverseitige Zugriffskontrolle fuer den Adminbereich. Wird von JEDER
|
||||
* Admin-Seite, Server-Action und jedem Admin-Endpunkt aufgerufen — der
|
||||
* Proxy (src/proxy.ts) leitet nur vorab um und ist keine Sicherheitsgrenze.
|
||||
*/
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Actor } from "./audit";
|
||||
import { getAuth } from "./auth";
|
||||
|
||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
||||
|
||||
async function currentAdmin(): Promise<AdminUser | null> {
|
||||
const session = await getAuth().api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
||||
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||
}
|
||||
|
||||
/** Fuer Seiten: leitet zur Anmeldung bzw. zur 2FA-Einrichtung um. */
|
||||
export async function requireAdminPage(options: { allowWithout2fa?: boolean } = {}): Promise<AdminUser> {
|
||||
const admin = await currentAdmin();
|
||||
if (!admin) redirect("/admin/anmelden");
|
||||
if (!admin.twoFactorEnabled && !options.allowWithout2fa) redirect("/admin/sicherheit");
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Fuer Server-Actions und Endpunkte: wirft statt umzuleiten. */
|
||||
export async function requireAdminAction(): Promise<AdminUser> {
|
||||
const admin = await currentAdmin();
|
||||
if (!admin || !admin.twoFactorEnabled) throw new Error("Nicht berechtigt.");
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Fuer Route-Handler: null statt Ausnahme. */
|
||||
export async function adminOrNull(): Promise<AdminUser | null> {
|
||||
const admin = await currentAdmin();
|
||||
return admin?.twoFactorEnabled ? admin : null;
|
||||
}
|
||||
|
||||
export function adminActor(admin: AdminUser): Actor {
|
||||
return { type: "ADMIN", id: admin.id };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { BookingStatus, PaymentStatus, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const BOOKING_STATUSES: BookingStatus[] = ["PENDING", "CONFIRMED", "CANCELED", "EXPIRED", "NEEDS_REVIEW"];
|
||||
const PAYMENT_STATUSES: PaymentStatus[] = ["UNPAID", "PROCESSING", "PARTIALLY_PAID", "PAID", "FAILED", "PARTIALLY_REFUNDED", "REFUNDED"];
|
||||
|
||||
export type BookingFilter = { status?: BookingStatus; zahlung?: PaymentStatus; modus: "test" | "live"; q?: string };
|
||||
|
||||
/** Liest Filter aus der URL; Unbekanntes wird ignoriert statt uebernommen. */
|
||||
export function parseBookingFilter(params: Record<string, string | string[] | undefined>, defaultMode: "test" | "live"): BookingFilter {
|
||||
const get = (k: string) => (typeof params[k] === "string" ? (params[k] as string) : undefined);
|
||||
const status = get("status");
|
||||
const zahlung = get("zahlung");
|
||||
const modus = get("modus");
|
||||
const q = get("q")?.trim().slice(0, 100);
|
||||
return {
|
||||
status: BOOKING_STATUSES.includes(status as BookingStatus) ? (status as BookingStatus) : undefined,
|
||||
zahlung: PAYMENT_STATUSES.includes(zahlung as PaymentStatus) ? (zahlung as PaymentStatus) : undefined,
|
||||
modus: modus === "live" || modus === "test" ? modus : defaultMode,
|
||||
q: q || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookingWhere(filter: BookingFilter): Prisma.BookingWhereInput {
|
||||
return {
|
||||
livemode: filter.modus === "live",
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
...(filter.zahlung ? { paymentStatus: filter.zahlung } : {}),
|
||||
...(filter.q
|
||||
? {
|
||||
OR: [
|
||||
{ reference: { contains: filter.q, mode: "insensitive" } },
|
||||
{ email: { contains: filter.q, mode: "insensitive" } },
|
||||
{ lastName: { contains: filter.q, mode: "insensitive" } },
|
||||
{ firstName: { contains: filter.q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterQuery(filter: BookingFilter): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("modus", filter.modus);
|
||||
if (filter.status) params.set("status", filter.status);
|
||||
if (filter.zahlung) params.set("zahlung", filter.zahlung);
|
||||
if (filter.q) params.set("q", filter.q);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** Schutz vor Formel-Injektion: Zellen, die mit = + - @ beginnen, werden entschaerft. */
|
||||
export function csvCell(value: string | number | null | undefined): string {
|
||||
let s = value === null || value === undefined ? "" : String(value);
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export { BOOKING_STATUSES, PAYMENT_STATUSES };
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Verwaltungsaktionen. Jede Aktion prueft ihre Eingaben, laeuft unter
|
||||
* Buchungssperre und wird im AuditLog festgehalten.
|
||||
*
|
||||
* Stornierung und Erstattung sind bewusst getrennt: Die freigegebenen
|
||||
* Stornobedingungen bestimmen, welcher Betrag erstattet wird — diese
|
||||
* Entscheidung trifft ein Mensch, nicht die Software (docs/BETRIEB.md).
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { z } from "zod";
|
||||
import { audit, type Actor } from "./audit";
|
||||
import { countOccupiedSeats, lockBooking, lockCamp } from "./booking-service";
|
||||
import { CAMP_ID, type Db } from "./db";
|
||||
import { enqueueEmail } from "./email";
|
||||
import { bookingConfirmedEmail } from "./email-templates";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { getCamp } from "./camp";
|
||||
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
||||
import { detachFinishedPaymentMethods } from "./worker-jobs";
|
||||
|
||||
export type ActionResult = { ok: true; message: string } | { ok: false; message: string };
|
||||
|
||||
export async function cancelBooking(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { bookingId: string; reason: string },
|
||||
actor: Actor,
|
||||
now = new Date(),
|
||||
): Promise<ActionResult> {
|
||||
const reason = params.reason.trim().slice(0, 500);
|
||||
if (!reason) return { ok: false, message: "Bitte einen Grund angeben." };
|
||||
|
||||
const before = await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, params.bookingId);
|
||||
const booking = await tx.booking.findUnique({ where: { id: params.bookingId }, include: { installments: true } });
|
||||
if (!booking || booking.status === "CANCELED") return null;
|
||||
await tx.installment.updateMany({
|
||||
where: { bookingId: booking.id, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "CANCELED", nextAttemptAt: null },
|
||||
});
|
||||
await tx.seatReservation.updateMany({
|
||||
where: { bookingId: booking.id, status: { not: "RELEASED" } },
|
||||
data: { status: "RELEASED", releasedAt: now, releaseReason: "canceled_by_admin" },
|
||||
});
|
||||
await tx.voucherRedemption.updateMany({
|
||||
where: { bookingId: booking.id, status: { not: "RELEASED" } },
|
||||
data: { status: "RELEASED", releasedAt: now },
|
||||
});
|
||||
await tx.booking.update({ where: { id: booking.id }, data: { status: "CANCELED", canceledAt: now, cancelReason: reason } });
|
||||
await audit(tx, actor, "booking.canceled", "Booking", booking.id, { reason, previousStatus: booking.status, paidCents: booking.paidCents });
|
||||
return booking;
|
||||
});
|
||||
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
||||
|
||||
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
|
||||
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
|
||||
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
|
||||
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
|
||||
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
||||
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
||||
}
|
||||
|
||||
export async function refundInstallment(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { installmentId: string; amountCents: number; requestKey: string; reason: string },
|
||||
actor: Actor,
|
||||
): Promise<ActionResult> {
|
||||
if (!Number.isSafeInteger(params.amountCents) || params.amountCents <= 0) return { ok: false, message: "Ungültiger Betrag." };
|
||||
const installment = await db.installment.findUnique({ where: { id: params.installmentId }, include: { refunds: true } });
|
||||
if (!installment || installment.status !== "PAID" || !installment.stripePaymentIntentId) {
|
||||
return { ok: false, message: "Nur bezahlte Raten können erstattet werden." };
|
||||
}
|
||||
const alreadyRequested = installment.refunds.filter((r) => r.status !== "failed" && r.status !== "canceled").reduce((s, r) => s + r.amountCents, 0);
|
||||
const refundable = installment.amountCents - Math.max(alreadyRequested, installment.refundedCents);
|
||||
if (params.amountCents > refundable) return { ok: false, message: `Höchstens ${(refundable / 100).toFixed(2)} € erstattbar.` };
|
||||
|
||||
const refund = await gateway.createRefund(
|
||||
{ paymentIntentId: installment.stripePaymentIntentId, amountCents: params.amountCents, metadata: { bookingId: installment.bookingId, installmentId: installment.id } },
|
||||
`refund:${params.requestKey}`,
|
||||
);
|
||||
await db.refund.upsert({
|
||||
where: { stripeRefundId: refund.id },
|
||||
create: {
|
||||
bookingId: installment.bookingId,
|
||||
installmentId: installment.id,
|
||||
stripeRefundId: refund.id,
|
||||
amountCents: refund.amount,
|
||||
status: refund.status,
|
||||
reason: params.reason.slice(0, 500) || null,
|
||||
createdBy: actor.id ?? null,
|
||||
},
|
||||
update: { status: refund.status },
|
||||
});
|
||||
await audit(db, actor, "payment.refund_requested", "Installment", installment.id, {
|
||||
bookingId: installment.bookingId,
|
||||
refundId: refund.id,
|
||||
amountCents: refund.amount,
|
||||
});
|
||||
return { ok: true, message: "Erstattung bei Stripe ausgelöst. Der Zahlungsstatus aktualisiert sich, sobald Stripe sie bestätigt." };
|
||||
}
|
||||
|
||||
/** Entscheidung ueber eine Buchung im Status NEEDS_REVIEW. */
|
||||
export async function confirmReviewedBooking(db: Db, bookingId: string, actor: Actor, now = new Date()): Promise<ActionResult> {
|
||||
return db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, bookingId);
|
||||
const booking = await tx.booking.findUnique({ where: { id: bookingId }, include: { reservation: true } });
|
||||
if (!booking || booking.status !== "NEEDS_REVIEW") return { ok: false, message: "Buchung ist nicht in Prüfung." };
|
||||
if (booking.paidCents <= 0) return { ok: false, message: "Ohne Zahlungseingang kann nicht bestätigt werden." };
|
||||
if (!booking.reservation) return { ok: false, message: "Zu dieser Buchung gibt es keine Platzreservierung." };
|
||||
if (booking.reservation.status !== "CONVERTED") {
|
||||
await lockCamp(tx);
|
||||
const camp = await getCamp(tx);
|
||||
const occupied = await countOccupiedSeats(tx, booking.livemode, now);
|
||||
if (occupied >= camp.capacity) {
|
||||
return { ok: false, message: "Kein Platz frei. Kapazität in den Einstellungen erhöhen oder die Buchung stornieren." };
|
||||
}
|
||||
await tx.seatReservation.update({ where: { bookingId }, data: { status: "CONVERTED", releasedAt: null, releaseReason: null } });
|
||||
}
|
||||
await tx.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED", confirmedAt: now, reviewReason: null } });
|
||||
await audit(tx, actor, "booking.review_confirmed", "Booking", bookingId, { previousReason: booking.reviewReason });
|
||||
return { ok: true, message: "Buchung bestätigt." };
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendConfirmation(db: Db, bookingId: string, secret: string, actor: Actor): Promise<ActionResult> {
|
||||
const booking = await db.booking.findUnique({ where: { id: bookingId }, include: { installments: { orderBy: { seq: "asc" } } } });
|
||||
if (!booking || booking.status !== "CONFIRMED") return { ok: false, message: "Nur bestätigte Buchungen." };
|
||||
const mail = bookingConfirmedEmail(booking, statusTokenFor(booking.id, secret));
|
||||
await enqueueEmail(db, { ...mail, dedupeKey: `${mail.dedupeKey}:resend:${Date.now()}` });
|
||||
await audit(db, actor, "booking.confirmation_resent", "Booking", bookingId);
|
||||
return { ok: true, message: "Bestätigung erneut in den Postausgang gelegt." };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gutscheine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dateOnly = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT");
|
||||
/** "Gueltig ab" = 00:00 Uhr Berlin des Tages. */
|
||||
const validFromDate = dateOnly.transform((v) => (v ? berlinStartOfDay(v) : null));
|
||||
/** "Gueltig bis einschliesslich" = exklusive Grenze 00:00 Uhr Berlin am Folgetag. */
|
||||
const validUntilDate = dateOnly.transform((v) => (v ? berlinStartOfDay(addDays(v, 1)) : null));
|
||||
|
||||
export const voucherInputSchema = z
|
||||
.object({
|
||||
code: z.string().transform((v, ctx) => {
|
||||
const code = normalizeVoucherCode(v);
|
||||
if (!code) {
|
||||
ctx.addIssue({ code: "custom", message: "Code: 4–40 Zeichen, nur Buchstaben, Ziffern und Bindestrich." });
|
||||
return z.NEVER;
|
||||
}
|
||||
return code;
|
||||
}),
|
||||
active: z.boolean(),
|
||||
validFrom: validFromDate,
|
||||
validUntil: validUntilDate,
|
||||
maxRedemptions: z.coerce.number().int().min(1).max(100),
|
||||
boundEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => (v ? normalizeEmail(v) : null))
|
||||
.refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
|
||||
internalNote: z.string().trim().max(500).transform((v) => v || null),
|
||||
})
|
||||
.refine((v) => !v.validFrom || !v.validUntil || v.validFrom < v.validUntil, { message: "Gültig bis muss nach Gültig ab liegen.", path: ["validUntil"] });
|
||||
|
||||
export type VoucherInput = z.infer<typeof voucherInputSchema>;
|
||||
|
||||
export async function saveVoucher(db: Db, id: string | null, input: VoucherInput, actor: Actor): Promise<ActionResult & { id?: string }> {
|
||||
const clash = await db.voucher.findUnique({ where: { code: input.code } });
|
||||
if (clash && clash.id !== id) return { ok: false, message: "Dieser Code existiert bereits." };
|
||||
const voucher = id ? await db.voucher.update({ where: { id }, data: input }) : await db.voucher.create({ data: input });
|
||||
await audit(db, actor, id ? "voucher.updated" : "voucher.created", "Voucher", voucher.id, {
|
||||
active: input.active,
|
||||
maxRedemptions: input.maxRedemptions,
|
||||
bound: input.boundEmail !== null,
|
||||
});
|
||||
return { ok: true, message: id ? "Gutschein gespeichert." : "Gutschein angelegt.", id: voucher.id };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camp-Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const text = (max: number) => z.string().trim().max(max).transform((v) => v || null);
|
||||
const isoDate = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), "Datum im Format JJJJ-MM-TT")
|
||||
.transform((v) => (v ? isoToDbDate(v) : null));
|
||||
|
||||
export const campInputSchema = z.object({
|
||||
salesEnabled: z.boolean(),
|
||||
capacity: z.coerce.number().int().min(0).max(200),
|
||||
capacityConfirmed: z.boolean(),
|
||||
stammmodelQuota: z.coerce.number().int().min(0).max(200),
|
||||
stammmodelQuotaConfirmed: z.boolean(),
|
||||
campStartDate: isoDate,
|
||||
campEndDate: isoDate,
|
||||
dateConfirmed: z.boolean(),
|
||||
locationName: text(200),
|
||||
locationRegion: text(200),
|
||||
locationConfirmed: z.boolean(),
|
||||
accommodationText: text(3000),
|
||||
accommodationConfirmed: z.boolean(),
|
||||
servicesConfirmed: z.boolean(),
|
||||
organizerName: text(200),
|
||||
organizerAddress: text(500),
|
||||
organizerEmail: text(200).refine((v) => v === null || z.email().safeParse(v).success, "Ungültige E-Mail-Adresse"),
|
||||
organizerPhone: text(60),
|
||||
organizerConfirmed: z.boolean(),
|
||||
taxNote: text(500),
|
||||
taxConfirmed: z.boolean(),
|
||||
termsVersion: text(60),
|
||||
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
|
||||
termsApproved: z.boolean(),
|
||||
travelLawChecked: z.boolean(),
|
||||
installmentsApproved: z.boolean(),
|
||||
installmentRuleApproved: z.boolean(),
|
||||
reservationMinutes: z.coerce.number().int().min(30).max(120),
|
||||
finalInstallmentLeadDays: z.coerce.number().int().min(0).max(120),
|
||||
minInstallmentGapDays: z.coerce.number().int().min(1).max(90),
|
||||
testPhaseOverride: z.enum(["", "EARLY_BIRD", "REGULAR"]).transform((v) => (v ? v : null)),
|
||||
});
|
||||
|
||||
export type CampInput = z.infer<typeof campInputSchema>;
|
||||
|
||||
export async function updateCamp(db: Db, input: CampInput, actor: Actor): Promise<ActionResult> {
|
||||
if (input.campStartDate && input.campEndDate && input.campEndDate < input.campStartDate) {
|
||||
return { ok: false, message: "Das Enddatum liegt vor dem Anreisetag." };
|
||||
}
|
||||
const before = await getCamp(db);
|
||||
const changed = (Object.keys(input) as (keyof CampInput)[]).filter((key) => {
|
||||
const a = before[key];
|
||||
const b = input[key];
|
||||
return (a instanceof Date ? a.getTime() : a) !== (b instanceof Date ? b.getTime() : b);
|
||||
});
|
||||
await db.camp.update({ where: { id: CAMP_ID }, data: { ...input, updatedBy: actor.id ?? null } });
|
||||
await audit(db, actor, "camp.updated", "Camp", CAMP_ID, { changed });
|
||||
return { ok: true, message: changed.length ? `Gespeichert (${changed.length} Änderungen).` : "Keine Änderungen." };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Gemeinsame Bausteine fuer die oeffentlichen API-Routen: einheitliche
|
||||
* Antwortform, Schutz schreibender Endpunkte, Rate Limiting.
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { z } from "zod";
|
||||
import { getDb } from "./db";
|
||||
import { env, requireAppSecret } from "./env";
|
||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
||||
|
||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
export function ok<T>(data: T, status = 200): NextResponse {
|
||||
return NextResponse.json({ success: true, data }, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
export function fail(status: number, error: ApiError, headers: Record<string, string> = {}): NextResponse {
|
||||
return NextResponse.json({ success: false, error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF-Schutz: schreibende Aufrufe nur von der eigenen Seite (Origin bzw.
|
||||
* Sec-Fetch-Site) und nur mit JSON — ein fremdes HTML-Formular kann weder
|
||||
* das eine noch das andere liefern.
|
||||
*/
|
||||
export function checkSameOrigin(request: Request): NextResponse | null {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("application/json")) {
|
||||
return fail(415, { code: "UNSUPPORTED_MEDIA_TYPE", message: "Anfrage muss JSON sein." });
|
||||
}
|
||||
const site = request.headers.get("sec-fetch-site");
|
||||
const origin = request.headers.get("origin");
|
||||
const forbidden = fail(403, { code: "FORBIDDEN", message: "Anfrage nicht erlaubt." });
|
||||
// Fail closed: ohne jede Herkunftsangabe keine Annahme.
|
||||
if (!site && !origin) return forbidden;
|
||||
if (site && site !== "same-origin") return forbidden;
|
||||
if (origin && origin !== new URL(env().APP_URL).origin) return forbidden;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson<T extends z.ZodType>(
|
||||
request: Request,
|
||||
schema: T,
|
||||
): Promise<{ ok: true; data: z.infer<T> } | { ok: false; response: NextResponse }> {
|
||||
const raw = await request.text();
|
||||
if (raw.length > MAX_BODY_BYTES) {
|
||||
return { ok: false, response: fail(413, { code: "TOO_LARGE", message: "Anfrage ist zu groß." }) };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, response: fail(400, { code: "INVALID_JSON", message: "Anfrage konnte nicht gelesen werden." }) };
|
||||
}
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (!fields[key]) fields[key] = issue.message;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
response: fail(422, { code: "VALIDATION", message: "Bitte prüfe die markierten Angaben.", fields }),
|
||||
};
|
||||
}
|
||||
return { ok: true, data: result.data };
|
||||
}
|
||||
|
||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
||||
if (result.allowed) return null;
|
||||
return fail(
|
||||
429,
|
||||
{ code: "RATE_LIMITED", message: "Zu viele Anfragen. Bitte warte einen Moment und versuche es dann erneut." },
|
||||
{ "Retry-After": String(result.retryAfterSeconds) },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ActorType, Prisma } from "@/generated/prisma/client";
|
||||
import type { Db, Tx } from "./db";
|
||||
|
||||
export type Actor = { type: ActorType; id?: string | null };
|
||||
|
||||
export const SYSTEM: Actor = { type: "SYSTEM" };
|
||||
export const STRIPE_ACTOR: Actor = { type: "STRIPE" };
|
||||
export const WORKER: Actor = { type: "WORKER" };
|
||||
export const CUSTOMER: Actor = { type: "CUSTOMER" };
|
||||
|
||||
/**
|
||||
* Protokolliert Buchungs-, Bestands- und Zahlungsaktionen. "details" enthaelt
|
||||
* Betraege, Status und Stripe-IDs — keine Kontaktdaten der Teilnehmerin.
|
||||
*/
|
||||
export async function audit(
|
||||
db: Db | Tx,
|
||||
actor: Actor,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
details?: Prisma.InputJsonValue,
|
||||
): Promise<void> {
|
||||
await db.auditLog.create({
|
||||
data: { actorType: actor.type, actorId: actor.id ?? null, action, entityType, entityId, details },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { twoFactorClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// Den zweiten Anmeldeschritt steuert LoginForm selbst (twoFactorRedirect).
|
||||
plugins: [twoFactorClient()],
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Anmeldung fuer den Adminbereich mit Better Auth (etablierte Loesung statt
|
||||
* Eigenbau).
|
||||
*
|
||||
* - Keine Selbstregistrierung: Konten entstehen nur per
|
||||
* `pnpm admin:create` (scripts/create-admin.ts), ohne Standardpasswort.
|
||||
* - Zwei-Faktor-Anmeldung (TOTP) ist Pflicht: admin-auth.ts laesst ohne
|
||||
* aktivierte 2FA nur die Einrichtungsseite zu.
|
||||
* - Rate Limits liegen in PostgreSQL und gelten damit ueber alle Replikate.
|
||||
* - Passwoerter werden gegen bekannte Datenlecks geprueft (k-Anonymitaet,
|
||||
* nur ein Hash-Praefix verlaesst den Server).
|
||||
*/
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { getDb } from "./db";
|
||||
import { env } from "./env";
|
||||
|
||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||
const e = env();
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret || secret.length < 32) throw new Error("BETTER_AUTH_SECRET fehlt oder ist kuerzer als 32 Zeichen.");
|
||||
return betterAuth({
|
||||
appName: "ARTISTA Modelcamp Verwaltung",
|
||||
baseURL: e.APP_URL,
|
||||
secret,
|
||||
database: prismaAdapter(getDb(), { provider: "postgresql" }),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: !options.allowSignUp,
|
||||
minPasswordLength: 12,
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 8,
|
||||
updateAge: 60 * 30,
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
storage: "database",
|
||||
modelName: "rateLimit",
|
||||
window: 60,
|
||||
max: 60,
|
||||
customRules: {
|
||||
"/sign-in/email": { window: 300, max: 5 },
|
||||
"/two-factor/verify-totp": { window: 300, max: 5 },
|
||||
"/two-factor/verify-backup-code": { window: 300, max: 5 },
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
||||
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
|
||||
},
|
||||
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||
});
|
||||
}
|
||||
|
||||
type Auth = ReturnType<typeof createAuth>;
|
||||
let instance: Auth | null = null;
|
||||
|
||||
/**
|
||||
* Nur fuer scripts/create-admin.ts (Kommandozeile im Cluster): eine eigene
|
||||
* Instanz, die Registrierung erlaubt. Der Webserver nutzt sie nie — dort
|
||||
* bleibt disableSignUp aktiv.
|
||||
*/
|
||||
export function createProvisioningAuth(): Auth {
|
||||
return createAuth({ allowSignUp: true });
|
||||
}
|
||||
|
||||
/** Lazy, damit der Build ohne Datenbank und Secrets auskommt. */
|
||||
export function getAuth(): Auth {
|
||||
if (!instance) instance = createAuth();
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Anlage einer Buchung samt Platzreservierung (Briefing Abschnitte 5–8).
|
||||
*
|
||||
* Nebenlaeufigkeit: Jede Transaktion, die Kapazitaet belegt, sperrt zuerst
|
||||
* die Camp-Zeile (SELECT … FOR UPDATE) und danach ggf. die Gutscheinzeile.
|
||||
* Parallele Checkouts laufen dadurch fuer den kurzen Moment der Pruefung
|
||||
* strikt nacheinander — zwei Personen koennen nie denselben letzten Platz
|
||||
* oder dieselbe letzte Einloesung erhalten. Die feste Sperrreihenfolge
|
||||
* (bestehende Buchung -> Camp -> Gutschein) schliesst Deadlocks aus: neue
|
||||
* Buchungen sperren nur Camp und Gutschein, Zahlungs- und Adminaktionen
|
||||
* sperren zuerst die Buchung und erst danach ggf. Camp bzw. Gutschein.
|
||||
*/
|
||||
import "./zod-de";
|
||||
import { z } from "zod";
|
||||
import type { Booking, Installment, PlanType as DbPlanType } from "@/generated/prisma/client";
|
||||
import { audit, CUSTOMER } from "./audit";
|
||||
import { campStartIso, getCamp, salesStateFor } from "./camp";
|
||||
import { CAMP_ID, type Db, type Tx } from "./db";
|
||||
import { buildSchedule, INSTALLMENT_COUNT, PLAN_TYPES, type PlanType, type ScheduledInstallment } from "./payment-plan";
|
||||
import { calculatePrice, type PriceBreakdown } from "./pricing";
|
||||
import { sellablePhase, type SalesState, type StripeMode } from "./sales-gate";
|
||||
import { generateReference, sha256Hex, statusTokenFor } from "./tokens";
|
||||
import { evaluateVoucher, normalizeEmail, type VoucherRejection } from "./vouchers";
|
||||
|
||||
/** Puffer zwischen Ablauf der Stripe-Session und Freigabe des Platzes, damit
|
||||
* ein kurz vor Ablauf abgeschlossener Checkout seinen Platz sicher behaelt. */
|
||||
export const RESERVATION_GRACE_MINUTES = 10;
|
||||
|
||||
export const participantSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "Bitte gib deinen Vornamen an.").max(80),
|
||||
lastName: z.string().trim().min(1, "Bitte gib deinen Nachnamen an.").max(80),
|
||||
email: z.email("Bitte gib eine gültige E-Mail-Adresse an.").max(200).transform(normalizeEmail),
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(6, "Bitte gib eine Telefonnummer an.")
|
||||
.max(40)
|
||||
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
|
||||
country: z.string().trim().min(2, "Bitte gib dein Land an.").max(60),
|
||||
notes: z.string().trim().max(1000).optional().transform((v) => (v ? v : undefined)),
|
||||
});
|
||||
|
||||
export const bookingRequestSchema = z
|
||||
.object({
|
||||
idempotencyKey: z.uuid(),
|
||||
participant: participantSchema,
|
||||
planType: z.enum(PLAN_TYPES as [PlanType, ...PlanType[]]),
|
||||
voucherCode: z.string().trim().max(60).optional().transform((v) => (v ? v : undefined)),
|
||||
consents: z.object({
|
||||
adult: z.literal(true, "Bitte bestätige, dass du volljährig bist."),
|
||||
terms: z.literal(true, "Bitte akzeptiere die Buchungsbedingungen."),
|
||||
accommodation: z.literal(true, "Bitte bestätige die Unterbringung."),
|
||||
privacy: z.literal(true, "Bitte bestätige die Datenschutzhinweise."),
|
||||
installmentMandate: z.boolean(),
|
||||
}),
|
||||
})
|
||||
.refine((v) => v.planType === "FULL" || v.consents.installmentMandate, {
|
||||
message: "Bitte stimme dem Einzug der weiteren Raten zu.",
|
||||
path: ["consents", "installmentMandate"],
|
||||
});
|
||||
|
||||
export type BookingRequest = z.infer<typeof bookingRequestSchema>;
|
||||
|
||||
export type BookingError =
|
||||
| { code: "SALES_CLOSED"; reason: SalesState["closedReason"] }
|
||||
| { code: "SOLD_OUT" }
|
||||
| { code: "VOUCHER_REJECTED"; reason: VoucherRejection }
|
||||
| { code: "PLAN_UNAVAILABLE" }
|
||||
| { code: "IDEMPOTENCY_CONFLICT" };
|
||||
|
||||
export type CreatedBooking = Booking & { installments: Installment[] };
|
||||
|
||||
export type CreateBookingResult =
|
||||
| { ok: true; reused: boolean; booking: CreatedBooking }
|
||||
| { ok: false; error: BookingError };
|
||||
|
||||
export type BookingContext = { now: Date; mode: StripeMode; secret: string };
|
||||
|
||||
/** Belegte Plaetze desselben Modus (Test/Live getrennt). */
|
||||
export async function countOccupiedSeats(db: Db | Tx, livemode: boolean, now: Date): Promise<number> {
|
||||
const rows = await db.$queryRaw<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n FROM "SeatReservation"
|
||||
WHERE "campId" = ${CAMP_ID} AND "livemode" = ${livemode}
|
||||
AND ("status" IN ('HELD', 'CONVERTED') OR ("status" = 'ACTIVE' AND "expiresAt" > ${now}))`;
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
export async function lockCamp(tx: Tx): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Camp" WHERE "id" = ${CAMP_ID} FOR UPDATE`;
|
||||
}
|
||||
|
||||
export async function createBooking(db: Db, request: BookingRequest, ctx: BookingContext): Promise<CreateBookingResult> {
|
||||
const livemode = ctx.mode === "live";
|
||||
await getCamp(db); // stellt sicher, dass die zu sperrende Zeile existiert
|
||||
|
||||
return db.$transaction(
|
||||
async (tx) => {
|
||||
// Erst sperren, dann nach dem Idempotenzschluessel suchen: ein parallel
|
||||
// laufender Doppelklick sieht so die bereits festgeschriebene Buchung.
|
||||
await lockCamp(tx);
|
||||
const existing = await tx.booking.findUnique({
|
||||
where: { idempotencyKey: request.idempotencyKey },
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.email !== request.participant.email) return { ok: false, error: { code: "IDEMPOTENCY_CONFLICT" } };
|
||||
return { ok: true, reused: true, booking: existing };
|
||||
}
|
||||
|
||||
const camp = await getCamp(tx);
|
||||
const state = salesStateFor(camp, ctx.mode, ctx.now);
|
||||
const phase = sellablePhase(state);
|
||||
if (!state.checkoutOpen || !phase) return { ok: false, error: { code: "SALES_CLOSED", reason: state.closedReason } };
|
||||
|
||||
const occupied = await countOccupiedSeats(tx, livemode, ctx.now);
|
||||
if (occupied >= camp.capacity) return { ok: false, error: { code: "SOLD_OUT" } };
|
||||
|
||||
let voucherId: string | null = null;
|
||||
if (request.voucherCode) {
|
||||
const evaluation = await evaluateVoucher(tx, {
|
||||
code: request.voucherCode,
|
||||
email: request.participant.email,
|
||||
now: ctx.now,
|
||||
livemode,
|
||||
quota: camp.stammmodelQuota,
|
||||
lock: true,
|
||||
});
|
||||
if (!evaluation.ok) return { ok: false, error: { code: "VOUCHER_REJECTED", reason: evaluation.reason } };
|
||||
voucherId = evaluation.voucher.id;
|
||||
}
|
||||
|
||||
const price = calculatePrice(phase, voucherId !== null);
|
||||
if (request.planType !== "FULL" && !state.installmentsAllowed) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
|
||||
const schedule = buildSchedule({
|
||||
planType: request.planType,
|
||||
totalCents: price.totalCents,
|
||||
now: ctx.now,
|
||||
campStartDate: campStartIso(camp),
|
||||
finalInstallmentLeadDays: camp.finalInstallmentLeadDays,
|
||||
minInstallmentGapDays: camp.minInstallmentGapDays,
|
||||
});
|
||||
if (!schedule.ok) return { ok: false, error: { code: "PLAN_UNAVAILABLE" } };
|
||||
|
||||
// Stripe verlangt fuer expires_at mindestens 30 Minuten; eine Minute
|
||||
// Reserve gleicht Uhrabweichungen aus.
|
||||
const priceLockedUntil = new Date(ctx.now.getTime() + (camp.reservationMinutes + 1) * 60_000);
|
||||
const reservationExpiresAt = new Date(priceLockedUntil.getTime() + RESERVATION_GRACE_MINUTES * 60_000);
|
||||
const p = request.participant;
|
||||
const bookingId = crypto.randomUUID();
|
||||
|
||||
const booking = await tx.booking.create({
|
||||
data: {
|
||||
id: bookingId,
|
||||
reference: generateReference(),
|
||||
campId: CAMP_ID,
|
||||
livemode,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
statusTokenHash: sha256Hex(statusTokenFor(bookingId, ctx.secret)),
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
email: p.email,
|
||||
phone: p.phone,
|
||||
country: p.country,
|
||||
notes: p.notes ?? null,
|
||||
adultConfirmedAt: ctx.now,
|
||||
termsAcceptedAt: ctx.now,
|
||||
termsVersion: camp.termsVersion ?? "ENTWURF-NICHT-FREIGEGEBEN",
|
||||
accommodationAcknowledgedAt: ctx.now,
|
||||
privacyAcknowledgedAt: ctx.now,
|
||||
installmentMandateAt: request.planType === "FULL" ? null : ctx.now,
|
||||
pricePhase: price.phase,
|
||||
basePriceCents: price.basePriceCents,
|
||||
phaseDiscountCents: price.phaseDiscountCents,
|
||||
voucherDiscountCents: price.voucherDiscountCents,
|
||||
totalCents: price.totalCents,
|
||||
planType: request.planType as DbPlanType,
|
||||
priceSnapshot: snapshotJson(price, schedule.installments, state, camp.termsVersion),
|
||||
priceLockedUntil,
|
||||
voucherId,
|
||||
installments: {
|
||||
create: schedule.installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueAt: i.dueAt })),
|
||||
},
|
||||
reservation: {
|
||||
create: { campId: CAMP_ID, livemode, status: "ACTIVE", expiresAt: reservationExpiresAt },
|
||||
},
|
||||
...(voucherId ? { redemption: { create: { voucherId, livemode, status: "RESERVED" } } } : {}),
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
|
||||
await audit(tx, CUSTOMER, "booking.created", "Booking", booking.id, {
|
||||
reference: booking.reference,
|
||||
livemode,
|
||||
phase: price.phase,
|
||||
totalCents: price.totalCents,
|
||||
planType: request.planType,
|
||||
voucher: voucherId !== null,
|
||||
seatsOccupiedBefore: occupied,
|
||||
reservationExpiresAt: reservationExpiresAt.toISOString(),
|
||||
});
|
||||
|
||||
return { ok: true, reused: false, booking };
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotJson(price: PriceBreakdown, installments: ScheduledInstallment[], state: SalesState, termsVersion: string | null) {
|
||||
return {
|
||||
currency: "EUR",
|
||||
phase: price.phase,
|
||||
phaseSimulatedForTest: state.phaseSimulated,
|
||||
basePriceCents: price.basePriceCents,
|
||||
phaseDiscountCents: price.phaseDiscountCents,
|
||||
phasePriceCents: price.phasePriceCents,
|
||||
voucherDiscountCents: price.voucherDiscountCents,
|
||||
totalCents: price.totalCents,
|
||||
installments: installments.map((i) => ({ seq: i.seq, amountCents: i.amountCents, dueDate: i.dueDate })),
|
||||
installmentCount: installments.length,
|
||||
termsVersion,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Platz und Gutscheinreservierung einer (noch) nicht bezahlten Buchung
|
||||
* frei. Muss innerhalb einer Transaktion mit gesperrter Buchungszeile laufen.
|
||||
*/
|
||||
export async function releaseBookingHold(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
params: { status: "EXPIRED" | "CANCELED"; reason: string; now: Date },
|
||||
): Promise<void> {
|
||||
await tx.seatReservation.updateMany({
|
||||
where: { bookingId, status: { in: ["ACTIVE", "HELD"] } },
|
||||
data: { status: "RELEASED", releasedAt: params.now, releaseReason: params.reason },
|
||||
});
|
||||
await tx.voucherRedemption.updateMany({
|
||||
where: { bookingId, status: "RESERVED" },
|
||||
data: { status: "RELEASED", releasedAt: params.now },
|
||||
});
|
||||
await tx.installment.updateMany({
|
||||
where: { bookingId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
|
||||
data: { status: "CANCELED" },
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data:
|
||||
params.status === "CANCELED"
|
||||
? { status: "CANCELED", canceledAt: params.now, cancelReason: params.reason }
|
||||
: { status: "EXPIRED" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function lockBooking(tx: Tx, bookingId: string): Promise<void> {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Booking" WHERE "id" = ${bookingId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
export function installmentCount(planType: PlanType): number {
|
||||
return INSTALLMENT_COUNT[planType];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Camp } from "@/generated/prisma/client";
|
||||
import { CAMP_ID, type Db, type Tx } from "./db";
|
||||
import { evaluateSales, type CampGateFields, type SalesState, type StripeMode } from "./sales-gate";
|
||||
import { dbDateToIso } from "./time";
|
||||
|
||||
/**
|
||||
* Laedt die Camp-Konfiguration. Fehlt die Zeile (frische Datenbank), wird
|
||||
* sie mit sicheren Standardwerten angelegt: Live-Verkauf aus, keine
|
||||
* Freigaben, 10 Plaetze und 4 Stammmodel-Buchungen als vorlaeufige Werte.
|
||||
*/
|
||||
export async function getCamp(db: Db | Tx): Promise<Camp> {
|
||||
return db.camp.upsert({ where: { id: CAMP_ID }, create: { id: CAMP_ID }, update: {} });
|
||||
}
|
||||
|
||||
export function campStartIso(camp: Pick<Camp, "campStartDate">): string | null {
|
||||
return camp.campStartDate ? dbDateToIso(camp.campStartDate) : null;
|
||||
}
|
||||
|
||||
export function toGateFields(camp: Camp): CampGateFields {
|
||||
return {
|
||||
salesEnabled: camp.salesEnabled,
|
||||
capacityConfirmed: camp.capacityConfirmed,
|
||||
stammmodelQuotaConfirmed: camp.stammmodelQuotaConfirmed,
|
||||
campStartDate: campStartIso(camp),
|
||||
dateConfirmed: camp.dateConfirmed,
|
||||
locationName: camp.locationName,
|
||||
locationConfirmed: camp.locationConfirmed,
|
||||
accommodationText: camp.accommodationText,
|
||||
accommodationConfirmed: camp.accommodationConfirmed,
|
||||
servicesConfirmed: camp.servicesConfirmed,
|
||||
organizerName: camp.organizerName,
|
||||
organizerAddress: camp.organizerAddress,
|
||||
organizerEmail: camp.organizerEmail,
|
||||
organizerConfirmed: camp.organizerConfirmed,
|
||||
taxNote: camp.taxNote,
|
||||
taxConfirmed: camp.taxConfirmed,
|
||||
termsVersion: camp.termsVersion,
|
||||
termsApproved: camp.termsApproved,
|
||||
travelLawChecked: camp.travelLawChecked,
|
||||
installmentsApproved: camp.installmentsApproved,
|
||||
installmentRuleApproved: camp.installmentRuleApproved,
|
||||
testPhaseOverride: camp.testPhaseOverride,
|
||||
};
|
||||
}
|
||||
|
||||
export function salesStateFor(camp: Camp, mode: StripeMode, now: Date = new Date()): SalesState {
|
||||
return evaluateSales(now, toGateFields(camp), mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffentlich anzeigbare, bestaetigte Fakten. Unbestaetigte Werte werden
|
||||
* hier bewusst zu null — die Seite zeigt dann "wird bekanntgegeben" statt
|
||||
* eines ungeprueften Datums oder Orts.
|
||||
*/
|
||||
export type PublicCampFacts = {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
location: string | null;
|
||||
region: string | null;
|
||||
accommodationText: string | null;
|
||||
taxNote: string | null;
|
||||
organizer: { name: string; address: string; email: string; phone: string | null } | null;
|
||||
termsUrl: string | null;
|
||||
termsVersion: string | null;
|
||||
};
|
||||
|
||||
export function publicCampFacts(camp: Camp): PublicCampFacts {
|
||||
return {
|
||||
startDate: camp.dateConfirmed ? campStartIso(camp) : null,
|
||||
endDate: camp.dateConfirmed && camp.campEndDate ? dbDateToIso(camp.campEndDate) : null,
|
||||
location: camp.locationConfirmed ? camp.locationName : null,
|
||||
region: camp.locationConfirmed ? camp.locationRegion : null,
|
||||
accommodationText: camp.accommodationConfirmed ? camp.accommodationText : null,
|
||||
taxNote: camp.taxConfirmed ? camp.taxNote : null,
|
||||
organizer:
|
||||
camp.organizerConfirmed && camp.organizerName && camp.organizerAddress && camp.organizerEmail
|
||||
? {
|
||||
name: camp.organizerName,
|
||||
address: camp.organizerAddress,
|
||||
email: camp.organizerEmail,
|
||||
phone: camp.organizerPhone,
|
||||
}
|
||||
: null,
|
||||
termsUrl: camp.termsApproved ? camp.termsUrl : null,
|
||||
termsVersion: camp.termsApproved ? camp.termsVersion : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Buchungsstrecke bis zur Stripe-Zahlungsseite.
|
||||
*
|
||||
* Preisbindung (Briefing Abschnitt 5): Massgeblich ist der Zeitpunkt, zu dem
|
||||
* die Buchung gestartet wird. Der dabei berechnete Preis gilt, wenn die
|
||||
* Zahlung innerhalb der Reservierungszeit (Standard 30 Minuten) abgeschlossen
|
||||
* wird. Stripe erzwingt das ueber expires_at der Checkout-Session: danach ist
|
||||
* sie nicht mehr bezahlbar. Ein neuer Buchungsstart wird zum dann gueltigen
|
||||
* Preis berechnet — auch ueber den Jahreswechsel hinweg: Wer am 31.12. um
|
||||
* 23:50 startet, zahlt den Early-Bird-Preis, sofern die Zahlung bis zum
|
||||
* angezeigten Ablauf gelingt; eine Verlaengerung gibt es nicht.
|
||||
*/
|
||||
import type { Booking, Installment } from "@/generated/prisma/client";
|
||||
import { audit, CUSTOMER } from "./audit";
|
||||
import { createBooking, lockBooking, releaseBookingHold, type BookingError, type BookingRequest } from "./booking-service";
|
||||
import type { Db } from "./db";
|
||||
import { appUrl } from "./env";
|
||||
import { formatEuro } from "./money";
|
||||
import type { PaymentGateway } from "./payment-gateway";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { formatDate, formatDateTime } from "./time";
|
||||
|
||||
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme";
|
||||
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
|
||||
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
|
||||
const MIN_STRIPE_SESSION_MS = 30 * 60_000;
|
||||
|
||||
export type CheckoutError =
|
||||
| BookingError
|
||||
| { code: "PAYMENT_PROVIDER" }
|
||||
| { code: "ATTEMPT_EXPIRED" }
|
||||
| { code: "ALREADY_COMPLETED"; statusUrl: string };
|
||||
|
||||
export type CheckoutResult = { ok: true; url: string; reference: string } | { ok: false; error: CheckoutError };
|
||||
|
||||
export function planSummary(installments: Pick<Installment, "seq" | "amountCents" | "dueAt">[], totalCents: number): string {
|
||||
const sorted = [...installments].sort((a, b) => a.seq - b.seq);
|
||||
const today = `Heute fällig: ${formatEuro(sorted[0]!.amountCents)}.`;
|
||||
const later = sorted
|
||||
.slice(1)
|
||||
.map((i) => `${formatEuro(i.amountCents)} am ${i.dueAt ? formatDate(i.dueAt) : "–"}`)
|
||||
.join(", ");
|
||||
return [
|
||||
`Gesamtpreis ${formatEuro(totalCents)}.`,
|
||||
today,
|
||||
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export async function startCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
request: BookingRequest,
|
||||
ctx: { now: Date; secret: string },
|
||||
): Promise<CheckoutResult> {
|
||||
const created = await createBooking(db, request, { now: ctx.now, mode: gateway.mode, secret: ctx.secret });
|
||||
if (!created.ok) return { ok: false, error: created.error };
|
||||
const booking = created.booking;
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
|
||||
if (created.reused) {
|
||||
if (booking.status !== "PENDING") {
|
||||
return { ok: false, error: { code: "ALREADY_COMPLETED", statusUrl: appUrl(`/buchung/status/${token}`) } };
|
||||
}
|
||||
if (booking.stripeCheckoutUrl && booking.priceLockedUntil > ctx.now) {
|
||||
return { ok: true, url: booking.stripeCheckoutUrl, reference: booking.reference };
|
||||
}
|
||||
}
|
||||
if (booking.priceLockedUntil.getTime() - ctx.now.getTime() < MIN_STRIPE_SESSION_MS - 60_000) {
|
||||
return { ok: false, error: { code: "ATTEMPT_EXPIRED" } };
|
||||
}
|
||||
|
||||
try {
|
||||
// Idempotenzschluessel je Buchung: ein wiederholter Aufruf (Doppelklick,
|
||||
// Netzabbruch) erzeugt bei Stripe keinen zweiten Kunden und keine zweite
|
||||
// Session.
|
||||
const customer = await gateway.createCustomer(
|
||||
{
|
||||
email: booking.email,
|
||||
name: `${booking.firstName} ${booking.lastName}`,
|
||||
metadata: { bookingId: booking.id, reference: booking.reference },
|
||||
},
|
||||
`customer:${booking.id}`,
|
||||
);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
const count = booking.installments.length;
|
||||
const session = await gateway.createCheckoutSession(
|
||||
{
|
||||
customerId: customer.id,
|
||||
amountCents: first.amountCents,
|
||||
productName: PRODUCT_NAME,
|
||||
description: count > 1 ? `${PRODUCT_DESCRIPTION} · Rate 1 von ${count}` : PRODUCT_DESCRIPTION,
|
||||
metadata: { kind: "booking", bookingId: booking.id, reference: booking.reference, installmentId: first.id },
|
||||
saveForFutureUsage: count > 1,
|
||||
expiresAt: booking.priceLockedUntil,
|
||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
||||
cancelUrl: appUrl(`/buchung/status/${token}?checkout=cancel`),
|
||||
submitMessage: `${planSummary(booking.installments, booking.totalCents)} Preis gebunden bis ${formatDateTime(booking.priceLockedUntil)}.`,
|
||||
},
|
||||
`checkout:${booking.id}`,
|
||||
);
|
||||
await db.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { stripeCustomerId: customer.id, stripeCheckoutSessionId: session.id, stripeCheckoutUrl: session.url },
|
||||
});
|
||||
await audit(db, CUSTOMER, "checkout.session_created", "Booking", booking.id, { sessionId: session.id });
|
||||
return { ok: true, url: session.url, reference: booking.reference };
|
||||
} catch (error) {
|
||||
console.error(`[checkout] Stripe-Fehler fuer Buchung ${booking.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, booking.id);
|
||||
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
if (current.status === "PENDING" && current.paymentStatus === "UNPAID" && !current.stripeCheckoutSessionId) {
|
||||
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "payment_provider_error", now: ctx.now });
|
||||
}
|
||||
});
|
||||
return { ok: false, error: { code: "PAYMENT_PROVIDER" } };
|
||||
}
|
||||
}
|
||||
|
||||
export type InstallmentCheckoutResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; error: "NOT_PAYABLE" | "PAYMENT_PROVIDER" };
|
||||
|
||||
/**
|
||||
* Kundin zahlt eine Folgerate selbst (nach fehlgeschlagenem Einzug, bei
|
||||
* verlangter Authentifizierung oder freiwillig vorzeitig). Die Rate wird
|
||||
* fuer die Laufzeit der Session markiert, damit der Worker sie nicht
|
||||
* gleichzeitig off-session einzieht.
|
||||
*/
|
||||
export async function startInstallmentCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
params: { bookingId: string; installmentId: string },
|
||||
ctx: { now: Date; secret: string },
|
||||
): Promise<InstallmentCheckoutResult> {
|
||||
const sessionExpiresAt = new Date(ctx.now.getTime() + MIN_STRIPE_SESSION_MS + 60_000);
|
||||
|
||||
const claim = await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, params.bookingId);
|
||||
const booking = await tx.booking.findUniqueOrThrow({
|
||||
where: { id: params.bookingId },
|
||||
include: { installments: true },
|
||||
});
|
||||
const installment = booking.installments.find((i) => i.id === params.installmentId);
|
||||
if (!installment || installment.seq === 1) return null;
|
||||
if (booking.status !== "CONFIRMED" || !booking.stripeCustomerId) return null;
|
||||
if (!["PENDING", "FAILED", "REQUIRES_ACTION"].includes(installment.status)) return null;
|
||||
if (booking.paidCents + installment.amountCents > booking.totalCents) return null;
|
||||
if (installment.checkoutSessionId && installment.checkoutExpiresAt && installment.checkoutExpiresAt > new Date(ctx.now.getTime() + 5 * 60_000)) {
|
||||
return { booking, installment, existingSessionId: installment.checkoutSessionId };
|
||||
}
|
||||
await tx.installment.update({
|
||||
where: { id: installment.id },
|
||||
data: { checkoutExpiresAt: sessionExpiresAt, checkoutSessionId: null },
|
||||
});
|
||||
return { booking, installment, existingSessionId: null as string | null };
|
||||
});
|
||||
if (!claim) return { ok: false, error: "NOT_PAYABLE" };
|
||||
|
||||
const { booking, installment } = claim;
|
||||
try {
|
||||
if (claim.existingSessionId) {
|
||||
const existing = await gateway.retrieveCheckoutSession(claim.existingSessionId);
|
||||
if (existing.status === "open" && existing.url) return { ok: true, url: existing.url };
|
||||
}
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
const session = await gateway.createCheckoutSession(
|
||||
{
|
||||
customerId: booking.stripeCustomerId!,
|
||||
amountCents: installment.amountCents,
|
||||
productName: PRODUCT_NAME,
|
||||
description: `${PRODUCT_DESCRIPTION} · Rate ${installment.seq} von ${booking.installments.length}`,
|
||||
metadata: { kind: "installment", bookingId: booking.id, reference: booking.reference, installmentId: installment.id },
|
||||
saveForFutureUsage: booking.installments.some((i) => i.seq > installment.seq && i.status !== "PAID"),
|
||||
expiresAt: sessionExpiresAt,
|
||||
successUrl: appUrl(`/buchung/status/${token}?checkout=success`),
|
||||
cancelUrl: appUrl(`/buchung/status/${token}`),
|
||||
submitMessage: `Rate ${installment.seq} über ${formatEuro(installment.amountCents)} für Buchung ${booking.reference}.`,
|
||||
},
|
||||
`installment-checkout:${installment.id}:${sessionExpiresAt.getTime()}`,
|
||||
);
|
||||
await db.installment.update({ where: { id: installment.id }, data: { checkoutSessionId: session.id } });
|
||||
await audit(db, CUSTOMER, "checkout.installment_session_created", "Installment", installment.id, { sessionId: session.id });
|
||||
return { ok: true, url: session.url };
|
||||
} catch (error) {
|
||||
console.error(`[checkout] Stripe-Fehler fuer Rate ${installment.id}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
await db.installment.updateMany({
|
||||
where: { id: installment.id, checkoutSessionId: null },
|
||||
data: { checkoutExpiresAt: null },
|
||||
});
|
||||
return { ok: false, error: "PAYMENT_PROVIDER" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Kundin verwirft eine begonnene, unbezahlte Buchung selbst. */
|
||||
export async function abandonCheckout(
|
||||
db: Db,
|
||||
gateway: PaymentGateway,
|
||||
booking: Pick<Booking, "id" | "stripeCheckoutSessionId">,
|
||||
ctx: { now: Date },
|
||||
): Promise<void> {
|
||||
if (booking.stripeCheckoutSessionId) {
|
||||
await gateway.expireCheckoutSession(booking.stripeCheckoutSessionId);
|
||||
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId);
|
||||
// Wurde doch noch bezahlt, entscheidet der Webhook — hier nichts freigeben.
|
||||
if (session.status === "complete") return;
|
||||
}
|
||||
await db.$transaction(async (tx) => {
|
||||
await lockBooking(tx, booking.id);
|
||||
const current = await tx.booking.findUniqueOrThrow({ where: { id: booking.id } });
|
||||
if (current.status !== "PENDING" || current.paymentStatus !== "UNPAID") return;
|
||||
await releaseBookingHold(tx, booking.id, { status: "EXPIRED", reason: "abandoned_by_customer", now: ctx.now });
|
||||
await audit(tx, CUSTOMER, "booking.abandoned", "Booking", booking.id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export type Db = PrismaClient;
|
||||
/** Transaktions-Client innerhalb von db.$transaction(async (tx) => …). */
|
||||
export type Tx = Parameters<Parameters<PrismaClient["$transaction"]>[0]>[0];
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createClient(): PrismaClient {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
|
||||
const adapter = new PrismaPg({ connectionString, max: Number(process.env.DATABASE_POOL_SIZE ?? 10) });
|
||||
return new PrismaClient({ adapter, log: ["error"] });
|
||||
}
|
||||
|
||||
/** Lazy, damit der Build (ohne Datenbank) Module importieren kann. */
|
||||
export function getDb(): PrismaClient {
|
||||
if (!globalForPrisma.prisma) globalForPrisma.prisma = createClient();
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
export const CAMP_ID = "modelcamp-2027";
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user