Compare commits
No files matched your search
@@ -26,7 +26,9 @@ BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
|
|||||||
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
||||||
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
||||||
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
||||||
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds.
|
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds und
|
||||||
|
# fuer die Folgeraten (Stripe Billing) zusaetzlich Products, Subscription
|
||||||
|
# Schedules, Subscriptions, Invoices (docs/BETRIEB.md, Abschnitt 4).
|
||||||
STRIPE_SECRET_KEY=<sk_test_…>
|
STRIPE_SECRET_KEY=<sk_test_…>
|
||||||
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
||||||
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
||||||
@@ -53,3 +55,9 @@ MEDIA_DIR=./media
|
|||||||
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
||||||
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
||||||
EXTERNAL_VIDEO_PROVIDER=none
|
EXTERNAL_VIDEO_PROVIDER=none
|
||||||
|
|
||||||
|
# --- Google-Rezensionen (optional) ------------------------------------------
|
||||||
|
# "Places API (New)" aktivieren, Abrechnung an, API-Schluessel (kein OAuth).
|
||||||
|
# Ohne Werte wird der Rezensionen-Abschnitt ausgeblendet.
|
||||||
|
GOOGLE_PLACES_API_KEY=
|
||||||
|
GOOGLE_PLACE_ID=
|
||||||
@@ -7,14 +7,101 @@ name: Docker-Images bauen und veroeffentlichen
|
|||||||
#
|
#
|
||||||
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
||||||
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
||||||
|
#
|
||||||
|
# Qualitaets-Gate: Images entstehen nur, wenn lint (inkl. audit), typecheck
|
||||||
|
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
|
||||||
|
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
|
||||||
|
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
|
||||||
|
- run: pnpm audit --prod --audit-level high
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 15
|
||||||
|
# embedded-postgres startet nicht als root; deshalb ein Datenbank-Service
|
||||||
|
# (gleiche Version wie im Cluster), genutzt ueber TEST_DATABASE_URL
|
||||||
|
# (tests/support/global-setup.ts).
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: artista_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/artista_test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
runs-on: docker-builder
|
runs-on: docker-builder
|
||||||
env:
|
env:
|
||||||
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ npm-debug.log*
|
|||||||
|
|
||||||
# Laufzeit-Cache des Design-Hooks (impeccable)
|
# Laufzeit-Cache des Design-Hooks (impeccable)
|
||||||
.impeccable/hook.cache.json
|
.impeccable/hook.cache.json
|
||||||
|
|
||||||
|
# Echte Kubernetes-Secrets (Vorlage: k8s/02-secret.yaml.example)
|
||||||
|
k8s/02-secret.yaml
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
"value": "arial",
|
"value": "arial",
|
||||||
"createdAt": "2026-09-27T03:25:29.714Z",
|
"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)"
|
"reason": "Claude: E-Mail-Clients laden keine Webfonts; Arial ist dort bewusster websicherer Fallback (src/lib/email-templates.ts)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "bounce-easing",
|
||||||
|
"value": "hero-bounce",
|
||||||
|
"createdAt": "2026-10-01T21:54:24.781Z",
|
||||||
|
"reason": "Claude: scroll hint chevron uses ease-in-out translateY(6px) loop, no bounce/elastic curve; rule matched the class name only"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
# Datenbankschema.
|
# Datenbankschema.
|
||||||
|
|
||||||
FROM node:22.23.2-alpine3.24 AS base
|
FROM node:22.23.2-alpine3.24 AS base
|
||||||
RUN corepack enable
|
# openssl: die Prisma-Schema-Engine (migrate deploy) braucht libssl unter
|
||||||
|
# Alpine; Prisma waehlt anhand der installierten Version die passende Binary.
|
||||||
|
RUN apk add --no-cache openssl && corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
@@ -26,8 +28,12 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Die Installation oben lief mit --ignore-scripts. Die Schema-Engine fuer
|
||||||
|
# "prisma migrate deploy" laedt aber erst das postinstall von @prisma/engines
|
||||||
|
# herunter — ohne diesen Schritt scheitert der Migrations-initContainer.
|
||||||
# Zur Build-Zeit wird keine Datenbank und kein Secret benoetigt.
|
# Zur Build-Zeit wird keine Datenbank und kein Secret benoetigt.
|
||||||
RUN mkdir -p public && pnpm prisma generate && pnpm next build
|
RUN pnpm rebuild @prisma/engines prisma \
|
||||||
|
&& mkdir -p public && pnpm prisma generate && pnpm next build
|
||||||
|
|
||||||
# --- Laufzeit: Anwendung ------------------------------------------------------
|
# --- Laufzeit: Anwendung ------------------------------------------------------
|
||||||
FROM node:22.23.2-alpine3.24 AS runner
|
FROM node:22.23.2-alpine3.24 AS runner
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
# 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.
|
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, Kontaktformular für ein Erstgespräch, 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.
|
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Die Seite läuft im Cluster unter https://modelcamp.artistasphotography.de (Namespace `artista`). 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.
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
Was gebaut, geprüft und ausgerollt ist und was noch offen ist, steht im Übersichts-Issue [#33 „Projektstand und Übersicht“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/33). Aufgaben, die nur der Inhaber erledigen kann (Freigaben, Rechtstexte, Secrets, Rollouts), sammelt [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32). Jedes Arbeitspaket hat ein eigenes Issue.
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design |
|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Kontaktformular, Design |
|
||||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, CI, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
||||||
|
| [k8s/README.md](k8s/README.md) | Manifeste Schritt für Schritt anwenden, neues Image ausrollen, Fehlersuche |
|
||||||
|
| [docs/MEDIEN.md](docs/MEDIEN.md) | Dateinamen für Logo, Headerbild, Intro-Video, Porträts und Reels; Medien aufbereiten |
|
||||||
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
| [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 |
|
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ Stripe-Testbuchung mit Webhooks lokal:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
stripe listen --forward-to localhost:3000/api/stripe/webhook # gibt das whsec_… für STRIPE_WEBHOOK_SECRET aus
|
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)
|
pnpm worker # Reservierungen, Raten-Abgleich, 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).
|
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).
|
||||||
@@ -46,14 +52,23 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
||||||
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
||||||
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
||||||
|
| `pnpm db:dev` | Eingebettetes PostgreSQL für die Entwicklung (Port 54329) |
|
||||||
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
||||||
|
| `pnpm db:seed` | Nur Entwicklung: Early Bird simulieren, Test-Gutschein `TEST-STAMMMODEL` |
|
||||||
| `pnpm worker` | Einmaliger Worker-Lauf |
|
| `pnpm worker` | Einmaliger Worker-Lauf |
|
||||||
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
||||||
|
| `pnpm media:optimize <ordner>` | Videos und große Bilder vor dem Hochladen aufbereiten (siehe docs/MEDIEN.md) |
|
||||||
|
|
||||||
|
Bei jedem Push auf `main` prüft die CI (`.gitea/workflows/docker-build.yml`) zuerst `lint` samt `pnpm audit`, `typecheck` und `test`; erst wenn alle drei grün sind, baut sie die beiden Images. Die Playwright-Tests laufen nicht in der CI.
|
||||||
|
|
||||||
## Wo was gepflegt wird
|
## 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.
|
- **Texte, Tagesablauf, Team, Reels, 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“.
|
- **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.
|
- **Preise und Preisphasen:** fest in [src/lib/pricing.ts](src/lib/pricing.ts), weil sie verbindlich vorgegeben sind.
|
||||||
- **Gutscheine (Stammmodel-Codes):** Adminbereich → „Gutscheine“.
|
- **Gutscheine (Stammmodel-Codes):** Adminbereich → „Gutscheine“.
|
||||||
- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
|
||||||
|
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
|
||||||
|
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
|
||||||
|
- **Empfänger des Formulars „Erstgespräch“:** die Veranstalter-E-Mail aus dem Adminbereich, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ist keine von beiden gesetzt, lehnt das Formular Anfragen ab.
|
||||||
|
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: artista
|
POSTGRES_USER: artista
|
||||||
POSTGRES_PASSWORD: artista-dev
|
POSTGRES_PASSWORD: artista-dev
|
||||||
|
|||||||
@@ -6,17 +6,20 @@
|
|||||||
|
|
||||||
## Gestaltung
|
## 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.
|
- **Idee:** Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild. Schlagzeile und Buchung stehen direkt unter dem Masthead und bleiben im ersten Bildschirm; Termin, Ort und Preis folgen als Cover-Line-Leiste unter dem Bild. 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.
|
- **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.
|
- **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.
|
- **Layout:** asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster und keine Versal-Labels über Überschriften. Überschrift und Einleitung stehen untereinander (kein „Split-Header“). Listen werden über Abstand gegliedert, nicht über eine Linie unter jeder Zeile. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
|
||||||
|
- **Formen:** Bedienelemente (Buttons) sind Pillen, Bild- und Tafelflächen haben rechte Winkel. Textlinks sind fein unterstrichen (`.link-quiet`), der wichtigste Link eines Abschnitts trägt eine rote Unterstreichung (`.link-strong`).
|
||||||
|
- **Rezensionen:** echte Google-Rezensionen (Places API) als Zitatstrecke: ein führendes Zitat in Bodoni, weitere in zwei Spalten, daneben Durchschnitt und Anzahl. Initialen statt Profilbildern (keine Verbindung zu Google-Servern im Browser), Links nur zu Google-Hosts. Ohne Daten entfällt der Abschnitt; es gibt keine Ersatzstimmen.
|
||||||
- **Bewegung:**
|
- **Bewegung:**
|
||||||
- eine orchestrierte Hero-Eröffnung in reinem CSS
|
- eine orchestrierte Hero-Eröffnung in reinem CSS
|
||||||
- Clip-Reveal nur auf Bildern
|
- Clip-Reveal nur auf Bildern
|
||||||
- Nav-Indikator
|
- Nav-Indikator
|
||||||
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
|
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
|
||||||
- `prefers-reduced-motion` wird überall respektiert, es gibt keine Scroll-Übernahme.
|
- `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.
|
- **Platzhalter:** Fehlende Bilder erscheinen als typografische Tafeln: ruhiger Palettenton, feine Papierstruktur, Passepartout und ein angeschnittener Bodoni-Buchstabe aus „ARTISTA“ (im Cover die Jahreszahl). Die früheren Verläufe mit Bogen- und Silhouettenformen wurden ersetzt, weil sie wie generische Füllbilder wirkten; die Tafeln sind eindeutig kein Foto und damit als Platzhalter erkennbar. In Entwicklung und Staging tragen sie zusätzlich das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
|
||||||
|
- **Hell-Modus:** Die Seite hat bewusst nur ein helles Druckbild (Magazin); die dunklen Flächen (Leistungen, Schluss) sind gesetzte Kontrastseiten, kein Dunkelmodus.
|
||||||
|
|
||||||
## Stack und Laufzeit
|
## Stack und Laufzeit
|
||||||
|
|
||||||
@@ -41,22 +44,27 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
|||||||
|
|
||||||
## ADR-3: Endliche Ratenpläne
|
## 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.
|
**Gewählt (seit 05.10.2026): Stripe Billing zieht die Folgeraten ein.** Die erste Rate läuft wie bisher über Stripe Checkout mit `setup_future_usage=off_session`. Sobald sie bezahlt ist, legt die Anwendung bei Stripe einen Subscription Schedule an (`src/lib/installment-billing.ts`). Danach kümmert sich Stripe um Einzug, Wiederholungen, 3-D-Secure-Links und Mahnungen; die Anwendung verbucht nur noch die Rechnungs-Events.
|
||||||
|
|
||||||
**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.
|
**Wie die Regel auf Stripe passt:** Die Folgeraten sind immer gleich hoch (ein Rest steckt in Rate 1). Der Zeitplan hat deshalb genau einen Preis: Start = Fälligkeit der zweiten Rate, Intervall = Abstand zwischen Rate 2 und 3 in Tagen (bei zwei Raten 1 Tag), Ende = Start + Anzahl Folgeraten × Intervall, `end_behavior=cancel`. Mit Stripe-Testuhr geprüft: genau eine Rechnung je Folgerate, danach endet das Abonnement ohne weitere Rechnung. Ein Wechsel von Preisen zwischen Phasen erzeugt dagegen keine Rechnung und wurde deshalb verworfen.
|
||||||
|
|
||||||
**Gründe für den gewählten Weg:**
|
**Vorher (bis 05.10.2026):** Off-Session-PaymentIntents, ausgelöst vom Worker-CronJob. Abgelöst, weil Wiederholungen, Bestätigungslinks und Mahnungen bei Stripe liegen sollen statt beim Betreiber. Invoice Payment Plans (Stripe) kamen nicht in Frage: Private Preview und kein automatischer Einzug.
|
||||||
- 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.
|
**Sicherungen:**
|
||||||
|
- Die Raten bleiben exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Jede Stripe-Rechnung wird genau einer Rate zugeordnet (`Installment.stripeInvoiceId`); Stripe erzeugt die Rechnungen nacheinander, die neue gehört zur ersten noch offenen Rate.
|
||||||
|
- Endlichkeit: Der Zeitplan endet nach der letzten Rate von selbst. Eine Zahlung ohne passende offene Rate oder über den Gesamtpreis hinaus landet als „Prüfung nötig“ im Admin. Nach der letzten Rate wird die Zahlungsmethode getrennt.
|
||||||
|
- Storno beendet den Zeitplan sofort und storniert offene Rechnungen (`void`). Scheitert das bei Stripe, holt der Worker es nach; die Zahlungsmethode wird erst danach getrennt.
|
||||||
|
- Fehlschlag: Stripe wiederholt nach den Einstellungen unter „Revenue recovery“ (Smart Retries). Die Rate steht auf `FAILED` mit nächstem Versuch, die Kundin bekommt eine Mail mit Zahlungslink. Gibt Stripe auf, wird die Buchung „Prüfung nötig“.
|
||||||
|
- Verlangt die Bank eine Bestätigung, geht die Rate auf `REQUIRES_ACTION`. Die Kundin zahlt über Stripes Rechnungsseite (`hosted_invoice_url`), auch mit einer anderen Zahlungsmethode. Eine eigene Checkout-Session gibt es bewusst nicht, sonst würde Stripe parallel weiter einziehen.
|
||||||
|
- „Prüfung nötig“ hält den Plan an: Gerät eine Buchung in Prüfung (z. B. Rückbuchung, Doppelzahlung), wird der Stripe-Plan beendet, offene Rechnungen werden storniert und unbezahlte Folgeraten gehen zurück auf `PENDING`. Bestätigt ein Mensch die Buchung, legt der Worker einen neuen Plan für die offenen Raten an (Start an der Fälligkeit oder, falls vorbei, am nächsten 9-Uhr-Termin; letzte Rate bleibt am Stichtag). So zieht Stripe wie früher der Worker nur bei bestätigten Buchungen ein.
|
||||||
|
- Idempotenz: Vor dem Anlegen sucht die Anwendung bei Stripe nach einem laufenden Plan dieser Buchung (Metadaten `bookingId`) und übernimmt ihn, auch wenn das Speichern der ID früher scheiterte. Angelegt wird mit Schlüssel `schedule:<buchung>:<generation>:<start>`; die Generation steigt mit jedem Anhalten, sonst lieferte Stripe binnen 24 Stunden den alten, beendeten Plan zurück. Doppelte oder ungeordnete Events werden über die Rechnungs-ID und den PaymentIntent nur einmal verbucht.
|
||||||
|
- Verlorene Webhooks: Der Worker legt fehlende Zeitpläne an und fragt bei Raten, die seit 6 Stunden fällig und nicht verbucht sind, die Rechnungen des Zeitplans ab.
|
||||||
- Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel `installmentMandateAt`.
|
- 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.
|
- Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters, das Ausfallrisiko bleibt beim Veranstalter. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
|
||||||
- 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).
|
**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.
|
**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. Stripe zieht ab dem Fälligkeitstag um 9:00 Uhr Berliner Zeit ein; liegt zwischen zwei Raten eine Zeitumstellung, verschiebt sich die Uhrzeit der dritten Rate um eine Stunde (Intervall in ganzen Tagen), der Tag bleibt gleich. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
|
||||||
|
|
||||||
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
|
## ADR-4: Kapazität und Gutscheine ohne Überbuchung
|
||||||
|
|
||||||
@@ -77,10 +85,22 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
|||||||
|
|
||||||
`src/lib/sales-gate.ts` wird von jedem Checkout-Endpunkt aufgerufen.
|
`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.
|
- **Livemodus (`sk_live_`):** Verkauf nur mit eingeschaltetem Live-Verkauf und allen elf Bestätigungen. Sind alle erteilt, startet der Verkauf sofort, auch vor dem 01.11.2026; bis dahin gilt der Early-Bird-Preis (seit 06.10.2026).
|
||||||
- **Testmodus:** Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
|
- **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.
|
- **Schutz vor Fehlkonfiguration:** Ein Live-Schlüssel außerhalb von `APP_ENV=production` bricht mit einem Fehler ab.
|
||||||
|
|
||||||
## ADR-7: Adminbereich
|
## 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.
|
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.
|
||||||
|
|
||||||
|
## ADR-8: Kontaktformular „Erstgespräch“ (seit 06.10.2026)
|
||||||
|
|
||||||
|
- Eigener Abschnitt nach den Fragen (Anker `#erstgespraech`, `POST /api/contact`). Pflicht sind Name, E-Mail, Telefon (seit 07.10.2026, gleiche Regel wie bei der Buchung) und die Bestätigung der Datenschutzhinweise; Erreichbarkeit und Nachricht sind optional.
|
||||||
|
- Kein eigenes Datenmodell: Die Anfrage geht als E-Mail über den transaktionalen Postausgang an die Veranstalter-E-Mail aus dem Admin, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ohne Empfänger lehnt das Formular ab.
|
||||||
|
- Bewusst keine automatische Bestätigungsmail an die angegebene Adresse, damit das Formular nicht zum Versand an Dritte missbraucht werden kann.
|
||||||
|
- Schutz wie bei der Buchung: nur JSON von der eigenen Seite, Rate Limit 5 Anfragen je Stunde und IP, Honeypot-Feld gegen Bots, doppeltes Absenden in derselben Minute ergibt eine Mail.
|
||||||
|
|
||||||
|
## ADR-9: Client-IP und Rate Limits (seit 12.10.2026)
|
||||||
|
|
||||||
|
- Rate Limits liegen in PostgreSQL (`RateLimitBucket`, gehashter IP-Schlüssel, Aufräumen nach einem Tag durch den Worker). IPv6-Adressen zählen je /64-Netz.
|
||||||
|
- Die Client-IP ist die Gegenstelle laut Traefik (letzter `X-Forwarded-For`-Eintrag). `CF-Connecting-IP` gilt nur als öffentliche Unicast-Adresse, `X-Real-IP` ohne `X-Forwarded-For` nur außerhalb der Produktion. Better Auth bekommt die IP ausschließlich über den serverseitig gesetzten Header `x-artista-client-ip`; ein vom Browser mitgeschickter Wert wird verworfen.
|
||||||
@@ -2,22 +2,24 @@
|
|||||||
|
|
||||||
## 1. Images bauen
|
## 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:
|
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` läuft bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“. Zuerst prüfen drei Jobs auf dem isolierten Runner (`ci-isolated`, ohne Docker-Socket) den Code: `lint` (inklusive `pnpm audit --prod --audit-level high`), `typecheck` und `test` (mit PostgreSQL 18.6 als Service). Nur wenn alle drei grün sind, baut `build-and-push` auf dem Runner `docker-builder` beide Images und pusht sie in die Gitea-Registry:
|
||||||
|
|
||||||
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
||||||
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
||||||
|
|
||||||
Einmalig einrichten:
|
Einmalig einrichten:
|
||||||
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
- 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)
|
- Runner mit den Labels `ci-isolated` (Prüfungen) und `docker-builder` (Image-Build) bereitstellen; im EnCry-Cluster sind beide vorhanden
|
||||||
|
|
||||||
Ein Release entsteht dann so: `git tag v0.1.0 && git push origin v0.1.0`, anschließend den Tag im Overlay setzen.
|
Die Manifeste in `k8s/` sind auf einen CI-Build gepinnt (`sha-<commit>`, Platzhalter `sha-REPLACE_ME` im Repo), nicht auf `latest`. Ausgerollt wird mit `kubectl set image`, siehe Abschnitt 3 und [k8s/README.md](../k8s/README.md).
|
||||||
|
|
||||||
|
Scheitert `build-and-push` beim Push mit `500 … cannot allocate memory`, lag das bisher an der Gitea-Registry, wenn gleichzeitig ein anderes Repo pushte. Dann den Lauf neu starten, sobald der andere Build fertig ist.
|
||||||
|
|
||||||
**Manuell:**
|
**Manuell:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:0.1.0 .
|
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:latest .
|
||||||
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0 .
|
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest .
|
||||||
docker push … # beide Images, immer mit identischem Tag
|
docker push … # beide Images, immer mit identischem Tag
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,54 +28,50 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
|
|||||||
## 2. Einmalig einrichten
|
## 2. Einmalig einrichten
|
||||||
|
|
||||||
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
|
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
|
||||||
- der Worker-CronJob, nötig für Folgeraten, Reservierungsfreigabe und Mail-Wiederholung
|
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
|
||||||
- ConfigMap
|
- ConfigMap
|
||||||
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
||||||
|
- der Sicherungs-CronJob `artista-db-backup` (täglicher `pg_dump`, Abschnitt 7)
|
||||||
|
|
||||||
Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert.
|
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `09-db-backup-cronjob`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
|
||||||
|
|
||||||
1. **Namespace:** `kubectl create namespace artista` und `kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted`
|
Im EnCry-Cluster liegen die SMB-Freigaben auf der Hetzner Storage Box, nicht auf einem lokalen NAS; „NAS“ meint im Folgenden diese Freigabe. Im Namespace `artista` läuft außerdem Artista-Telefonie (eigenes Repo `Artista-TeleTracking`). Sie nutzt eine eigene Datenbank `telefonie` in derselben PostgreSQL-Instanz `artista-modelcamp-postgres`. Wer die Instanz oder den Namespace anfasst, trifft beide Anwendungen.
|
||||||
2. **Overlay anpassen** (`k8s/overlays/production/`): Image-Tag, Domain in `kustomization.yaml` (APP_URL) und `ingress.yaml`, `ingressClassName` (`kubectl get ingressclass`), ClusterIssuer (Beispiel `letsencrypt-prod`) und TLS-Secret-Name. Die HTTP→HTTPS-Umleitung erwartet das Manifest in Traefik selbst.
|
|
||||||
3. **SMB-Speicher** (`k8s/overlays/production/smb-volumes.yaml`):
|
|
||||||
1. csi-driver-smb installieren, Befehl steht im Kopf der Datei.
|
|
||||||
2. Auf dem NAS die Ordner `modelcamp-postgres` (leer) und `modelcamp-media` anlegen und `source` in der Datei anpassen.
|
|
||||||
3. SMB-Secret anlegen: `kubectl -n artista create secret generic artista-modelcamp-smb --from-literal=username='…' --from-literal=password='…'`. Empfohlen ist ein eigener NAS-Benutzer, der nur diese Ordner sieht.
|
|
||||||
4. Das NAS muss SMB 3.1.1 mit Verschlüsselung erlauben (`seal`), z. B. Synology unter „SMB-Verschlüsselung für Übertragung“, Windows mit `Set-SmbShare -EncryptData $true`.
|
|
||||||
|
|
||||||
Die Rechte setzen die `mountOptions`, auf SMB wirkt `fsGroup` nicht: Postgres `uid=70`, `0700`/`0600`; Medien `uid=1001`, für die App nur lesbar (`0550`/`0440`).
|
Zu PostgreSQL:
|
||||||
|
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
|
||||||
**PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden. Gemildert wird das durch `hard`, `cache=strict`, Verschlüsselung und ein 120 Sekunden langes, sauberes Herunterfahren. Die NAS-Sicherung ist nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Ein Dateikopie-Backup eines laufenden Datenverzeichnisses lässt sich nicht zuverlässig wiederherstellen. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7).
|
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
|
||||||
4. **Secret anlegen:** Befehl steht in `k8s/secret.example.yaml`, besser über Sealed Secrets oder den External Secrets Operator.
|
- **Sicherung:** Eine Kopie der Freigabe ist für die laufende Datenbank nur mit **atomaren Snapshots** (Btrfs/ZFS) konsistent. Deshalb zieht der CronJob `artista-db-backup` jede Nacht einen `pg_dump` (Abschnitt 7).
|
||||||
5. **Anwenden:** `kubectl apply -k k8s/overlays/production`. Der initContainer migriert die Datenbank vor dem App-Start.
|
- **Nach SMB-Unterbrechungen** melden lang laufende Postgres-Prozesse gelegentlich `could not seek to end of file … Bad file descriptor`. Abhilfe: `kubectl -n artista rollout restart sts/artista-modelcamp-postgres` (fährt sauber herunter).
|
||||||
6. **Erstes 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-modelcamp-tools:0.1.0 \
|
|
||||||
--overrides='{"spec":{"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:0.1.0","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}]}]}}'
|
|
||||||
```
|
|
||||||
Das einmalige Passwort wird ausgegeben. Bei der ersten Anmeldung unter `/admin` wird die 2FA eingerichtet.
|
|
||||||
|
|
||||||
## 3. Update ausrollen
|
## 3. Update ausrollen
|
||||||
|
|
||||||
|
App, Migration und Worker immer mit demselben Tag `sha-<commit>` aus der CI ausrollen (genauer Befehl in [k8s/README.md](../k8s/README.md), Abschnitt „Bei einem neuen Image“):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Neuen Tag im Overlay setzen und anwenden. Jeder neue App-Pod migriert per
|
TAG=sha-<commit>
|
||||||
# initContainer "migrate" zuerst die Datenbank; scheitert das, startet er nicht
|
kubectl -n artista set image deployment/artista-modelcamp-app migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
# und die alten Pods bleiben aktiv (maxUnavailable: 0).
|
kubectl -n artista set image cronjob/artista-modelcamp-worker worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
kubectl apply -k k8s/overlays/production
|
|
||||||
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
||||||
# Migrationsausgabe bei Bedarf:
|
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
|
||||||
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein `rollout restart` allein holt kein neues Image, weil die Manifeste auf einen festen Tag zeigen.
|
||||||
|
|
||||||
|
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
|
||||||
|
|
||||||
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.
|
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
|
## 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.
|
- **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:
|
- **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`.
|
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `invoice.paid`, `invoice.payment_failed`, `invoice.payment_action_required`, `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.
|
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.
|
- **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.
|
||||||
|
- **Folgeraten (Stripe Billing):** Stripe zieht die Raten 2 und 3 über einen Subscription Schedule ein (docs/ARCHITEKTUR.md, ADR-3). Im Dashboard unter Billing → Einstellungen festlegen:
|
||||||
|
- **Revenue recovery → Retries:** Smart Retries an (z. B. 4 Versuche innerhalb von 2 Wochen). Nach dem letzten Versuch: Abonnement kündigen. Die Buchung erscheint dann als „Prüfung nötig“.
|
||||||
|
- **E-Mails an Kunden:** „Zahlung fehlgeschlagen“ und „Bestätigung für 3-D Secure erforderlich“ einschalten, damit Stripe selbst mahnt und Bestätigungslinks schickt. Die App schickt zusätzlich eine eigene deutschsprachige Mail mit Link zur Statusseite.
|
||||||
|
- Der eingeschränkte Schlüssel braucht zusätzlich Schreibrechte für Products, Subscription Schedules, Subscriptions und Invoices.
|
||||||
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
|
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -85,8 +83,10 @@ Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, spät
|
|||||||
|
|
||||||
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
|
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
|
||||||
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
|
||||||
- hängende Einzüge
|
- hängende erste Zahlungen (SEPA)
|
||||||
- fällige Raten
|
- fehlende Stripe-Ratenpläne anlegen (z. B. nach Bestätigung einer geprüften Buchung)
|
||||||
|
- Abgleich überfälliger Raten mit den Stripe-Rechnungen (verlorene Webhooks)
|
||||||
|
- Ratenpläne stornierter Buchungen beenden, falls das beim Storno scheiterte
|
||||||
- das Trennen der Zahlungsmethode nach der letzten Rate
|
- das Trennen der Zahlungsmethode nach der letzten Rate
|
||||||
- den E-Mail-Versand
|
- den E-Mail-Versand
|
||||||
- das Aufräumen der Rate Limits
|
- das Aufräumen der Rate Limits
|
||||||
@@ -95,10 +95,10 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
|
|
||||||
## 7. Sicherung und Wiederherstellung
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
- **Sicherung:** Die NAS-Sicherung der SMB-Freigabe übernimmt das Speichersystem. Für PostgreSQL gilt das nur mit atomaren Snapshots, siehe Abschnitt 2. Im Cluster läuft kein Backup-Job.
|
- **Täglicher Dump im Cluster:** Der CronJob `artista-db-backup` (`k8s/09-db-backup-cronjob.yaml`) läuft um 02:25 Uhr (Zeitzone des Clusters) und schreibt `pg_dump -Fc` der Datenbank `artista` in die PVC `artista-db-backup` (StorageClass `smb-backup`, Ordner `Backups/artista/` auf der Storage Box). Aufbewahrung: 14 Tage täglich (`daily/`), 8 Wochen sonntags (`weekly/`). Die Datenbank `telefonie` derselben Instanz ist darin nicht enthalten. Stand prüfen: `kubectl -n artista get cronjob artista-db-backup` und `kubectl -n artista get jobs | grep artista-db-backup`.
|
||||||
- **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf):
|
- **Logischer Dump von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
|
||||||
```bash
|
```bash
|
||||||
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1)
|
POD=$(kubectl -n artista get pod -l app=artista-modelcamp-postgres -o name | head -1)
|
||||||
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
||||||
```
|
```
|
||||||
- **Wiederherstellen:**
|
- **Wiederherstellen:**
|
||||||
@@ -106,8 +106,8 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
||||||
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
|
3. App wieder hochskalieren (der initContainer migriert dabei), 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“).
|
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:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten.
|
- **Medien:** liegen im SMB-Ordner `modelcamp-media`; gesichert sind sie nur über die Sicherung der Freigabe selbst.
|
||||||
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
|
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. Das ist noch nicht geschehen.
|
||||||
|
|
||||||
## 8. Stornierung und Erstattung
|
## 8. Stornierung und Erstattung
|
||||||
|
|
||||||
@@ -122,4 +122,4 @@ Die Software trifft keine Storno-Entscheidung, sie setzt sie nur nachvollziehbar
|
|||||||
|
|
||||||
Dateien (jpg, png, webp, avif, mp4, webm) direkt in den SMB-Ordner `modelcamp-media` legen, z. B. über den Windows-Explorer (`\\nas…\artista\modelcamp-media`). Die App sieht sie sofort unter `/media/<dateiname>`. Im Pod ist das Volume schreibgeschützt eingehängt.
|
Dateien (jpg, png, webp, avif, mp4, webm) direkt in den SMB-Ordner `modelcamp-media` legen, z. B. über den Windows-Explorer (`\\nas…\artista\modelcamp-media`). Die App sieht sie sofort unter `/media/<dateiname>`. Im Pod ist das Volume schreibgeschützt eingehängt.
|
||||||
|
|
||||||
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder, etwa von Booking.com, ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen.
|
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen. Die Unterkunftsbilder liegen als lokale Kopie in `public/images/unterkunft/` und sind Teil des Container-Images.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Medien: Dateinamen
|
||||||
|
|
||||||
|
Logo, Headerbild, Porträts und Reels liegen im Medienverzeichnis. Im Cluster ist das das Volume `artista-modelcamp-media`, gemountet unter `/app/media`. Lokal ist es `./media` (`MEDIA_DIR`).
|
||||||
|
|
||||||
|
Lege eine Datei unter dem passenden Namen dort ab, dann erscheint sie beim nächsten Seitenaufruf. Neustart und Deployment sind nicht nötig. Fehlt eine Datei, bleibt der Platzhalter oder das Standardbild sichtbar.
|
||||||
|
|
||||||
|
Dateinamen bitte so schreiben: ohne Leerzeichen und Umlaute. Gross- und Kleinschreibung ist egal (`Reel-1.JPG` wird auch erkannt). Bei mehreren Endungen nimmt die Seite die erste, die sie findet.
|
||||||
|
|
||||||
|
## Logo
|
||||||
|
|
||||||
|
| Datei | Wofür | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `logo.svg` (oder `.png`, `.webp`) | Dunkles Logo für helle Flächen: Kopfzeile nach dem Scrollen, Fußbereich | SVG bevorzugt, PNG mit transparentem Hintergrund |
|
||||||
|
| `logo-hell.svg` (oder `.png`, `.webp`) | Helles bzw. weißes Logo über dem Headerbild | Fehlt es, wird `logo.*` verwendet |
|
||||||
|
|
||||||
|
Das Logo wird über dem Headerbild bis 176 px hoch angezeigt, nach dem Scrollen 80 px. Die Seite misst selbst, ob eine Datei hell oder dunkel ist, und setzt sie auf den passenden Untergrund. Liegt nur eine helle Fassung vor, wird sie auf hellen Flächen automatisch abgedunkelt (und umgekehrt). Große PNGs werden verkleinert ausgeliefert.
|
||||||
|
|
||||||
|
## Headerbild
|
||||||
|
|
||||||
|
| Datei | Wofür | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `header.jpg` (oder `.webp`, `.png`) | Bild über die ganze Breite oben | Querformat, mindestens 2400 px breit. Ohne Datei erscheint die Luftaufnahme der Villa |
|
||||||
|
| `header.mp4` (optional) | Video statt Standbild, läuft stumm in Schleife | Kurz halten (unter 15 s, unter 10 MB). `header.jpg` dient als Vorschaubild |
|
||||||
|
|
||||||
|
## Video unter dem Headerbild
|
||||||
|
|
||||||
|
| Datei | Wofür | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `intro.mp4` | Video direkt unter dem Headerbild, startet erst nach Klick | Quer- oder Hochformat. Ohne Datei entfällt der Abschnitt |
|
||||||
|
| `intro.jpg` (oder `.webp`, `.png`) | Vorschaubild des Videos | Empfohlen: Mit Vorschaubild lädt vom Video vorab nichts |
|
||||||
|
| `intro.web.mp4` (optional) | Web-Fassung, wird bevorzugt angezeigt | Erzeugen mit `pnpm media:optimize`, siehe unten |
|
||||||
|
|
||||||
|
## Team (Ordner `team/`)
|
||||||
|
|
||||||
|
Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Das Bild wird oben rund (als Bogen) beschnitten.
|
||||||
|
|
||||||
|
| Person | Datei |
|
||||||
|
|---|---|
|
||||||
|
| Eric Heitz | `team/eric-heitz.jpg` |
|
||||||
|
| Michele Ietro | `team/michele-ietro.jpg` |
|
||||||
|
| Lola Stoll | `team/lola-stoll.jpg` |
|
||||||
|
| Eileen Kaupp | `team/eileen-kaupp.jpg` |
|
||||||
|
| Gabriela Pronkin | `team/gabriela-pronkin.jpg` |
|
||||||
|
| Saskia Sirowatka | `team/saskia-sirowatka.jpg` |
|
||||||
|
| André Hartshauser | `team/andre-hartshauser.jpg` |
|
||||||
|
| Ante Rejo | `team/ante-rejo.jpg` |
|
||||||
|
|
||||||
|
## Reels (Ordner `reels/`)
|
||||||
|
|
||||||
|
Hochformat 9:16. Das Video startet erst nach Klick und nie von selbst mit Ton.
|
||||||
|
|
||||||
|
| Slot | Video | Vorschaubild |
|
||||||
|
|---|---|---|
|
||||||
|
| Reel 1 | `reels/reel-1.mp4` | `reels/reel-1.jpg` |
|
||||||
|
| Reel 2 | `reels/reel-2.mp4` | `reels/reel-2.jpg` |
|
||||||
|
| Reel 3 | `reels/reel-3.mp4` | `reels/reel-3.jpg` |
|
||||||
|
|
||||||
|
Ein Reel erscheint öffentlich erst, wenn das Video vorhanden ist. Namen und Begleittext stehen in `src/content/site.ts` unter `reels.slots`. Sie dürfen nur mit Freigabe der gezeigten Person eingetragen werden.
|
||||||
|
|
||||||
|
## Vor dem Hochladen: Videos und große Bilder aufbereiten
|
||||||
|
|
||||||
|
Videos direkt aus Kamera oder Schnittprogramm sind oft riesig (ein Reel hatte 1,6 GB in 4K) und haben den Index am Dateiende. Der Browser muss dann sehr viel laden, bevor überhaupt etwas läuft. Deshalb einmal aufbereiten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm media:optimize C:\Pfad\zu\meinen-medien
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript legt daneben einen Ordner `meinen-medien-web` mit gleicher Struktur an:
|
||||||
|
|
||||||
|
- aus jedem Video eine `<name>.web.mp4` (höchstens 1920 px, H.264, sofort abspielbar; `header` ohne Ton) und, falls kein Vorschaubild vorhanden ist, eine `<name>.jpg`
|
||||||
|
- große Bilder verkleinert (Logo 1600 px, sonst 2560 px längste Seite), kleinere unverändert
|
||||||
|
|
||||||
|
Den Inhalt dieses Ordners ins Medienverzeichnis kopieren. Liegt `<name>.web.mp4` neben dem Original, zeigt die Seite automatisch die Web-Fassung; das Original kann dann auch entfernt werden. Bereits aufbereitete Dateien überspringt das Skript beim nächsten Lauf.
|
||||||
|
|
||||||
|
## Ablage im Cluster
|
||||||
|
|
||||||
|
Die Dateien kommen auf die SMB-Freigabe hinter dem Volume (siehe `k8s/03-storage.yaml`), zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
modelcamp-media/
|
||||||
|
logo.svg
|
||||||
|
logo-hell.svg
|
||||||
|
header.jpg
|
||||||
|
team/eric-heitz.jpg
|
||||||
|
reels/reel-1.mp4
|
||||||
|
reels/reel-1.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Container liest das Verzeichnis nur. Ein ersetztes Bild ist sofort sichtbar, weil die Seite das Änderungsdatum an die Bildadresse hängt.
|
||||||
|
|
||||||
|
## Fest eingebaute Bilder
|
||||||
|
|
||||||
|
Die Unterkunftsbilder der Villa liegen in `public/images/unterkunft/` und sind Teil des Container-Images. Austausch dort nur mit neuem Build.
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
# Noch benötigte Inhalte und Freigaben
|
# 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“.
|
Stand 16.10.2026. Dieselben Punkte stehen mit Verweis auf die Detail-Issues in der Gitea-Sammelliste [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32).
|
||||||
|
|
||||||
|
Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` bzw. `20261002090000_termin_mai_2027` eingetragen (im Admin änderbar). Damit sind im Admin die Freigaben Termin, Ort, Unterkunft und Platzzahl gesetzt, sofern sie dort niemand zurückgenommen hat:
|
||||||
|
|
||||||
|
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
|
||||||
|
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
||||||
|
- 13 verkaufbare Plätze
|
||||||
|
- Team (Namen und Rollen) und Unterkunftsbilder; die Booking-Seite wird bewusst nicht verlinkt
|
||||||
|
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
|
||||||
|
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
|
||||||
|
|
||||||
|
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“. Sind alle Freigaben erteilt und ist der Live-Verkauf eingeschaltet, startet der Verkauf sofort zum Early-Bird-Preis; die Early-Bird-Phase läuft vom 01.11. bis 31.12.2026.
|
||||||
|
|
||||||
## Inhalte
|
## Inhalte
|
||||||
|
|
||||||
| Punkt | Wo eintragen |
|
| Punkt | Wo eintragen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Camp-Termin (Anreise/Abreise) **(Freigabe)** | Admin → Einstellungen |
|
| Logo (`logo.svg`, `logo-hell.svg`) und Headerbild (`header.jpg`) | Medienverzeichnis, Dateinamen in `docs/MEDIEN.md` |
|
||||||
| Genauer Ort und Region **(Freigabe)** | Admin → Einstellungen |
|
| Porträts der acht Teammitglieder | Medienverzeichnis `team/<name>.jpg`, siehe `docs/MEDIEN.md` |
|
||||||
| Verbindliche Zimmer- und Bettbeschreibung, insbesondere ob ein französisches Doppelbett geteilt wird **(Freigabe)** | Admin → Einstellungen |
|
| Freigegebene Kurzbeschreibungen der Teammitglieder | `site.ts` → `team` |
|
||||||
| Booking-Link der Unterkunft | `src/content/site.ts` → `accommodation.bookingUrl` |
|
| Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter |
|
||||||
| 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` |
|
| 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` |
|
| Drei Reels (Video + Vorschaubild) mit Freigabe der gezeigten Personen | Medienverzeichnis `reels/reel-1.mp4` usw., Namen in `site.ts` → `reels` |
|
||||||
| Hero-Leitbild oder -Video | `site.ts` → `hero.media` |
|
| Bestätigte Leistungen (Workshops, Verpflegung) **(Freigabe)** | `site.ts` → `services`, danach Admin „Leistungen bestätigt“ |
|
||||||
| 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 |
|
| Veranstalter- und Kontaktdaten **(Freigabe)** | Admin → Einstellungen |
|
||||||
| Freigabe der Entwurfstexte (Headline, Camp-Text, Zielgruppe) | `site.ts` |
|
| Freigabe der Entwurfstexte (Headline, Camp-Text, Zielgruppe) | `site.ts` |
|
||||||
|
|
||||||
@@ -25,17 +32,18 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
|
|||||||
| Punkt | Hinweis |
|
| Punkt | Hinweis |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Steuerliche Behandlung und korrekte Preisangabe **(Freigabe)** | Es wird bewusst keine USt-Aussage angezeigt, bis der Hinweis eingetragen und bestätigt ist |
|
| Steuerliche Behandlung und korrekte Preisangabe **(Freigabe)** | Es wird bewusst keine USt-Aussage angezeigt, bis der Hinweis eingetragen und bestätigt ist |
|
||||||
|
| Widerrufsbelehrung **(Freigabe)** | Admin → Einstellungen → „Widerrufsbelehrung“; erscheint unter `/widerruf`. Es wurde kein Rechtstext formuliert |
|
||||||
| Buchungs-, Storno- und Zahlungsbedingungen **(Freigabe)** | Als Dokument oder Seite hinterlegen (Link und Version im Admin). Es wurden keine AGB formuliert |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Kontingent Stammmodel-Buchungen (vorläufig 4) **(Freigabe)** | Admin → Einstellungen |
|
||||||
| Ratenzahlung: rechtliche Prüfung, Terminregel, Freigabe für den Livemodus | Admin → Einstellungen (zwei Häkchen) |
|
| 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 |
|
| Teilnahme Minderjähriger | Die Buchung verlangt derzeit die Erklärung „Ich bin volljährig“. Bitte bestätigen oder die Regel vorgeben |
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
|
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt, Billing-Einstellungen für Folgeraten gesetzt (docs/BETRIEB.md, Abschnitt 4).
|
||||||
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
|
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain; Veranstalter-E-Mail oder `ADMIN_NOTIFY_EMAIL` gesetzt, sonst lehnt das Formular „Erstgespräch“ Anfragen ab.
|
||||||
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
|
- Erledigt: Domain `modelcamp.artistasphotography.de`, TLS über cert-manager, SMB-Speicher und die StorageClass `smb-backup` im Cluster.
|
||||||
- Wiederherstellung aus der Sicherung einmal geübt.
|
- Wiederherstellung aus der Sicherung einmal geübt (noch offen).
|
||||||
|
- Optional: Google-Rezensionen (`GOOGLE_PLACES_API_KEY`, `GOOGLE_PLACE_ID` im Secret); ohne Werte bleibt der Abschnitt ausgeblendet.
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
# Tests und Abnahme
|
# Tests und Abnahme
|
||||||
|
|
||||||
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden.
|
Stand 16.10.2026. Die Abschnitte unten sind chronologisch gewachsen; der aktuelle Stand steht hier oben.
|
||||||
|
|
||||||
|
**Aktuell:** `pnpm test` umfasst 10 Testdateien (rund 130 Tests; 132 beim lokalen Lauf am 14.10.2026 zu Commit `f29dbcf`, seither kam mit `9785eb2` ein Regressionstest hinzu). Seit `0421d7d` (14.10.2026) laufen `lint` mit `pnpm audit`, `typecheck` und `test` bei jedem Push auf `main` in der CI; der Test-Job nutzt PostgreSQL 18.6 als Service (`TEST_DATABASE_URL`). Letzter CI-Lauf (`9785eb2`, 15.10.2026): lint, typecheck und test grün, nur der Image-Build schlug fehl (Issue #20). Die Playwright-Tests (`pnpm test:e2e`, 3 Tests je Profil) laufen nur lokal.
|
||||||
|
|
||||||
|
Seit dem ersten Stand hinzugekommene Testdateien: `client-ip.test.ts` (Client-IP hinter Traefik und Cloudflare, IPv6 je /64, Proxy um NextRequest), `contact.integration.test.ts` (Formular „Erstgespräch“), `email-outbox.integration.test.ts` (Postausgang, endgültige und vorübergehende SMTP-Fehler), `review-format.test.ts` (Google-Rezensionen).
|
||||||
|
|
||||||
## Automatisierte Tests
|
## 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.
|
Stand 27.09.2026: `pnpm test` umfasste 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
||||||
|
|
||||||
| Geschäftsrisiko | Datei |
|
| Geschäftsrisiko | Datei |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,6 +37,10 @@ Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden
|
|||||||
|
|
||||||
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
||||||
|
|
||||||
|
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit dem damaligen Termin 08.05.2027 fiel die letzte Rate auf den 08.04.2027; seit dem Termin 03.05.2027 (Migration vom 02.10.2026) ist es der 03.04.2027.
|
||||||
|
|
||||||
|
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
|
||||||
|
|
||||||
## Manuell über HTTP geprüft
|
## Manuell über HTTP geprüft
|
||||||
|
|
||||||
- Admin-Ablauf:
|
- Admin-Ablauf:
|
||||||
@@ -45,9 +53,12 @@ Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
|||||||
- Der Standalone-Build läuft mit Datenbank. Worker-Lauf und `admin:create` funktionieren, eine doppelte Anlage wird abgewiesen.
|
- 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.
|
- Build, Typprüfung und ESLint laufen ohne Fehler, Kustomize rendert 17 Ressourcen.
|
||||||
|
|
||||||
|
Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalten bei Prüfung und Schutz vor doppeltem Plan): 88/88 Unit- und Integrationstests, Typprüfung und ESLint ohne Fehler. Zusätzlich `StripeGateway` gegen die echte Stripe-Test-API mit Testuhr geprüft: Zeitplan mit zwei Folgeraten erzeugt genau zwei bezahlte Rechnungen (je 700 €, Metadaten `bookingId`/`kind`, PaymentIntent mit Charge) und endet danach (`completed`); abgelehnte Karte ergibt offene Rechnung mit geplantem Wiederholungsversuch und Zahlungslink; Storno setzt den Zeitplan auf `canceled` und die offene Rechnung auf `void`, auch wiederholt und vor dem Start.
|
||||||
|
|
||||||
## Nicht ausgeführt / Einschränkungen
|
## 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).
|
- **Vollständige Stripe-Testbuchung** über die Oberfläche bis zum Webhook: In der Testsuite läuft die Stripe-Logik gegen eine Attrappe mit Stripe-Semantik (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Gegen die echte Stripe-Test-API geprüft ist bisher nur der Ratenplan (`StripeGateway` mit Testuhr, Lauf vom 06.10.2026). Ein dokumentierter Durchlauf Buchung → Checkout → Webhook → Bestätigungsmail auf dem Cluster fehlt (Issue #27). Anleitung lokal 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.
|
- **Docker-Image und Cluster:** Seit dem 27.09.2026 baut die CI beide Images; die Seite läuft im Cluster (zuletzt belegt ausgerollt: `sha-5a88f3d`, 13.10.2026). Eine automatisierte Prüfung nach dem Rollout gibt es nicht; der Monitoring-Dienst Gatus im Cluster prüft die Erreichbarkeit.
|
||||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
- **SMTP-Versand:** im Repo nur über den Datei-Testausgang geprüft. Ob im Cluster ein echter SMTP-Zugang eingetragen ist, ist hier nicht dokumentiert.
|
||||||
|
- **E2E-Tests** laufen nicht in der CI.
|
||||||
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
||||||
@@ -10,9 +10,13 @@ import { expect, test } from "@playwright/test";
|
|||||||
test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => {
|
test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA");
|
await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA");
|
||||||
for (const id of ["camp", "leistungen", "unterkunft", "dozentinnen", "artista", "preis", "fragen"]) {
|
for (const id of ["termin", "erwartet", "ablauf", "unterkunft", "preis", "team", "fragen"]) {
|
||||||
await expect(page.locator(`#${id}`)).toBeAttached();
|
await expect(page.locator(`#${id}`)).toBeAttached();
|
||||||
}
|
}
|
||||||
|
// Regulaerer Preis durchgestrichen, kein Verkaufsstartdatum, keine Booking-Seite.
|
||||||
|
await expect(page.locator("#preis s")).toContainText("2.750");
|
||||||
|
await expect(page.locator("body")).not.toContainText(/öffnet am|Buchung startet|ab 1\. November/);
|
||||||
|
await expect(page.locator('a[href*="booking.com"]')).toHaveCount(0);
|
||||||
// Keine oeffentliche Platzzahl und kein Restplatz-Zaehler.
|
// Keine oeffentliche Platzzahl und kein Restplatz-Zaehler.
|
||||||
await expect(page.locator("body")).not.toContainText(/Restpl|noch \d+ Pl|Plätze frei/);
|
await expect(page.locator("body")).not.toContainText(/Restpl|noch \d+ Pl|Plätze frei/);
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ p
|
|||||||
|
|
||||||
test("Buchungsdialog: Daten, Gutschein, Ratenplan, Erklaerungen, Fokus", async ({ page }, testInfo) => {
|
test("Buchungsdialog: Daten, Gutschein, Ratenplan, Erklaerungen, Fokus", async ({ page }, testInfo) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const trigger = page.locator("#top").getByRole("button", { name: "Platz buchen" });
|
const trigger = page.locator("#top").getByRole("button", { name: "Jetzt Platz sichern" });
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Pod Security: "baseline" wird erzwungen (blockiert privilegierte Pods,
|
||||||
|
# hostPath, Host-Netzwerk usw.), laesst aber den HTTP-01-Solver-Pod von
|
||||||
|
# cert-manager zu. "restricted" wird gemeldet (warn/audit); die eigenen Pods
|
||||||
|
# dieses Namespaces erfuellen es bereits (non-root, keine Capabilities,
|
||||||
|
# seccomp RuntimeDefault).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: artista
|
||||||
|
labels:
|
||||||
|
pod-security.kubernetes.io/enforce: baseline
|
||||||
|
pod-security.kubernetes.io/warn: restricted
|
||||||
|
pod-security.kubernetes.io/audit: restricted
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Nicht geheime Einstellungen. ANPASSEN: APP_URL, MAIL_FROM, ADMIN_NOTIFY_EMAIL.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-config
|
||||||
|
namespace: artista
|
||||||
|
data:
|
||||||
|
APP_ENV: "production"
|
||||||
|
APP_URL: "https://modelcamp.example.org"
|
||||||
|
MAIL_TRANSPORT: "smtp"
|
||||||
|
MAIL_FROM: "ARTISTA MODELCAMP <[email protected]>"
|
||||||
|
ADMIN_NOTIFY_EMAIL: "[email protected]"
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_SECURE: "false"
|
||||||
|
MEDIA_DIR: "/app/media"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Kopieren nach k8s/02-secret.yaml (ist in .gitignore), Werte eintragen,
|
||||||
|
# dann: kubectl apply -f k8s/02-secret.yaml
|
||||||
|
# Zufallswerte: openssl rand -base64 48
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-secrets
|
||||||
|
namespace: artista
|
||||||
|
stringData:
|
||||||
|
DB_PASSWORD: "PASSWORT"
|
||||||
|
DATABASE_URL: "postgresql://artista:PASSWORT@artista-modelcamp-postgres:5432/artista"
|
||||||
|
APP_SECRET: "ZUFALL-MIND-32-ZEICHEN"
|
||||||
|
BETTER_AUTH_SECRET: "ANDERER-ZUFALL-MIND-32-ZEICHEN"
|
||||||
|
STRIPE_SECRET_KEY: "sk_test_..."
|
||||||
|
STRIPE_WEBHOOK_SECRET: "whsec_..."
|
||||||
|
SMTP_HOST: "smtp.example.org"
|
||||||
|
SMTP_USER: "benutzer"
|
||||||
|
SMTP_PASSWORD: "passwort"
|
||||||
|
# Optional: Google-Rezensionen (Places API New). Leer lassen = Abschnitt aus.
|
||||||
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
GOOGLE_PLACE_ID: ""
|
||||||
|
---
|
||||||
|
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-smb
|
||||||
|
namespace: artista
|
||||||
|
stringData:
|
||||||
|
username: "NAS-BENUTZER"
|
||||||
|
password: "NAS-PASSWORT"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Speicher auf der SMB-Freigabe (benoetigt csi-driver-smb, siehe README).
|
||||||
|
# ANPASSEN: "source" (//<nas>/<freigabe>/<ordner>). Ordner vorher anlegen.
|
||||||
|
#
|
||||||
|
# PostgreSQL auf SMB ist eine bewusste Entscheidung (Risiko siehe
|
||||||
|
# docs/BETRIEB.md). NAS-Backups sind fuer die laufende Datenbank nur mit
|
||||||
|
# atomaren Snapshots (Btrfs/ZFS) konsistent.
|
||||||
|
|
||||||
|
# --- PostgreSQL-Daten ---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-postgres
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 10Gi
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
persistentVolumeReclaimPolicy: Retain
|
||||||
|
storageClassName: ""
|
||||||
|
mountOptions: [vers=3.1.1, seal, hard, cache=strict, uid=70, gid=70, dir_mode=0700, file_mode=0600, mfsymlinks, nobrl]
|
||||||
|
csi:
|
||||||
|
driver: smb.csi.k8s.io
|
||||||
|
volumeHandle: artista-modelcamp-postgres
|
||||||
|
volumeAttributes:
|
||||||
|
source: //nas.example.lan/artista/modelcamp-postgres
|
||||||
|
nodeStageSecretRef:
|
||||||
|
name: artista-modelcamp-smb
|
||||||
|
namespace: artista
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-postgres
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
storageClassName: ""
|
||||||
|
volumeName: artista-modelcamp-postgres
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
# --- Medien (Bilder/Videos; App liest nur, Dateien direkt aufs NAS legen) ---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolume
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-media
|
||||||
|
spec:
|
||||||
|
capacity:
|
||||||
|
storage: 5Gi
|
||||||
|
accessModes: ["ReadWriteMany"]
|
||||||
|
persistentVolumeReclaimPolicy: Retain
|
||||||
|
storageClassName: ""
|
||||||
|
mountOptions: [vers=3.1.1, seal, uid=1001, gid=1001, dir_mode=0550, file_mode=0440, mfsymlinks, nobrl]
|
||||||
|
csi:
|
||||||
|
driver: smb.csi.k8s.io
|
||||||
|
volumeHandle: artista-modelcamp-media
|
||||||
|
volumeAttributes:
|
||||||
|
source: //nas.example.lan/artista/modelcamp-media
|
||||||
|
nodeStageSecretRef:
|
||||||
|
name: artista-modelcamp-smb
|
||||||
|
namespace: artista
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-media
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteMany"]
|
||||||
|
storageClassName: ""
|
||||||
|
volumeName: artista-modelcamp-media
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
@@ -1,30 +1,21 @@
|
|||||||
# PostgreSQL fuer Buchungen, Zahlungen, Gutscheine und Protokoll.
|
|
||||||
#
|
|
||||||
# Datenverzeichnis: PVC "artista-modelcamp-postgres-data" (storage.yaml), im
|
|
||||||
# Produktions-Overlay auf SMB. Das ist eine BEWUSSTE, vom Betreiber
|
|
||||||
# akzeptierte Ausnahme von der Empfehlung "Blockspeicher": SMB garantiert
|
|
||||||
# Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Die
|
|
||||||
# Mount-Optionen in smb-volumes.yaml sind darauf ausgelegt; Risiken und
|
|
||||||
# Hinweise zur Sicherung stehen in docs/BETRIEB.md.
|
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
name: artista-modelcamp-postgres
|
name: artista-modelcamp-postgres
|
||||||
|
namespace: artista
|
||||||
spec:
|
spec:
|
||||||
serviceName: artista-modelcamp-postgres
|
serviceName: artista-modelcamp-postgres
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: artista-modelcamp-postgres
|
app: artista-modelcamp-postgres
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: artista-modelcamp-postgres
|
app: artista-modelcamp-postgres
|
||||||
spec:
|
spec:
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
# Beim Beenden sauber herunterfahren (Checkpoint), bevor das Volume
|
terminationGracePeriodSeconds: 120 # sauber herunterfahren vor dem Aushaengen
|
||||||
# ausgehaengt wird — auf Netzwerkspeicher besonders wichtig.
|
|
||||||
terminationGracePeriodSeconds: 120
|
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 70
|
runAsUser: 70
|
||||||
@@ -32,7 +23,7 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5432
|
- containerPort: 5432
|
||||||
env:
|
env:
|
||||||
@@ -44,34 +35,29 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD }
|
secretKeyRef: { name: artista-modelcamp-secrets, key: DB_PASSWORD }
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
# Ab PostgreSQL 18 liegt PGDATA unter /var/lib/postgresql/18/docker.
|
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /var/lib/postgresql
|
mountPath: /var/lib/postgresql
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
livenessProbe:
|
|
||||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
|
||||||
initialDelaySeconds: 60
|
|
||||||
periodSeconds: 20
|
|
||||||
failureThreshold: 6
|
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 100m, memory: 256Mi }
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 1Gi }
|
limits: { memory: 1Gi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities: { drop: ["ALL"] }
|
capabilities: { drop: ["ALL"] }
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim: { claimName: artista-modelcamp-postgres-data }
|
persistentVolumeClaim: { claimName: artista-modelcamp-postgres }
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: artista-modelcamp-postgres
|
name: artista-modelcamp-postgres
|
||||||
|
namespace: artista
|
||||||
spec:
|
spec:
|
||||||
clusterIP: None
|
clusterIP: None
|
||||||
selector:
|
selector:
|
||||||
app.kubernetes.io/name: artista-modelcamp-postgres
|
app: artista-modelcamp-postgres
|
||||||
ports:
|
ports:
|
||||||
- port: 5432
|
- port: 5432
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Website. Der initContainer migriert vor jedem Start die Datenbank.
|
||||||
|
# Update nach neuem Image: kubectl -n artista rollout restart deploy/artista-modelcamp-app
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-app
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: artista-modelcamp-app
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: artista-modelcamp-app
|
||||||
|
db-client: "true"
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
initContainers:
|
||||||
|
- name: migrate
|
||||||
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["prisma", "migrate", "deploy"]
|
||||||
|
envFrom:
|
||||||
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
|
limits: { memory: 512Mi }
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef: { name: artista-modelcamp-config }
|
||||||
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /api/health/ready, port: http }
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: { path: /api/health, port: http }
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 20
|
||||||
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
|
limits: { memory: 768Mi }
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
volumeMounts:
|
||||||
|
- name: media
|
||||||
|
mountPath: /app/media
|
||||||
|
readOnly: true
|
||||||
|
- name: cache
|
||||||
|
mountPath: /app/.next/cache
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: media
|
||||||
|
persistentVolumeClaim: { claimName: artista-modelcamp-media }
|
||||||
|
- name: cache
|
||||||
|
emptyDir: { sizeLimit: 512Mi }
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: { sizeLimit: 64Mi }
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-app
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: artista-modelcamp-app
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: http
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Ingress fuer Traefik + cert-manager (gleiches Muster wie Wabenhain).
|
||||||
|
# ANPASSEN: Domain (2x, auch APP_URL in 01-configmap.yaml).
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp
|
||||||
|
namespace: artista
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
# Challenge-Pfad direkt in DIESEN Ingress eintragen statt in einen
|
||||||
|
# eigenen ohne TLS: sonst leitet die HTTP->HTTPS-Umleitung die
|
||||||
|
# Let's-Encrypt-Pruefung auf HTTPS um, wo nur die App antwortet
|
||||||
|
# ("expected <token> but got <!doctype html>").
|
||||||
|
acme.cert-manager.io/http01-edit-in-place: "true"
|
||||||
|
# HTTP -> HTTPS ueber die Middleware unten (Praefix = Namespace).
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: artista-redirect-to-https@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts: [modelcamp.example.org]
|
||||||
|
secretName: artista-modelcamp-tls
|
||||||
|
rules:
|
||||||
|
- host: modelcamp.example.org
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: artista-modelcamp-app
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: redirect-to-https
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
permanent: true
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Alle 10 Minuten: abgelaufene Reservierungen freigeben, fehlende
|
||||||
|
# Stripe-Ratenplaene anlegen, ueberfaellige Raten mit Stripe abgleichen,
|
||||||
|
# E-Mails nachsenden (Einzug der Folgeraten macht Stripe Billing,
|
||||||
|
# docs/ARCHITEKTUR.md ADR-3). Ohne ihn funktioniert Ratenzahlung nicht.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-worker
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
schedule: "*/10 * * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
# Kein Sofort-Neustart: der naechste Lauf kommt nach 10 Minuten ohnehin,
|
||||||
|
# ein zweiter Pod je Fehlschlag verdoppelt nur Fehler-Pods und Versuche.
|
||||||
|
backoffLimit: 0
|
||||||
|
activeDeadlineSeconds: 900
|
||||||
|
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
|
||||||
|
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
|
||||||
|
ttlSecondsAfterFinished: 86400
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
db-client: "true"
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
seccompProfile: { type: RuntimeDefault }
|
||||||
|
containers:
|
||||||
|
- name: worker
|
||||||
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["tsx", "scripts/worker.ts"]
|
||||||
|
envFrom:
|
||||||
|
- configMapRef: { name: artista-modelcamp-config }
|
||||||
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
|
limits: { memory: 512Mi }
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Datenbank: nur fuer Pods mit dem Label db-client=true erreichbar
|
||||||
|
# (App inkl. Migration, Worker, Admin-Anlage).
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-postgres
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: artista-modelcamp-postgres
|
||||||
|
policyTypes: ["Ingress"]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
db-client: "true"
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
---
|
||||||
|
# Website: nur ueber Traefik erreichbar. Traefik laeuft in diesem Cluster
|
||||||
|
# (RKE2) in kube-system und NICHT im Host-Netzwerk — geprueft mit:
|
||||||
|
# kubectl get pods -A -l 'app.kubernetes.io/name in (traefik,rke2-traefik)' \
|
||||||
|
# -o custom-columns=NS:.metadata.namespace,HOSTNET:.spec.hostNetwork
|
||||||
|
# Namespace UND Label muessen passen; das Label allein koennte jeder setzen.
|
||||||
|
# Der Zertifikats-Solver von cert-manager ist ein eigener Pod und davon nicht
|
||||||
|
# betroffen.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: artista-modelcamp-app
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: artista-modelcamp-app
|
||||||
|
policyTypes: ["Ingress"]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values: [traefik, rke2-traefik]
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Taegliche pg_dump-Sicherung der Modelcamp-Datenbank auf die Storage Box.
|
||||||
|
# Voraussetzung im Cluster: StorageClass smb-backup (SMB 3.1.1 mit seal),
|
||||||
|
# legt pro PVC einen Ordner unter Server/Backups/<namespace>/ an.
|
||||||
|
# Aufbewahrung: 14 Tage taeglich, 8 Wochen sonntags.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteMany]
|
||||||
|
storageClassName: smb-backup
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
schedule: "25 2 * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 3
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: artista-db-backup
|
||||||
|
db-client: "true"
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 70
|
||||||
|
runAsGroup: 70
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
stamp=$(date +%Y-%m-%d)
|
||||||
|
kind=daily; [ "$(date +%u)" = "7" ] && kind=weekly
|
||||||
|
mkdir -p /backups/daily /backups/weekly
|
||||||
|
pg_dump -h artista-modelcamp-postgres -Fc -f /backups/$kind/artista-$stamp.dump.tmp
|
||||||
|
mv /backups/$kind/artista-$stamp.dump.tmp /backups/$kind/artista-$stamp.dump
|
||||||
|
find /backups/daily -name '*.dump' -mtime +14 -delete
|
||||||
|
find /backups/weekly -name '*.dump' -mtime +56 -delete
|
||||||
|
ls -l /backups/$kind | tail -3
|
||||||
|
env:
|
||||||
|
- {name: PGUSER, value: artista}
|
||||||
|
- {name: PGDATABASE, value: artista}
|
||||||
|
- name: PGPASSWORD
|
||||||
|
valueFrom: {secretKeyRef: {name: artista-modelcamp-secrets, key: DB_PASSWORD}}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: "0", memory: "0"}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: backups, mountPath: /backups}
|
||||||
|
volumes:
|
||||||
|
- name: backups
|
||||||
|
persistentVolumeClaim: {claimName: artista-db-backup}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# ARTISTA Modelcamp in Kubernetes (RKE2)
|
||||||
|
|
||||||
|
Rohe Manifeste wie bei Wabenhain, Nummerierung = Reihenfolge. Voraussetzungen im Cluster (wie bei Wabenhain):
|
||||||
|
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
||||||
|
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
||||||
|
- **csi-driver-smb** für die SMB-Volumes
|
||||||
|
- die StorageClass **`smb-backup`** für den Sicherungs-CronJob (`09-db-backup-cronjob.yaml`); im EnCry-Cluster seit 12.10.2026 vorhanden
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get ingressclass
|
||||||
|
kubectl get clusterissuer letsencrypt-prod
|
||||||
|
kubectl get csidrivers | grep smb
|
||||||
|
kubectl get storageclass smb-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Im EnCry-Cluster läuft die Seite unter `modelcamp.artistasphotography.de`. Der DNS-Eintrag ist bei Cloudflare bewusst nicht geproxyt (DNS-only), deshalb bekommt dieser Ingress keine `cloudflare-only`-Middleware. Im selben Namespace läuft Artista-Telefonie (Repo `Artista-TeleTracking`) mit einer eigenen Datenbank `telefonie` in `artista-modelcamp-postgres`.
|
||||||
|
|
||||||
|
## Einmalige Vorbereitung
|
||||||
|
|
||||||
|
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
|
||||||
|
2. **csi-driver-smb** (falls noch nicht vorhanden):
|
||||||
|
```bash
|
||||||
|
helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts
|
||||||
|
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
||||||
|
```
|
||||||
|
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
|
||||||
|
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen (im EnCry-Cluster `modelcamp.artistasphotography.de`). Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
|
||||||
|
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
|
||||||
|
6. **Image-Tag:** In `05-app-deployment.yaml` (2×) und `07-worker-cronjob.yaml` steht der Platzhalter `sha-REPLACE_ME`. Vor dem ersten Anwenden durch den Tag des letzten grünen CI-Laufs ersetzen (`sha-<commit>`, 7 Zeichen) oder nach dem Anwenden wie unter „Bei einem neuen Image“ setzen.
|
||||||
|
|
||||||
|
## Deployment (auch für einen kompletten Neuaufbau)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/00-namespace.yaml
|
||||||
|
kubectl apply -f k8s/01-configmap.yaml
|
||||||
|
kubectl apply -f k8s/02-secret.yaml
|
||||||
|
kubectl apply -f k8s/03-storage.yaml
|
||||||
|
kubectl apply -f k8s/04-postgres.yaml
|
||||||
|
kubectl wait --for=condition=ready pod -l app=artista-modelcamp-postgres -n artista --timeout=180s
|
||||||
|
kubectl apply -f k8s/05-app-deployment.yaml
|
||||||
|
kubectl apply -f k8s/06-ingress.yaml
|
||||||
|
kubectl apply -f k8s/07-worker-cronjob.yaml
|
||||||
|
kubectl apply -f k8s/08-networkpolicy.yaml
|
||||||
|
kubectl apply -f k8s/09-db-backup-cronjob.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl rollout status deployment/artista-modelcamp-app -n artista
|
||||||
|
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Neuaufbau mit `kubectl delete namespace artista`:** Achtung, das löscht auch Artista-Telefonie samt deren Secret; deren Datenbank liegt auf demselben Postgres-Volume. Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
|
||||||
|
kubectl patch pv artista-modelcamp-media -p '{"spec":{"claimRef":null}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Soll die Datenbank wirklich leer neu starten, den Ordner `modelcamp-postgres` auf dem NAS vorher leeren.
|
||||||
|
|
||||||
|
## Erstes Admin-Konto
|
||||||
|
|
||||||
|
E-Mail und Name ersetzen. Das einmalige Passwort wird ausgegeben, die 2FA richtest du bei der ersten Anmeldung unter `/admin` ein.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n artista run admin-create --rm -i --restart=Never \
|
||||||
|
--image=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest --labels=db-client=true \
|
||||||
|
--overrides='{"spec":{"securityContext":{"runAsNonRoot":true,"runAsUser":1001,"seccompProfile":{"type":"RuntimeDefault"}},"containers":[{"name":"admin-create","image":"git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest","command":["tsx","scripts/create-admin.ts","--email","[email protected]","--name","Dein Name"],"envFrom":[{"configMapRef":{"name":"artista-modelcamp-config"}},{"secretRef":{"name":"artista-modelcamp-secrets"}}],"securityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}}}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bei einem neuen Image (Redeploy)
|
||||||
|
|
||||||
|
Die Manifeste sind auf einen Build gepinnt, nicht auf `:latest`. Den Tag `sha-<commit>` (7 Zeichen) zeigt der CI-Lauf in der Zusammenfassung. App und Werkzeuge immer mit demselben Tag ausrollen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TAG=sha-<commit>
|
||||||
|
kubectl -n artista set image deployment/artista-modelcamp-app \
|
||||||
|
migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG \
|
||||||
|
app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
|
kubectl -n artista set image cronjob/artista-modelcamp-worker \
|
||||||
|
worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
|
kubectl -n artista rollout status deployment/artista-modelcamp-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurueck auf den vorherigen Stand: `kubectl -n artista rollout undo deployment/artista-modelcamp-app` (den CronJob dann wieder auf den alten Tag setzen).
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n artista get certificate,certificaterequest,order,challenge # Zertifikat
|
||||||
|
kubectl -n artista describe challenge
|
||||||
|
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migration
|
||||||
|
kubectl -n artista logs deploy/artista-modelcamp-app -c app # App
|
||||||
|
kubectl -n artista get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
```
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Next.js-Anwendung. Zustandslos: Buchungen, Kapazitaet, Gutscheine,
|
|
||||||
# Reservierungen, Rate Limits, E-Mail-Ausgang und Zahlungsjobs liegen in
|
|
||||||
# PostgreSQL — beliebig viele Replikate moeglich.
|
|
||||||
#
|
|
||||||
# Migrationen laufen als eigener Schritt VOR dem App-Container: initContainer
|
|
||||||
# "migrate" (Werkzeug-Image, `prisma migrate deploy`). Scheitert er, startet
|
|
||||||
# die App nicht. Mehrere Replikate gleichzeitig sind unkritisch — Prisma
|
|
||||||
# serialisiert Migrationen ueber eine Datenbank-Sperre.
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-app
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
revisionHistoryLimit: 5
|
|
||||||
strategy:
|
|
||||||
type: RollingUpdate
|
|
||||||
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: artista-modelcamp-app
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: artista-modelcamp-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-modelcamp-app
|
|
||||||
initContainers:
|
|
||||||
- name: migrate
|
|
||||||
image: artista-modelcamp-tools
|
|
||||||
command: ["prisma", "migrate", "deploy"]
|
|
||||||
envFrom:
|
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
|
||||||
resources:
|
|
||||||
requests: { cpu: 50m, memory: 128Mi }
|
|
||||||
limits: { memory: 512Mi }
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities: { drop: ["ALL"] }
|
|
||||||
containers:
|
|
||||||
- name: app
|
|
||||||
image: artista-modelcamp
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 3000
|
|
||||||
envFrom:
|
|
||||||
- configMapRef: { name: artista-modelcamp-config }
|
|
||||||
- secretRef: { name: artista-modelcamp-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-modelcamp-media }
|
|
||||||
- name: next-cache
|
|
||||||
emptyDir: { sizeLimit: 512Mi }
|
|
||||||
- name: tmp
|
|
||||||
emptyDir: { sizeLimit: 64Mi }
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-app
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app.kubernetes.io/name: artista-modelcamp-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-modelcamp-app-ingress
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: artista-modelcamp-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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Der Namespace "artista" wird einmalig von Hand angelegt:
|
|
||||||
# kubectl create namespace artista
|
|
||||||
# kubectl label namespace artista pod-security.kubernetes.io/enforce=restricted
|
|
||||||
#
|
|
||||||
# Nicht geheime Einstellungen. Geheimnisse: k8s/secret.example.yaml.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-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"
|
|
||||||
---
|
|
||||||
# Die Datenbank ist nur fuer Pods mit dem Label artista/db-client erreichbar
|
|
||||||
# (App inkl. Migrations-initContainer, Worker).
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: NetworkPolicy
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-postgres-access
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: artista-modelcamp-postgres
|
|
||||||
policyTypes: ["Ingress"]
|
|
||||||
ingress:
|
|
||||||
- from:
|
|
||||||
- podSelector:
|
|
||||||
matchLabels:
|
|
||||||
artista/db-client: "true"
|
|
||||||
ports:
|
|
||||||
- port: 5432
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
- storage.yaml
|
|
||||||
- postgres.yaml
|
|
||||||
- app.yaml
|
|
||||||
- worker.yaml
|
|
||||||
labels:
|
|
||||||
- pairs:
|
|
||||||
app.kubernetes.io/part-of: artista-modelcamp
|
|
||||||
includeSelectors: false
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Die beiden dauerhaften Speicher. Im Produktions-Overlay werden sie fest an
|
|
||||||
# die SMB-PersistentVolumes gebunden (k8s/overlays/production/smb-volumes.yaml).
|
|
||||||
# Eine separate Sicherung gibt es nicht: die SMB-Freigabe wird auf dem NAS
|
|
||||||
# selbst mehrfach gesichert.
|
|
||||||
|
|
||||||
# PostgreSQL-Datenverzeichnis.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-postgres-data
|
|
||||||
spec:
|
|
||||||
accessModes: ["ReadWriteOnce"]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
---
|
|
||||||
# Freigegebene Bilder und Videos. ReadWriteMany, weil mehrere App-Replikate
|
|
||||||
# lesen.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-media
|
|
||||||
spec:
|
|
||||||
accessModes: ["ReadWriteMany"]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 5Gi
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Worker: gibt abgelaufene Reservierungen frei (nach Rueckfrage bei Stripe),
|
|
||||||
# zieht faellige Raten ein, trennt Zahlungsmethoden nach der letzten Rate und
|
|
||||||
# versendet den E-Mail-Ausgang. Ohne ihn laufen Ratenzahlung und
|
|
||||||
# Reservierungsfreigabe nicht. Idempotent; Forbid verhindert Ueberlappung.
|
|
||||||
apiVersion: batch/v1
|
|
||||||
kind: CronJob
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-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-modelcamp-config }
|
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
|
||||||
resources:
|
|
||||||
requests: { cpu: 50m, memory: 192Mi }
|
|
||||||
limits: { memory: 512Mi }
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities: { drop: ["ALL"] }
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Ingress fuer Traefik mit cert-manager.
|
|
||||||
# Anpassen: Host (auch APP_URL in kustomization.yaml), ingressClassName
|
|
||||||
# ("kubectl get ingressclass"), ClusterIssuer, TLS-Secret-Name.
|
|
||||||
# HTTP->HTTPS-Umleitung erwartet dieses Manifest EntryPoint-weit in Traefik
|
|
||||||
# (Helm: ports.web.redirections.entryPoint.to=websecure).
|
|
||||||
#
|
|
||||||
# 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-modelcamp
|
|
||||||
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-modelcamp-tls
|
|
||||||
rules:
|
|
||||||
- host: modelcamp.example.org
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: artista-modelcamp-app
|
|
||||||
port:
|
|
||||||
name: http
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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
|
|
||||||
# PostgreSQL-Daten und Medien auf SMB.
|
|
||||||
# Ohne SMB: diese Zeile und die beiden PVC-Patches unten entfernen.
|
|
||||||
- smb-volumes.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-modelcamp
|
|
||||||
newTag: "0.1.0"
|
|
||||||
- name: artista-modelcamp-tools
|
|
||||||
newName: git.encry.de/nick.rosenloecher/artista-modelcamp-tools
|
|
||||||
newTag: "0.1.0"
|
|
||||||
|
|
||||||
patches:
|
|
||||||
# PVCs fest an die SMB-PersistentVolumes binden (statisch, ohne StorageClass).
|
|
||||||
- target: { kind: PersistentVolumeClaim, name: artista-modelcamp-media }
|
|
||||||
patch: |-
|
|
||||||
- op: add
|
|
||||||
path: /spec/storageClassName
|
|
||||||
value: ""
|
|
||||||
- op: add
|
|
||||||
path: /spec/volumeName
|
|
||||||
value: artista-modelcamp-media-smb
|
|
||||||
- target: { kind: PersistentVolumeClaim, name: artista-modelcamp-postgres-data }
|
|
||||||
patch: |-
|
|
||||||
- op: add
|
|
||||||
path: /spec/storageClassName
|
|
||||||
value: ""
|
|
||||||
- op: add
|
|
||||||
path: /spec/volumeName
|
|
||||||
value: artista-modelcamp-postgres-smb
|
|
||||||
- target: { kind: ConfigMap, name: artista-modelcamp-config }
|
|
||||||
patch: |-
|
|
||||||
- op: replace
|
|
||||||
path: /data/APP_URL
|
|
||||||
value: https://modelcamp.example.org
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# PersistentVolumes auf einer SMB-Freigabe (csi-driver-smb, smb.csi.k8s.io).
|
|
||||||
#
|
|
||||||
# VORAUSSETZUNG: csi-driver-smb im Cluster installiert, z. B.:
|
|
||||||
# helm repo add csi-driver-smb https://raw.githubusercontent.com/kubernetes-csi/csi-driver-smb/master/charts
|
|
||||||
# helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
|
||||||
# und das Zugangs-Secret "artista-modelcamp-smb" (k8s/secret.example.yaml).
|
|
||||||
#
|
|
||||||
# ANZUPASSEN: "source" (//<nas>/<freigabe>/<ordner>) je Volume. Die Ordner
|
|
||||||
# muessen auf der Freigabe bereits existieren; der Postgres-Ordner leer.
|
|
||||||
#
|
|
||||||
# Beide Verbindungen verschluesselt (SMB 3.1.1 + seal); das NAS muss
|
|
||||||
# SMB-Verschluesselung erlauben. fsGroup wirkt auf SMB nicht: Besitz und
|
|
||||||
# Rechte setzen uid/gid/dir_mode/file_mode. reclaimPolicy Retain: Loeschen
|
|
||||||
# von PVC/PV laesst die Dateien auf dem NAS.
|
|
||||||
# Keine eigene Sicherung im Cluster: die Freigabe wird auf dem NAS gesichert.
|
|
||||||
|
|
||||||
# --- PostgreSQL-Datenverzeichnis ------------------------------------------------
|
|
||||||
# BEWUSSTE AUSNAHME (Entscheidung des Betreibers): Datenbank auf SMB.
|
|
||||||
# Risiko: SMB garantiert Sperren/fsync nicht in jedem Fall wie ein lokales
|
|
||||||
# Dateisystem; bei Netzwerk- oder NAS-Ausfall waehrend Schreibvorgaengen kann
|
|
||||||
# die Datenbank beschaedigt werden. Die Optionen unten minimieren das:
|
|
||||||
# hard E/A wartet bei Netzstoerung statt Fehler an PostgreSQL zu
|
|
||||||
# melden (kein "soft"-Abbruch mitten im Schreiben)
|
|
||||||
# cache=strict strikte Cache-Kohaerenz, fsync geht bis zum Server
|
|
||||||
# nobrl keine Byte-Range-Locks (PostgreSQL braucht sie nicht)
|
|
||||||
# uid/gid 70, 0700/0600 PostgreSQL verlangt ein privates Datenverzeichnis
|
|
||||||
# Sicherung: nur NAS-Snapshots, die atomar sind (Btrfs/ZFS-Snapshot der ganzen
|
|
||||||
# Freigabe), sind fuer ein laufendes Datenverzeichnis konsistent. Einfaches
|
|
||||||
# Dateikopieren waehrend des Betriebs ergibt KEINE brauchbare Sicherung.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolume
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-postgres-smb
|
|
||||||
spec:
|
|
||||||
capacity:
|
|
||||||
storage: 10Gi
|
|
||||||
accessModes: ["ReadWriteOnce"]
|
|
||||||
persistentVolumeReclaimPolicy: Retain
|
|
||||||
storageClassName: ""
|
|
||||||
mountOptions:
|
|
||||||
- vers=3.1.1
|
|
||||||
- seal
|
|
||||||
- hard
|
|
||||||
- cache=strict
|
|
||||||
- uid=70
|
|
||||||
- gid=70
|
|
||||||
- dir_mode=0700
|
|
||||||
- file_mode=0600
|
|
||||||
- mfsymlinks
|
|
||||||
- nobrl
|
|
||||||
csi:
|
|
||||||
driver: smb.csi.k8s.io
|
|
||||||
volumeHandle: artista-modelcamp-postgres-smb
|
|
||||||
volumeAttributes:
|
|
||||||
source: //nas.example.lan/artista/modelcamp-postgres
|
|
||||||
nodeStageSecretRef:
|
|
||||||
name: artista-modelcamp-smb
|
|
||||||
namespace: artista
|
|
||||||
---
|
|
||||||
# --- Medien (Bilder/Videos) ------------------------------------------------------
|
|
||||||
# Die App liest nur. Neue Bilder legst du direkt ueber die SMB-Freigabe ab,
|
|
||||||
# z. B. vom Windows-Explorer aus.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolume
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-media-smb
|
|
||||||
spec:
|
|
||||||
capacity:
|
|
||||||
storage: 5Gi
|
|
||||||
accessModes: ["ReadWriteMany"]
|
|
||||||
persistentVolumeReclaimPolicy: Retain
|
|
||||||
storageClassName: ""
|
|
||||||
mountOptions:
|
|
||||||
- vers=3.1.1
|
|
||||||
- seal
|
|
||||||
- uid=1001 # Containerbenutzer "nextjs" (Dockerfile)
|
|
||||||
- gid=1001
|
|
||||||
- dir_mode=0550 # App liest nur; Schreiben ausschliesslich ueber das NAS
|
|
||||||
- file_mode=0440
|
|
||||||
- mfsymlinks
|
|
||||||
- nobrl
|
|
||||||
csi:
|
|
||||||
driver: smb.csi.k8s.io
|
|
||||||
volumeHandle: artista-modelcamp-media-smb
|
|
||||||
volumeAttributes:
|
|
||||||
source: //nas.example.lan/artista/modelcamp-media
|
|
||||||
nodeStageSecretRef:
|
|
||||||
name: artista-modelcamp-smb
|
|
||||||
namespace: artista
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# VORLAGE — nicht mit echten Werten ins Repository einchecken.
|
|
||||||
# Anlegen z. B. per:
|
|
||||||
# kubectl -n artista create secret generic artista-modelcamp-secrets \
|
|
||||||
# --from-literal=DB_PASSWORD='…' \
|
|
||||||
# --from-literal=DATABASE_URL='postgresql://artista:…@artista-modelcamp-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-modelcamp-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-modelcamp-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>"
|
|
||||||
---
|
|
||||||
# Zugang zur SMB-Freigabe fuer die PersistentVolumes
|
|
||||||
# (k8s/overlays/production/smb-volumes.yaml). Eigener, auf die Artista-
|
|
||||||
# Ordner beschraenkter NAS-Benutzer empfohlen.
|
|
||||||
# kubectl -n artista create secret generic artista-modelcamp-smb \
|
|
||||||
# --from-literal=username='…' --from-literal=password='…'
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: artista-modelcamp-smb
|
|
||||||
namespace: artista
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
username: "<nas-benutzer>"
|
|
||||||
password: "<nas-passwort>"
|
|
||||||
# Optional bei Domaenenkonten:
|
|
||||||
# domain: "<domaene>"
|
|
||||||
@@ -7,9 +7,12 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
qualities: [75, 85],
|
qualities: [75, 85],
|
||||||
// Freigegebene Bilder liegen im persistenten Medienverzeichnis und
|
// Optimierte Bilder 30 Tage im Browser cachen: Medien-URLs tragen ?v=<Aenderungsdatum>,
|
||||||
// werden unter /media/… ausgeliefert (src/app/media/[...path]/route.ts).
|
// ein ersetztes Bild hat also ohnehin eine neue Adresse.
|
||||||
localPatterns: [{ pathname: "/media/**" }],
|
minimumCacheTTL: 2_592_000,
|
||||||
|
// Feste Bilder (Unterkunft, Team) liegen in public/images/, austauschbare
|
||||||
|
// im persistenten Medienverzeichnis unter /media/… (src/app/media/[...path]/route.ts).
|
||||||
|
localPatterns: [{ pathname: "/media/**" }, { pathname: "/images/**" }],
|
||||||
dangerouslyAllowSVG: false,
|
dangerouslyAllowSVG: false,
|
||||||
},
|
},
|
||||||
// Turbopack-Dateicaches kollidieren im OneDrive-synchronisierten
|
// Turbopack-Dateicaches kollidieren im OneDrive-synchronisierten
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:seed": "tsx scripts/seed-dev.ts",
|
"db:seed": "tsx scripts/seed-dev.ts",
|
||||||
"admin:create": "tsx scripts/create-admin.ts",
|
"admin:create": "tsx scripts/create-admin.ts",
|
||||||
"worker": "tsx scripts/worker.ts",
|
"worker": "tsx scripts/worker.ts",
|
||||||
|
"media:optimize": "tsx scripts/optimize-media.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
@@ -26,13 +27,14 @@
|
|||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"better-auth": "1.7.6",
|
"better-auth": "1.7.6",
|
||||||
"motion": "13.4.4",
|
"motion": "13.4.4",
|
||||||
"next": "16.3.6",
|
"next": "16.3.8",
|
||||||
"nodemailer": "10.0.10",
|
"nodemailer": "10.0.10",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"stripe": "22.6.2",
|
"stripe": "22.6.2",
|
||||||
"zod": "4.6.5"
|
"zod": "4.6.5"
|
||||||
},
|
},
|
||||||
@@ -49,7 +51,8 @@
|
|||||||
"dotenv": "18.0.4",
|
"dotenv": "18.0.4",
|
||||||
"embedded-postgres": "18.4.0-beta.17",
|
"embedded-postgres": "18.4.0-beta.17",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.3.6",
|
"eslint-config-next": "16.3.8",
|
||||||
|
"ffmpeg-static": "5.3.0",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.0",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
|
|||||||
@@ -3,7 +3,21 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
ffmpeg-static: true
|
||||||
embedded-postgres: true
|
embedded-postgres: true
|
||||||
'@embedded-postgres/windows-x64': true
|
'@embedded-postgres/windows-x64': true
|
||||||
'@embedded-postgres/linux-x64': true
|
'@embedded-postgres/linux-x64': true
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
|
# Transitive Luecken ab "high" (pnpm audit --prod, CI-Gate in
|
||||||
|
# .gitea/workflows/docker-build.yml). Gleiche Untergrenzen wie im Wabenhain-Repo.
|
||||||
|
overrides:
|
||||||
|
# mysql2 und deepmerge-ts kommen ueber die prisma-CLI (@prisma/config) herein,
|
||||||
|
# nicht ueber den Laufzeitpfad (die App nutzt @prisma/adapter-pg).
|
||||||
|
# deepmerge-ts ist ein Hauptversionssprung; "prisma generate" laeuft durch
|
||||||
|
# @prisma/config und prueft ihn bei jedem typecheck/build mit.
|
||||||
|
mysql2: '>=3.23.1'
|
||||||
|
deepmerge-ts: '>=8.0.0'
|
||||||
|
# Transitiv ueber next (next pinnt sharp sonst auf 0.35.4); reine Patch-Spruenge.
|
||||||
|
sharp: '>=0.35.5'
|
||||||
|
source-map-js: '>=1.2.2'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Vom Auftraggeber am 01.10.2026 bestaetigte Eckdaten:
|
||||||
|
-- Termin 08.05.–15.05.2027, Villa La Chiesina in Arcidosso (Toskana),
|
||||||
|
-- eigenes Bett im geteilten Zimmer, 13 verkaufbare Plaetze.
|
||||||
|
-- Bereits im Adminbereich gepflegte Werte werden nicht ueberschrieben
|
||||||
|
-- (nur leere bzw. unbestaetigte Felder werden gesetzt).
|
||||||
|
|
||||||
|
ALTER TABLE "Camp" ALTER COLUMN "capacity" SET DEFAULT 13;
|
||||||
|
|
||||||
|
INSERT INTO "Camp" ("id", "updatedAt") VALUES ('modelcamp-2027', CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"campStartDate" = DATE '2027-05-08',
|
||||||
|
"campEndDate" = DATE '2027-05-15',
|
||||||
|
"dateConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "dateConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"locationName" = 'Villa La Chiesina, Arcidosso',
|
||||||
|
"locationRegion" = 'Toskana, Italien',
|
||||||
|
"locationConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "locationConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"accommodationText" = 'Du hast dein eigenes Bett und teilst dir das Zimmer mit anderen Teilnehmerinnen. Einzelzimmer und Zimmer-Upgrades sind nicht buchbar.',
|
||||||
|
"accommodationConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "accommodationConfirmed" = false;
|
||||||
|
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"capacity" = 13,
|
||||||
|
"capacityConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027' AND "capacityConfirmed" = false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Widerrufsbelehrung: Text und Freigabe, Voraussetzung fuer den Live-Verkauf.
|
||||||
|
ALTER TABLE "Camp" ADD COLUMN "withdrawalText" TEXT;
|
||||||
|
ALTER TABLE "Camp" ADD COLUMN "withdrawalApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Termin laut Wochenplan des Auftraggebers (02.10.2026):
|
||||||
|
-- Montag, 03.05.2027 bis Sonntag, 09.05.2027.
|
||||||
|
-- Ersetzt nur den per Migration 20261001120000 gesetzten Termin; ein im
|
||||||
|
-- Adminbereich bereits anders gepflegter Termin bleibt unveraendert.
|
||||||
|
UPDATE "Camp" SET
|
||||||
|
"campStartDate" = DATE '2027-05-03',
|
||||||
|
"campEndDate" = DATE '2027-05-09',
|
||||||
|
"dateConfirmed" = true,
|
||||||
|
"updatedAt" = CURRENT_TIMESTAMP,
|
||||||
|
"updatedBy" = 'migration'
|
||||||
|
WHERE "id" = 'modelcamp-2027'
|
||||||
|
AND ("campStartDate" IS NULL OR "campStartDate" = DATE '2027-05-08');
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Folgeraten ueber Stripe Billing (Subscription Schedule) statt eigenem Off-Session-Einzug.
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "stripeScheduleId" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "Booking_stripeScheduleId_key" ON "Booking"("stripeScheduleId");
|
||||||
|
|
||||||
|
ALTER TABLE "Installment" ADD COLUMN "stripeInvoiceId" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "Installment_stripeInvoiceId_key" ON "Installment"("stripeInvoiceId");
|
||||||
@@ -38,7 +38,7 @@ model Camp {
|
|||||||
/// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts).
|
/// vollstaendigen Freigabe-Checkliste (src/lib/sales-gate.ts).
|
||||||
salesEnabled Boolean @default(false)
|
salesEnabled Boolean @default(false)
|
||||||
|
|
||||||
capacity Int @default(10)
|
capacity Int @default(13)
|
||||||
capacityConfirmed Boolean @default(false)
|
capacityConfirmed Boolean @default(false)
|
||||||
stammmodelQuota Int @default(4)
|
stammmodelQuota Int @default(4)
|
||||||
stammmodelQuotaConfirmed Boolean @default(false)
|
stammmodelQuotaConfirmed Boolean @default(false)
|
||||||
@@ -76,6 +76,11 @@ model Camp {
|
|||||||
termsUrl String?
|
termsUrl String?
|
||||||
termsApproved Boolean @default(false)
|
termsApproved Boolean @default(false)
|
||||||
|
|
||||||
|
/// Freigegebene Widerrufsbelehrung bzw. Hinweis zum Widerrufsrecht,
|
||||||
|
/// erscheint unter /widerruf. Ohne Freigabe kein Live-Verkauf.
|
||||||
|
withdrawalText String?
|
||||||
|
withdrawalApproved Boolean @default(false)
|
||||||
|
|
||||||
travelLawChecked Boolean @default(false)
|
travelLawChecked Boolean @default(false)
|
||||||
|
|
||||||
installmentsApproved Boolean @default(false)
|
installmentsApproved Boolean @default(false)
|
||||||
@@ -186,6 +191,9 @@ model Booking {
|
|||||||
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
/// Zahlungsmethode. Wird nach der letzten Rate bei Stripe getrennt.
|
||||||
stripePaymentMethodId String?
|
stripePaymentMethodId String?
|
||||||
paymentMethodDetachedAt DateTime?
|
paymentMethodDetachedAt DateTime?
|
||||||
|
/// Stripe-Billing-Zeitplan, der die Folgeraten einzieht (nur Ratenzahlung).
|
||||||
|
/// Stripe uebernimmt Einzug, Wiederholungen und Mahnungen.
|
||||||
|
stripeScheduleId String? @unique
|
||||||
|
|
||||||
paidCents Int @default(0)
|
paidCents Int @default(0)
|
||||||
refundedCents Int @default(0)
|
refundedCents Int @default(0)
|
||||||
@@ -241,6 +249,8 @@ model Installment {
|
|||||||
|
|
||||||
stripePaymentIntentId String? @unique
|
stripePaymentIntentId String? @unique
|
||||||
stripeChargeId String? @unique
|
stripeChargeId String? @unique
|
||||||
|
/// Stripe-Rechnung, ueber die Stripe Billing diese Folgerate einzieht.
|
||||||
|
stripeInvoiceId String? @unique
|
||||||
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
/// Offene Checkout-Session, ueber die die Kundin diese Rate selbst zahlt.
|
||||||
checkoutSessionId String? @unique
|
checkoutSessionId String? @unique
|
||||||
checkoutExpiresAt DateTime?
|
checkoutExpiresAt DateTime?
|
||||||
|
|||||||
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
|
||||||
|
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
|
||||||
|
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
|
||||||
|
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Runner-Isolation verletzt: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable() {
|
||||||
|
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
|
||||||
|
# gelegentlich erst beim zweiten Versuch.
|
||||||
|
resolve() {
|
||||||
|
local address=""
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
|
||||||
|
if [ -n "$address" ]; then echo "$address"; return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Kein Zugang zum Docker-Daemon über den Socket.
|
||||||
|
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
|
||||||
|
for socket in /var/run/docker.sock /run/docker.sock; do
|
||||||
|
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
|
||||||
|
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
|
||||||
|
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
|
||||||
|
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
|
||||||
|
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
|
||||||
|
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
|
||||||
|
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
|
||||||
|
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
|
||||||
|
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
|
||||||
|
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
|
||||||
|
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
|
||||||
|
host=${target%:*}
|
||||||
|
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
|
||||||
|
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
|
||||||
|
|
||||||
|
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Bereitet Bilder und Videos fuer das Medienverzeichnis auf, bevor sie
|
||||||
|
* hochgeladen werden (docs/MEDIEN.md):
|
||||||
|
*
|
||||||
|
* pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]
|
||||||
|
*
|
||||||
|
* - Videos (.mp4, .mov, .m4v, .webm) → "<name>.web.mp4": laengste Seite
|
||||||
|
* hoechstens 1920 px, H.264, Index am Dateianfang (faststart), damit der
|
||||||
|
* Browser sofort abspielen kann statt erst die ganze Datei zu laden.
|
||||||
|
* Headervideos ohne Ton. Fehlt ein Vorschaubild, wird eins erzeugt.
|
||||||
|
* Die Seite nimmt automatisch die .web.mp4, wenn sie daneben liegt.
|
||||||
|
* - Bilder groesser als noetig werden verkleinert (Logo 1600 px, sonst
|
||||||
|
* 2560 px laengste Seite), kleinere unveraendert kopiert.
|
||||||
|
*
|
||||||
|
* Ausgabe standardmaessig in "<eingabe-ordner>-web" mit gleicher
|
||||||
|
* Ordnerstruktur; diesen Inhalt ins Medienverzeichnis kopieren. Bereits
|
||||||
|
* aufbereitete Dateien (neuer als das Original) werden uebersprungen.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import ffmpegPath from "ffmpeg-static";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
|
||||||
|
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
||||||
|
const MAX_VIDEO_EDGE = 1920;
|
||||||
|
const MAX_IMAGE_EDGE = 2560;
|
||||||
|
const MAX_LOGO_EDGE = 1600;
|
||||||
|
const POSTER_SECOND = 1;
|
||||||
|
|
||||||
|
async function* walk(dir: string): AsyncGenerator<string> {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) yield* walk(full);
|
||||||
|
else if (entry.isFile()) yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFresh(output: string, input: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(output)).mtimeMs >= (await stat(input)).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string): Promise<boolean> {
|
||||||
|
return stat(file).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffmpeg(args: string[]): Promise<void> {
|
||||||
|
const binary = ffmpegPath as unknown as string | null;
|
||||||
|
if (!binary) throw new Error("ffmpeg-static liefert fuer dieses System kein ffmpeg.");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(binary, ["-hide_banner", "-loglevel", "error", "-stats", "-y", ...args], { stdio: ["ignore", "inherit", "inherit"] });
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg endete mit Code ${code}`))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laengste Seite auf MAX_VIDEO_EDGE begrenzen, nie vergroessern, gerade Masse fuer H.264. */
|
||||||
|
const SCALE_FILTER = `scale='if(gte(iw,ih),min(${MAX_VIDEO_EDGE},iw),-2)':'if(gte(iw,ih),-2,min(${MAX_VIDEO_EDGE},ih))'`;
|
||||||
|
|
||||||
|
async function hasPoster(input: string, base: string): Promise<boolean> {
|
||||||
|
const dir = path.dirname(input);
|
||||||
|
const name = path.basename(base);
|
||||||
|
for (const ext of IMAGE_EXTS) {
|
||||||
|
if (await exists(path.join(dir, `${name}${ext}`))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeVideo(input: string, outDir: string, base: string): Promise<string[]> {
|
||||||
|
const output = path.join(outDir, `${base}.web.mp4`);
|
||||||
|
const written: string[] = [];
|
||||||
|
if (!(await isFresh(output, input))) {
|
||||||
|
const silent = base.toLowerCase() === "header";
|
||||||
|
await ffmpeg([
|
||||||
|
"-i", input,
|
||||||
|
"-vf", SCALE_FILTER,
|
||||||
|
"-c:v", "libx264", "-preset", "slow", "-crf", "23", "-profile:v", "high", "-pix_fmt", "yuv420p",
|
||||||
|
"-maxrate", "6M", "-bufsize", "12M",
|
||||||
|
...(silent ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"]),
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
output,
|
||||||
|
]);
|
||||||
|
written.push(output);
|
||||||
|
}
|
||||||
|
const poster = path.join(outDir, `${base}.jpg`);
|
||||||
|
if (!(await hasPoster(input, base)) && !(await isFresh(poster, input))) {
|
||||||
|
await ffmpeg(["-ss", String(POSTER_SECOND), "-i", input, "-frames:v", "1", "-vf", SCALE_FILTER, "-q:v", "3", poster]);
|
||||||
|
written.push(poster);
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optimizeImage(input: string, output: string): Promise<string[]> {
|
||||||
|
if (await isFresh(output, input)) return [];
|
||||||
|
const isLogo = path.basename(input).toLowerCase().startsWith("logo");
|
||||||
|
const limit = isLogo ? MAX_LOGO_EDGE : MAX_IMAGE_EDGE;
|
||||||
|
const meta = await sharp(input).metadata();
|
||||||
|
if (Math.max(meta.width ?? 0, meta.height ?? 0) <= limit) {
|
||||||
|
await copyFile(input, output);
|
||||||
|
return [output];
|
||||||
|
}
|
||||||
|
const resized = sharp(input).rotate().resize(limit, limit, { fit: "inside", withoutEnlargement: true });
|
||||||
|
const ext = path.extname(input).toLowerCase();
|
||||||
|
if (ext === ".png") await resized.png({ compressionLevel: 9 }).toFile(output);
|
||||||
|
else if (ext === ".webp") await resized.webp({ quality: 82 }).toFile(output);
|
||||||
|
else await resized.jpeg({ quality: 82, mozjpeg: true }).toFile(output);
|
||||||
|
return [output];
|
||||||
|
}
|
||||||
|
|
||||||
|
function megabytes(bytes: number): string {
|
||||||
|
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [inputArg, outputArg] = process.argv.slice(2);
|
||||||
|
if (!inputArg) {
|
||||||
|
console.error("Aufruf: pnpm media:optimize <eingabe-ordner> [ausgabe-ordner]");
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inputDir = path.resolve(inputArg);
|
||||||
|
const outputDir = path.resolve(outputArg ?? `${inputDir}-web`);
|
||||||
|
if (outputDir === inputDir) throw new Error("Ausgabe-Ordner muss sich vom Eingabe-Ordner unterscheiden.");
|
||||||
|
|
||||||
|
let before = 0;
|
||||||
|
let after = 0;
|
||||||
|
for await (const input of walk(inputDir)) {
|
||||||
|
const rel = path.relative(inputDir, input);
|
||||||
|
const ext = path.extname(rel).toLowerCase();
|
||||||
|
if (!VIDEO_EXTS.has(ext) && !IMAGE_EXTS.has(ext)) continue;
|
||||||
|
if (rel.toLowerCase().endsWith(".web.mp4")) continue;
|
||||||
|
const outDir = path.join(outputDir, path.dirname(rel));
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
console.log(`→ ${rel}`);
|
||||||
|
const written = VIDEO_EXTS.has(ext)
|
||||||
|
? await optimizeVideo(input, outDir, path.basename(rel, path.extname(rel)))
|
||||||
|
: await optimizeImage(input, path.join(outDir, path.basename(rel)));
|
||||||
|
if (written.length === 0) {
|
||||||
|
console.log(" bereits aktuell");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inSize = (await stat(input)).size;
|
||||||
|
const outSize = (await Promise.all(written.map((f) => stat(f)))).reduce((sum, s) => sum + s.size, 0);
|
||||||
|
before += inSize;
|
||||||
|
after += outSize;
|
||||||
|
console.log(` ${megabytes(inSize)} → ${megabytes(outSize)}: ${written.map((f) => path.relative(outputDir, f)).join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log(`Fertig: ${megabytes(before)} → ${megabytes(after)}. Inhalt von ${outputDir} ins Medienverzeichnis kopieren.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(`Abbruch: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -2,14 +2,11 @@
|
|||||||
* Testdaten NUR fuer Entwicklung und Staging, damit eine Stripe-Testbuchung
|
* Testdaten NUR fuer Entwicklung und Staging, damit eine Stripe-Testbuchung
|
||||||
* sofort moeglich ist:
|
* sofort moeglich ist:
|
||||||
* - Preisphase "Early Bird" simuliert (wirkt nur mit Stripe-Testschluessel)
|
* - 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)
|
* - ein Test-Gutschein TEST-STAMMMODEL (nicht der Beispielcode aus dem Briefing)
|
||||||
*
|
*
|
||||||
* Verweigert den Lauf mit APP_ENV=production.
|
* Verweigert den Lauf mit APP_ENV=production.
|
||||||
*/
|
*/
|
||||||
import { getDb, CAMP_ID } from "@/lib/db";
|
import { getDb, CAMP_ID } from "@/lib/db";
|
||||||
import { isoToDbDate } from "@/lib/time";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.loadEnvFile();
|
process.loadEnvFile();
|
||||||
@@ -26,15 +23,16 @@ const db = getDb();
|
|||||||
try {
|
try {
|
||||||
await db.camp.upsert({
|
await db.camp.upsert({
|
||||||
where: { id: CAMP_ID },
|
where: { id: CAMP_ID },
|
||||||
create: { id: CAMP_ID, testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
// Termin und Ort setzt die Migration 20261001120000_camp_2027_bestaetigt.
|
||||||
update: { testPhaseOverride: "EARLY_BIRD", campStartDate: isoToDbDate("2027-06-05"), dateConfirmed: false },
|
create: { id: CAMP_ID, testPhaseOverride: "EARLY_BIRD" },
|
||||||
|
update: { testPhaseOverride: "EARLY_BIRD" },
|
||||||
});
|
});
|
||||||
await db.voucher.upsert({
|
await db.voucher.upsert({
|
||||||
where: { code: "TEST-STAMMMODEL" },
|
where: { code: "TEST-STAMMMODEL" },
|
||||||
create: { code: "TEST-STAMMMODEL", maxRedemptions: 3, internalNote: "Entwicklung: Testgutschein" },
|
create: { code: "TEST-STAMMMODEL", maxRedemptions: 3, internalNote: "Entwicklung: Testgutschein" },
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
console.log("Entwicklungsdaten gesetzt: Early Bird simuliert, Test-Anreisetag 05.06.2027 (unbestätigt), Gutschein TEST-STAMMMODEL.");
|
console.log("Entwicklungsdaten gesetzt: Early Bird simuliert, Gutschein TEST-STAMMMODEL.");
|
||||||
} finally {
|
} finally {
|
||||||
await db.$disconnect();
|
await db.$disconnect();
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,11 @@ try {
|
|||||||
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
adminEmail: env().ADMIN_NOTIFY_EMAIL ?? null,
|
||||||
});
|
});
|
||||||
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
|
console.log(`[worker] ${new Date().toISOString()} ${JSON.stringify(result)}`);
|
||||||
process.exitCode = result.emails.failed > 0 ? 1 : 0;
|
// Teilfehler (einzelne Buchungen bei Stripe) machen den Job sichtbar
|
||||||
|
// fehlgeschlagen, ohne die uebrigen Aufgaben des Laufs zu verhindern.
|
||||||
|
const partialFailures =
|
||||||
|
result.emails.failed + result.held.failed + result.schedules.failed + result.installments.failed + result.stopped.failed;
|
||||||
|
process.exitCode = partialFailures > 0 ? 1 : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
console.error(`[worker] Abbruch: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
|
|||||||
<Field name="termsUrl" label="Link zu den Bedingungen" defaultValue={camp.termsUrl} hint="https://… oder /pfad" />
|
<Field name="termsUrl" label="Link zu den Bedingungen" defaultValue={camp.termsUrl} hint="https://… oder /pfad" />
|
||||||
</div>
|
</div>
|
||||||
<Checkbox name="termsApproved" label="Buchungs-, Storno- und Zahlungsbedingungen freigegeben" defaultChecked={camp.termsApproved} />
|
<Checkbox name="termsApproved" label="Buchungs-, Storno- und Zahlungsbedingungen freigegeben" defaultChecked={camp.termsApproved} />
|
||||||
|
<Field
|
||||||
|
name="withdrawalText"
|
||||||
|
label="Widerrufsbelehrung"
|
||||||
|
defaultValue={camp.withdrawalText}
|
||||||
|
multiline
|
||||||
|
hint="Geprüfter Text, erscheint unter /widerruf. Absätze durch Leerzeilen trennen."
|
||||||
|
/>
|
||||||
|
<Checkbox name="withdrawalApproved" label="Widerrufsbelehrung freigegeben" defaultChecked={camp.withdrawalApproved} />
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="travelLawChecked"
|
name="travelLawChecked"
|
||||||
label="Reiserechtliche Einordnung geprüft"
|
label="Reiserechtliche Einordnung geprüft"
|
||||||
@@ -100,7 +108,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
|
|||||||
name="installmentRuleApproved"
|
name="installmentRuleApproved"
|
||||||
label="Terminregel für Raten freigegeben"
|
label="Terminregel für Raten freigegeben"
|
||||||
defaultChecked={camp.installmentRuleApproved}
|
defaultChecked={camp.installmentRuleApproved}
|
||||||
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Einzug 9 Uhr."
|
hint="Letzte Rate am Stichtag, bei drei Raten die zweite in der Mitte; Stripe zieht ab 9 Uhr ein."
|
||||||
/>
|
/>
|
||||||
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
<Checkbox name="installmentsApproved" label="Ratenzahlung für den Live-Verkauf freigegeben" defaultChecked={camp.installmentsApproved} />
|
||||||
<label className="mt-3 block">
|
<label className="mt-3 block">
|
||||||
|
|||||||
@@ -3,11 +3,33 @@ import { notFound } from "next/navigation";
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||||
|
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
|
import { stripeModeOrNull } from "@/lib/env";
|
||||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||||
|
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
|
||||||
|
|
||||||
export const metadata = { title: "Gutschein" };
|
export const metadata = { title: "Gutschein" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft wie der Buchungsdialog, ob der Code gerade einloesbar ist, und nennt
|
||||||
|
* den Grund, den Kundinnen bewusst nicht sehen (neutrale Ablehnung).
|
||||||
|
* Eine E-Mail-Bindung prueft erst der Checkout.
|
||||||
|
*/
|
||||||
|
async function redeemability(code: string): Promise<{ ok: boolean; text: string }> {
|
||||||
|
const mode = stripeModeOrNull();
|
||||||
|
if (!mode) return { ok: false, text: "Nicht prüfbar: STRIPE_SECRET_KEY fehlt oder ist ungültig." };
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const camp = await getCamp(db);
|
||||||
|
const evaluation = await evaluateVoucher(db, { code, email: null, now, livemode: mode === "live", quota: camp.stammmodelQuota, lock: false });
|
||||||
|
if (!evaluation.ok) return { ok: false, text: `Derzeit nicht einlösbar: ${VOUCHER_REJECTION_ADMIN_TEXT[evaluation.reason]}.` };
|
||||||
|
if (!salesStateFor(camp, mode, now).checkoutOpen) {
|
||||||
|
return { ok: false, text: "Code ist gültig, aber die Buchung ist noch nicht geöffnet. Der Dialog zeigt deshalb keinen Preis und keinen Rabatt." };
|
||||||
|
}
|
||||||
|
return { ok: true, text: "Derzeit einlösbar (10 % auf den gültigen Preis)." };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||||
await connection();
|
await connection();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,11 +39,13 @@ export default async function VoucherPage({ params, searchParams }: { params: Pr
|
|||||||
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
||||||
});
|
});
|
||||||
if (!voucher) notFound();
|
if (!voucher) notFound();
|
||||||
|
const check = await redeemability(voucher.code);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||||
<PageTitle>{voucher.code}</PageTitle>
|
<PageTitle>{voucher.code}</PageTitle>
|
||||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||||
|
<p className={`mb-6 rounded-lg border p-4 ${check.ok ? "border-line" : "border-rosso/40 text-rosso"}`}>{check.text}</p>
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
<VoucherForm voucher={voucher} />
|
<VoucherForm voucher={voucher} />
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export async function updateCampAction(form: FormData) {
|
|||||||
termsVersion: str(form, "termsVersion"),
|
termsVersion: str(form, "termsVersion"),
|
||||||
termsUrl: str(form, "termsUrl"),
|
termsUrl: str(form, "termsUrl"),
|
||||||
termsApproved: bool(form, "termsApproved"),
|
termsApproved: bool(form, "termsApproved"),
|
||||||
|
withdrawalText: str(form, "withdrawalText"),
|
||||||
|
withdrawalApproved: bool(form, "withdrawalApproved"),
|
||||||
travelLawChecked: bool(form, "travelLawChecked"),
|
travelLawChecked: bool(form, "travelLawChecked"),
|
||||||
installmentsApproved: bool(form, "installmentsApproved"),
|
installmentsApproved: bool(form, "installmentsApproved"),
|
||||||
installmentRuleApproved: bool(form, "installmentRuleApproved"),
|
installmentRuleApproved: bool(form, "installmentRuleApproved"),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
import { getAuth } from "@/lib/auth";
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).GET(request);
|
return toNextJsHandler(getAuth()).GET(await withClientIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).POST(request);
|
return toNextJsHandler(getAuth()).POST(await withClientIp(request));
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||||
import { abandonCheckout, startInstallmentCheckout } from "@/lib/checkout";
|
import { abandonCheckout, installmentPaymentUrl } from "@/lib/checkout";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { requireAppSecret } from "@/lib/env";
|
|
||||||
import { getGateway } from "@/lib/payment-gateway";
|
import { getGateway } from "@/lib/payment-gateway";
|
||||||
import { RATE_LIMITS } from "@/lib/rate-limit";
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
import { isWellFormedStatusToken, sha256Hex } from "@/lib/tokens";
|
||||||
@@ -37,7 +36,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
switch (body.data.action) {
|
switch (body.data.action) {
|
||||||
case "pay_installment": {
|
case "pay_installment": {
|
||||||
const result = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId }, { now, secret: requireAppSecret() });
|
const result = await installmentPaymentUrl(db, gateway, { bookingId: booking.id, installmentId: body.data.installmentId });
|
||||||
if (result.ok) return ok({ url: result.url });
|
if (result.ok) return ok({ url: result.url });
|
||||||
return result.error === "NOT_PAYABLE"
|
return result.error === "NOT_PAYABLE"
|
||||||
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
? fail(409, { code: "NOT_PAYABLE", message: "Diese Rate kann gerade nicht bezahlt werden. Bitte lade die Seite neu." })
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Formular "Erstgespraech" (src/components/sections/ContactSection.tsx).
|
||||||
|
* Gleiche Schutzschicht wie die Buchung: nur JSON von der eigenen Seite,
|
||||||
|
* Rate Limit je IP, serverseitige Pruefung aller Felder.
|
||||||
|
*/
|
||||||
|
import { after } from "next/server";
|
||||||
|
import { checkSameOrigin, fail, ok, rateLimited, readJson } from "@/lib/api";
|
||||||
|
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
|
||||||
|
import { getDb } from "@/lib/db";
|
||||||
|
import { flushEmails } from "@/lib/email";
|
||||||
|
import { requireAppSecret } from "@/lib/env";
|
||||||
|
import { RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const blocked = checkSameOrigin(request);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const limited = await rateLimited(request, RATE_LIMITS.contact);
|
||||||
|
if (limited) return limited;
|
||||||
|
const body = await readJson(request, contactRequestSchema);
|
||||||
|
if (!body.ok) return body.response;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const result = await submitContactRequest(db, body.data, { now: new Date(), secret: requireAppSecret() });
|
||||||
|
if (!result.ok) {
|
||||||
|
return fail(503, { code: "NOT_CONFIGURED", message: "Das Formular ist gerade nicht verfügbar. Bitte schreib uns direkt eine E-Mail." });
|
||||||
|
}
|
||||||
|
// Sofort zustellen statt auf den naechsten Worker-Lauf zu warten; schlaegt
|
||||||
|
// das fehl, versucht es der Worker erneut.
|
||||||
|
if (result.delivered) {
|
||||||
|
after(() => flushEmails(db, { limit: 5 }).catch(() => undefined));
|
||||||
|
}
|
||||||
|
return ok({ received: true });
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export default function DatenschutzPage() {
|
|||||||
<li>Buchungsdaten: Name, E-Mail, Telefon, Land, optionale Anmerkungen, Zeitpunkte der Erklärungen; gespeichert in einer selbst betriebenen PostgreSQL-Datenbank.</li>
|
<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>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>E-Mail-Versand über den konfigurierten SMTP-Anbieter.</li>
|
||||||
|
<li>Formular Erstgespräch: Name, E-Mail, optional Telefon, Erreichbarkeit und Nachricht; wird nicht in einer eigenen Tabelle gespeichert, sondern als E-Mail an den Veranstalter geschickt (Kopie im Postausgang der Datenbank).</li>
|
||||||
<li>Rate Limiting speichert nur einen gehashten IP-Schlüssel, maximal einen Tag.</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>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>Externe Videoanbieter nur nach Klick und nur, wenn EXTERNAL_VIDEO_PROVIDER gesetzt ist.</li>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
--color-rosso-deep: #6f2c20;
|
--color-rosso-deep: #6f2c20;
|
||||||
--color-sand: #c9b79c;
|
--color-sand: #c9b79c;
|
||||||
|
|
||||||
--font-display: var(--font-bodoni), "Bodoni 72", "Didot", Georgia, serif;
|
--font-display: var(--font-figtree), "Helvetica Neue", Arial, sans-serif;
|
||||||
--font-sans: var(--font-hanken), "Helvetica Neue", Arial, sans-serif;
|
--font-sans: var(--font-figtree), "Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
|
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,10 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 5rem;
|
scroll-padding-top: 5.5rem;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scrollbar-color: var(--color-stone) var(--color-plaster);
|
||||||
|
accent-color: var(--color-rosso);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -37,6 +39,22 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
caret-color: var(--color-rosso);
|
||||||
|
font-variant-numeric: lining-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-underline-offset: 0.22em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -83,17 +101,17 @@
|
|||||||
|
|
||||||
.display {
|
.display {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.02;
|
line-height: 1.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4.25rem);
|
font-size: clamp(2.25rem, 1.4rem + 3.2vw, 3.75rem);
|
||||||
line-height: 1.02;
|
line-height: 1.05;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-body {
|
.prose-body {
|
||||||
@@ -164,6 +182,28 @@
|
|||||||
border-color: var(--color-rosso);
|
border-color: var(--color-rosso);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Textlinks: ruhig unterstrichen, beim Zeigen kraeftiger. */
|
||||||
|
.link-quiet {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||||
|
text-underline-offset: 0.22em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
transition: text-decoration-color 160ms var(--ease-editorial);
|
||||||
|
}
|
||||||
|
.link-quiet:hover {
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
.link-strong {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: var(--color-rosso);
|
||||||
|
text-underline-offset: 0.3em;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
transition: color 160ms var(--ease-editorial);
|
||||||
|
}
|
||||||
|
.link-strong:hover {
|
||||||
|
color: var(--color-rosso);
|
||||||
|
}
|
||||||
|
|
||||||
.draft-flag {
|
.draft-flag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -175,64 +215,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Masthead: der eine laute Moment der Seite. */
|
/*
|
||||||
.masthead {
|
* Papierstruktur der Platzhalter-Tafeln (inline SVG als data-URI, CSP:
|
||||||
font-family: var(--font-display);
|
* img-src data: ist erlaubt). Statisch, keine Animation.
|
||||||
font-weight: 500;
|
*/
|
||||||
font-size: clamp(4.25rem, 21vw, 19rem);
|
.placeholder-plate::before {
|
||||||
line-height: 0.8;
|
content: "";
|
||||||
letter-spacing: -0.035em;
|
position: absolute;
|
||||||
font-variation-settings: "opsz" 96;
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.35;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%27160%27><filter id=%27n%27><feTurbulence type=%27fractalNoise%27 baseFrequency=%270.9%27 numOctaves=%272%27 stitchTiles=%27stitch%27/><feColorMatrix values=%270 0 0 0 0.45 0 0 0 0 0.4 0 0 0 0 0.35 0 0 0 0.22 0%27/></filter><rect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23n)%27/></svg>");
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt
|
* Eroeffnung: Headerbild zoomt langsam heran, Titel, Text und Buttons
|
||||||
* sich, die Cover-Lines folgen. Reines CSS, kein JavaScript noetig; bei
|
* blenden nacheinander ein. Reines CSS; bei reduzierter Bewegung greift
|
||||||
* reduzierter Bewegung greift die Regel in @layer base.
|
* die Regel in @layer base, das Headervideo wird dann ausgeblendet.
|
||||||
*/
|
*/
|
||||||
@keyframes hero-rise {
|
@keyframes hero-zoom {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
transform: scale(1.12);
|
||||||
transform: translateY(0.18em);
|
|
||||||
clip-path: inset(0 0 100% 0);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
transform: scale(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 {
|
@keyframes hero-fade {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(14px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hero-rise {
|
@keyframes hero-bounce {
|
||||||
animation: hero-rise 1.1s var(--ease-editorial) both;
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.hero-image {
|
.hero-zoom {
|
||||||
overflow: hidden;
|
animation: hero-zoom 9s cubic-bezier(0.2, 0.6, 0.3, 1) both;
|
||||||
}
|
|
||||||
.hero-image > * {
|
|
||||||
animation: hero-settle 1.5s var(--ease-editorial) 0.15s both;
|
|
||||||
}
|
}
|
||||||
.hero-fade {
|
.hero-fade {
|
||||||
animation: hero-fade 0.9s var(--ease-editorial) 0.5s both;
|
animation: hero-fade 0.9s var(--ease-editorial) 0.2s both;
|
||||||
|
}
|
||||||
|
.hero-fade-late {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
.hero-fade-later {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
.hero-bounce {
|
||||||
|
animation: hero-bounce 2.4s ease-in-out 1.5s infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-video {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Buchungsleiste: Inhalt am Seitenende nie verdecken. */
|
/* Mobile Buchungsleiste: Inhalt am Seitenende nie verdecken. */
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Bodoni_Moda, Hanken_Grotesk } from "next/font/google";
|
import { Figtree } from "next/font/google";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
// next/font laedt die Schriften zur Build-Zeit herunter und liefert sie
|
// next/font laedt die Schriften zur Build-Zeit herunter und liefert sie
|
||||||
// selbst aus: Besucherinnen stellen keine Verbindung zu Google her.
|
// selbst aus: Besucherinnen stellen keine Verbindung zu Google her.
|
||||||
const bodoni = Bodoni_Moda({
|
// Eine Schriftfamilie fuer alles, wie auf der Filmcamp-Referenz: runde,
|
||||||
|
// klare Grotesk, Ueberschriften im Halbfett.
|
||||||
|
const figtree = Figtree({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-bodoni",
|
variable: "--font-figtree",
|
||||||
display: "swap",
|
|
||||||
axes: ["opsz"],
|
|
||||||
style: ["normal", "italic"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const hanken = Hanken_Grotesk({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-hanken",
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const TITLE = "ARTISTA MODELCAMP 2027";
|
const TITLE = "ARTISTA MODELCAMP 2027";
|
||||||
const DESCRIPTION =
|
const DESCRIPTION =
|
||||||
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Dozentinnen, Preis und Buchung.";
|
"Das ARTISTA Modelcamp 2027 in der Toskana: eine Woche lernen, arbeiten und wohnen in der Villa La Chiesina. Alle Infos zu Ablauf, Unterkunft, Team und Buchung.";
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
const { APP_URL, APP_ENV } = env();
|
const { APP_URL, APP_ENV } = env();
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(APP_URL),
|
metadataBase: new URL(APP_URL),
|
||||||
title: { default: TITLE, template: `%s – ${TITLE}` },
|
title: { default: TITLE, template: `%s | ${TITLE}` },
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
alternates: { canonical: "/" },
|
alternates: { canonical: "/" },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -52,7 +47,7 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="de" className={`${bodoni.variable} ${hanken.variable}`}>
|
<html lang="de" className={figtree.variable}>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const TYPES: Record<string, string> = {
|
|||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".avif": "image/avif",
|
".avif": "image/avif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
".webm": "video/webm",
|
".webm": "video/webm",
|
||||||
".vtt": "text/vtt",
|
".vtt": "text/vtt",
|
||||||
@@ -46,8 +47,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ path
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": type,
|
"Content-Type": type,
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Cache-Control": "public, max-age=86400",
|
// Mit ?v=<Aenderungsdatum> (site-media.ts) aendert sich die Adresse bei
|
||||||
|
// jedem Austausch: dann ein Jahr cachen, sonst einen Tag.
|
||||||
|
"Cache-Control": new URL(request.url).searchParams.has("v") ? "public, max-age=31536000, immutable" : "public, max-age=86400",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
// SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen.
|
||||||
|
...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
|
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
|
||||||
|
After Width: | Height: | Size: 130 KiB |
@@ -1,37 +1,59 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { BookingProvider } from "@/components/booking/BookingProvider";
|
import { BookingProvider } from "@/components/booking/BookingProvider";
|
||||||
import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
|
import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
|
||||||
import { Hero, priceLine } from "@/components/Hero";
|
import { DateBar, Hero, priceLine } from "@/components/Hero";
|
||||||
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
|
import { AccommodationSection } from "@/components/sections/CampSections";
|
||||||
|
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
||||||
|
import { ContactSection } from "@/components/sections/ContactSection";
|
||||||
|
import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { EventJsonLd } from "@/components/EventJsonLd";
|
import { EventJsonLd } from "@/components/EventJsonLd";
|
||||||
|
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||||
import { loadPublicPage } from "@/lib/public-page";
|
import { loadPublicPage } from "@/lib/public-page";
|
||||||
|
import { loadSiteMedia } from "@/lib/site-media";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gliederung nach der Filmcamp-Referenz: Headerbild, Video, Termin, was dich
|
||||||
|
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
|
||||||
|
* Fragen, Abschluss.
|
||||||
|
*/
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
// Preisphase, Verkaufsfreigabe und Medien haengen von Uhrzeit, Datenbank
|
||||||
|
// und Medienverzeichnis ab.
|
||||||
await connection();
|
await connection();
|
||||||
const { facts, pricing, showDrafts, externalVideoProvider } = await loadPublicPage();
|
// Rezensionen parallel laden; ein Fehler dort liefert null und blendet
|
||||||
|
// nur den Abschnitt aus, nie die Seite.
|
||||||
|
const [{ facts, pricing, showDrafts }, media, reviews] = await Promise.all([
|
||||||
|
loadPublicPage(),
|
||||||
|
loadSiteMedia(),
|
||||||
|
fetchGoogleReviews().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BookingProvider pricing={pricing} facts={facts}>
|
<BookingProvider pricing={pricing} facts={facts}>
|
||||||
<SiteHeader />
|
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
|
||||||
<main id="inhalt">
|
<main id="inhalt">
|
||||||
<Hero facts={facts} pricing={pricing} showDrafts={showDrafts} />
|
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
|
||||||
<CampSection showDrafts={showDrafts} />
|
<IntroVideoSection video={media.intro} showDrafts={showDrafts} />
|
||||||
<ServicesSection showDrafts={showDrafts} />
|
<DateBar facts={facts} />
|
||||||
|
<ExpectSection />
|
||||||
|
<ScheduleSection facts={facts} />
|
||||||
<AccommodationSection facts={facts} 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} />
|
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
||||||
|
<TeamSection showDrafts={showDrafts} portraits={media.portraits} />
|
||||||
|
<ReelsSection showDrafts={showDrafts} reels={media.reels} />
|
||||||
|
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||||
|
<AboutSection showDrafts={showDrafts} />
|
||||||
<FaqSection facts={facts} pricing={pricing} />
|
<FaqSection facts={facts} pricing={pricing} />
|
||||||
|
<ContactSection />
|
||||||
<ClosingSection pricing={pricing} />
|
<ClosingSection pricing={pricing} />
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter facts={facts} showDrafts={showDrafts} />
|
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
|
||||||
<MobileBookingBar priceLabel={priceLine(pricing)} />
|
<MobileBookingBar priceLabel={priceLine(pricing)} />
|
||||||
<EventJsonLd facts={facts} pricing={pricing} />
|
<EventJsonLd facts={facts} pricing={pricing} />
|
||||||
</BookingProvider>
|
</BookingProvider>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
|
||||||
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
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: "Widerrufsrecht", alternates: { canonical: "/widerruf" } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Widerrufsbelehrung wird im Adminbereich gepflegt und freigegeben.
|
||||||
|
* Hier wird kein Rechtstext formuliert: ohne Freigabe erscheint nur der
|
||||||
|
* Hinweis, dass der Text vor dem Verkaufsstart veroeffentlicht wird.
|
||||||
|
*/
|
||||||
|
export default async function WithdrawalPage() {
|
||||||
|
await connection();
|
||||||
|
const facts = publicCampFacts(await getCamp(getDb()));
|
||||||
|
return (
|
||||||
|
<LegalPage title="Widerrufsrecht">
|
||||||
|
{facts.withdrawalText ? (
|
||||||
|
facts.withdrawalText
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((paragraph) => paragraph.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((paragraph) => (
|
||||||
|
<p key={paragraph} className="whitespace-pre-line">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<PendingLegalNotice what="Die Belehrung zu deinem Widerrufsrecht" />
|
||||||
|
)}
|
||||||
|
</LegalPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Langer Text, der zunaechst auf wenige Zeilen begrenzt ist. Der volle Text
|
||||||
|
* steht immer im DOM (Screenreader lesen ihn ganz); gekuerzt wird nur
|
||||||
|
* optisch. Der Schalter meldet seinen Zustand ueber aria-expanded.
|
||||||
|
*/
|
||||||
|
export function ExpandableText({
|
||||||
|
text,
|
||||||
|
clamp,
|
||||||
|
className = "",
|
||||||
|
clampClass = "line-clamp-6",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
clamp: boolean;
|
||||||
|
className?: string;
|
||||||
|
clampClass?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p id={id} className={`whitespace-pre-line ${className} ${clamp && !open ? clampClass : ""}`}>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{clamp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="link-quiet mt-3 text-[0.9375rem] font-semibold"
|
||||||
|
>
|
||||||
|
{open ? "Weniger anzeigen" : "Weiterlesen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,17 +31,19 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
|
|||||||
};
|
};
|
||||||
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
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.
|
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein
|
||||||
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
|
// Panorama, danach Dreierreihen. Auf dem Smartphone zwei Spalten, damit
|
||||||
|
// die Galerie nicht endlos lang wird; Bild 1 und das Panorama volle Breite.
|
||||||
|
const layout = ["col-span-2 md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "col-span-2 md:col-span-12"];
|
||||||
const aspect = (index: number) =>
|
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]";
|
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-square md:aspect-[3/2]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
|
<ul className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-12 md:gap-4">
|
||||||
{images.map((media, index) => (
|
{images.map((media, index) => (
|
||||||
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
||||||
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
<Reveal delay={Math.min(index, 4) * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
||||||
<button
|
<button
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
triggers.current[index] = el;
|
triggers.current[index] = el;
|
||||||
@@ -54,7 +56,7 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
|
|||||||
<MediaFrame
|
<MediaFrame
|
||||||
media={media}
|
media={media}
|
||||||
aspect={aspect(index)}
|
aspect={aspect(index)}
|
||||||
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
|
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 50vw"}
|
||||||
showLabel={showLabels}
|
showLabel={showLabels}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { site } from "@/content/site";
|
import Image from "next/image";
|
||||||
|
import { site, type Media } from "@/content/site";
|
||||||
import type { PublicCampFacts } from "@/lib/camp";
|
import type { PublicCampFacts } from "@/lib/camp";
|
||||||
import { formatEuroShort } from "@/lib/money";
|
import { formatEuroShort } from "@/lib/money";
|
||||||
import type { PublicPricing } from "@/lib/public-page";
|
import type { PublicPricing } from "@/lib/public-page";
|
||||||
@@ -6,83 +7,91 @@ import { formatIsoDateLong } from "@/lib/time";
|
|||||||
import { MediaFrame } from "./MediaFrame";
|
import { MediaFrame } from "./MediaFrame";
|
||||||
import { BookButton } from "./booking/BookingProvider";
|
import { BookButton } from "./booking/BookingProvider";
|
||||||
|
|
||||||
export function dateLine(facts: PublicCampFacts): string {
|
export function dateLine(facts: PublicCampFacts): string | null {
|
||||||
if (!facts.startDate) return "Termin wird bekanntgegeben";
|
if (!facts.startDate) return null;
|
||||||
if (!facts.endDate) return `ab ${formatIsoDateLong(facts.startDate)}`;
|
if (!facts.endDate) return `ab ${formatIsoDateLong(facts.startDate)}`;
|
||||||
|
// Gleicher Monat: "8. bis 15. Mai 2027" statt zweimal Monat und Jahr.
|
||||||
|
if (facts.startDate.slice(0, 7) === facts.endDate.slice(0, 7)) {
|
||||||
|
return `${Number(facts.startDate.slice(8, 10))}. bis ${formatIsoDateLong(facts.endDate)}`;
|
||||||
|
}
|
||||||
return `${formatIsoDateLong(facts.startDate)} bis ${formatIsoDateLong(facts.endDate)}`;
|
return `${formatIsoDateLong(facts.startDate)} bis ${formatIsoDateLong(facts.endDate)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kurzform fuer die mobile Buchungsleiste. Ohne Startdatum des Verkaufs. */
|
||||||
export function priceLine(pricing: PublicPricing): string {
|
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`;
|
if (pricing.phase === "REGULAR" && pricing.currentCents !== null) return `${formatEuroShort(pricing.currentCents)} pro Person`;
|
||||||
return `Early Bird ab 1. November 2026: ${formatEuroShort(pricing.earlyBirdCents)}`;
|
return `Early Bird ${formatEuroShort(pricing.earlyBirdCents)} statt ${formatEuroShort(pricing.regularCents)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backdrop({ media, video }: { media: Media; video: string | null }) {
|
||||||
|
if (media.kind !== "image") {
|
||||||
|
return <MediaFrame media={media} aspect="h-full w-full" sizes="100vw" showLabel={false} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Image src={media.src} alt={media.alt} fill priority sizes="100vw" className="hero-zoom object-cover" />
|
||||||
|
{video && (
|
||||||
|
// Stumm und ohne Ton-Autoplay; bei reduzierter Bewegung blendet
|
||||||
|
// globals.css das Video aus und das Standbild bleibt.
|
||||||
|
<video src={video} poster={media.src} autoPlay muted loop playsInline preload="metadata" aria-hidden className="hero-video absolute inset-0 h-full w-full object-cover" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
* Headerbild ueber die volle Breite und Hoehe, Titel und Buchung mittig,
|
||||||
* Termin, Ort und Preis stehen als Cover-Lines daneben.
|
* wie bei der Filmcamp-Referenz. Bewusst ohne Preis: der steht im
|
||||||
|
* Preisabschnitt.
|
||||||
*/
|
*/
|
||||||
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
export function Hero({ pricing, media, video }: { pricing: PublicPricing; media: Media; video: string | null }) {
|
||||||
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
|
const { hero } = site;
|
||||||
return (
|
return (
|
||||||
<section id="top" aria-labelledby="hero-title" className="relative pb-16 pt-4 md:pb-24">
|
<section id="top" aria-labelledby="hero-title" className="on-dark relative isolate flex min-h-[100svh] items-center overflow-hidden bg-ink text-paper">
|
||||||
<div className="container-edge">
|
<div className="absolute inset-0 -z-10">
|
||||||
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
|
<Backdrop media={media} video={video} />
|
||||||
<span>Modelcamp 2027</span>
|
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgb(35_33_31/0.6)_0%,rgb(35_33_31/0.45)_45%,rgb(35_33_31/0.75)_100%)]" />
|
||||||
<span>{place}</span>
|
</div>
|
||||||
</p>
|
|
||||||
<h1 id="hero-title" className="relative z-10 mt-3">
|
<div className="container-edge flex flex-col items-center pb-20 pt-44 text-center md:pt-60">
|
||||||
<span className="masthead hero-rise block" aria-hidden>
|
<h1 id="hero-title" className="hero-fade max-w-4xl text-[clamp(2.75rem,8vw,6.25rem)] font-extrabold leading-[0.98] tracking-[-0.03em]">
|
||||||
ARTISTA
|
<span className="sr-only">ARTISTA </span>
|
||||||
</span>
|
{hero.title}
|
||||||
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="hero-fade hero-fade-late mt-7 max-w-2xl text-lg text-paper/90 md:text-xl">{hero.subline}</p>
|
||||||
<div className="mt-[-3.2vw] grid gap-8 md:grid-cols-12 md:gap-10">
|
<div className="hero-fade hero-fade-later mt-10 flex flex-wrap justify-center gap-3">
|
||||||
<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 ? (
|
{pricing.checkoutOpen ? (
|
||||||
<BookButton className="btn btn-primary">Platz buchen</BookButton>
|
<BookButton className="btn btn-primary px-8 text-lg">{hero.cta}</BookButton>
|
||||||
) : (
|
) : (
|
||||||
<a href="#preis" className="btn btn-primary">
|
<a href="#preis" className="btn btn-primary px-8 text-lg">
|
||||||
Preis und Buchungsstart
|
{hero.cta}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a href="#camp" className="btn btn-quiet">
|
<a href="#erwartet" className="btn btn-quiet px-8 text-lg">
|
||||||
Mehr über das Camp
|
Was dich erwartet
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<a href="#termin" aria-label="Weiter zum Termin" className="hero-fade hero-fade-later absolute bottom-6 left-1/2 -translate-x-1/2 p-2 text-paper/80 hover:text-paper">
|
||||||
|
<svg viewBox="0 0 24 24" className="hero-bounce h-7 w-7" aria-hidden>
|
||||||
|
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dunkle Leiste unter dem Header mit dem Termin, wie bei der Referenz. */
|
||||||
|
export function DateBar({ facts }: { facts: PublicCampFacts }) {
|
||||||
|
const dates = dateLine(facts);
|
||||||
|
if (!dates) return null;
|
||||||
|
return (
|
||||||
|
<section id="termin" aria-label="Termin" className="on-dark bg-ink py-10 text-center text-paper md:py-14">
|
||||||
|
<p className="container-edge text-xl font-semibold md:text-2xl">
|
||||||
|
Verbringe eine unvergessliche Woche mit uns vom <span className="text-sand">{dates}</span>
|
||||||
|
{facts.location && <span className="mt-2 block text-base font-normal text-paper/75 md:text-lg">{facts.location}{facts.region ? `, ${facts.region}` : ""}</span>}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umfaerbung, wenn keine passende Fassung hochgeladen wurde (siehe
|
||||||
|
* resolveLogos in site-media.ts): ein helles Logo wird auf heller Flaeche
|
||||||
|
* dunkel, ein dunkles auf Foto weiss.
|
||||||
|
*/
|
||||||
|
const TONE_FILTER: Record<LogoImage["tone"], string> = {
|
||||||
|
"as-is": "",
|
||||||
|
darken: "brightness(0.28) contrast(1.2)",
|
||||||
|
lighten: "brightness(0) invert(1)",
|
||||||
|
};
|
||||||
|
/** Ueber dem Foto hebt ein weicher Schatten das Logo vom Bild ab. */
|
||||||
|
const PHOTO_SHADOW = "drop-shadow(0 2px 14px rgb(0 0 0 / 0.55))";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARTISTA-Logo aus dem Medienverzeichnis (logo.* bzw. logo-hell.*). Ohne
|
||||||
|
* Datei erscheint ein Schriftzug. Bewusst <img> statt next/image: die URL
|
||||||
|
* ist bereits optimiert (Raster) bzw. ein SVG, und die Hoehe kommt vom
|
||||||
|
* Elternelement.
|
||||||
|
*/
|
||||||
|
export function Logo({ logo, onPhoto = false, className = "" }: { logo: LogoImage | null; onPhoto?: boolean; className?: string }) {
|
||||||
|
if (logo) {
|
||||||
|
const filter = [TONE_FILTER[logo.tone], onPhoto ? PHOTO_SHADOW : ""].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={logo.src} alt="ARTISTA Modelcamp 2027" style={filter ? { filter } : undefined} className={`h-full w-auto ${className}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className={`font-bold uppercase tracking-[0.32em] ${className}`}>Artista</span>;
|
||||||
|
}
|
||||||
@@ -1,49 +1,28 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Media, PlaceholderTone } from "@/content/site";
|
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" },
|
* Platzhalter als typografische Tafeln: ruhiger Farbton aus der Palette,
|
||||||
terrace: { background: "linear-gradient(185deg, #e9dcc6 0%, #d7c29d 60%, #9f8b62 100%)", shape: "cypress" },
|
* ein angeschnittener Bodoni-Buchstabe aus "ARTISTA" (bzw. die Jahreszahl
|
||||||
room: { background: "linear-gradient(160deg, #f3ebdf 0%, #e3d3bb 60%, #cdb592 100%)", shape: "window" },
|
* im Cover) und ein feines Passepartout. Sie wirken gestaltet, sind aber
|
||||||
garden: { background: "linear-gradient(190deg, #e6e0cc 0%, #c9c3a0 55%, #8c8a64 100%)", shape: "cypress" },
|
* eindeutig kein Foto und damit als Platzhalter erkennbar.
|
||||||
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" },
|
type Plate = { background: string; glyph: string; italic?: boolean; style: React.CSSProperties };
|
||||||
|
|
||||||
|
// Groessen und Lagen in Container-Einheiten (cqh/cqw): der Anschnitt sieht
|
||||||
|
// in jeder Tafelgroesse gleich aus.
|
||||||
|
const TONES: Record<PlaceholderTone, Plate> = {
|
||||||
|
cover: { background: "#ddd0bd", glyph: "27", italic: true, style: { fontSize: "92cqh", right: "-3cqw", bottom: "-20cqh" } },
|
||||||
|
villa: { background: "#e2d6c4", glyph: "A", style: { fontSize: "105cqh", left: "5cqw", bottom: "-22cqh" } },
|
||||||
|
terrace: { background: "#d6ccb8", glyph: "R", italic: true, style: { fontSize: "118cqh", right: "-4cqw", bottom: "-30cqh" } },
|
||||||
|
room: { background: "#e8dfd2", glyph: "T", style: { fontSize: "112cqh", left: "22cqw", top: "-34cqh" } },
|
||||||
|
garden: { background: "#cfcab6", glyph: "S", italic: true, style: { fontSize: "150cqh", right: "7cqw", bottom: "-58cqh" } },
|
||||||
|
portrait: { background: "#dccfbe", glyph: "R", style: { fontSize: "92cqh", left: "-10cqw", bottom: "-14cqh" } },
|
||||||
|
studio: { background: "#d2c9bd", glyph: "A", italic: true, style: { fontSize: "96cqh", right: "-16cqw", bottom: "-16cqh" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
function Shape({ shape }: { shape: (typeof TONES)[PlaceholderTone]["shape"] }) {
|
// Initialen auf Portraet-Platzhaltern: gross, angeschnitten am unteren Rand.
|
||||||
const common = { fill: "currentColor", className: "absolute inset-0 h-full w-full text-ink/10" } as const;
|
const INITIALS_STYLE: React.CSSProperties = { fontSize: "34cqh", left: "8cqw", bottom: "-4cqh", letterSpacing: "-0.03em" };
|
||||||
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 = {
|
type Props = {
|
||||||
media: Media;
|
media: Media;
|
||||||
@@ -53,17 +32,22 @@ type Props = {
|
|||||||
showLabel: boolean;
|
showLabel: boolean;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** "arch": oben rund wie ein Rundbogenfenster (Team-Portraets). */
|
||||||
|
shape?: "rect" | "arch";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ARCH = "rounded-t-[50vw]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
||||||
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
||||||
* uebersehen werden.
|
* uebersehen werden.
|
||||||
*/
|
*/
|
||||||
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
|
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "", shape = "rect" }: Props) {
|
||||||
|
const shapeClass = shape === "arch" ? ARCH : "";
|
||||||
if (media.kind === "image") {
|
if (media.kind === "image") {
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
|
<div className={`relative overflow-hidden bg-sand ${aspect} ${shapeClass} ${className}`}>
|
||||||
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -73,12 +57,19 @@ export function MediaFrame({ media, aspect, sizes, showLabel, priority, classNam
|
|||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Platzhalter: ${media.label}`}
|
aria-label={`Platzhalter: ${media.label}`}
|
||||||
className={`relative overflow-hidden ${aspect} ${className}`}
|
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${shapeClass} ${className}`}
|
||||||
style={{ background: tone.background }}
|
style={{ backgroundColor: tone.background, containerType: "size" }}
|
||||||
>
|
>
|
||||||
<Shape shape={tone.shape} />
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`display pointer-events-none absolute select-none leading-none ${media.glyph ? "text-ink/[0.13]" : "text-ink/[0.07]"} ${tone.italic && !media.glyph ? "italic" : ""}`}
|
||||||
|
style={media.glyph ? INITIALS_STYLE : tone.style}
|
||||||
|
>
|
||||||
|
{media.glyph ?? tone.glyph}
|
||||||
|
</span>
|
||||||
|
{shape === "rect" && <span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="absolute bottom-3 left-3 rounded-full bg-paper/85 px-3 py-1 text-xs font-semibold text-ink">
|
<span className="absolute bottom-6 left-6 bg-paper/90 px-2.5 py-1 text-xs font-semibold text-ink md:bottom-7 md:left-7">
|
||||||
Platzhalter: {media.label}
|
Platzhalter: {media.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
|||||||
* Animation warten muss.
|
* Animation warten muss.
|
||||||
*/
|
*/
|
||||||
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
||||||
|
// Immer dasselbe Element rendern: der Server kennt die Bewegungs-
|
||||||
|
// einstellung nicht, ein anderer Elementbaum fuehrt zu Hydration-Fehlern.
|
||||||
|
// Bei reduzierter Bewegung wird ohne Dauer sofort aufgedeckt.
|
||||||
const reduce = useReducedMotion();
|
const reduce = useReducedMotion();
|
||||||
if (reduce) return <div className={className}>{children}</div>;
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<m.div
|
<m.div
|
||||||
@@ -17,7 +19,7 @@ export function Reveal({ children, className = "", delay = 0 }: { children: Reac
|
|||||||
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
||||||
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
||||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
transition={reduce ? { duration: 0 } : { duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ScheduleItem } from "@/content/site";
|
||||||
|
|
||||||
|
export type ScheduleCard = { label: string; date: string | null; title: string; items: ScheduleItem[] };
|
||||||
|
|
||||||
|
/** Toleranz in Pixeln fuer "ganz am Anfang/Ende" (Rundung beim Einrasten). */
|
||||||
|
const EDGE_TOLERANCE = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tagesablauf als waagerechte Kartenreihe mit Einrasten (scroll-snap).
|
||||||
|
* Wischen auf dem Smartphone, Pfeile und Punkte auf allen Groessen. Die
|
||||||
|
* Liste bleibt eine echte Liste: ohne JavaScript scrollt sie trotzdem.
|
||||||
|
*
|
||||||
|
* Positionen werden aus getBoundingClientRect berechnet, nicht aus
|
||||||
|
* offsetLeft: offsetLeft bezieht sich auf das naechste positionierte
|
||||||
|
* Element (hier <body>) und verfehlte den Einrastpunkt, sodass "Weiter"
|
||||||
|
* zwei Tage sprang und "Zurueck" am Ende haengen blieb.
|
||||||
|
*/
|
||||||
|
export function ScheduleCarousel({ days }: { days: ScheduleCard[] }) {
|
||||||
|
const track = useRef<HTMLOListElement>(null);
|
||||||
|
const cards = useRef<(HTMLLIElement | null)[]>([]);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [atStart, setAtStart] = useState(true);
|
||||||
|
const [atEnd, setAtEnd] = useState(days.length <= 1);
|
||||||
|
|
||||||
|
const snapInset = (root: HTMLElement) => parseFloat(getComputedStyle(root).scrollPaddingLeft) || 0;
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const root = track.current;
|
||||||
|
if (!root) return;
|
||||||
|
const anchor = root.getBoundingClientRect().left + snapInset(root);
|
||||||
|
let nearest = 0;
|
||||||
|
let best = Infinity;
|
||||||
|
cards.current.forEach((card, index) => {
|
||||||
|
if (!card) return;
|
||||||
|
const distance = Math.abs(card.getBoundingClientRect().left - anchor);
|
||||||
|
if (distance < best) {
|
||||||
|
best = distance;
|
||||||
|
nearest = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCurrent(nearest);
|
||||||
|
setAtStart(root.scrollLeft <= EDGE_TOLERANCE);
|
||||||
|
setAtEnd(root.scrollLeft >= root.scrollWidth - root.clientWidth - EDGE_TOLERANCE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = track.current;
|
||||||
|
if (!root) return;
|
||||||
|
let frame = 0;
|
||||||
|
const onScroll = () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
root.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("resize", onScroll);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
root.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
};
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
|
const go = (index: number) => {
|
||||||
|
const root = track.current;
|
||||||
|
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
|
||||||
|
if (!root || !target) return;
|
||||||
|
const left = root.scrollLeft + target.getBoundingClientRect().left - root.getBoundingClientRect().left - snapInset(root);
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
root.scrollTo({ left, behavior: reduce ? "auto" : "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<button
|
||||||
|
key={day.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(index)}
|
||||||
|
aria-label={`${day.label}${day.date ? `, ${day.date}` : ""} zeigen`}
|
||||||
|
aria-current={index === current ? "true" : undefined}
|
||||||
|
className="grid h-8 w-6 place-items-center"
|
||||||
|
>
|
||||||
|
<span className={`block h-1.5 rounded-full transition-all duration-300 ${index === current ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => go(current - 1)} disabled={atStart} aria-label="Vorheriger Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
||||||
|
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => go(current + 1)} disabled={atEnd} aria-label="Nächster Tag" className="grid h-11 w-11 place-items-center rounded-full border border-ink/25 hover:border-ink disabled:opacity-35">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden>
|
||||||
|
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol
|
||||||
|
ref={track}
|
||||||
|
aria-label="Tage im Camp"
|
||||||
|
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
|
||||||
|
tabIndex={0}
|
||||||
|
// Smartphone: Karte 85vw breit und mittig (7,5vw Rand links und
|
||||||
|
// rechts), Nachbarkarten lugen an beiden Seiten hervor.
|
||||||
|
// Ab md: Karten passen vollstaendig in den Inhaltsbereich (2 bzw. 3
|
||||||
|
// nebeneinander), nichts wird am Rand abgeschnitten.
|
||||||
|
className="-mx-4 flex snap-x snap-mandatory scroll-px-[7.5vw] gap-4 overflow-x-auto px-[7.5vw] pb-4 [scrollbar-width:none] sm:scroll-px-4 sm:px-4 md:mx-0 md:scroll-px-0 md:gap-6 md:px-0 [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<li
|
||||||
|
key={day.label}
|
||||||
|
ref={(el) => {
|
||||||
|
cards.current[index] = el;
|
||||||
|
}}
|
||||||
|
className="flex w-[85vw] shrink-0 snap-start flex-col rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] sm:w-[62%] md:w-[calc((100%-1.5rem)/2)] md:p-8 lg:w-[calc((100%-3rem)/3)]"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">
|
||||||
|
{day.label} · {day.title}
|
||||||
|
</p>
|
||||||
|
{day.date && <p className="mt-1 text-xl font-bold md:text-2xl">{day.date}</p>}
|
||||||
|
<ul className="mt-5 divide-y divide-line border-t border-line">
|
||||||
|
{day.items.map((item) => (
|
||||||
|
<li key={`${item.time} ${item.text}`} className="grid grid-cols-[5.75rem_1fr] gap-x-3 py-3 text-[0.9375rem] leading-snug">
|
||||||
|
<span className="font-semibold tabular-nums text-ink/75">{item.time}</span>
|
||||||
|
<span>
|
||||||
|
{item.text}
|
||||||
|
{item.photo && (
|
||||||
|
<span className="ml-2 inline-block translate-y-[-1px] rounded bg-sand/60 px-1.5 py-px align-middle text-[0.6875rem] font-bold uppercase tracking-[0.08em]">
|
||||||
|
Foto
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.coach && <span className="mt-0.5 block text-sm text-stone">mit {item.coach}</span>}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { PublicCampFacts } from "@/lib/camp";
|
import type { PublicCampFacts } from "@/lib/camp";
|
||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: LogoImage | null }) {
|
||||||
const organizer = facts.organizer;
|
const organizer = facts.organizer;
|
||||||
return (
|
return (
|
||||||
<footer id="site-footer" className="border-t border-line bg-plaster">
|
<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="container-edge grid gap-10 py-14 md:grid-cols-12">
|
||||||
<div className="md:col-span-5">
|
<div className="md:col-span-5">
|
||||||
<p className="display text-4xl">ARTISTA</p>
|
<p className="flex h-32 items-center text-2xl">
|
||||||
|
<Logo logo={logo} />
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-4">
|
<div className="md:col-span-4">
|
||||||
@@ -39,6 +43,7 @@ export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; show
|
|||||||
<li><a href="/impressum" className="hover:underline">Impressum</a></li>
|
<li><a href="/impressum" className="hover:underline">Impressum</a></li>
|
||||||
<li><a href="/datenschutz" className="hover:underline">Datenschutz</a></li>
|
<li><a href="/datenschutz" className="hover:underline">Datenschutz</a></li>
|
||||||
<li><a href={facts.termsUrl ?? "/buchungsbedingungen"} className="hover:underline">Buchungsbedingungen</a></li>
|
<li><a href={facts.termsUrl ?? "/buchungsbedingungen"} className="hover:underline">Buchungsbedingungen</a></li>
|
||||||
|
<li><a href="/widerruf" className="hover:underline">Widerrufsrecht</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,20 @@
|
|||||||
import { LazyMotion, domAnimation, m } from "motion/react";
|
import { LazyMotion, domAnimation, m } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { NAV_ITEMS } from "@/content/site";
|
import { NAV_ITEMS } from "@/content/site";
|
||||||
|
import type { LogoImage } from "@/lib/site-media";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
export function SiteHeader() {
|
const LEFT = NAV_ITEMS.slice(0, 3);
|
||||||
|
const RIGHT = NAV_ITEMS.slice(3);
|
||||||
|
|
||||||
|
type NavItem = (typeof NAV_ITEMS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopfzeile wie bei der Filmcamp-Referenz: Logo mittig, Abschnitte links und
|
||||||
|
* rechts. Ueber dem Headerbild transparent mit heller Schrift, nach dem
|
||||||
|
* Scrollen auf hellem Grund.
|
||||||
|
*/
|
||||||
|
export function SiteHeader({ logoLight, logoDark }: { logoLight: LogoImage | null; logoDark: LogoImage | null }) {
|
||||||
const [active, setActive] = useState<string | null>(null);
|
const [active, setActive] = useState<string | null>(null);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -44,55 +56,73 @@ export function SiteHeader() {
|
|||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
return (
|
const solid = scrolled || menuOpen;
|
||||||
<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"}`}
|
const navList = (items: readonly NavItem[], align: string) => (
|
||||||
>
|
<ul className={`flex items-center gap-7 text-[0.9375rem] font-medium ${align}`}>
|
||||||
<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">
|
{items.map((item) => (
|
||||||
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">
|
<li key={item.id} className="relative">
|
||||||
<a
|
<a href={`#${item.id}`} aria-current={active === item.id ? "location" : undefined} className="block py-2 opacity-85 transition-opacity hover:opacity-100 aria-[current]:opacity-100">
|
||||||
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}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
{active === item.id && (
|
{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 }} />
|
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-0.5 rounded-full bg-current" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
);
|
||||||
</LazyMotion>
|
|
||||||
<div className="flex items-center gap-3">
|
return (
|
||||||
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
|
<header
|
||||||
Platz buchen
|
className={`fixed inset-x-0 top-0 z-40 transition-[background-color,color,box-shadow] duration-300 ${
|
||||||
|
solid ? "bg-plaster/95 text-ink shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "on-dark bg-transparent text-paper"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 focus:text-ink">
|
||||||
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
|
<LazyMotion features={domAnimation}>
|
||||||
|
<div
|
||||||
|
className={`container-edge grid grid-cols-[1fr_auto_1fr] items-center gap-4 transition-[height] duration-300 ${solid ? "h-24" : "h-32 md:h-48"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
ref={menuButton}
|
ref={menuButton}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-11 items-center rounded-full border border-ink/30 px-4 text-sm font-semibold lg:hidden"
|
className="inline-flex h-11 items-center rounded-full border border-current/40 px-4 text-sm font-semibold xl:hidden"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
onClick={() => setMenuOpen((v) => !v)}
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
{menuOpen ? "Schließen" : "Menü"}
|
{menuOpen ? "Schließen" : "Menü"}
|
||||||
</button>
|
</button>
|
||||||
|
<nav aria-label="Abschnitte" className="hidden xl:block">
|
||||||
|
{navList(LEFT, "justify-start")}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo ueber dem Headerbild gross, nach dem Scrollen kompakter. */}
|
||||||
|
<a
|
||||||
|
href="#top"
|
||||||
|
aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang"
|
||||||
|
className={`flex items-center text-xl transition-[height] duration-300 md:text-2xl ${solid ? "h-20" : "h-28 md:h-44"}`}
|
||||||
|
>
|
||||||
|
<Logo logo={solid ? logoDark : logoLight} onPhoto={!solid} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-7">
|
||||||
|
<nav aria-label="Weitere Abschnitte" className="hidden xl:block">
|
||||||
|
{navList(RIGHT, "justify-end")}
|
||||||
|
</nav>
|
||||||
|
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
|
||||||
|
Platz sichern
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</LazyMotion>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster lg:hidden">
|
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster text-ink xl:hidden">
|
||||||
<ul className="container-edge py-4">
|
<ul className="container-edge py-4">
|
||||||
{NAV_ITEMS.map((item, index) => (
|
{NAV_ITEMS.map((item, index) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Media } from "@/content/site";
|
||||||
|
|
||||||
|
const INTERVAL_MS = 5500;
|
||||||
|
|
||||||
|
type ImageMedia = Extract<Media, { kind: "image" }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bilderfolge der Villa: weiche Ueberblendung mit langsamem Zoom.
|
||||||
|
* Laeuft von selbst, laesst sich aber jederzeit anhalten (WCAG 2.2.2) und
|
||||||
|
* pausiert bei Maus oder Tastaturfokus. Bei reduzierter Bewegung kein
|
||||||
|
* automatischer Wechsel und kein Zoom, nur die Punkte zum Umschalten.
|
||||||
|
*/
|
||||||
|
export function Slideshow({ images, className = "" }: { images: readonly Media[]; className?: string }) {
|
||||||
|
const slides = images.filter((m): m is ImageMedia => m.kind === "image");
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const running = !reduce && !paused && !hovered && slides.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
const timer = window.setInterval(() => setIndex((i) => (i + 1) % slides.length), INTERVAL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running, slides.length]);
|
||||||
|
|
||||||
|
if (slides.length === 0) return null;
|
||||||
|
const current = slides[index]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyMotion features={domAnimation}>
|
||||||
|
<figure
|
||||||
|
className={className}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onFocus={() => setHovered(true)}
|
||||||
|
onBlur={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-sand md:aspect-[5/6]">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<m.div
|
||||||
|
key={current.src}
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: reduce ? 0 : 1.2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={{ scale: reduce ? 1 : 1.08 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: reduce ? 0 : INTERVAL_MS / 1000 + 1.2, ease: "linear" }}
|
||||||
|
>
|
||||||
|
<Image src={current.src} alt={current.alt} fill sizes="(min-width: 768px) 50vw, 100vw" className="object-cover" />
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-4">
|
||||||
|
<figcaption aria-live={running ? "off" : "polite"} className="text-sm text-stone">
|
||||||
|
{current.alt}
|
||||||
|
</figcaption>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={slide.src}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
aria-label={`Bild ${i + 1} von ${slides.length} zeigen`}
|
||||||
|
aria-current={i === index ? "true" : undefined}
|
||||||
|
className="grid h-8 w-6 place-items-center"
|
||||||
|
>
|
||||||
|
<span className={`block h-1.5 rounded-full transition-all duration-300 ${i === index ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!reduce && slides.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaused((p) => !p)}
|
||||||
|
aria-label={paused ? "Bilderfolge abspielen" : "Bilderfolge anhalten"}
|
||||||
|
className="ml-2 grid h-9 w-9 place-items-center rounded-full border border-ink/25 hover:border-ink"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden>
|
||||||
|
{paused ? <path d="M8 5v14l11-7z" fill="currentColor" /> : <path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</LazyMotion>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { starsLabel } from "@/lib/review-format";
|
||||||
|
|
||||||
|
const STAR_PATH = "M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8z";
|
||||||
|
|
||||||
|
function Row({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`flex gap-[0.2em] ${className}`}>
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<svg key={i} viewBox="0 0 24 24" className="h-[1em] w-[1em] shrink-0" aria-hidden focusable="false">
|
||||||
|
<path d={STAR_PATH} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sternebewertung von 0 bis 5, auch mit Nachkommastelle (Durchschnitt).
|
||||||
|
* Die gefuellte Reihe wird auf den Anteil beschnitten; Screenreader lesen
|
||||||
|
* nur die Textalternative, z. B. "4 von 5 Sternen".
|
||||||
|
*/
|
||||||
|
export function Stars({ rating, className = "" }: { rating: number; className?: string }) {
|
||||||
|
const clamped = Math.max(0, Math.min(5, rating));
|
||||||
|
// Naeherung: die Abstaende zwischen den Sternen zaehlen zur Breite.
|
||||||
|
const percent = (clamped / 5) * 100;
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label={starsLabel(Math.round(clamped * 10) / 10)} className={`relative inline-flex ${className}`}>
|
||||||
|
<Row className="text-line" />
|
||||||
|
<span className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${percent}%` }}>
|
||||||
|
<Row className="text-rosso" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,88 +1,54 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { Media } from "@/content/site";
|
import type { Media } from "@/content/site";
|
||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
import { MediaFrame } from "./MediaFrame";
|
import { MediaFrame } from "./MediaFrame";
|
||||||
|
|
||||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null; externalId: string | null };
|
/** Reels sind hoch und hoechstens ein Drittel breit. */
|
||||||
|
const POSTER_WIDTH = 1080;
|
||||||
|
|
||||||
const EXTERNAL = {
|
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
||||||
"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
|
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
|
||||||
* startet nie von selbst mit Ton. Externe Player (YouTube/Vimeo) werden nur
|
* (quer oder hoch). Es startet nie von selbst. Mit Vorschaubild laedt vorab
|
||||||
* geladen, wenn das Datenschutzkonzept sie erlaubt (EXTERNAL_VIDEO_PROVIDER)
|
* nichts vom Video, ohne nur Metadaten und erstes Bild (#t=0.1). Kein
|
||||||
* und die Besucherin dem Laden ausdruecklich zustimmt.
|
* externer Anbieter.
|
||||||
*/
|
*/
|
||||||
export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; index: number; provider: "none" | "youtube-nocookie" | "vimeo"; showDrafts: boolean }) {
|
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
||||||
const [stage, setStage] = useState<"poster" | "consent" | "playing">("poster");
|
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
|
||||||
const label = slot.name ? `Video von ${slot.name} abspielen` : `Feedback-Video ${index + 1} abspielen`;
|
// Vorschaubild ueber die Bildoptimierung statt im Original (ein Reel-PNG
|
||||||
const external = slot.externalId && provider !== "none" ? EXTERNAL[provider] : null;
|
// hatte 1,3 MB); mit Vorschaubild laedt vom Video vorab nichts.
|
||||||
const playable = Boolean(slot.src) || Boolean(external);
|
const poster = slot.poster?.kind === "image" ? optimizedImageUrl(slot.poster.src, POSTER_WIDTH) : undefined;
|
||||||
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Feedback-Video ${index + 1} folgt`, tone: "studio" };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure>
|
<figure>
|
||||||
<div className="relative aspect-[4/5] overflow-hidden bg-ink">
|
{slot.src ? (
|
||||||
{stage === "playing" && slot.src && (
|
<video
|
||||||
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
|
src={poster ? slot.src : `${slot.src}#t=0.1`}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload={poster ? "none" : "metadata"}
|
||||||
|
aria-label={label}
|
||||||
|
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
|
||||||
|
>
|
||||||
<track kind="captions" />
|
<track kind="captions" />
|
||||||
</video>
|
</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>
|
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl">
|
||||||
|
<MediaFrame
|
||||||
|
media={slot.poster ?? { kind: "placeholder", label: `Video ${index + 1} folgt`, tone: "studio" }}
|
||||||
|
aspect="h-full w-full"
|
||||||
|
sizes="(min-width: 640px) 33vw, 100vw"
|
||||||
|
showLabel={showDrafts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{slot.caption && <span className="mt-1 block text-ink/80">{slot.caption}</span>}
|
{(slot.name || slot.caption || (showDrafts && !slot.src)) && (
|
||||||
|
<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 opacity-80">{slot.caption}</span>}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
)}
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,8 @@ export function BookingDialog({
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
// Keine Vorauswahl: Preise erscheinen erst unter der gewaehlten Zahlungsweise.
|
||||||
|
const [chosenPlan, setPlanType] = useState<PlanType | null>(null);
|
||||||
const [voucherInput, setVoucherInput] = useState("");
|
const [voucherInput, setVoucherInput] = useState("");
|
||||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||||
@@ -114,11 +115,11 @@ export function BookingDialog({
|
|||||||
};
|
};
|
||||||
}, [open, quote]);
|
}, [open, quote]);
|
||||||
|
|
||||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
// Eine nicht (mehr) verfuegbare Variante gilt als nicht gewaehlt.
|
||||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
const planType: PlanType | null = chosen?.available ? chosenPlan : null;
|
||||||
|
|
||||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
const selectedPlan = chosen?.available ? chosen : null;
|
||||||
|
|
||||||
function updateParticipant(field: keyof Participant, value: string) {
|
function updateParticipant(field: keyof Participant, value: string) {
|
||||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -174,6 +175,7 @@ export function BookingDialog({
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
if (!planType) return;
|
||||||
const missing: FieldErrors = {};
|
const missing: FieldErrors = {};
|
||||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
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.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||||
@@ -312,7 +314,7 @@ export function BookingDialog({
|
|||||||
<label className="field-label" htmlFor="bk-notes">Anmerkungen (optional)</label>
|
<label className="field-label" htmlFor="bk-notes">Anmerkungen (optional)</label>
|
||||||
<textarea {...fieldProps("notes")} rows={3} maxLength={1000} aria-describedby="bk-notes-hint" />
|
<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">
|
<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.
|
Bitte hier keine Gesundheitsdaten eintragen. Dazu sprechen wir bei Bedarf persönlich.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,10 +326,11 @@ export function BookingDialog({
|
|||||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
{quote?.plans.map((plan) => (
|
{quote?.plans.map((plan) => (
|
||||||
<label
|
<div
|
||||||
key={plan.type}
|
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"}`}
|
className={`rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "opacity-60"}`}
|
||||||
>
|
>
|
||||||
|
<label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="planType"
|
name="planType"
|
||||||
@@ -341,15 +344,11 @@ export function BookingDialog({
|
|||||||
/>
|
/>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className="block font-semibold">{plan.label}</span>
|
<span className="block font-semibold">{plan.label}</span>
|
||||||
{plan.available ? (
|
{!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
|
||||||
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
|
||||||
|
</div>
|
||||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -384,8 +383,6 @@ export function BookingDialog({
|
|||||||
{voucherMessage}
|
{voucherMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -462,7 +459,7 @@ export function BookingDialog({
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
gelesen.
|
gelesen.
|
||||||
</Consent>
|
</Consent>
|
||||||
{planType !== "FULL" && (
|
{planType !== null && planType !== "FULL" && (
|
||||||
<Consent
|
<Consent
|
||||||
id="installmentMandate"
|
id="installmentMandate"
|
||||||
checked={consents.installmentMandate}
|
checked={consents.installmentMandate}
|
||||||
@@ -494,7 +491,7 @@ export function BookingDialog({
|
|||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
{step < STEPS.length - 1 ? (
|
{step < STEPS.length - 1 ? (
|
||||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && (!quote?.price || !selectedPlan)}>
|
||||||
Weiter
|
Weiter
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -509,6 +506,27 @@ export function BookingDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preis und Raten unter der gewaehlten Zahlungsweise. */
|
||||||
|
function PlanDetails({ plan, quote }: { plan: QuoteData["plans"][number]; quote: QuoteData }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pl-9">
|
||||||
|
<PriceBreakdown quote={quote} />
|
||||||
|
{plan.installments.length > 1 && (
|
||||||
|
<ul className="mt-3 space-y-1 text-[0.9375rem] text-ink/80">
|
||||||
|
{plan.installments.map((i) => (
|
||||||
|
<li key={i.seq} className="flex justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
Rate {i.seq}, {i.dueDate ? `fällig ${isoDate(i.dueDate)}` : "heute"}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{money(i.amountCents)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||||
const p = quote.price!;
|
const p = quote.price!;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function MobileBookingBar({ priceLabel }: { priceLabel: string }) {
|
|||||||
<span className="text-stone">{priceLabel}</span>
|
<span className="text-stone">{priceLabel}</span>
|
||||||
</p>
|
</p>
|
||||||
{pricing.checkoutOpen ? (
|
{pricing.checkoutOpen ? (
|
||||||
<BookButton className="btn btn-primary min-h-11 px-5 py-2 text-sm">Platz buchen</BookButton>
|
<BookButton className="btn btn-primary min-h-11 px-5 py-2 text-sm">Platz sichern</BookButton>
|
||||||
) : (
|
) : (
|
||||||
<a href="#preis" className="btn btn-quiet min-h-11 px-5 py-2 text-sm">
|
<a href="#preis" className="btn btn-quiet min-h-11 px-5 py-2 text-sm">
|
||||||
Zum Preis
|
Zum Preis
|
||||||
|
|||||||
@@ -2,135 +2,33 @@ import { site } from "@/content/site";
|
|||||||
import type { PublicCampFacts } from "@/lib/camp";
|
import type { PublicCampFacts } from "@/lib/camp";
|
||||||
import { Gallery } from "../Gallery";
|
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 }) {
|
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||||
const { accommodation } = site;
|
const { accommodation } = site;
|
||||||
return (
|
return (
|
||||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-20 md:py-32">
|
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<div className="grid gap-8 md:grid-cols-12">
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{accommodation.name} · {accommodation.place}</p>
|
||||||
<h2 id="unterkunft-title" className="section-title md:col-span-6">{accommodation.title}</h2>
|
<h2 id="unterkunft-title" className="section-title mt-3">{accommodation.title}</h2>
|
||||||
<p className="text-lg text-ink/85 md:col-span-5 md:col-start-8 md:self-end">{accommodation.lead}</p>
|
<p className="display mt-6 max-w-2xl text-[1.5rem] leading-[1.25] text-ink/85 md:text-[1.875rem]">{accommodation.lead}</p>
|
||||||
</div>
|
|
||||||
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||||
<div className="md:col-span-7">
|
<div className="md:col-span-7">
|
||||||
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
<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 ? (
|
{facts.accommodationText ? (
|
||||||
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 max-w-2xl text-stone">
|
<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.
|
Gebucht wird ein Platz pro Person mit geteilter Unterbringung; Einzelzimmer sind nicht buchbar. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht, in jedem Fall bevor du buchst.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-4 md:col-start-9">
|
<div className="md:col-span-4 md:col-start-9">
|
||||||
{accommodation.bookingUrl ? (
|
<h3 className="text-lg font-semibold">Ausstattung</h3>
|
||||||
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
|
<ul className="mt-3 divide-y divide-line border-y border-line">
|
||||||
Unterkunft ansehen (externe Seite)
|
{accommodation.features.map((feature) => (
|
||||||
</a>
|
<li key={feature} className="py-2.5">{feature}</li>
|
||||||
) : (
|
))}
|
||||||
showDrafts && <span className="draft-flag">Link zur Unterkunft folgt</span>
|
</ul>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
|
||||||
|
type Fields = Record<string, string>;
|
||||||
|
|
||||||
|
const FIELD_IDS = ["name", "email", "phone", "availability", "message", "privacy"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anfrage fuer ein unverbindliches Erstgespraech (POST /api/contact). Die
|
||||||
|
* Pruefung passiert auf dem Server; Fehler erscheinen am jeweiligen Feld,
|
||||||
|
* der Fokus springt auf das erste fehlerhafte. Das Feld "website" ist eine
|
||||||
|
* fuer Menschen unsichtbare Falle fuer Bots.
|
||||||
|
*/
|
||||||
|
export function ContactSection() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: "idle" });
|
||||||
|
const [errors, setErrors] = useState<Fields>({});
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const text = (key: string) => String(form.get(key) ?? "");
|
||||||
|
setStatus({ kind: "sending" });
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contact", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
availability: text("availability"),
|
||||||
|
message: text("message"),
|
||||||
|
privacy: form.get("privacy") === "on",
|
||||||
|
website: text("website"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = (await response.json().catch(() => null)) as { success: boolean; error?: { message: string; fields?: Fields } } | null;
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
setStatus({ kind: "sent" });
|
||||||
|
formRef.current?.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fields = result?.error?.fields ?? {};
|
||||||
|
setErrors(fields);
|
||||||
|
setStatus({ kind: "error", message: result?.error?.message ?? "Senden hat nicht geklappt. Bitte versuche es erneut." });
|
||||||
|
const first = FIELD_IDS.find((id) => fields[id]);
|
||||||
|
if (first) requestAnimationFrame(() => document.getElementById(`ct-${first}`)?.focus());
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: "error", message: "Keine Verbindung. Bitte prüfe dein Internet und versuche es erneut." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldProps = (id: (typeof FIELD_IDS)[number]) => ({
|
||||||
|
id: `ct-${id}`,
|
||||||
|
name: id,
|
||||||
|
"aria-invalid": errors[id] ? true : undefined,
|
||||||
|
"aria-describedby": errors[id] ? `ct-${id}-error` : undefined,
|
||||||
|
});
|
||||||
|
const error = (id: string) =>
|
||||||
|
errors[id] ? (
|
||||||
|
<p id={`ct-${id}-error`} className="mt-1.5 text-sm font-medium text-rosso">
|
||||||
|
{errors[id]}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="erstgespraech" aria-labelledby="erstgespraech-title" className="bg-plaster py-24 md:py-32">
|
||||||
|
<div className="container-edge grid gap-12 md:grid-cols-12">
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<h2 id="erstgespraech-title" className="section-title">
|
||||||
|
Erstgespräch
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 max-w-md text-lg text-ink/80">
|
||||||
|
Du bist dir noch unsicher, ob das Modelcamp zu dir passt? Lass uns unverbindlich sprechen. Schreib uns kurz, wann du erreichbar bist, wir melden uns bei dir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
{status.kind === "sent" ? (
|
||||||
|
<div role="status" className="rounded-2xl bg-paper p-8 shadow-[0_1px_0_var(--color-line)]">
|
||||||
|
<p className="text-xl font-bold">Danke, deine Anfrage ist angekommen.</p>
|
||||||
|
<p className="mt-2 text-ink/80">Wir melden uns in den nächsten Tagen bei dir.</p>
|
||||||
|
<button type="button" onClick={() => setStatus({ kind: "idle" })} className="btn btn-quiet mt-6">
|
||||||
|
Weitere Anfrage senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form ref={formRef} onSubmit={submit} noValidate className="relative grid gap-5 rounded-2xl bg-paper p-6 shadow-[0_1px_0_var(--color-line)] md:grid-cols-2 md:p-8">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-name">Name</label>
|
||||||
|
<input {...fieldProps("name")} className="field" autoComplete="name" required maxLength={120} />
|
||||||
|
{error("name")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-email">E-Mail</label>
|
||||||
|
<input {...fieldProps("email")} className="field" type="email" autoComplete="email" inputMode="email" required maxLength={200} />
|
||||||
|
{error("email")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-phone">Telefon</label>
|
||||||
|
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
|
||||||
|
{error("phone")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="ct-availability">Wann bist du erreichbar? (optional)</label>
|
||||||
|
<input {...fieldProps("availability")} className="field" placeholder="z. B. werktags ab 18 Uhr" maxLength={200} />
|
||||||
|
{error("availability")}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="field-label" htmlFor="ct-message">Worüber möchtest du sprechen? (optional)</label>
|
||||||
|
<textarea {...fieldProps("message")} className="field min-h-32" rows={4} maxLength={2000} />
|
||||||
|
{error("message")}
|
||||||
|
</div>
|
||||||
|
{/* Falle fuer Bots: nicht sichtbar, nicht per Tab erreichbar. */}
|
||||||
|
<div aria-hidden className="absolute -left-[9999px] h-px w-px overflow-hidden">
|
||||||
|
<label htmlFor="ct-website">Webseite</label>
|
||||||
|
<input id="ct-website" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="ct-privacy" className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||||
|
<input {...fieldProps("privacy")} type="checkbox" required className="mt-0.5 h-5 w-5 shrink-0 accent-ink" />
|
||||||
|
<span>
|
||||||
|
Ich bin einverstanden, dass meine Angaben zur Bearbeitung der Anfrage verwendet werden (
|
||||||
|
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||||
|
Datenschutzhinweise
|
||||||
|
</a>
|
||||||
|
).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error("privacy")}
|
||||||
|
</div>
|
||||||
|
{status.kind === "error" && (
|
||||||
|
<p role="alert" className="rounded-lg border border-rosso/40 bg-plaster p-4 text-[0.9375rem] font-medium text-rosso md:col-span-2">
|
||||||
|
{status.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<button type="submit" disabled={status.kind === "sending"} className="btn btn-primary px-8 disabled:opacity-60">
|
||||||
|
{status.kind === "sending" ? "Wird gesendet …" : "Erstgespräch anfragen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { site } from "@/content/site";
|
||||||
|
import type { PublicCampFacts } from "@/lib/camp";
|
||||||
|
import { addDays, formatIsoWeekdayDate } from "@/lib/time";
|
||||||
|
import { ScheduleCarousel, type ScheduleCard } from "../ScheduleCarousel";
|
||||||
|
import { Slideshow } from "../Slideshow";
|
||||||
|
|
||||||
|
/** Stellt die Villa vor: Text links, laufende Bilderfolge rechts. */
|
||||||
|
export function ExpectSection() {
|
||||||
|
const { expect } = site;
|
||||||
|
return (
|
||||||
|
<section id="erwartet" aria-labelledby="erwartet-title" className="py-24 md:py-32">
|
||||||
|
<div className="container-edge grid items-center gap-12 md:grid-cols-12 md:gap-10">
|
||||||
|
<div className="md:col-span-5">
|
||||||
|
<h2 id="erwartet-title" className="section-title">{expect.title}</h2>
|
||||||
|
<p className="mt-6 text-xl font-semibold leading-snug md:text-2xl">{expect.lead}</p>
|
||||||
|
<div className="prose-body mt-6 text-ink/85">
|
||||||
|
{expect.paragraphs.map((p) => (
|
||||||
|
<p key={p}>{p}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-8 space-y-3">
|
||||||
|
{expect.highlights.map((item) => (
|
||||||
|
<li key={item} className="flex items-center gap-3 font-medium">
|
||||||
|
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-rosso text-paper">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4">
|
||||||
|
<path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Slideshow images={expect.slides} className="md:col-span-7 md:col-start-6 lg:col-span-6 lg:col-start-7" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCards(facts: PublicCampFacts): ScheduleCard[] {
|
||||||
|
return site.schedule.days.map((day, index) => ({
|
||||||
|
label: `Tag ${index + 1}`,
|
||||||
|
date: facts.startDate ? formatIsoWeekdayDate(addDays(facts.startDate, index)) : null,
|
||||||
|
title: day.title,
|
||||||
|
items: day.items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tagesablauf als Karten pro Tag, wie das Programm bei der Referenz. */
|
||||||
|
export function ScheduleSection({ facts }: { facts: PublicCampFacts }) {
|
||||||
|
const { schedule } = site;
|
||||||
|
return (
|
||||||
|
<section id="ablauf" aria-labelledby="ablauf-title" className="bg-sand/35 py-24 md:py-32">
|
||||||
|
<div className="container-edge">
|
||||||
|
<h2 id="ablauf-title" className="section-title">{schedule.title}</h2>
|
||||||
|
<div className="mt-12">
|
||||||
|
<ScheduleCarousel days={scheduleCards(facts)} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-8 text-ink/80">{schedule.footnote}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
q: "Wie läuft die Buchung ab?",
|
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.",
|
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?",
|
q: "Wie lange gilt der angezeigte Preis?",
|
||||||
@@ -18,7 +18,7 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
|||||||
{
|
{
|
||||||
q: "Kann ich in Raten zahlen?",
|
q: "Kann ich in Raten zahlen?",
|
||||||
a: pricing.installmentsAllowed
|
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."
|
? "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.",
|
: "Ob eine Ratenzahlung möglich ist, geben wir bekannt, sobald der Camp-Termin feststeht.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,11 +29,25 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
|||||||
q: "Wie bin ich untergebracht?",
|
q: "Wie bin ich untergebracht?",
|
||||||
a: facts.accommodationText
|
a: facts.accommodationText
|
||||||
? `Du buchst einen Platz pro Person mit geteilter Unterbringung. ${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.",
|
: "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?",
|
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."}`,
|
a: `Du reist selbst nach Florenz an. Am Anreisetag fährt um 15:30 Uhr ein Bus vom Bahnhof Firenze Santa Maria Novella zur Villa. Kommst du mit dem Auto, fährst du direkt zur Unterkunft und bist zwischen 17:30 und 18:00 Uhr da. Am Abreisetag bringt dich der Bus zwischen 09:00 und 12:00 Uhr zurück nach Florenz. Die Anreise nach Italien und zurück ist nicht im Preis enthalten.${facts.location ? ` Das Camp findet in der ${facts.location} statt.` : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Habe ich ein Widerrufsrecht?",
|
||||||
|
a: facts.withdrawalText ? (
|
||||||
|
<>
|
||||||
|
Alle Hinweise zu deinem Widerrufsrecht findest du in unserer{" "}
|
||||||
|
<a href="/widerruf" className="underline">
|
||||||
|
Widerrufsbelehrung
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Die Belehrung zu deinem Widerrufsrecht veröffentlichen wir vor dem Verkaufsstart unter „Widerrufsrecht“ im Fußbereich der Seite."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "Was ist nicht im Preis enthalten?",
|
q: "Was ist nicht im Preis enthalten?",
|
||||||
@@ -62,16 +76,17 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
|||||||
|
|
||||||
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||||
return (
|
return (
|
||||||
<section id="fragen" aria-labelledby="fragen-title" className="py-20 md:py-32">
|
<section id="fragen" aria-labelledby="fragen-title" className="py-24 md:py-36">
|
||||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
<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>
|
<h2 id="fragen-title" className="section-title md:col-span-4">{site.faq.title}</h2>
|
||||||
<div className="md:col-span-8">
|
<div className="md:col-span-8">
|
||||||
{faqs(facts, pricing).map((item) => (
|
{faqs(facts, pricing).map((item) => (
|
||||||
<details key={item.q} className="group border-b border-line py-1 first:border-t">
|
<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">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-6 py-5 text-lg font-semibold transition-colors hover:text-rosso [&::-webkit-details-marker]:hidden">
|
||||||
{item.q}
|
{item.q}
|
||||||
<span aria-hidden className="text-2xl font-normal text-rosso transition-transform duration-200 group-open:rotate-45">
|
<span aria-hidden className="relative h-4 w-4 shrink-0 text-rosso transition-transform duration-300 ease-[var(--ease-editorial)] group-open:rotate-45">
|
||||||
+
|
<span className="absolute inset-x-0 top-1/2 h-[1.5px] -translate-y-1/2 bg-current" />
|
||||||
|
<span className="absolute inset-y-0 left-1/2 w-[1.5px] -translate-x-1/2 bg-current" />
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
||||||
@@ -88,14 +103,14 @@ export function ClosingSection({ pricing }: { pricing: PublicPricing }) {
|
|||||||
<section aria-labelledby="schluss-title" className="on-dark bg-ink py-24 text-paper md:py-36">
|
<section aria-labelledby="schluss-title" className="on-dark bg-ink py-24 text-paper md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="schluss-title" className="display max-w-4xl text-[clamp(2.75rem,7vw,6rem)] leading-[0.98]">
|
<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.
|
Wir freuen uns auf dich in der Toskana.
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-10 flex flex-wrap gap-3">
|
<div className="mt-10 flex flex-wrap gap-3">
|
||||||
{pricing.checkoutOpen ? (
|
{pricing.checkoutOpen ? (
|
||||||
<BookButton className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">Platz buchen</BookButton>
|
<BookButton className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">Jetzt Platz sichern</BookButton>
|
||||||
) : (
|
) : (
|
||||||
<a href="#preis" className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">
|
<a href="#preis" className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">
|
||||||
Zum Buchungsstart
|
Jetzt Platz sichern
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a href="#fragen" className="btn btn-quiet text-paper">
|
<a href="#fragen" className="btn btn-quiet text-paper">
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
|
import type { IntroVideo } from "@/lib/site-media";
|
||||||
|
|
||||||
|
/** Breite des Vorschaubilds: scharf bis zur vollen Inhaltsbreite. */
|
||||||
|
const POSTER_WIDTH = 1920;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video direkt unter dem Headerbild (intro.mp4 im Medienverzeichnis,
|
||||||
|
* Vorschaubild intro.jpg, docs/MEDIEN.md). Startet nur nach Klick. Mit
|
||||||
|
* Vorschaubild laedt der Browser vorher gar nichts vom Video
|
||||||
|
* (preload="none"); ohne wird nur das erste Bild als Vorschau geholt.
|
||||||
|
*/
|
||||||
|
export function IntroVideoSection({ video, showDrafts }: { video: IntroVideo; showDrafts: boolean }) {
|
||||||
|
if (!video.src) {
|
||||||
|
if (!showDrafts) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 text-paper/70 md:py-16">
|
||||||
|
<p className="container-edge text-center">
|
||||||
|
<span className="draft-flag">Platzhalter:</span> Hier erscheint das Video, sobald <code>intro.mp4</code> im Medienverzeichnis liegt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = video.poster?.kind === "image" ? optimizedImageUrl(video.poster.src, POSTER_WIDTH) : undefined;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 md:py-20">
|
||||||
|
<div className="container-edge">
|
||||||
|
<video
|
||||||
|
src={poster ? video.src : `${video.src}#t=0.1`}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload={poster ? "none" : "metadata"}
|
||||||
|
aria-label="Video ARTISTA Modelcamp"
|
||||||
|
// Groesse folgt dem Video (quer oder hoch): ohne volle Breite keine
|
||||||
|
// schwarzen Balken neben einem Hochformat-Video.
|
||||||
|
className="mx-auto block h-auto max-h-[85vh] w-auto max-w-full rounded-2xl bg-black"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,66 @@
|
|||||||
import { site } from "@/content/site";
|
import { site, type Media } from "@/content/site";
|
||||||
|
import type { Reel } from "@/lib/site-media";
|
||||||
import { MediaFrame } from "../MediaFrame";
|
import { MediaFrame } from "../MediaFrame";
|
||||||
import { Reveal } from "../Reveal";
|
import { Reveal } from "../Reveal";
|
||||||
import { VideoSlot } from "../VideoSlot";
|
import { VideoSlot } from "../VideoSlot";
|
||||||
|
|
||||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
type TeamMember = { slug: string; name: string; role: string; bio: string | null; portrait: Media };
|
||||||
const { teachers } = site;
|
|
||||||
|
function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) {
|
||||||
|
if (bio) return <p className="mt-3 max-w-md">{bio}</p>;
|
||||||
|
return showDrafts ? <p className="mt-3"><span className="draft-flag">Kurzbeschreibung folgt</span></p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team: Eric Heitz als Head of Modelcamp gross vorangestellt, darunter alle
|
||||||
|
* weiteren gleichwertig im Raster. Portraets im Rundbogen, ein Motiv aus
|
||||||
|
* der Architektur der Villa. Liegt team/<slug>.jpg im Medienverzeichnis,
|
||||||
|
* ersetzt das Foto den Initialen-Platzhalter.
|
||||||
|
*/
|
||||||
|
export function TeamSection({ showDrafts, portraits }: { showDrafts: boolean; portraits: Record<string, Media> }) {
|
||||||
|
const { team } = site;
|
||||||
|
const { head } = team;
|
||||||
|
const portraitOf = (member: TeamMember) => portraits[member.slug] ?? member.portrait;
|
||||||
|
const missing = [head, ...team.people].some((member) => portraitOf(member).kind !== "image");
|
||||||
return (
|
return (
|
||||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-20 md:py-32">
|
<section id="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
<div className="grid gap-6 md:grid-cols-12 md:gap-10">
|
||||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
<h2 id="team-title" className="section-title md:col-span-5">{team.title}</h2>
|
||||||
{teachers.people.map((person, index) => (
|
<p className="max-w-xl text-lg text-ink/85 md:col-span-6 md:col-start-7 md:pt-3">{team.lead}</p>
|
||||||
<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>
|
||||||
|
|
||||||
|
<article aria-label={head.name} className="mt-14 grid items-end gap-8 md:mt-20 md:grid-cols-12 md:gap-10">
|
||||||
|
<Reveal className="md:col-span-5">
|
||||||
|
<MediaFrame media={portraitOf(head)} aspect="aspect-[4/5]" shape="arch" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
||||||
|
</Reveal>
|
||||||
|
<div className="md:col-span-6 md:col-start-7 md:pb-10">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{head.title}</p>
|
||||||
|
<h3 className="mt-4 text-[3rem] font-extrabold leading-[0.95] tracking-[-0.03em] md:text-[5rem]">{head.name}</h3>
|
||||||
|
<p className="mt-5 border-t border-ink pt-4 text-lg">{head.role}</p>
|
||||||
|
<Bio bio={head.bio} showDrafts={showDrafts} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
|
||||||
|
{team.people.map((person: TeamMember, index) => (
|
||||||
|
<li key={person.slug}>
|
||||||
|
<article aria-label={person.name}>
|
||||||
|
<Reveal delay={(index % 4) * 0.06}>
|
||||||
|
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
|
||||||
|
</Reveal>
|
||||||
|
<h3 className="display mt-4 text-[1.375rem] leading-tight md:text-[1.625rem]">{person.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-stone md:text-[0.9375rem]">{person.role}</p>
|
||||||
|
<Bio bio={person.bio} showDrafts={showDrafts} />
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{showDrafts && missing && (
|
||||||
|
<p className="mt-12">
|
||||||
|
<span className="draft-flag">Porträts folgen</span> Platzhalter mit Initialen, bis die Fotos im Medienverzeichnis liegen (docs/MEDIEN.md).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -38,22 +68,17 @@ export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
|
|
||||||
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
||||||
const { about } = site;
|
const { about } = site;
|
||||||
|
if (about.paragraphs.length === 0 && !showDrafts) return null;
|
||||||
return (
|
return (
|
||||||
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-32">
|
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-28">
|
||||||
<div className="container-edge grid gap-8 md:grid-cols-12">
|
<div className="container-edge grid gap-6 border-t border-ink pt-10 md:grid-cols-12 md:gap-10 md:pt-14">
|
||||||
<h2 id="artista-title" className="section-title md:col-span-5">{about.title}</h2>
|
<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">
|
<div className="prose-body text-lg md:col-span-6 md:col-start-7 md:pt-2">
|
||||||
{about.paragraphs.length > 0 ? (
|
{about.paragraphs.length > 0 ? (
|
||||||
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
||||||
) : (
|
) : (
|
||||||
<p className="text-stone">
|
<p className="text-stone">
|
||||||
{showDrafts ? (
|
|
||||||
<>
|
|
||||||
<span className="draft-flag">Inhalt folgt</span> Grundlage wird die ARTISTA-Stammmodel-Seite (Link folgt).
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{about.sourceUrl && (
|
{about.sourceUrl && (
|
||||||
@@ -69,21 +94,29 @@ export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoicesSection({ showDrafts, provider }: { showDrafts: boolean; provider: "none" | "youtube-nocookie" | "vimeo" }) {
|
/**
|
||||||
const { videos } = site;
|
* Bis zu drei Videos (Models ueber ARTISTA). Ein einzelnes Video steht
|
||||||
const filled = videos.slots.filter((s) => s.src || s.externalId);
|
* breit in der Mitte, mehrere im Raster. Leere Slots erscheinen nur in
|
||||||
// Leere Slots nur in Entwicklung/Staging zeigen; oeffentlich nie ein
|
* Entwicklung und Staging; oeffentlich nie ein erfundenes Testimonial.
|
||||||
// erfundenes Testimonial als Ersatz.
|
*/
|
||||||
const slots = showDrafts ? videos.slots : filled;
|
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
|
||||||
if (slots.length === 0) return null;
|
const { reels: content } = site;
|
||||||
|
const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
|
||||||
|
const filled = slots.filter((s) => s.src);
|
||||||
|
const visible = showDrafts ? [...filled, ...slots.filter((s) => !s.src)] : filled;
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
const single = visible.length === 1;
|
||||||
return (
|
return (
|
||||||
<section id="stimmen" aria-labelledby="stimmen-title" className="border-t border-line py-20 md:py-28">
|
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="stimmen-title" className="section-title">{videos.title}</h2>
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
<ul className="mt-12 grid gap-10 md:grid-cols-3">
|
<h2 id="reels-title" className="section-title">{content.title}</h2>
|
||||||
{slots.map((slot, index) => (
|
<p className="max-w-md text-paper/75">{content.lead}</p>
|
||||||
|
</div>
|
||||||
|
<ul className={`mx-auto mt-12 grid gap-6 md:gap-8 ${single ? "max-w-4xl" : "max-w-5xl sm:grid-cols-3"}`}>
|
||||||
|
{visible.map((slot, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<VideoSlot slot={slot} index={index} provider={provider} showDrafts={showDrafts} />
|
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,20 +4,47 @@ import { formatEuroShort } from "@/lib/money";
|
|||||||
import type { PublicPricing } from "@/lib/public-page";
|
import type { PublicPricing } from "@/lib/public-page";
|
||||||
import { BookButton } from "../booking/BookingProvider";
|
import { BookButton } from "../booking/BookingProvider";
|
||||||
|
|
||||||
|
// Bewusst ohne Startdatum des Verkaufs (Wunsch des Auftraggebers).
|
||||||
const CLOSED_TEXT: Record<Exclude<PublicPricing["closedReason"], null>, string> = {
|
const CLOSED_TEXT: Record<Exclude<PublicPricing["closedReason"], null>, string> = {
|
||||||
PREVIEW: "Die Buchung öffnet am 1. November 2026.",
|
PREVIEW: "Bald buchbar.",
|
||||||
NOT_RELEASED: "Die Buchung ist noch nicht freigeschaltet. Wir veröffentlichen alle verbindlichen Angaben vor dem Verkaufsstart.",
|
NOT_RELEASED: "Die Buchung ist noch nicht freigeschaltet. Wir veröffentlichen alle verbindlichen Angaben vor dem Verkaufsstart.",
|
||||||
CAMP_STARTED: "Die Buchung ist geschlossen.",
|
CAMP_STARTED: "Die Buchung ist geschlossen.",
|
||||||
NOT_CONFIGURED: "Die Buchung ist derzeit nicht verfügbar.",
|
NOT_CONFIGURED: "Die Buchung ist derzeit nicht verfügbar.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function PriceDisplay({ pricing }: { pricing: PublicPricing }) {
|
||||||
|
if (pricing.phase === "REGULAR" && pricing.currentCents !== null) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-[clamp(4rem,12vw,7.5rem)] font-extrabold leading-none tracking-[-0.03em] tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
|
||||||
|
<p className="mt-3 text-lg">pro Person</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Vorschau und Early Bird: regulaerer Preis durchgestrichen, Early Bird gross.
|
||||||
|
const earlyBird = pricing.currentCents ?? pricing.earlyBirdCents;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-lg text-stone">
|
||||||
|
Regulärer Preis <s className="tabular-nums">{formatEuroShort(pricing.regularCents)}</s>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||||
|
<span className="text-[clamp(4rem,12vw,7.5rem)] font-extrabold leading-none tracking-[-0.03em] tabular-nums">{formatEuroShort(earlyBird)}</span>
|
||||||
|
<span className="rounded-full bg-rosso px-3 py-1 text-sm font-bold uppercase tracking-[0.12em] text-paper">Early Bird</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg">Early-Bird-Preis pro Person, gültig bis einschließlich 31. Dezember 2026.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PricingSection({ pricing, facts, showDrafts }: { pricing: PublicPricing; facts: PublicCampFacts; showDrafts: boolean }) {
|
export function PricingSection({ pricing, facts, showDrafts }: { pricing: PublicPricing; facts: PublicCampFacts; showDrafts: boolean }) {
|
||||||
const included = site.services.included.filter((s) => s.confirmed);
|
const included = site.services.included.filter((s) => s.confirmed);
|
||||||
|
const title = pricing.phase === "REGULAR" ? "Jetzt Platz sichern" : "Jetzt Platz sichern mit Early Bird";
|
||||||
return (
|
return (
|
||||||
<section id="preis" aria-labelledby="preis-title" className="bg-paper py-20 md:py-32">
|
<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="container-edge grid gap-14 md:grid-cols-12">
|
||||||
<div className="md:col-span-7">
|
<div className="md:col-span-7">
|
||||||
<h2 id="preis-title" className="section-title">Preis und Buchung</h2>
|
<h2 id="preis-title" className="section-title">{title}</h2>
|
||||||
|
|
||||||
{pricing.mode === "test" && (
|
{pricing.mode === "test" && (
|
||||||
<p className="mt-6 rounded-lg border border-line bg-plaster p-4 text-[0.9375rem]">
|
<p className="mt-6 rounded-lg border border-line bg-plaster p-4 text-[0.9375rem]">
|
||||||
@@ -27,33 +54,7 @@ export function PricingSection({ pricing, facts, showDrafts }: { pricing: Public
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
{pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null && (
|
<PriceDisplay pricing={pricing} />
|
||||||
<>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<ul className="mt-8 max-w-xl space-y-2 text-[0.9375rem] text-ink/85">
|
<ul className="mt-8 max-w-xl space-y-2 text-[0.9375rem] text-ink/85">
|
||||||
@@ -77,7 +78,7 @@ export function PricingSection({ pricing, facts, showDrafts }: { pricing: Public
|
|||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
{pricing.checkoutOpen ? (
|
{pricing.checkoutOpen ? (
|
||||||
<BookButton className="btn btn-primary px-8 text-lg">Platz buchen</BookButton>
|
<BookButton className="btn btn-primary px-8 text-lg">Jetzt Platz sichern</BookButton>
|
||||||
) : (
|
) : (
|
||||||
<p className="max-w-xl text-lg font-semibold">{CLOSED_TEXT[pricing.closedReason ?? "NOT_CONFIGURED"]}</p>
|
<p className="max-w-xl text-lg font-semibold">{CLOSED_TEXT[pricing.closedReason ?? "NOT_CONFIGURED"]}</p>
|
||||||
)}
|
)}
|
||||||
@@ -91,12 +92,13 @@ export function PricingSection({ pricing, facts, showDrafts }: { pricing: Public
|
|||||||
<li key={item.text} className="py-3">{item.text}</li>
|
<li key={item.text} className="py-3">{item.text}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-6 text-[0.9375rem] text-stone">
|
<p className="mt-6 text-[0.9375rem] text-stone">Nicht enthalten: {site.services.excluded.join(", ")}.</p>
|
||||||
Nicht enthalten: {site.services.excluded.join(", ")}.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-[0.9375rem] text-stone">
|
<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.
|
Vor der Zahlung siehst du Gesamtpreis, heute fälligen Betrag, alle weiteren Raten mit Terminen und die Beschreibung der Unterbringung auf einen Blick.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-4 text-[0.9375rem] text-stone">
|
||||||
|
Hinweise zu deinem <a href="/widerruf" className="link-quiet">Widerrufsrecht</a>.
|
||||||
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ExpandableText } from "@/components/ExpandableText";
|
||||||
|
import { Stars } from "@/components/Stars";
|
||||||
|
import type { GoogleReview, GoogleReviewSummary } from "@/lib/google-reviews";
|
||||||
|
import { formatRating, initialsOf, needsClamp, reviewCountLabel } from "@/lib/review-format";
|
||||||
|
|
||||||
|
const EXTERNAL = { target: "_blank", rel: "noopener noreferrer" } as const;
|
||||||
|
|
||||||
|
function Attribution({ review }: { review: GoogleReview }) {
|
||||||
|
return (
|
||||||
|
<figcaption className="mt-6 flex items-center gap-4">
|
||||||
|
{/* Initialen statt Profilbild: kein Abruf von Google-Servern. */}
|
||||||
|
<span aria-hidden className="display flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-ink/25 text-lg">
|
||||||
|
{initialsOf(review.author)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-[0.9375rem] leading-snug">
|
||||||
|
<cite className="block font-semibold not-italic">{review.author}</cite>
|
||||||
|
<span className="text-stone">
|
||||||
|
{review.relativeTime && <>{review.relativeTime}, </>}
|
||||||
|
{review.authorUrl ? (
|
||||||
|
<a href={review.authorUrl} {...EXTERNAL} className="link-quiet">
|
||||||
|
Auf Google ansehen<span className="sr-only"> (Rezension von {review.author}, öffnet in neuem Tab)</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"auf Google"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ data }: { data: GoogleReviewSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-6 md:mt-10">
|
||||||
|
{data.rating !== null && (
|
||||||
|
<p className="flex items-end gap-5">
|
||||||
|
<span className="display text-[5.5rem] leading-[0.8] tabular-nums md:text-[7rem]">{formatRating(data.rating)}</span>
|
||||||
|
<span className="pb-1 text-[0.9375rem] leading-snug text-stone">
|
||||||
|
<Stars rating={data.rating} className="mb-2 text-xl" />
|
||||||
|
<span className="block">
|
||||||
|
Durchschnitt
|
||||||
|
{data.totalCount !== null && <> aus {reviewCountLabel(data.totalCount)}</>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-8 text-[0.9375rem] text-stone">Rezensionen von Google. Angezeigt werden die Rezensionen, die Google für diesen Eintrag ausliefert.</p>
|
||||||
|
{data.mapsUrl && (
|
||||||
|
<p className="mt-4">
|
||||||
|
<a href={data.mapsUrl} {...EXTERNAL} className="link-strong text-[0.9375rem] font-semibold">
|
||||||
|
Alle Rezensionen auf Google Maps<span className="sr-only"> (öffnet in neuem Tab)</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echte Google-Rezensionen (Places API). Ohne Daten erscheint der Abschnitt
|
||||||
|
* nicht; in Entwicklung/Staging steht dort ein Hinweis zur Einrichtung.
|
||||||
|
* Keine Ersatz- oder Beispielstimmen.
|
||||||
|
*/
|
||||||
|
export function ReviewsSection({ data, showDrafts }: { data: GoogleReviewSummary | null; showDrafts: boolean }) {
|
||||||
|
if (!data) {
|
||||||
|
if (!showDrafts) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="Google-Rezensionen (Entwurf)" className="border-t border-line py-10">
|
||||||
|
<p className="container-edge text-[0.9375rem] text-stone">
|
||||||
|
<span className="draft-flag mr-2">Entwurf</span>
|
||||||
|
Google-Rezensionen erscheinen, sobald GOOGLE_PLACES_API_KEY und GOOGLE_PLACE_ID gesetzt sind.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lead, ...rest] = data.reviews;
|
||||||
|
return (
|
||||||
|
<section id="rezensionen" aria-labelledby="rezensionen-title" className="py-24 md:py-36">
|
||||||
|
<div className="container-edge">
|
||||||
|
<h2 id="rezensionen-title" className="section-title max-w-4xl">
|
||||||
|
Was andere über ARTISTA sagen
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="container-edge mt-4 grid gap-14 md:mt-6 md:grid-cols-12 md:gap-10">
|
||||||
|
<div className="md:col-span-5 lg:col-span-4">
|
||||||
|
<div className="md:sticky md:top-28">
|
||||||
|
<Summary data={data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7 md:col-start-6 md:pt-10 lg:col-span-7 lg:col-start-6 lg:pt-12">
|
||||||
|
{lead && (
|
||||||
|
<figure>
|
||||||
|
<Stars rating={lead.rating} className="text-base" />
|
||||||
|
<blockquote className="mt-5">
|
||||||
|
<ExpandableText
|
||||||
|
text={lead.text}
|
||||||
|
clamp={needsClamp(lead.text)}
|
||||||
|
clampClass="line-clamp-7"
|
||||||
|
className="display text-[1.625rem] leading-[1.28] md:text-[2.125rem] md:leading-[1.24]"
|
||||||
|
/>
|
||||||
|
</blockquote>
|
||||||
|
<Attribution review={lead} />
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<ul className="mt-16 grid gap-x-10 gap-y-12 border-t border-ink pt-10 sm:grid-cols-2">
|
||||||
|
{rest.map((review, index) => (
|
||||||
|
<li key={`${review.author}-${index}`}>
|
||||||
|
<figure>
|
||||||
|
<Stars rating={review.rating} className="text-sm" />
|
||||||
|
<blockquote className="mt-4">
|
||||||
|
<ExpandableText text={review.text} clamp={needsClamp(review.text)} className="text-[1.0625rem] leading-relaxed text-ink/90" />
|
||||||
|
</blockquote>
|
||||||
|
<Attribution review={review} />
|
||||||
|
</figure>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,66 +8,171 @@
|
|||||||
* und Staging sichtbar als solche markiert und werden im Produktivbetrieb
|
* und Staging sichtbar als solche markiert und werden im Produktivbetrieb
|
||||||
* (APP_ENV=production) ausgeblendet oder durch einen neutralen Hinweis
|
* (APP_ENV=production) ausgeblendet oder durch einen neutralen Hinweis
|
||||||
* ersetzt.
|
* ersetzt.
|
||||||
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
|
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis,
|
||||||
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
|
* Bedingungen und Widerrufsbelehrung pflegt der Adminbereich (Datenbank),
|
||||||
* Live-Verkauf freischalten. Hier steht alles Uebrige.
|
* weil sie den Live-Verkauf freischalten. Hier steht alles Uebrige.
|
||||||
* - Bilder: nur mit bestaetigter Nutzungsberechtigung, als lokale Datei im
|
* - Bilder: nur mit bestaetigter Nutzungsberechtigung und immer als lokale
|
||||||
* Medienverzeichnis (MEDIA_DIR, ausgeliefert unter /media/…). Kein
|
* Datei, nie per Hotlinking. Feste Bilder liegen in public/images/.
|
||||||
* Hotlinking fremder Bilder (z. B. von Booking.com).
|
* Logo, Headerbild, Portraets und Reels kommen ueber feste Dateinamen aus
|
||||||
|
* dem Medienverzeichnis (src/lib/site-media.ts, docs/MEDIEN.md).
|
||||||
|
* Die Unterkunftsbilder stammen aus dem Booking.com-Eintrag der Villa
|
||||||
|
* und wurden vom Auftraggeber am 01.10.2026 zur Nutzung freigegeben.
|
||||||
|
* - Keine Gedankenstriche in Texten (Wunsch des Auftraggebers).
|
||||||
*
|
*
|
||||||
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type PlaceholderTone = "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
export type PlaceholderTone = "cover" | "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
||||||
|
|
||||||
export type Media =
|
export type Media =
|
||||||
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
| { kind: "placeholder"; label: string; tone: PlaceholderTone; glyph?: string }
|
||||||
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
| { kind: "image"; src: string; alt: string; width: number; height: number };
|
||||||
|
|
||||||
export type Confirmable<T> = T & { confirmed: boolean };
|
export type Confirmable<T> = T & { confirmed: boolean };
|
||||||
|
|
||||||
const placeholder = (label: string, tone: PlaceholderTone): Media => ({ kind: "placeholder", label, tone });
|
export type ScheduleItem = { time: string; text: string; coach?: string; photo?: boolean };
|
||||||
|
export type ScheduleDay = { title: string; items: ScheduleItem[] };
|
||||||
|
|
||||||
|
const portrait = (name: string, initials: string): Media => ({ kind: "placeholder", label: `Porträt ${name}`, tone: "portrait", glyph: initials });
|
||||||
|
/** Lokales Bild aus public/images/unterkunft (1280 x 853 px). */
|
||||||
|
const villa = (file: string, alt: string): Media => ({ kind: "image", src: `/images/unterkunft/${file}.jpg`, alt, width: 1280, height: 853 });
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
hero: {
|
hero: {
|
||||||
|
title: "Modelcamp Toskana 2027",
|
||||||
// Entwurf zur Freigabe: bewusst ohne Versprechen zu Shootings,
|
// Entwurf zur Freigabe: bewusst ohne Versprechen zu Shootings,
|
||||||
// Vermittlung oder Ergebnissen.
|
// Vermittlung oder Ergebnissen.
|
||||||
headline: "Gemeinsam wachsen. In Italien.",
|
|
||||||
subline:
|
subline:
|
||||||
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
"Eine Woche in einer Villa in der Toskana. Lernen vor und hinter der Kamera, mit einem Team aus Fotografen, Models und Content Creators, und Abende, an denen alle zusammen am Tisch sitzen.",
|
||||||
media: placeholder("Leitmotiv: Bild oder Video folgt", "villa"),
|
cta: "Jetzt Platz sichern",
|
||||||
|
// Standardbild, solange kein header.jpg im Medienverzeichnis liegt.
|
||||||
|
media: villa("villa-luftbild", "Luftaufnahme der Villa La Chiesina zwischen Zypressen und Wiesen in der Toskana"),
|
||||||
},
|
},
|
||||||
|
|
||||||
camp: {
|
expect: {
|
||||||
title: "Das Camp",
|
title: "Was dich erwartet",
|
||||||
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.",
|
lead: "Eine Woche in der Villa La Chiesina, mitten in der Toskana.",
|
||||||
paragraphs: [
|
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.",
|
"Die Villa liegt zwischen Zypressen oberhalb von Arcidosso, mit Blick über die Hügel am Monte Amiata. Hier wohnen, arbeiten und essen wir eine Woche lang zusammen.",
|
||||||
"Das Programm wird gerade zusammengestellt. Was bereits feststeht, findest du unten unter Leistungen – alles andere ergänzen wir, sobald es verbindlich ist.",
|
"Garten, Pool, alte Steintreppen und Räume mit Holzbalkendecken: Die Villa bietet viele Orte zum Fotografieren und zum Durchatmen.",
|
||||||
],
|
],
|
||||||
audience: {
|
highlights: ["Eigenes Bett im geteilten Zimmer", "Bustransfer ab und bis Florenz", "20 Workshops und Shootings mit dem Team"],
|
||||||
title: "Für wen das Camp gedacht ist",
|
slides: [
|
||||||
items: [
|
villa("villa-eingang", "Die Villa La Chiesina mit Steintreppe und Rasen im Frühling"),
|
||||||
"Models, die bereits mit ARTISTA arbeiten",
|
villa("pool-zypressen", "Pool zwischen hohen Zypressen, im Hintergrund die Hügel der Toskana"),
|
||||||
"Models, die ARTISTA und die Arbeit vor der Kamera näher kennenlernen möchten",
|
villa("wohnzimmer-kamin", "Wohnzimmer mit Holzbalkendecke, Natursteinwand und Kamin"),
|
||||||
"Alle, die Lust auf eine konzentrierte Zeit in einer kleinen Gruppe haben",
|
villa("terrasse", "Sitzplatz auf der Gartenterrasse"),
|
||||||
],
|
villa("anwesen-luftbild", "Luftaufnahme des Anwesens mit Zypressenallee, Pool und Tennisplatz"),
|
||||||
confirmed: false,
|
] as Media[],
|
||||||
},
|
},
|
||||||
// Ausdruecklich KEINE Aussagen zu Agenturvermittlung, Auftraegen,
|
|
||||||
// Verdienst oder Karriereerfolg (Briefing Abschnitt 4).
|
schedule: {
|
||||||
|
title: "Tagesablauf",
|
||||||
|
// Quelle: Modelcamp_2027_Wochenplan.pdf vom Auftraggeber (02.10.2026),
|
||||||
|
// Seiten 2 bis 4. Ein Eintrag pro Tag ab Anreisetag; Wochentag und
|
||||||
|
// Datum kommen aus dem Camp-Termin im Adminbereich.
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
title: "Anreise",
|
||||||
|
items: [
|
||||||
|
{ time: "Vormittag", text: "Eigene Anreise nach Florenz" },
|
||||||
|
{ time: "15:30", text: "Bustransfer vom Bahnhof Santa Maria Novella zur Unterkunft" },
|
||||||
|
{ time: "17:30 bis 18:00", text: "Ankunft der Selbstfahrer" },
|
||||||
|
{ time: "18:00", text: "Zimmerverteilung" },
|
||||||
|
{ time: "18:30", text: "Kennenlernen" },
|
||||||
|
{ time: "19:00", text: "Abendessen" },
|
||||||
|
{ time: "20:00", text: "Abendausklang am Kamin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fundament",
|
||||||
|
items: [
|
||||||
|
{ time: "08:30 bis 09:30", text: "Frühstück, ab 09:00 Einführung und Ziele der Woche" },
|
||||||
|
{ time: "10:00 bis 11:15", text: "Stretching & Mobility: Yoga und Quick-Routine", coach: "Lola" },
|
||||||
|
{ time: "11:15 bis 13:00", text: "Catwalk-Grundlagen, inkl. Vorher-Video (gefilmt von Eric und Saskia)", coach: "Eileen" },
|
||||||
|
{ time: "13:30 bis 14:30", text: "Make-up-Basics: Clean / Natural Look", coach: "Gabi" },
|
||||||
|
{ time: "15:00 bis 16:30", text: "Social-Media-Grundlagen", coach: "Saskia" },
|
||||||
|
{ time: "17:30 bis 18:45", text: "Stimmtraining", coach: "Eric" },
|
||||||
|
{ time: "19:00", text: "Abendessen" },
|
||||||
|
{ time: "20:00 bis 21:00", text: "Toastmasters Runde 1: Stegreif-Reden, erste Wirkung", coach: "Eric" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ausdruck & Kamera",
|
||||||
|
items: [
|
||||||
|
{ time: "07:30 bis 08:15", text: "Frühsport (optional)", coach: "Gabi" },
|
||||||
|
{ time: "08:30 bis 09:30", text: "Frühstück" },
|
||||||
|
{ time: "10:00 bis 11:30", text: "Mimikkontrolle", coach: "Eric", photo: true },
|
||||||
|
{ time: "11:30 bis 13:30", text: "Range: Angel or Devil Shoot, Emotion vor der Kamera", coach: "Lola, Eric, Michele", photo: true },
|
||||||
|
{ time: "14:00 bis 15:00", text: "Commercial-Posing", coach: "Gabi" },
|
||||||
|
{ time: "15:00 bis 16:00", text: "Testimonial-Look", coach: "Gabi", photo: true },
|
||||||
|
{ time: "16:15 bis 17:15", text: "Runway-Posen am Laufstegende", coach: "Eileen", photo: true },
|
||||||
|
{ time: "17:30 bis 18:30", text: "Tennisshoot", coach: "Michele" },
|
||||||
|
{ time: "19:00", text: "Abendessen" },
|
||||||
|
{ time: "20:00", text: "Angel-or-Devil-Bildbesprechung und Spieleabend", coach: "Lola" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ausflugstag Arcidosso",
|
||||||
|
items: [
|
||||||
|
{ time: "08:30 bis 09:30", text: "Frühstück / Sandwichstation" },
|
||||||
|
{ time: "10:00 bis 11:00", text: "Spaziergang nach Arcidosso" },
|
||||||
|
{ time: "11:00 bis 12:30", text: "Foto-Content fürs eigene Profil, vor Ort", coach: "Saskia", photo: true },
|
||||||
|
{ time: "12:30 bis 14:00", text: "Freies Shooten in Arcidosso" },
|
||||||
|
{ time: "14:00", text: "Treffen und Austausch" },
|
||||||
|
{ time: "14:30 bis 15:30", text: "Spaziergang zurück zum Camp" },
|
||||||
|
{ time: "16:30 bis 18:00", text: "Range: Acting & Performance", coach: "Lola" },
|
||||||
|
{ time: "19:00", text: "Abendessen" },
|
||||||
|
{ time: "20:00", text: "Lagerfeuer und Musik" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Walk, Content & Pool",
|
||||||
|
items: [
|
||||||
|
{ time: "08:30 bis 09:30", text: "Frühstück" },
|
||||||
|
{ time: "10:00 bis 13:00", text: "Walk: Runway-Analyse, High-Fashion & Commercial, Fashion Cities, Runway Practice", coach: "Lola" },
|
||||||
|
{ time: "13:30 bis 14:15", text: "Walk mit Musik & Rhythmus", coach: "Eileen", photo: true },
|
||||||
|
{ time: "14:15 bis 16:15", text: "Content & Reels produzieren", coach: "Saskia", photo: true },
|
||||||
|
{ time: "16:30 bis 18:30", text: "Poolparty mit Cocktails und Fingerfood" },
|
||||||
|
{ time: "18:30", text: "Gruppenfoto" },
|
||||||
|
{ time: "19:00", text: "Abendessen" },
|
||||||
|
{ time: "20:00 bis 21:00", text: "Toastmasters Runde 2: Fortschritt sichtbar machen", coach: "Eric" },
|
||||||
|
{ time: "21:30", text: "Special-Programm für Mutige" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Editorial, Pitch & Finale",
|
||||||
|
items: [
|
||||||
|
{ time: "07:30 bis 08:15", text: "Frühsport (optional)", coach: "Gabi" },
|
||||||
|
{ time: "08:30 bis 09:30", text: "Frühstück" },
|
||||||
|
{ time: "10:00 bis 13:00", text: "Editorial Posing inkl. Test-Shoot", coach: "Lola, Michele", photo: true },
|
||||||
|
{ time: "13:45 bis 14:30", text: "Elevator Pitch: erarbeiten", coach: "Eric" },
|
||||||
|
{ time: "14:30 bis 15:30", text: "Elevator Pitch: vorstellen, als Vorstellungsvideo (gefilmt von Saskia)", coach: "Eric", photo: true },
|
||||||
|
{ time: "16:00 bis 17:30", text: "Bildauswahl und Einzelfeedback", coach: "Team" },
|
||||||
|
{ time: "17:30 bis 19:00", text: "Pause und Styling" },
|
||||||
|
{ time: "19:00 bis 20:30", text: "Golden-Hour-Finalshooting", coach: "Michele", photo: true },
|
||||||
|
{ time: "20:30", text: "Abschiedsdinner, Reel- und Bildervorstellung, Abschiedsfeier" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Abreise",
|
||||||
|
items: [
|
||||||
|
{ time: "08:00 bis 08:45", text: "Sandwichstation" },
|
||||||
|
{ time: "09:00 bis 12:00", text: "Bustransfer nach Florenz" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as ScheduleDay[],
|
||||||
|
footnote: "Frühstück, Snacks über den Tag und Abendessen sind für alle inklusive.",
|
||||||
},
|
},
|
||||||
|
|
||||||
services: {
|
services: {
|
||||||
title: "Erlebnisse und Leistungen",
|
|
||||||
included: [
|
included: [
|
||||||
// Bestaetigt durch das Buchungsprodukt selbst:
|
// Bestaetigt durch das Buchungsprodukt bzw. den Auftraggeber
|
||||||
{ text: "Teilnahme am Modelcamp 2027", confirmed: true },
|
// (Wochenplan-PDF vom 02.10.2026):
|
||||||
{ text: "Unterbringung in einer geteilten Unterkunft in Italien", confirmed: true },
|
{ text: "Teilnahme am Modelcamp 2027 mit 20 Workshops und Shootings", confirmed: true },
|
||||||
// Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt")
|
{ text: "Sechs Nächte in der Villa La Chiesina, eigenes Bett im geteilten Zimmer", confirmed: true },
|
||||||
// auf confirmed: true setzen.
|
{ text: "Bustransfer vom Bahnhof Firenze Santa Maria Novella zur Villa und zurück", confirmed: true },
|
||||||
{ text: "Workshops mit den beiden Dozentinnen", confirmed: false },
|
{ text: "Frühstück, Snacks über den Tag und Abendessen", confirmed: true },
|
||||||
{ text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false },
|
|
||||||
] satisfies Confirmable<{ text: string }>[],
|
] satisfies Confirmable<{ text: string }>[],
|
||||||
excluded: [
|
excluded: [
|
||||||
// Laut Vorlage ausdruecklich nicht enthalten.
|
// Laut Vorlage ausdruecklich nicht enthalten.
|
||||||
@@ -75,36 +180,60 @@ export const site = {
|
|||||||
"Persönliche Ausgaben vor Ort",
|
"Persönliche Ausgaben vor Ort",
|
||||||
"Reiserücktrittsversicherung",
|
"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: {
|
accommodation: {
|
||||||
title: "Die Unterkunft",
|
title: "Die Unterkunft",
|
||||||
lead: "Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
|
lead: "Eine Villa zwischen Zypressen, mit Blick über die Hügel am Monte Amiata. Wohnen, arbeiten und abends zusammensitzen, alles an einem Ort.",
|
||||||
// Link zur Unterkunft (z. B. Booking.com), nachzureichen.
|
name: "Villa La Chiesina",
|
||||||
bookingUrl: null as string | null,
|
place: "Arcidosso, Toskana",
|
||||||
|
// Ausstattung laut Unterkunftsbeschreibung (Booking.com, Stand 01.10.2026).
|
||||||
|
// Die Booking-Seite selbst wird bewusst nicht verlinkt.
|
||||||
|
features: [
|
||||||
|
"Eigener Außenpool (saisonal)",
|
||||||
|
"Großer Garten mit Terrasse",
|
||||||
|
"Blick auf die Berge",
|
||||||
|
"Gemeinsames Wohnzimmer",
|
||||||
|
"Tennisplatz",
|
||||||
|
"Bettwäsche und Handtücher",
|
||||||
|
"WLAN",
|
||||||
|
],
|
||||||
images: [
|
images: [
|
||||||
placeholder("Unterkunft: Außenansicht", "villa"),
|
villa("villa-eingang", "Die Villa La Chiesina mit Steintreppe und Rasen im Frühling"),
|
||||||
placeholder("Unterkunft: Garten oder Terrasse", "terrace"),
|
villa("pool", "Pool mit Liegen im Garten der Villa"),
|
||||||
placeholder("Unterkunft: Zimmer", "room"),
|
villa("wohnzimmer-kamin", "Wohnzimmer mit Holzbalkendecke, Natursteinwand und Kamin"),
|
||||||
placeholder("Unterkunft: Gemeinschaftsbereich", "garden"),
|
villa("anwesen-luftbild", "Luftaufnahme des Anwesens mit Zypressenallee, Pool und Tennisplatz"),
|
||||||
|
villa("schlafzimmer-einzelbetten", "Schlafzimmer mit zwei Einzelbetten und Holzbalkendecke"),
|
||||||
|
villa("esszimmer", "Esszimmer mit großem Holztisch und Kamin"),
|
||||||
|
villa("terrasse", "Sitzplatz auf der Gartenterrasse"),
|
||||||
|
villa("pool-zypressen", "Pool zwischen hohen Zypressen, im Hintergrund die Hügel der Toskana"),
|
||||||
|
villa("steintreppe", "Alte Steintreppe im Garten unter Bäumen"),
|
||||||
|
villa("arcidosso", "Blick auf den mittelalterlichen Ort Arcidosso mit Burg"),
|
||||||
] as Media[],
|
] as Media[],
|
||||||
},
|
},
|
||||||
|
|
||||||
teachers: {
|
team: {
|
||||||
title: "Die Dozentinnen",
|
title: "Mit dabei",
|
||||||
|
lead: "Fotografie, Modelcoaching, Content und eine Küche, die alle zusammenbringt: Diese Menschen sind beim Modelcamp 2027 dabei.",
|
||||||
|
// Namen und Rollen vom Auftraggeber (01.10.2026). Portraets kommen ueber
|
||||||
|
// das Medienverzeichnis als team/<slug>.jpg (docs/MEDIEN.md).
|
||||||
|
// Kurzbeschreibungen nur aus freigegebenen Angaben, keine erfundenen Referenzen.
|
||||||
|
head: {
|
||||||
|
slug: "eric-heitz",
|
||||||
|
name: "Eric Heitz",
|
||||||
|
title: "Head of Modelcamp",
|
||||||
|
role: "Fotograf · Content Creator · Modelcoach",
|
||||||
|
bio: null as string | null,
|
||||||
|
portrait: portrait("Eric Heitz", "EH"),
|
||||||
|
},
|
||||||
people: [
|
people: [
|
||||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "portrait") as Media },
|
{ slug: "michele-ietro", name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") },
|
||||||
{ name: null as string | null, role: null as string | null, bio: null as string | null, portrait: placeholder("Porträt Dozentin", "studio") as Media },
|
{ slug: "lola-stoll", name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
|
||||||
|
{ slug: "eileen-kaupp", name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
|
||||||
|
{ slug: "gabriela-pronkin", name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
|
||||||
|
{ slug: "saskia-sirowatka", name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
|
||||||
|
{ slug: "andre-hartshauser", name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
|
||||||
|
{ slug: "ante-rejo", name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -116,13 +245,16 @@ export const site = {
|
|||||||
paragraphs: [] as string[],
|
paragraphs: [] as string[],
|
||||||
},
|
},
|
||||||
|
|
||||||
videos: {
|
reels: {
|
||||||
title: "Stimmen",
|
title: "Stimmen",
|
||||||
// Genau drei Slots. Nur echte, freigegebene Videos eintragen.
|
lead: "Models erzählen, wie sie ARTISTA erleben.",
|
||||||
|
// Genau drei Slots. Video und Vorschaubild kommen ueber das
|
||||||
|
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
|
||||||
|
// Name und Begleittext nur mit Freigabe der gezeigten Person.
|
||||||
slots: [
|
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 },
|
||||||
{ 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 },
|
||||||
{ 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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -131,12 +263,13 @@ export const site = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Links in der Kopfzeile: die ersten drei links vom Logo, der Rest rechts. */
|
||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ id: "camp", label: "Das Camp" },
|
{ id: "erwartet", label: "Was dich erwartet" },
|
||||||
{ id: "leistungen", label: "Leistungen" },
|
{ id: "ablauf", label: "Tagesablauf" },
|
||||||
{ id: "unterkunft", label: "Unterkunft" },
|
{ id: "unterkunft", label: "Unterkunft" },
|
||||||
{ id: "dozentinnen", label: "Dozentinnen" },
|
|
||||||
{ id: "artista", label: "Über ARTISTA" },
|
|
||||||
{ id: "preis", label: "Preis" },
|
{ id: "preis", label: "Preis" },
|
||||||
|
{ id: "team", label: "Team" },
|
||||||
{ id: "fragen", label: "Fragen" },
|
{ id: "fragen", label: "Fragen" },
|
||||||
|
{ id: "erstgespraech", label: "Erstgespräch" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Actor } from "./audit";
|
import type { Actor } from "./audit";
|
||||||
import { getAuth } from "./auth";
|
import { getAuth } from "./auth";
|
||||||
|
import { headersWithClientIp } from "./rate-limit";
|
||||||
|
|
||||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ async function currentAdmin(): Promise<AdminUser | null> {
|
|||||||
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
||||||
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const session = await getAuth().api.getSession({ headers: requestHeaders });
|
const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | 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) };
|
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { enqueueEmail } from "./email";
|
|||||||
import { bookingConfirmedEmail } from "./email-templates";
|
import { bookingConfirmedEmail } from "./email-templates";
|
||||||
import type { PaymentGateway } from "./payment-gateway";
|
import type { PaymentGateway } from "./payment-gateway";
|
||||||
import { getCamp } from "./camp";
|
import { getCamp } from "./camp";
|
||||||
|
import { stopCanceledInstallmentPlans } from "./installment-billing";
|
||||||
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
import { addDays, berlinStartOfDay, isoToDbDate } from "./time";
|
||||||
import { statusTokenFor } from "./tokens";
|
import { statusTokenFor } from "./tokens";
|
||||||
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
import { normalizeEmail, normalizeVoucherCode } from "./vouchers";
|
||||||
@@ -54,10 +55,17 @@ export async function cancelBooking(
|
|||||||
});
|
});
|
||||||
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
if (!before) return { ok: false, message: "Buchung nicht gefunden oder bereits storniert." };
|
||||||
|
|
||||||
// Offene Stripe-Sessions schliessen und gespeicherte Zahlungsmethode
|
// Offene Session schliessen, Stripe-Ratenplan beenden und gespeicherte
|
||||||
// trennen: nach einer Stornierung darf nichts mehr abgebucht werden.
|
// Zahlungsmethode trennen: nach einer Stornierung darf nichts mehr
|
||||||
const sessions = [before.stripeCheckoutSessionId, ...before.installments.map((i) => i.checkoutSessionId)].filter(Boolean) as string[];
|
// abgebucht werden.
|
||||||
for (const id of sessions) await gateway.expireCheckoutSession(id).catch(() => undefined);
|
if (before.stripeCheckoutSessionId) await gateway.expireCheckoutSession(before.stripeCheckoutSessionId).catch(() => undefined);
|
||||||
|
const plan = await stopCanceledInstallmentPlans(db, gateway, [before.id], actor);
|
||||||
|
if (plan.failed > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "Buchung storniert, aber der Ratenplan bei Stripe ließ sich noch nicht beenden. Der Hintergrundlauf versucht es alle 10 Minuten erneut.",
|
||||||
|
};
|
||||||
|
}
|
||||||
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
await detachFinishedPaymentMethods(db, gateway, [before.id]);
|
||||||
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
return { ok: true, message: "Buchung storniert. Eine Erstattung wird separat ausgelöst." };
|
||||||
}
|
}
|
||||||
@@ -220,6 +228,8 @@ export const campInputSchema = z.object({
|
|||||||
termsVersion: text(60),
|
termsVersion: text(60),
|
||||||
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
|
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
|
||||||
termsApproved: z.boolean(),
|
termsApproved: z.boolean(),
|
||||||
|
withdrawalText: text(10000),
|
||||||
|
withdrawalApproved: z.boolean(),
|
||||||
travelLawChecked: z.boolean(),
|
travelLawChecked: z.boolean(),
|
||||||
installmentsApproved: z.boolean(),
|
installmentsApproved: z.boolean(),
|
||||||
installmentRuleApproved: z.boolean(),
|
installmentRuleApproved: z.boolean(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env, requireAppSecret } from "./env";
|
import { env, requireAppSecret } from "./env";
|
||||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit";
|
||||||
|
|
||||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret());
|
||||||
if (result.allowed) return null;
|
if (result.allowed) return null;
|
||||||
return fail(
|
return fail(
|
||||||
429,
|
429,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
|||||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { CLIENT_IP_HEADER } from "./rate-limit";
|
||||||
|
|
||||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||||
const e = env();
|
const e = env();
|
||||||
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
|
|||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
useSecureCookies: e.APP_URL.startsWith("https://"),
|
||||||
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
|
// Nur die vom Server ermittelte IP (withClientIp im Auth-Endpunkt), nie X-Real-IP
|
||||||
|
// oder X-Forwarded-For direkt: ueber Cloudflare kaemen sie vom Client.
|
||||||
|
ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
|
||||||
},
|
},
|
||||||
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||||
});
|
});
|
||||||
|
|||||||