From 7203cd1e6e08747ceabf222d7f6e077b478d7f99 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 23:53:18 +0200 Subject: [PATCH] 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) --- README.md | 8 +- docs/MEDIEN.md | 71 ++++++++ docs/OFFENE-PUNKTE.md | 17 +- e2e/onepage.spec.ts | 8 +- .../20261001150000_widerruf/migration.sql | 3 + prisma/schema.prisma | 5 + .../admin/(bereich)/einstellungen/page.tsx | 8 + src/app/admin/actions.ts | 2 + src/app/globals.css | 89 +++++----- src/app/layout.tsx | 23 ++- src/app/media/[...path]/route.ts | 3 + src/app/opengraph-image.alt.txt | 2 +- src/app/page.tsx | 38 +++-- src/app/twitter-image.alt.txt | 2 +- src/app/widerruf/page.tsx | 34 ++++ src/components/Hero.tsx | 152 +++++++++--------- src/components/Logo.tsx | 12 ++ src/components/ScheduleCarousel.tsx | 106 ++++++++++++ src/components/SiteFooter.tsx | 8 +- src/components/SiteHeader.tsx | 106 +++++++----- src/components/Slideshow.tsx | 99 ++++++++++++ src/components/VideoSlot.tsx | 76 +++------ src/components/booking/BookingDialog.tsx | 2 +- src/components/booking/MobileBookingBar.tsx | 2 +- src/components/sections/CampSections.tsx | 102 +----------- src/components/sections/ExpectSections.tsx | 65 ++++++++ src/components/sections/FaqSection.tsx | 28 +++- src/components/sections/PeopleSections.tsx | 73 ++++----- src/components/sections/PricingSection.tsx | 68 ++++---- src/content/site.ts | 144 ++++++++++------- src/lib/admin-service.ts | 2 + src/lib/camp.ts | 4 + src/lib/checkout.ts | 4 +- src/lib/email-templates.ts | 12 +- src/lib/sales-gate.ts | 5 + src/lib/site-media.ts | 84 ++++++++++ src/lib/time.ts | 12 ++ 37 files changed, 962 insertions(+), 517 deletions(-) create mode 100644 docs/MEDIEN.md create mode 100644 prisma/migrations/20261001150000_widerruf/migration.sql create mode 100644 src/app/widerruf/page.tsx create mode 100644 src/components/Logo.tsx create mode 100644 src/components/ScheduleCarousel.tsx create mode 100644 src/components/Slideshow.tsx create mode 100644 src/components/sections/ExpectSections.tsx create mode 100644 src/lib/site-media.ts diff --git a/README.md b/README.md index 0fa3775..d6da786 100644 --- a/README.md +++ b/README.md @@ -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 -- **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“. - **Preise und Preisphasen:** fest in [src/lib/pricing.ts](src/lib/pricing.ts), weil sie verbindlich vorgegeben sind. - **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). -- **Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen. +- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben). +- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md). +- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`. +- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen. diff --git a/docs/MEDIEN.md b/docs/MEDIEN.md new file mode 100644 index 0000000..37d3d1e --- /dev/null +++ b/docs/MEDIEN.md @@ -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. diff --git a/docs/OFFENE-PUNKTE.md b/docs/OFFENE-PUNKTE.md index 9f6004b..bfec21f 100644 --- a/docs/OFFENE-PUNKTE.md +++ b/docs/OFFENE-PUNKTE.md @@ -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) - Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer - 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“. @@ -13,13 +15,15 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf | 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/.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 | | Link zur ARTISTA-Stammmodel-Seite und daraus abgeleiteter Text „Über ARTISTA“ | `site.ts` → `about` | -| Drei echte, freigegebene Feedback-Videos mit Namen und Begleittext | Medienvolume + `site.ts` → `videos` | -| Hero-Leitbild oder -Video | `site.ts` → `hero.media` | -| Bestätigte Leistungen (Workshops, Verpflegung, Programm) **(Freigabe)** | `site.ts` → `services`, danach Admin „Leistungen bestätigt“ | -| Verbindliches Programm (bis dahin nur als Beispiel gekennzeichnet) | `site.ts` → `exampleSchedule` | +| Drei Reels (Video + Vorschaubild) mit Freigabe der gezeigten Personen | Medienverzeichnis `reels/reel-1.mp4` usw., Namen in `site.ts` → `reels` | +| Bestätigte Leistungen (Workshops, Verpflegung) **(Freigabe)** | `site.ts` → `services`, danach Admin „Leistungen bestätigt“ | | Veranstalter- und Kontaktdaten **(Freigabe)** | Admin → Einstellungen | | 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 | |---|---| | 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 | | 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) | diff --git a/e2e/onepage.spec.ts b/e2e/onepage.spec.ts index 04f7ca9..8656d94 100644 --- a/e2e/onepage.spec.ts +++ b/e2e/onepage.spec.ts @@ -10,9 +10,13 @@ import { expect, test } from "@playwright/test"; test("Onepage: Aufbau, kein horizontales Scrollen, Barrierefreiheit", async ({ page }, testInfo) => { await page.goto("/"); await expect(page.getByRole("heading", { level: 1 })).toContainText("ARTISTA"); - for (const id of ["camp", "leistungen", "unterkunft", "team", "artista", "preis", "fragen"]) { + for (const id of ["termin", "erwartet", "ablauf", "unterkunft", "preis", "team", "fragen"]) { 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. 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) => { 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(); const dialog = page.getByRole("dialog"); diff --git a/prisma/migrations/20261001150000_widerruf/migration.sql b/prisma/migrations/20261001150000_widerruf/migration.sql new file mode 100644 index 0000000..566a779 --- /dev/null +++ b/prisma/migrations/20261001150000_widerruf/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1f83b7e..4e85863 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,6 +76,11 @@ model Camp { termsUrl String? 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) installmentsApproved Boolean @default(false) diff --git a/src/app/admin/(bereich)/einstellungen/page.tsx b/src/app/admin/(bereich)/einstellungen/page.tsx index a7617f0..4f67c8f 100644 --- a/src/app/admin/(bereich)/einstellungen/page.tsx +++ b/src/app/admin/(bereich)/einstellungen/page.tsx @@ -73,6 +73,14 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro + + "); } -/* Masthead: der eine laute Moment der Seite. */ -.masthead { - font-family: var(--font-display); - font-weight: 500; - font-size: clamp(4.25rem, 21vw, 19rem); - line-height: 0.8; - letter-spacing: -0.035em; - font-variation-settings: "opsz" 96; -} - /* - * Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt - * sich, die Cover-Lines folgen. Reines CSS, kein JavaScript noetig; bei - * reduzierter Bewegung greift die Regel in @layer base. + * Eroeffnung: Headerbild zoomt langsam heran, Titel, Text und Buttons + * blenden nacheinander ein. Reines CSS; bei reduzierter Bewegung greift + * die Regel in @layer base, das Headervideo wird dann ausgeblendet. */ -@keyframes hero-rise { +@keyframes hero-zoom { from { - opacity: 0; - transform: translateY(0.18em); - clip-path: inset(0 0 100% 0); + transform: scale(1.12); } to { - opacity: 1; - transform: none; - clip-path: inset(0 0 0 0); - } -} -@keyframes hero-settle { - from { - opacity: 0; - transform: scale(1.035); - } - to { - opacity: 1; - transform: none; + transform: scale(1); } } @keyframes hero-fade { from { opacity: 0; - transform: translateY(10px); + transform: translateY(14px); } to { opacity: 1; transform: none; } } -.hero-rise { - animation: hero-rise 1.1s var(--ease-editorial) both; +@keyframes hero-bounce { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(6px); + } } -.hero-image { - overflow: hidden; -} -.hero-image > * { - animation: hero-settle 1.5s var(--ease-editorial) 0.15s both; +.hero-zoom { + animation: hero-zoom 9s cubic-bezier(0.2, 0.6, 0.3, 1) both; } .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. */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e9f05fc..fa9c0c0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,28 @@ 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 "./globals.css"; // next/font laedt die Schriften zur Build-Zeit herunter und liefert sie // selbst aus: Besucherinnen stellen keine Verbindung zu Google her. -const bodoni = Bodoni_Moda({ +// Eine Schriftfamilie fuer alles, wie auf der Filmcamp-Referenz: runde, +// klare Grotesk, Ueberschriften im Halbfett. +const figtree = Figtree({ subsets: ["latin"], - variable: "--font-bodoni", - display: "swap", - axes: ["opsz"], - style: ["normal", "italic"], -}); - -const hanken = Hanken_Grotesk({ - subsets: ["latin"], - variable: "--font-hanken", + variable: "--font-figtree", display: "swap", + weight: ["400", "500", "600", "700", "800"], }); const TITLE = "ARTISTA MODELCAMP 2027"; 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 { const { APP_URL, APP_ENV } = env(); return { metadataBase: new URL(APP_URL), - title: { default: TITLE, template: `%s – ${TITLE}` }, + title: { default: TITLE, template: `%s | ${TITLE}` }, description: DESCRIPTION, alternates: { canonical: "/" }, openGraph: { @@ -52,7 +47,7 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} ); diff --git a/src/app/media/[...path]/route.ts b/src/app/media/[...path]/route.ts index e327ee4..7239a36 100644 --- a/src/app/media/[...path]/route.ts +++ b/src/app/media/[...path]/route.ts @@ -17,6 +17,7 @@ const TYPES: Record = { ".png": "image/png", ".webp": "image/webp", ".avif": "image/avif", + ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".vtt": "text/vtt", @@ -48,6 +49,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ path "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400", "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") ?? ""); diff --git a/src/app/opengraph-image.alt.txt b/src/app/opengraph-image.alt.txt index 9a06a37..b3c721d 100644 --- a/src/app/opengraph-image.alt.txt +++ b/src/app/opengraph-image.alt.txt @@ -1 +1 @@ -ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen \ No newline at end of file +ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 896bef1..92d8b44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,11 @@ import { connection } from "next/server"; import { BookingProvider } from "@/components/booking/BookingProvider"; import { MobileBookingBar } from "@/components/booking/MobileBookingBar"; -import { Hero, priceLine } from "@/components/Hero"; -import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections"; +import { DateBar, Hero, priceLine } from "@/components/Hero"; +import { AccommodationSection } from "@/components/sections/CampSections"; +import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections"; 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 { ReviewsSection } from "@/components/sections/ReviewsSection"; import { SiteFooter } from "@/components/SiteFooter"; @@ -12,34 +13,43 @@ import { SiteHeader } from "@/components/SiteHeader"; import { EventJsonLd } from "@/components/EventJsonLd"; import { fetchGoogleReviews } from "@/lib/google-reviews"; 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() { - // Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab. + // Preisphase, Verkaufsfreigabe und Medien haengen von Uhrzeit, Datenbank + // und Medienverzeichnis ab. await connection(); // Rezensionen parallel laden; ein Fehler dort liefert null und blendet // 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(), + loadSiteMedia(), fetchGoogleReviews().catch(() => null), ]); return ( - +
- - - + + + + - - - - + + + +
- +
diff --git a/src/app/twitter-image.alt.txt b/src/app/twitter-image.alt.txt index 9a06a37..b3c721d 100644 --- a/src/app/twitter-image.alt.txt +++ b/src/app/twitter-image.alt.txt @@ -1 +1 @@ -ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen \ No newline at end of file +ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen \ No newline at end of file diff --git a/src/app/widerruf/page.tsx b/src/app/widerruf/page.tsx new file mode 100644 index 0000000..7a45fa8 --- /dev/null +++ b/src/app/widerruf/page.tsx @@ -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 ( + + {facts.withdrawalText ? ( + facts.withdrawalText + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph) => ( +

+ {paragraph} +

+ )) + ) : ( + + )} +
+ ); +} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index b14fc36..ca84f6b 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -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 { formatEuroShort } from "@/lib/money"; import type { PublicPricing } from "@/lib/public-page"; @@ -6,94 +7,91 @@ import { formatIsoDateLong } from "@/lib/time"; import { MediaFrame } from "./MediaFrame"; import { BookButton } from "./booking/BookingProvider"; -export function dateLine(facts: PublicCampFacts): string { - if (!facts.startDate) return "Termin wird bekanntgegeben"; +export function dateLine(facts: PublicCampFacts): string | null { + if (!facts.startDate) return null; 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)}`; } +/** Kurzform fuer die mobile Buchungsleiste. Ohne Startdatum des Verkaufs. */ export function priceLine(pricing: PublicPricing): string { - if (pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null) { - return `${formatEuroShort(pricing.currentCents)} Early Bird statt ${formatEuroShort(pricing.regularCents)}`; - } if (pricing.phase === "REGULAR" && pricing.currentCents !== null) return `${formatEuroShort(pricing.currentCents)} pro Person`; - return `Early Bird ab 1. November 2026: ${formatEuroShort(pricing.earlyBirdCents)}`; + return `Early Bird ${formatEuroShort(pricing.earlyBirdCents)} statt ${formatEuroShort(pricing.regularCents)}`; +} + +function Backdrop({ media, video }: { media: Media; video: string | null }) { + if (media.kind !== "image") { + return ; + } + return ( + <> + {media.alt} + {video && ( + // Stumm und ohne Ton-Autoplay; bei reduzierter Bewegung blendet + // globals.css das Video aus und das Standbild bleibt. +