feat: Onepage nach Filmcamp-Gliederung umgebaut

- Figtree als einzige Schrift, Logo mittig in fester Kopfzeile
- Headerbild ueber volle Breite und Hoehe, ohne Preis, mit Terminleiste
- "Was dich erwartet" stellt die Villa mit animierter Bilderfolge vor,
  darunter Tagesablauf als Karussell (Programm folgt als PDF)
- Preis: regulaerer Preis durchgestrichen, kein Verkaufsstartdatum,
  Ueberschrift "Jetzt Platz sichern mit Early Bird"
- Team als "Mit dabei", danach drei Reels im Hochformat
- Logo, Headerbild, Portraets und Reels ueber feste Dateinamen im
  Medienverzeichnis (docs/MEDIEN.md), ohne Neustart austauschbar
- Widerrufsbelehrung im Admin pflegbar, Seite /widerruf, Live-Verkauf
  ohne Freigabe gesperrt
- Anreise: Abholung am Bahnhof Firenze Santa Maria Novella
- Booking-Link entfernt, Gedankenstriche aus Kundentexten entfernt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 23:53:18 +02:00
1 parent 3cba3e2341
commit 7203cd1e6e
37 files changed
+962 -517

No files matched your search

+5 -3
View File
@@ -52,9 +52,11 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
## Wo was gepflegt wird ## Wo was gepflegt wird
- **Texte, Bilder, Team, 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“.
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben) und `public/images/team/` (Porträts, folgen). - **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen. - **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`.
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
+71
View File
@@ -0,0 +1,71 @@
# 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 genau so schreiben: alles klein, ohne Leerzeichen und Umlaute. 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 32 bis 40 px hoch angezeigt. Ein querformatiges Logo ohne Rand wirkt am besten.
## 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 |
## 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` |
| Stefan Höchtl | `team/stefan-hoechtl.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.
## 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.
+11 -6
View File
@@ -5,7 +5,9 @@ Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027
- Termin 08.05.–15.05.2027, Villa La Chiesina, Arcidosso (Toskana) - Termin 08.05.–15.05.2027, Villa La Chiesina, Arcidosso (Toskana)
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer - Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
- 13 verkaufbare Plätze - 13 verkaufbare Plätze
- Team (Namen und Rollen) sowie Booking-Link und Unterkunftsbilder - Team (Namen und Rollen) und Unterkunftsbilder; die Booking-Seite wird bewusst nicht verlinkt
- Abholung am Anreisetag vom Bahnhof Firenze Santa Maria Novella
- 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“. 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“.
@@ -13,13 +15,15 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
| Punkt | Wo eintragen | | Punkt | Wo eintragen |
|---|---| |---|---|
| Porträts und freigegebene Kurzbeschreibungen der neun Teammitglieder | `public/images/team/` + `site.ts` → `team` | | Logo (`logo.svg`, `logo-hell.svg`) und Headerbild (`header.jpg`) | Medienverzeichnis, Dateinamen in `docs/MEDIEN.md` |
| Porträts der neun Teammitglieder | Medienverzeichnis `team/<name>.jpg`, siehe `docs/MEDIEN.md` |
| Freigegebene Kurzbeschreibungen der Teammitglieder | `site.ts` → `team` |
| Tagesablauf (Programm als PDF angekündigt) | `site.ts` → `schedule.days` |
| Uhrzeit der Abholung in Florenz | `site.ts` → `schedule.days[0]` |
| Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter | | Schriftliche Bestätigung des Vermieters, dass die Booking-Bilder genutzt werden dürfen (Urheber ist der Vermieter bzw. Fotograf) | Ablage beim Veranstalter |
| 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` |
@@ -28,6 +32,7 @@ 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) |
+6 -2
View File
@@ -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", "team", "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,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;
+5
View File
@@ -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)
@@ -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"
+2
View File
@@ -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"),
+41 -48
View File
@@ -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);
} }
@@ -101,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 {
@@ -229,64 +229,57 @@
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>"); 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>");
} }
/* Masthead: der eine laute Moment der Seite. */
.masthead {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(4.25rem, 21vw, 19rem);
line-height: 0.8;
letter-spacing: -0.035em;
font-variation-settings: "opsz" 96;
}
/* /*
* Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt * 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. */
+9 -14
View File
@@ -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, Team, 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>
); );
+3
View File
@@ -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",
@@ -48,6 +49,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ path
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400", "Cache-Control": "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") ?? "");
+1 -1
View File
@@ -1 +1 @@
ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
+24 -14
View File
@@ -1,10 +1,11 @@
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, TeamSection, VoicesSection } from "@/components/sections/PeopleSections"; import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
import { PricingSection } from "@/components/sections/PricingSection"; import { PricingSection } from "@/components/sections/PricingSection";
import { ReviewsSection } from "@/components/sections/ReviewsSection"; import { ReviewsSection } from "@/components/sections/ReviewsSection";
import { SiteFooter } from "@/components/SiteFooter"; import { SiteFooter } from "@/components/SiteFooter";
@@ -12,34 +13,43 @@ import { SiteHeader } from "@/components/SiteHeader";
import { EventJsonLd } from "@/components/EventJsonLd"; import { EventJsonLd } from "@/components/EventJsonLd";
import { fetchGoogleReviews } from "@/lib/google-reviews"; 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, 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();
// Rezensionen parallel laden; ein Fehler dort liefert null und blendet // Rezensionen parallel laden; ein Fehler dort liefert null und blendet
// nur den Abschnitt aus, nie die Seite. // nur den Abschnitt aus, nie die Seite.
const [{ facts, pricing, showDrafts, externalVideoProvider }, reviews] = await Promise.all([ const [{ facts, pricing, showDrafts }, media, reviews] = await Promise.all([
loadPublicPage(), loadPublicPage(),
loadSiteMedia(),
fetchGoogleReviews().catch(() => null), 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} /> <DateBar facts={facts} />
<ServicesSection showDrafts={showDrafts} /> <ExpectSection />
<ScheduleSection facts={facts} />
<AccommodationSection facts={facts} showDrafts={showDrafts} /> <AccommodationSection facts={facts} showDrafts={showDrafts} />
<TeamSection showDrafts={showDrafts} />
<AboutSection showDrafts={showDrafts} />
<ReviewsSection data={reviews} 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} />
<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>
+1 -1
View File
@@ -1 +1 @@
ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
+34
View File
@@ -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>
);
}
+75 -77
View File
@@ -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,94 +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,
* Schlagzeile und Buchung stehen daneben und bleiben im ersten Bildschirm. * wie bei der Filmcamp-Referenz. Bewusst ohne Preis: der steht im
* Termin, Ort und Preis folgen als Cover-Lines in einer eigenen Leiste. * 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;
const coverLines = [
{ term: "Termin", value: dateLine(facts) },
{ term: "Ort", value: facts.location ?? "Italien, genauer Ort folgt" },
{ term: "Preis", value: priceLine(pricing) },
];
return ( return (
<section id="top" aria-labelledby="hero-title" className="relative pb-20 pt-3 md:pb-28"> <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>
</p>
<h1 id="hero-title" className="relative z-10 mt-2 md:mt-3">
<span className="masthead hero-rise block" aria-hidden>
ARTISTA
</span>
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
</h1>
<div className="mt-[-3.4vw] grid gap-9 md:grid-cols-12 md:gap-10">
<div className="hero-image md:col-span-7">
<MediaFrame
media={site.hero.media}
aspect="aspect-[4/3] md:aspect-[4/5] lg:aspect-[7/6]"
sizes="(min-width: 768px) 58vw, 100vw"
showLabel={showDrafts}
priority
/>
</div>
<div className="hero-fade flex flex-col md:col-span-5 md:pt-[5.5vw] lg:col-start-8">
<p className="display text-[2.5rem] leading-[1.02] tracking-[-0.02em] md:text-[clamp(2.25rem,3.6vw,3.375rem)]">
{/* Jeder Satz eine Zeile: bricht nie mitten im Gedanken um. */}
{site.hero.headline.split(/(?<=\.)\s+/).map((sentence) => (
<span key={sentence} className="block">
{sentence}
</span>
))}
</p>
<p className="mt-6 max-w-[34ch] text-lg leading-relaxed text-ink/85">{site.hero.subline}</p>
<div className="mt-9 flex flex-wrap items-center gap-x-7 gap-y-4">
{pricing.checkoutOpen ? (
<BookButton className="btn btn-primary px-7">Platz buchen</BookButton>
) : (
<a href="#preis" className="btn btn-primary px-7">
Preis und Buchungsstart
</a>
)}
<a href="#camp" className="link-quiet font-semibold">
Mehr über das Camp
</a>
</div>
</div>
</div>
<dl className="hero-fade mt-12 grid border-t border-ink sm:grid-cols-3 md:mt-16">
{coverLines.map((line, index) => (
<div
key={line.term}
className={`border-b border-line py-5 sm:border-b-0 sm:py-6 ${index > 0 ? "sm:border-l sm:pl-6 md:pl-8" : "sm:pr-6"}`}
>
<dt className="text-sm text-stone">{line.term}</dt>
<dd className="display mt-2 text-[1.5rem] leading-tight sm:text-[1.25rem] md:text-[1.375rem] lg:text-[1.75rem]">{line.value}</dd>
</div>
))}
</dl>
</div> </div>
<div className="container-edge flex flex-col items-center pb-20 pt-32 text-center">
<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]">
<span className="sr-only">ARTISTA </span>
{hero.title}
</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="hero-fade hero-fade-later mt-10 flex flex-wrap justify-center gap-3">
{pricing.checkoutOpen ? (
<BookButton className="btn btn-primary px-8 text-lg">{hero.cta}</BookButton>
) : (
<a href="#preis" className="btn btn-primary px-8 text-lg">
{hero.cta}
</a>
)}
<a href="#erwartet" className="btn btn-quiet px-8 text-lg">
Was dich erwartet
</a>
</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>
); );
} }
+12
View File
@@ -0,0 +1,12 @@
/**
* ARTISTA-Logo aus dem Medienverzeichnis (logo.svg/png bzw. logo-hell.*).
* Ohne Datei erscheint ein Schriftzug. Bewusst <img> statt next/image:
* SVG-Logos laufen nicht durch die Bildoptimierung.
*/
export function Logo({ src, className = "" }: { src: string | null; className?: string }) {
if (src) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="ARTISTA" className={`h-full w-auto ${className}`} />;
}
return <span className={`font-bold uppercase tracking-[0.32em] ${className}`}>Artista</span>;
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
export type ScheduleCard = { label: string; date: string | null; title: string | null; items: string[] };
/**
* 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.
*/
export function ScheduleCarousel({ days, emptyText }: { days: ScheduleCard[]; emptyText: string }) {
const track = useRef<HTMLOListElement>(null);
const cards = useRef<(HTMLLIElement | null)[]>([]);
const [current, setCurrent] = useState(0);
useEffect(() => {
const root = track.current;
if (!root) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting).map((e) => Number((e.target as HTMLElement).dataset.index));
if (visible.length) setCurrent(Math.min(...visible));
},
{ root, threshold: 0.6 },
);
cards.current.forEach((card) => card && observer.observe(card));
return () => observer.disconnect();
}, []);
const go = (index: number) => {
const target = cards.current[Math.max(0, Math.min(days.length - 1, index))];
const root = track.current;
if (!target || !root) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.scrollTo({ left: target.offsetLeft - root.offsetLeft, behavior: reduce ? "auto" : "smooth" });
};
return (
<div>
<ol
ref={track}
aria-label="Tage im Camp"
// Per Tastatur scrollbar (Pfeiltasten), WCAG 2.1.1.
tabIndex={0}
className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-4 overflow-x-auto px-4 pb-4 md:scroll-px-10 [scrollbar-width:none] md:-mx-10 md:gap-6 md:px-10 [&::-webkit-scrollbar]:hidden"
>
{days.map((day, index) => (
<li
key={day.label}
ref={(el) => {
cards.current[index] = el;
}}
data-index={index}
className="flex w-[85%] shrink-0 snap-start flex-col rounded-2xl bg-paper p-7 shadow-[0_1px_0_var(--color-line)] sm:w-[60%] md:w-[44%] md:p-9 lg:w-[31%]"
>
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-rosso">{day.label}</p>
{day.date && <p className="mt-1 text-xl font-bold md:text-2xl">{day.date}</p>}
{day.title && <p className="mt-1 text-stone">{day.title}</p>}
{day.items.length > 0 ? (
<ul className="mt-6 space-y-3 border-t border-line pt-5">
{day.items.map((item) => (
<li key={item} className="flex gap-3">
<span aria-hidden className="mt-[0.6em] h-1.5 w-1.5 shrink-0 rounded-full bg-rosso" />
{item}
</li>
))}
</ul>
) : (
<p className="mt-6 border-t border-line pt-5 text-stone">{emptyText}</p>
)}
</li>
))}
</ol>
<div className="mt-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={current === 0} 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={current >= days.length - 1} 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>
</div>
);
}
+6 -2
View File
@@ -1,12 +1,15 @@
import type { PublicCampFacts } from "@/lib/camp"; import type { PublicCampFacts } from "@/lib/camp";
import { Logo } from "./Logo";
export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) { export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: string | 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-10 items-center text-2xl">
<Logo src={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 +42,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>
+64 -42
View File
@@ -3,8 +3,19 @@
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 { 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: string | null; logoDark: string | 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 +55,66 @@ export function SiteHeader() {
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [menuOpen]); }, [menuOpen]);
const solid = scrolled || menuOpen;
const navList = (items: readonly NavItem[], align: string) => (
<ul className={`flex items-center gap-7 text-[0.9375rem] font-medium ${align}`}>
{items.map((item) => (
<li key={item.id} className="relative">
<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">
{item.label}
</a>
{active === item.id && (
<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>
))}
</ul>
);
return ( return (
<header <header
className={`sticky top-0 z-40 transition-colors duration-300 ${scrolled ? "bg-plaster/95 shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "bg-transparent"}`} 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"> <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 Zum Inhalt springen
</a> </a>
<div className="container-edge flex h-16 items-center justify-between gap-6"> <LazyMotion features={domAnimation}>
<a href="#top" className="display text-xl tracking-tight" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang"> <div className="container-edge grid h-[4.5rem] grid-cols-[1fr_auto_1fr] items-center gap-4">
ARTISTA <span className="hidden font-sans text-sm text-stone sm:inline">Modelcamp 2027</span> <div className="flex items-center">
</a> <button
<LazyMotion features={domAnimation}> ref={menuButton}
<nav aria-label="Abschnitte" className="hidden lg:block"> type="button"
<ul className="flex items-center gap-6 text-[0.9375rem]"> className="inline-flex h-11 items-center rounded-full border border-current/40 px-4 text-sm font-semibold xl:hidden"
{NAV_ITEMS.map((item) => ( aria-expanded={menuOpen}
<li key={item.id} className="relative"> aria-controls="mobile-menu"
<a onClick={() => setMenuOpen((v) => !v)}
href={`#${item.id}`} >
aria-current={active === item.id ? "location" : undefined} {menuOpen ? "Schließen" : "Menü"}
className="block py-2 text-ink/80 hover:text-ink aria-[current]:text-ink" </button>
> <nav aria-label="Abschnitte" className="hidden xl:block">
{item.label} {navList(LEFT, "justify-start")}
</a> </nav>
{active === item.id && ( </div>
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-px bg-rosso" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
)} <a href="#top" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang" className="flex h-8 items-center text-xl md:h-10 md:text-2xl">
</li> <Logo src={solid ? logoDark : logoLight} />
))}
</ul>
</nav>
</LazyMotion>
<div className="flex items-center gap-3">
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
Platz buchen
</a> </a>
<button
ref={menuButton} <div className="flex items-center justify-end gap-7">
type="button" <nav aria-label="Weitere Abschnitte" className="hidden xl:block">
className="inline-flex h-11 items-center rounded-full border border-ink/30 px-4 text-sm font-semibold lg:hidden" {navList(RIGHT, "justify-end")}
aria-expanded={menuOpen} </nav>
aria-controls="mobile-menu" <a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
onClick={() => setMenuOpen((v) => !v)} Platz sichern
> </a>
{menuOpen ? "Schließen" : "Menü"} </div>
</button>
</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}>
+99
View File
@@ -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>
);
}
+20 -56
View File
@@ -4,50 +4,32 @@ import { useState } from "react";
import type { Media } from "@/content/site"; import type { Media } from "@/content/site";
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 }; type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
const EXTERNAL = {
"youtube-nocookie": { label: "YouTube", url: (id: string) => `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0` },
vimeo: { label: "Vimeo", url: (id: string) => `https://player.vimeo.com/video/${encodeURIComponent(id)}?autoplay=1&dnt=1` },
} as const;
/** /**
* Ein Feedback-Video. Geladen wird erst nach Klick (preload="none"); es * Ein Reel im Hochformat (9:16) aus dem eigenen Medienverzeichnis. Das
* startet nie von selbst mit Ton. Externe Player (YouTube/Vimeo) werden nur * Video laedt erst nach Klick (preload="none") und startet nie von selbst
* geladen, wenn das Datenschutzkonzept sie erlaubt (EXTERNAL_VIDEO_PROVIDER) * mit Ton. Kein externer Anbieter, keine Datenuebertragung an Dritte.
* und die Besucherin dem Laden ausdruecklich zustimmt.
*/ */
export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; index: number; provider: "none" | "youtube-nocookie" | "vimeo"; showDrafts: boolean }) { export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
const [stage, setStage] = useState<"poster" | "consent" | "playing">("poster"); const [playing, setPlaying] = useState(false);
const label = slot.name ? `Video von ${slot.name} abspielen` : `Feedback-Video ${index + 1} abspielen`; const label = slot.name ? `Reel von ${slot.name} abspielen` : `Reel ${index + 1} abspielen`;
const external = slot.externalId && provider !== "none" ? EXTERNAL[provider] : null; const poster: Media = slot.poster ?? { kind: "placeholder", label: `Reel ${index + 1} folgt`, tone: "studio" };
const playable = Boolean(slot.src) || Boolean(external);
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"> <div className="relative aspect-[9/16] overflow-hidden rounded-2xl bg-ink">
{stage === "playing" && slot.src && ( {playing && slot.src ? (
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover"> <video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
<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} /> <MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 640px) 33vw, 100vw" showLabel={showDrafts} />
{playable && stage === "poster" && ( {slot.src && (
<button <button
type="button" type="button"
onClick={() => setStage(external && !slot.src ? "consent" : "playing")} onClick={() => setPlaying(true)}
aria-label={label} 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" 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"
> >
@@ -56,33 +38,15 @@ export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; i
</svg> </svg>
</button> </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> </div>
<figcaption className="mt-4"> {(slot.name || slot.caption || showDrafts) && (
{slot.name ? ( <figcaption className="mt-4">
<span className="block font-semibold">{slot.name}</span> {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>}
showDrafts && <span className="draft-flag">Name und Freigabe folgen</span> </figcaption>
)} )}
{slot.caption && <span className="mt-1 block text-ink/80">{slot.caption}</span>}
</figcaption>
</figure> </figure>
); );
} }
+1 -1
View File
@@ -312,7 +312,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>
+1 -1
View File
@@ -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
+1 -101
View File
@@ -2,99 +2,6 @@ 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-24 md:py-36">
<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-24 text-paper md:py-36">
<div className="container-edge">
<div className="grid gap-14 md:grid-cols-12 md:gap-10">
<h2 id="leistungen-title" className="section-title md:col-span-5">{services.title}</h2>
<div className="md:col-span-7 md:col-start-6 lg:col-span-6 lg:col-start-7">
<h3 className="text-[0.9375rem] font-semibold text-paper/75">Im Preis enthalten</h3>
<ul className="mt-5 space-y-4">
{included.map((item) => (
<li key={item.text} className="display text-[1.625rem] leading-[1.2] md:text-[2rem]">
{item.text}
{!item.confirmed && <DraftFlag show />}
</li>
))}
</ul>
{!showDrafts && included.length < services.included.length && (
<p className="mt-6 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
)}
<h3 className="mt-14 text-[0.9375rem] font-semibold text-paper/75">Nicht enthalten</h3>
<p className="mt-3 max-w-xl text-lg text-paper/85">{services.excluded.join(", ")}.</p>
</div>
</div>
<div className="mt-24 md:mt-32">
<h3 className="display max-w-2xl text-[2rem] leading-[1.1] md:text-[2.5rem]">So könnte ein Tag im Camp aussehen</h3>
<p className="mt-4 max-w-xl text-[0.9375rem] text-paper/75">{services.exampleSchedule.note}</p>
{/* Nummeriert, weil es eine Abfolge ist; ausdruecklich als Beispiel gekennzeichnet. */}
<ol className="mt-12 grid border-t border-paper/30 md:grid-cols-3">
{services.exampleSchedule.steps.map((step, index) => (
<li
key={step.title}
className={`border-b border-paper/15 py-7 md:border-b-0 md:py-9 ${index > 0 ? "md:border-l md:border-paper/15 md:pl-8" : "md:pr-8"}`}
>
<p className="flex items-baseline gap-4">
<span className="text-sm tabular-nums text-paper/60">{index + 1}</span>
<span className="display text-[1.75rem] italic">{step.title}</span>
</p>
<p className="mt-3 max-w-xs 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 (
@@ -111,7 +18,7 @@ export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampF
<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">
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. 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>
@@ -123,13 +30,6 @@ export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampF
))} ))}
</ul> </ul>
<p className="mt-2 text-sm text-stone">Laut Beschreibung der Unterkunft.</p> <p className="mt-2 text-sm text-stone">Laut Beschreibung der Unterkunft.</p>
{accommodation.bookingUrl ? (
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet mt-6">
Unterkunft ansehen (externe Seite)
</a>
) : (
showDrafts && <span className="draft-flag">Link zur Unterkunft folgt</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,65 @@
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">
<div className="flex flex-wrap items-end justify-between gap-6">
<h2 id="ablauf-title" className="section-title">{schedule.title}</h2>
<p className="max-w-md text-ink/80">{schedule.note}</p>
</div>
<div className="mt-12">
<ScheduleCarousel days={scheduleCards(facts)} emptyText="Programm folgt." />
</div>
</div>
</section>
);
}
+21 -7
View File
@@ -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 holen wir dich am Bahnhof Firenze Santa Maria Novella ab und bringen dich zur Villa. Kommst du mit dem Auto, fährst du direkt zur Unterkunft. 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?",
@@ -89,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">
+35 -38
View File
@@ -1,9 +1,10 @@
import { site, type Media } 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";
type TeamMember = { name: string; role: string; bio: string | null; portrait: Media }; type TeamMember = { slug: string; name: string; role: string; bio: string | null; portrait: Media };
function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) { function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) {
if (bio) return <p className="mt-3 max-w-md">{bio}</p>; if (bio) return <p className="mt-3 max-w-md">{bio}</p>;
@@ -12,13 +13,15 @@ function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) {
/** /**
* Team: Eric Heitz als Head of Modelcamp gross vorangestellt, darunter alle * Team: Eric Heitz als Head of Modelcamp gross vorangestellt, darunter alle
* weiteren gleichwertig im Raster. Portraets im Rundbogen – ein Motiv aus * weiteren gleichwertig im Raster. Portraets im Rundbogen, ein Motiv aus
* der Architektur der Villa. Die Spalten sind auf breiten Bildschirmen * der Architektur der Villa. Liegt team/<slug>.jpg im Medienverzeichnis,
* leicht versetzt, damit das Raster wie eine Magazinseite wirkt. * ersetzt das Foto den Initialen-Platzhalter.
*/ */
export function TeamSection({ showDrafts }: { showDrafts: boolean }) { export function TeamSection({ showDrafts, portraits }: { showDrafts: boolean; portraits: Record<string, Media> }) {
const { team } = site; const { team } = site;
const { head } = team; 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="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36"> <section id="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36">
<div className="container-edge"> <div className="container-edge">
@@ -29,11 +32,11 @@ export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
<article aria-label={head.name} className="mt-14 grid items-end gap-8 md:mt-20 md:grid-cols-12 md:gap-10"> <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"> <Reveal className="md:col-span-5">
<MediaFrame media={head.portrait} aspect="aspect-[4/5]" shape="arch" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} /> <MediaFrame media={portraitOf(head)} aspect="aspect-[4/5]" shape="arch" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
</Reveal> </Reveal>
<div className="md:col-span-6 md:col-start-7 md:pb-10"> <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> <p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{head.title}</p>
<h3 className="display mt-4 text-[3rem] leading-[0.95] md:text-[5.5rem]">{head.name}</h3> <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> <p className="mt-5 border-t border-ink pt-4 text-lg">{head.role}</p>
<Bio bio={head.bio} showDrafts={showDrafts} /> <Bio bio={head.bio} showDrafts={showDrafts} />
</div> </div>
@@ -41,27 +44,21 @@ export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
<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"> <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) => ( {team.people.map((person: TeamMember, index) => (
<li key={person.name} className={index % 2 === 1 ? "md:mt-16" : ""}> <li key={person.slug} className={index % 2 === 1 ? "md:mt-16" : ""}>
<article aria-label={person.name}> <article aria-label={person.name}>
<Reveal delay={(index % 4) * 0.06}> <Reveal delay={(index % 4) * 0.06}>
<MediaFrame <MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
media={person.portrait}
aspect="aspect-[3/4]"
shape="arch"
sizes="(min-width: 768px) 22vw, 50vw"
showLabel={false}
/>
</Reveal> </Reveal>
<h3 className="display mt-4 text-[1.375rem] leading-tight md:text-[1.75rem]">{person.name}</h3> <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> <p className="mt-1 text-sm text-stone md:text-[0.9375rem]">{person.role}</p>
<Bio bio={person.bio} showDrafts={showDrafts} /> <Bio bio={person.bio} showDrafts={showDrafts} />
</article> </article>
</li> </li>
))} ))}
</ul> </ul>
{showDrafts && ( {showDrafts && missing && (
<p className="mt-12"> <p className="mt-12">
<span className="draft-flag">Porträts folgen</span> Platzhalter mit Initialen, bis freigegebene Fotos vorliegen. <span className="draft-flag">Porträts folgen</span> Platzhalter mit Initialen, bis die Fotos im Medienverzeichnis liegen (docs/MEDIEN.md).
</p> </p>
)} )}
</div> </div>
@@ -71,22 +68,17 @@ export function TeamSection({ 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-28"> <section id="artista" aria-labelledby="artista-title" className="py-20 md:py-28">
<div className="container-edge grid gap-6 border-t border-ink pt-10 md:grid-cols-12 md:gap-10 md:pt-14"> <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="display text-[2.25rem] leading-none md:col-span-5 md:text-[3rem]">{about.title}</h2> <h2 id="artista-title" className="section-title md:col-span-5">{about.title}</h2>
<div className="prose-body text-lg md:col-span-6 md:col-start-7 md:pt-2"> <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 && (
@@ -102,21 +94,26 @@ export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
); );
} }
export function VoicesSection({ showDrafts, provider }: { showDrafts: boolean; provider: "none" | "youtube-nocookie" | "vimeo" }) { /**
const { videos } = site; * Drei Reels im Hochformat. Leere Slots erscheinen nur in Entwicklung und
const filled = videos.slots.filter((s) => s.src || s.externalId); * Staging; oeffentlich nie ein erfundenes Testimonial als Ersatz.
// Leere Slots nur in Entwicklung/Staging zeigen; oeffentlich nie ein */
// erfundenes Testimonial als Ersatz. export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
const slots = showDrafts ? videos.slots : filled; const { reels: content } = site;
if (slots.length === 0) return null; const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
const visible = showDrafts ? slots : slots.filter((s) => s.src);
if (visible.length === 0) return null;
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 max-w-5xl gap-6 sm:grid-cols-3 md:gap-8">
{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>
+35 -33
View File
@@ -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>
+82 -62
View File
@@ -8,14 +8,16 @@
* 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 und immer als lokale * - Bilder: nur mit bestaetigter Nutzungsberechtigung und immer als lokale
* Datei, nie per Hotlinking. Feste Bilder liegen in public/images/, * Datei, nie per Hotlinking. Feste Bilder liegen in public/images/.
* austauschbare Medien im Medienverzeichnis (MEDIA_DIR, /media/…). * 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 * Die Unterkunftsbilder stammen aus dem Booking.com-Eintrag der Villa
* und wurden vom Auftraggeber am 01.10.2026 zur Nutzung freigegeben. * 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
*/ */
@@ -28,49 +30,75 @@ export type Media =
export type Confirmable<T> = T & { confirmed: boolean }; export type Confirmable<T> = T & { confirmed: boolean };
export type ScheduleDay = { title: string | null; items: string[] };
const portrait = (name: string, initials: string): Media => ({ kind: "placeholder", label: `Porträt ${name}`, tone: "portrait", glyph: initials }); 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). */ /** 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 }); 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.",
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"), 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", "Abholung am Bahnhof in Florenz", "Kleine Gruppe, ein ganzes 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",
note: "Programmentwurf. Das finale Programm kann sich noch ändern.",
// Ein Eintrag pro Tag ab Anreisetag; das Datum kommt aus dem
// Adminbereich. Das Programm liefert der Auftraggeber als PDF nach.
// Leere Tage zeigen "Programm folgt". Nichts erfinden.
days: [
{
title: "Anreise",
items: [
"Eigene Anreise nach Florenz",
"Abholung am Bahnhof Firenze Santa Maria Novella und gemeinsame Fahrt zur Villa",
"Mit dem Auto? Dann fährst du direkt zur Villa",
],
},
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: "Abreise", items: [] },
] as ScheduleDay[],
}, },
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 }, { text: "Teilnahme am Modelcamp 2027", confirmed: true },
{ text: "Unterbringung in einer geteilten Unterkunft in Italien", confirmed: true }, { text: "Sieben Nächte in der Villa La Chiesina, eigenes Bett im geteilten Zimmer", confirmed: true },
{ text: "Abholung am Anreisetag vom Bahnhof Firenze Santa Maria Novella", confirmed: true },
// Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt") // Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt")
// auf confirmed: true setzen. // auf confirmed: true setzen.
{ text: "Workshops mit den beiden Dozentinnen", confirmed: false }, { text: "Workshops und Coachings mit dem ARTISTA-Team", confirmed: false },
{ text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false }, { text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false },
] satisfies Confirmable<{ text: string }>[], ] satisfies Confirmable<{ text: string }>[],
excluded: [ excluded: [
@@ -79,26 +107,15 @@ 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: "Eine Villa zwischen Zypressen, mit Blick über die Hügel am Monte Amiata. 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.",
name: "Villa La Chiesina", name: "Villa La Chiesina",
place: "Arcidosso, Toskana", place: "Arcidosso, Toskana",
// Ohne Tracking-Parameter; Termin und Personenzahl waehlt man dort selbst.
bookingUrl: "https://www.booking.com/hotel/it/villa-la-chiesina-arcidosso.de.html" as string | null,
// Ausstattung laut Unterkunftsbeschreibung (Booking.com, Stand 01.10.2026). // Ausstattung laut Unterkunftsbeschreibung (Booking.com, Stand 01.10.2026).
// Die Booking-Seite selbst wird bewusst nicht verlinkt.
features: [ features: [
"Eigener Außenpool (saisonal)", "Eigener Außenpool (saisonal)",
"Großer Garten mit Terrasse", "Großer Garten mit Terrasse",
@@ -122,13 +139,13 @@ export const site = {
}, },
team: { team: {
title: "Das Team", title: "Mit dabei",
lead: "Fotografie, Modelcoaching, Content und eine Küche, die alle zusammenbringt: Diese Menschen sind beim Modelcamp 2027 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). Porträts folgen: // Namen und Rollen vom Auftraggeber (01.10.2026). Portraets kommen ueber
// Datei in public/images/team/ legen und "portrait" ersetzen, z. B. // das Medienverzeichnis als team/<slug>.jpg (docs/MEDIEN.md).
// { kind: "image", src: "/images/team/eric-heitz.jpg", alt: "Porträt Eric Heitz", width: 1200, height: 1500 }.
// Kurzbeschreibungen nur aus freigegebenen Angaben, keine erfundenen Referenzen. // Kurzbeschreibungen nur aus freigegebenen Angaben, keine erfundenen Referenzen.
head: { head: {
slug: "eric-heitz",
name: "Eric Heitz", name: "Eric Heitz",
title: "Head of Modelcamp", title: "Head of Modelcamp",
role: "Fotograf · Content Creator · Modelcoach", role: "Fotograf · Content Creator · Modelcoach",
@@ -136,14 +153,14 @@ export const site = {
portrait: portrait("Eric Heitz", "EH"), portrait: portrait("Eric Heitz", "EH"),
}, },
people: [ people: [
{ name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") }, { slug: "michele-ietro", name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") },
{ name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") }, { slug: "lola-stoll", name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
{ name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") }, { slug: "eileen-kaupp", name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
{ name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") }, { slug: "gabriela-pronkin", name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
{ name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") }, { slug: "stefan-hoechtl", name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") },
{ name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") }, { slug: "saskia-sirowatka", name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
{ name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") }, { slug: "andre-hartshauser", name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
{ name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") }, { slug: "ante-rejo", name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
], ],
}, },
@@ -155,13 +172,16 @@ export const site = {
paragraphs: [] as string[], paragraphs: [] as string[],
}, },
videos: { reels: {
title: "Stimmen", title: "Reels",
// Genau drei Slots. Nur echte, freigegebene Videos eintragen. lead: "Eindrücke und Stimmen in drei kurzen Clips.",
// 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 },
], ],
}, },
@@ -170,12 +190,12 @@ 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: "team", label: "Team" },
{ 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" },
] as const; ] as const;
+2
View File
@@ -220,6 +220,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(),
+4
View File
@@ -37,6 +37,8 @@ export function toGateFields(camp: Camp): CampGateFields {
taxConfirmed: camp.taxConfirmed, taxConfirmed: camp.taxConfirmed,
termsVersion: camp.termsVersion, termsVersion: camp.termsVersion,
termsApproved: camp.termsApproved, termsApproved: camp.termsApproved,
withdrawalText: camp.withdrawalText,
withdrawalApproved: camp.withdrawalApproved,
travelLawChecked: camp.travelLawChecked, travelLawChecked: camp.travelLawChecked,
installmentsApproved: camp.installmentsApproved, installmentsApproved: camp.installmentsApproved,
installmentRuleApproved: camp.installmentRuleApproved, installmentRuleApproved: camp.installmentRuleApproved,
@@ -63,6 +65,7 @@ export type PublicCampFacts = {
organizer: { name: string; address: string; email: string; phone: string | null } | null; organizer: { name: string; address: string; email: string; phone: string | null } | null;
termsUrl: string | null; termsUrl: string | null;
termsVersion: string | null; termsVersion: string | null;
withdrawalText: string | null;
}; };
export function publicCampFacts(camp: Camp): PublicCampFacts { export function publicCampFacts(camp: Camp): PublicCampFacts {
@@ -84,5 +87,6 @@ export function publicCampFacts(camp: Camp): PublicCampFacts {
: null, : null,
termsUrl: camp.termsApproved ? camp.termsUrl : null, termsUrl: camp.termsApproved ? camp.termsUrl : null,
termsVersion: camp.termsApproved ? camp.termsVersion : null, termsVersion: camp.termsApproved ? camp.termsVersion : null,
withdrawalText: camp.withdrawalApproved ? camp.withdrawalText : null,
}; };
} }
+2 -2
View File
@@ -20,7 +20,7 @@ import type { PaymentGateway } from "./payment-gateway";
import { statusTokenFor } from "./tokens"; import { statusTokenFor } from "./tokens";
import { formatDate, formatDateTime } from "./time"; import { formatDate, formatDateTime } from "./time";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme"; export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027, Teilnahme";
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung"; export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */ /** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
const MIN_STRIPE_SESSION_MS = 30 * 60_000; const MIN_STRIPE_SESSION_MS = 30 * 60_000;
@@ -43,7 +43,7 @@ export function planSummary(installments: Pick<Installment, "seq" | "amountCents
return [ return [
`Gesamtpreis ${formatEuro(totalCents)}.`, `Gesamtpreis ${formatEuro(totalCents)}.`,
today, today,
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "", later ? `Weitere Raten: ${later}, automatisch über die hier gespeicherte Zahlungsmethode.` : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
+6 -6
View File
@@ -26,7 +26,7 @@ function planLines(booking: BookingWithInstallments): string[] {
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung"; const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
const state = const state =
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen"; i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
return `Rate ${i.seq}: ${formatEuro(i.amountCents)} – ${due} – ${state}`; return `Rate ${i.seq}: ${formatEuro(i.amountCents)}, ${due}, ${state}`;
}); });
} }
@@ -43,12 +43,12 @@ ${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;back
} }
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft { function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
return { dedupeKey: key, to: booking.email, subject: `${subject} – ${BRAND}`, bookingId: booking.id, ...body }; return { dedupeKey: key, to: booking.email, subject: `${subject} | ${BRAND}`, bookingId: booking.id, ...body };
} }
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft { export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents; const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt – erste Rate erhalten"; const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt, erste Rate erhalten";
const lines = [ const lines = [
`Hallo ${booking.firstName},`, `Hallo ${booking.firstName},`,
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`, `vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
@@ -96,7 +96,7 @@ export function paymentFailedEmail(booking: Booking): EmailDraft {
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft { export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents; const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Vollständig bezahlt – danke!" : `Rate ${installment.seq} erhalten`; const subject = fullyPaid ? "Vollständig bezahlt. Danke!" : `Rate ${installment.seq} erhalten`;
return draft( return draft(
`installment-paid:${installment.id}`, `installment-paid:${installment.id}`,
booking, booking,
@@ -129,7 +129,7 @@ export function installmentActionEmail(booking: Booking, installment: Installmen
requiresAction requiresAction
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.` ? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`, : `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
"Du kannst die Rate über den folgenden Link selbst begleichen – auch mit einer anderen Zahlungsmethode.", "Du kannst die Rate über den folgenden Link selbst begleichen, auch mit einer anderen Zahlungsmethode.",
], ],
{ href: statusLink(token), label: "Rate jetzt bezahlen" }, { href: statusLink(token), label: "Rate jetzt bezahlen" },
), ),
@@ -147,7 +147,7 @@ export function adminReviewEmail(to: string, booking: Booking, reason: string):
} }
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft { export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung ist eingegangen – wir melden uns"; const subject = "Deine Zahlung ist eingegangen, wir melden uns";
return draft( return draft(
`review-customer:${booking.id}`, `review-customer:${booking.id}`,
booking, booking,
+5
View File
@@ -34,6 +34,8 @@ export type CampGateFields = {
taxConfirmed: boolean; taxConfirmed: boolean;
termsVersion: string | null; termsVersion: string | null;
termsApproved: boolean; termsApproved: boolean;
withdrawalText: string | null;
withdrawalApproved: boolean;
travelLawChecked: boolean; travelLawChecked: boolean;
installmentsApproved: boolean; installmentsApproved: boolean;
installmentRuleApproved: boolean; installmentRuleApproved: boolean;
@@ -49,6 +51,7 @@ export type ReleaseBlocker =
| "ORGANIZER" | "ORGANIZER"
| "TAX" | "TAX"
| "TERMS" | "TERMS"
| "WITHDRAWAL"
| "TRAVEL_LAW" | "TRAVEL_LAW"
| "CAPACITY" | "CAPACITY"
| "STAMMMODEL_QUOTA"; | "STAMMMODEL_QUOTA";
@@ -62,6 +65,7 @@ export const BLOCKER_TEXT: Record<ReleaseBlocker, string> = {
ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.", ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.",
TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.", TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.",
TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.", TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.",
WITHDRAWAL: "Widerrufsbelehrung ist nicht eingetragen oder nicht freigegeben.",
TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.", TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.",
CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.", CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.",
STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.", STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.",
@@ -93,6 +97,7 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
} }
if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX"); if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX");
if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS"); if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS");
if (!camp.withdrawalText || !camp.withdrawalApproved) blockers.push("WITHDRAWAL");
if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW"); if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW");
if (!camp.capacityConfirmed) blockers.push("CAPACITY"); if (!camp.capacityConfirmed) blockers.push("CAPACITY");
if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA"); if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA");
+84
View File
@@ -0,0 +1,84 @@
/**
* Austauschbare Medien ueber feste Dateinamen im Medienverzeichnis
* (MEDIA_DIR, im Cluster ein PersistentVolume, ausgeliefert unter /media/…).
*
* Liegt eine Datei unter dem erwarteten Namen dort, erscheint sie ohne
* Neustart und ohne Codeaenderung auf der Seite. Fehlt sie, bleibt der
* Platzhalter bzw. das Standardbild. Die Liste aller Namen steht in
* docs/MEDIEN.md.
*
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
*/
import { stat } from "node:fs/promises";
import path from "node:path";
import { site, type Media } from "@/content/site";
import { env } from "./env";
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
const LOGO_EXTS = [".svg", ".png", ".webp"];
const VIDEO_EXTS = [".mp4", ".webm"];
type Found = { src: string; ext: string };
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
const dir = path.resolve(env().MEDIA_DIR);
for (const ext of exts) {
try {
const info = await stat(path.join(dir, `${base}${ext}`));
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
} catch {
// Datei fehlt: naechste Endung probieren.
}
}
return null;
}
async function image(base: string, alt: string): Promise<Media | null> {
const found = await find(base, IMAGE_EXTS);
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
}
export type Reel = { src: string | null; poster: Media | null };
export type SiteMedia = {
/** Logo fuer helle Flaechen (dunkles Logo). */
logoDark: string | null;
/** Logo auf Foto oder dunklem Grund (helles Logo). */
logoLight: string | null;
header: Media;
headerVideo: string | null;
portraits: Record<string, Media>;
reels: Reel[];
};
export async function loadSiteMedia(): Promise<SiteMedia> {
const people = [site.team.head, ...site.team.people];
const [logoDark, logoLight, header, headerVideo, portraitList, reels] = await Promise.all([
find("logo", LOGO_EXTS),
find("logo-hell", LOGO_EXTS),
image("header", "ARTISTA Modelcamp in der Toskana"),
find("header", VIDEO_EXTS),
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
Promise.all(
[1, 2, 3].map(async (n): Promise<Reel> => {
const [video, poster] = await Promise.all([find(`reels/reel-${n}`, VIDEO_EXTS), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
return { src: video?.src ?? null, poster };
}),
),
]);
const portraits: Record<string, Media> = {};
people.forEach((p, index) => {
portraits[p.slug] = portraitList[index] ?? p.portrait;
});
return {
logoDark: logoDark?.src ?? logoLight?.src ?? null,
logoLight: logoLight?.src ?? logoDark?.src ?? null,
header: header ?? site.hero.media,
headerVideo: headerVideo?.src ?? null,
portraits,
reels,
};
}
+12
View File
@@ -130,3 +130,15 @@ export function formatIsoDateLong(isoDate: string): string {
function pad(n: number): string { function pad(n: number): string {
return String(n).padStart(2, "0"); return String(n).padStart(2, "0");
} }
const weekdayDateFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
weekday: "long",
day: "numeric",
month: "long",
});
/** "Samstag, 8. Mai" fuer reine Datumswerte (YYYY-MM-DD). */
export function formatIsoWeekdayDate(isoDate: string): string {
return weekdayDateFormatter.format(isoToDbDate(isoDate));
}