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

+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 { 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 <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,
* Schlagzeile und Buchung stehen daneben und bleiben im ersten Bildschirm.
* Termin, Ort und Preis folgen als Cover-Lines in einer eigenen Leiste.
* Headerbild ueber die volle Breite und Hoehe, Titel und Buchung mittig,
* wie bei der Filmcamp-Referenz. Bewusst ohne Preis: der steht im
* Preisabschnitt.
*/
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
const coverLines = [
{ term: "Termin", value: dateLine(facts) },
{ term: "Ort", value: facts.location ?? "Italien, genauer Ort folgt" },
{ term: "Preis", value: priceLine(pricing) },
];
export function Hero({ pricing, media, video }: { pricing: PublicPricing; media: Media; video: string | null }) {
const { hero } = site;
return (
<section id="top" aria-labelledby="hero-title" className="relative pb-20 pt-3 md:pb-28">
<div className="container-edge">
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
<span>Modelcamp 2027</span>
<span>{place}</span>
</p>
<h1 id="hero-title" className="relative z-10 mt-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>
<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="absolute inset-0 -z-10">
<Backdrop media={media} video={video} />
<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%)]" />
</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>
);
}
+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 { 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;
return (
<footer id="site-footer" className="border-t border-line bg-plaster">
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
<div className="md:col-span-5">
<p className="display text-4xl">ARTISTA</p>
<p className="flex h-10 items-center text-2xl">
<Logo src={logo} />
</p>
<p className="mt-1 text-stone">Modelcamp 2027</p>
</div>
<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="/datenschutz" className="hover:underline">Datenschutz</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>
</nav>
</div>
+64 -42
View File
@@ -3,8 +3,19 @@
import { LazyMotion, domAnimation, m } from "motion/react";
import { useEffect, useRef, useState } from "react";
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 [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@@ -44,55 +55,66 @@ export function SiteHeader() {
return () => document.removeEventListener("keydown", onKey);
}, [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 (
<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
</a>
<div className="container-edge flex h-16 items-center justify-between gap-6">
<a href="#top" className="display text-xl tracking-tight" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang">
ARTISTA <span className="hidden font-sans text-sm text-stone sm:inline">Modelcamp 2027</span>
</a>
<LazyMotion features={domAnimation}>
<nav aria-label="Abschnitte" className="hidden lg:block">
<ul className="flex items-center gap-6 text-[0.9375rem]">
{NAV_ITEMS.map((item) => (
<li key={item.id} className="relative">
<a
href={`#${item.id}`}
aria-current={active === item.id ? "location" : undefined}
className="block py-2 text-ink/80 hover:text-ink aria-[current]:text-ink"
>
{item.label}
</a>
{active === item.id && (
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-px bg-rosso" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
)}
</li>
))}
</ul>
</nav>
</LazyMotion>
<div className="flex items-center gap-3">
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
Platz buchen
<LazyMotion features={domAnimation}>
<div className="container-edge grid h-[4.5rem] grid-cols-[1fr_auto_1fr] items-center gap-4">
<div className="flex items-center">
<button
ref={menuButton}
type="button"
className="inline-flex h-11 items-center rounded-full border border-current/40 px-4 text-sm font-semibold xl:hidden"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen((v) => !v)}
>
{menuOpen ? "Schließen" : "Menü"}
</button>
<nav aria-label="Abschnitte" className="hidden xl:block">
{navList(LEFT, "justify-start")}
</nav>
</div>
<a href="#top" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang" className="flex h-8 items-center text-xl md:h-10 md:text-2xl">
<Logo src={solid ? logoDark : logoLight} />
</a>
<button
ref={menuButton}
type="button"
className="inline-flex h-11 items-center rounded-full border border-ink/30 px-4 text-sm font-semibold lg:hidden"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen((v) => !v)}
>
{menuOpen ? "Schließen" : "Menü"}
</button>
<div className="flex items-center justify-end gap-7">
<nav aria-label="Weitere Abschnitte" className="hidden xl:block">
{navList(RIGHT, "justify-end")}
</nav>
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
Platz sichern
</a>
</div>
</div>
</div>
</LazyMotion>
{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">
{NAV_ITEMS.map((item, index) => (
<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 { MediaFrame } from "./MediaFrame";
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null; externalId: string | null };
const EXTERNAL = {
"youtube-nocookie": { label: "YouTube", url: (id: string) => `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0` },
vimeo: { label: "Vimeo", url: (id: string) => `https://player.vimeo.com/video/${encodeURIComponent(id)}?autoplay=1&dnt=1` },
} as const;
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
/**
* Ein Feedback-Video. Geladen wird erst nach Klick (preload="none"); es
* startet nie von selbst mit Ton. Externe Player (YouTube/Vimeo) werden nur
* geladen, wenn das Datenschutzkonzept sie erlaubt (EXTERNAL_VIDEO_PROVIDER)
* und die Besucherin dem Laden ausdruecklich zustimmt.
* Ein Reel im Hochformat (9:16) aus dem eigenen Medienverzeichnis. Das
* Video laedt erst nach Klick (preload="none") und startet nie von selbst
* mit Ton. Kein externer Anbieter, keine Datenuebertragung an Dritte.
*/
export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; index: number; provider: "none" | "youtube-nocookie" | "vimeo"; showDrafts: boolean }) {
const [stage, setStage] = useState<"poster" | "consent" | "playing">("poster");
const label = slot.name ? `Video von ${slot.name} abspielen` : `Feedback-Video ${index + 1} abspielen`;
const external = slot.externalId && provider !== "none" ? EXTERNAL[provider] : null;
const playable = Boolean(slot.src) || Boolean(external);
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Feedback-Video ${index + 1} folgt`, tone: "studio" };
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
const [playing, setPlaying] = useState(false);
const label = slot.name ? `Reel von ${slot.name} abspielen` : `Reel ${index + 1} abspielen`;
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Reel ${index + 1} folgt`, tone: "studio" };
return (
<figure>
<div className="relative aspect-[4/5] overflow-hidden bg-ink">
{stage === "playing" && slot.src && (
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl bg-ink">
{playing && slot.src ? (
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
<track kind="captions" />
</video>
)}
{stage === "playing" && !slot.src && external && slot.externalId && (
<iframe
src={external.url(slot.externalId)}
title={slot.name ? `Video von ${slot.name}` : `Feedback-Video ${index + 1}`}
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
className="h-full w-full"
/>
)}
{stage !== "playing" && (
) : (
<>
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 768px) 33vw, 100vw" showLabel={showDrafts} />
{playable && stage === "poster" && (
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 640px) 33vw, 100vw" showLabel={showDrafts} />
{slot.src && (
<button
type="button"
onClick={() => setStage(external && !slot.src ? "consent" : "playing")}
onClick={() => setPlaying(true)}
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"
>
@@ -56,33 +38,15 @@ export function VideoSlot({ slot, index, provider, showDrafts }: { slot: Slot; i
</svg>
</button>
)}
{stage === "consent" && external && (
<div className="absolute inset-0 flex flex-col justify-end gap-3 bg-ink/85 p-5 text-paper">
<p className="text-[0.9375rem]">
Beim Abspielen wird eine Verbindung zu {external.label} hergestellt und Daten werden dorthin übertragen. Details in der{" "}
<a href="/datenschutz" className="underline">Datenschutzerklärung</a>.
</p>
<div className="flex gap-2">
<button type="button" className="btn bg-paper text-ink" onClick={() => setStage("playing")}>
Video laden
</button>
<button type="button" className="btn btn-quiet text-paper" onClick={() => setStage("poster")}>
Abbrechen
</button>
</div>
</div>
)}
</>
)}
</div>
<figcaption className="mt-4">
{slot.name ? (
<span className="block font-semibold">{slot.name}</span>
) : (
showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>
)}
{slot.caption && <span className="mt-1 block text-ink/80">{slot.caption}</span>}
</figcaption>
{(slot.name || slot.caption || showDrafts) && (
<figcaption className="mt-4">
{slot.name ? <span className="block font-semibold">{slot.name}</span> : showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>}
{slot.caption && <span className="mt-1 block opacity-80">{slot.caption}</span>}
</figcaption>
)}
</figure>
);
}
+1 -1
View File
@@ -312,7 +312,7 @@ export function BookingDialog({
<label className="field-label" htmlFor="bk-notes">Anmerkungen (optional)</label>
<textarea {...fieldProps("notes")} rows={3} maxLength={1000} aria-describedby="bk-notes-hint" />
<p id="bk-notes-hint" className="mt-1.5 text-sm text-stone">
Bitte hier keine Gesundheitsdaten eintragen – dazu sprechen wir bei Bedarf persönlich.
Bitte hier keine Gesundheitsdaten eintragen. Dazu sprechen wir bei Bedarf persönlich.
</p>
</div>
</div>
+1 -1
View File
@@ -54,7 +54,7 @@ export function MobileBookingBar({ priceLabel }: { priceLabel: string }) {
<span className="text-stone">{priceLabel}</span>
</p>
{pricing.checkoutOpen ? (
<BookButton className="btn btn-primary min-h-11 px-5 py-2 text-sm">Platz buchen</BookButton>
<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">
Zum Preis
+1 -101
View File
@@ -2,99 +2,6 @@ import { site } from "@/content/site";
import type { PublicCampFacts } from "@/lib/camp";
import { Gallery } from "../Gallery";
function DraftFlag({ show }: { show: boolean }) {
return show ? <span className="draft-flag ml-2 align-middle">Entwurf, nicht bestätigt</span> : null;
}
export function CampSection({ showDrafts }: { showDrafts: boolean }) {
const { camp } = site;
const showAudience = camp.audience.confirmed || showDrafts;
return (
<section id="camp" aria-labelledby="camp-title" className="py-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 }) {
const { accommodation } = site;
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 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>
)}
</div>
@@ -123,13 +30,6 @@ export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampF
))}
</ul>
<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>
@@ -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 [
{
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?",
@@ -18,7 +18,7 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
{
q: "Kann ich in Raten zahlen?",
a: pricing.installmentsAllowed
? "Ja, in zwei oder drei Raten. Die erste Rate zahlst du bei der Buchung, die weiteren werden zu festen Terminen über deine hinterlegte Zahlungsmethode eingezogen – die letzte spätestens 30 Tage vor Anreise. Alle Beträge und Termine siehst du vor der Zahlung. Nach der letzten Rate finden keine weiteren Abbuchungen statt."
? "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.",
},
{
@@ -29,11 +29,25 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
q: "Wie bin ich untergebracht?",
a: facts.accommodationText
? `Du buchst einen Platz pro Person mit geteilter Unterbringung. ${facts.accommodationText}`
: "Du buchst einen Platz pro Person mit geteilter Unterbringung. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.",
: "Du buchst einen Platz pro Person mit geteilter Unterbringung. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht, in jedem Fall bevor du buchst.",
},
{
q: "Wie reise ich an?",
a: `Die Anreise nach Italien und zurück organisierst du selbst; sie ist nicht im Preis enthalten.${facts.location ? ` Das Camp findet in ${facts.location} statt.` : " Den genauen Ort geben wir bekannt, sobald er feststeht."}`,
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?",
@@ -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">
<div className="container-edge">
<h2 id="schluss-title" className="display max-w-4xl text-[clamp(2.75rem,7vw,6rem)] leading-[0.98]">
Wir freuen uns auf dich in Italien.
Wir freuen uns auf dich in der Toskana.
</h2>
<div className="mt-10 flex flex-wrap gap-3">
{pricing.checkoutOpen ? (
<BookButton className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">Platz buchen</BookButton>
<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">
Zum Buchungsstart
Jetzt Platz sichern
</a>
)}
<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 type { Reel } from "@/lib/site-media";
import { MediaFrame } from "../MediaFrame";
import { Reveal } from "../Reveal";
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 }) {
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
* weiteren gleichwertig im Raster. Portraets im Rundbogen – ein Motiv aus
* der Architektur der Villa. Die Spalten sind auf breiten Bildschirmen
* leicht versetzt, damit das Raster wie eine Magazinseite wirkt.
* weiteren gleichwertig im Raster. Portraets im Rundbogen, ein Motiv aus
* der Architektur der Villa. Liegt team/<slug>.jpg im Medienverzeichnis,
* ersetzt das Foto den Initialen-Platzhalter.
*/
export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
export function TeamSection({ showDrafts, portraits }: { showDrafts: boolean; portraits: Record<string, Media> }) {
const { team } = site;
const { head } = team;
const portraitOf = (member: TeamMember) => portraits[member.slug] ?? member.portrait;
const missing = [head, ...team.people].some((member) => portraitOf(member).kind !== "image");
return (
<section id="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36">
<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">
<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>
<div className="md:col-span-6 md:col-start-7 md:pb-10">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{head.title}</p>
<h3 className="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>
<Bio bio={head.bio} showDrafts={showDrafts} />
</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">
{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}>
<Reveal delay={(index % 4) * 0.06}>
<MediaFrame
media={person.portrait}
aspect="aspect-[3/4]"
shape="arch"
sizes="(min-width: 768px) 22vw, 50vw"
showLabel={false}
/>
<MediaFrame media={portraitOf(person)} aspect="aspect-[3/4]" shape="arch" sizes="(min-width: 768px) 22vw, 50vw" showLabel={false} />
</Reveal>
<h3 className="display mt-4 text-[1.375rem] leading-tight md:text-[1.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>
<Bio bio={person.bio} showDrafts={showDrafts} />
</article>
</li>
))}
</ul>
{showDrafts && (
{showDrafts && missing && (
<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>
)}
</div>
@@ -71,22 +68,17 @@ export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
const { about } = site;
if (about.paragraphs.length === 0 && !showDrafts) return null;
return (
<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">
<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">
{about.paragraphs.length > 0 ? (
about.paragraphs.map((p) => <p key={p}>{p}</p>)
) : (
<p className="text-stone">
{showDrafts ? (
<>
<span className="draft-flag">Inhalt folgt</span> Grundlage wird die ARTISTA-Stammmodel-Seite (Link folgt).
</>
) : (
"Mehr über ARTISTA erfährst du in Kürze hier."
)}
<span className="draft-flag">Inhalt folgt</span> Grundlage wird die ARTISTA-Stammmodel-Seite (Link folgt).
</p>
)}
{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;
const filled = videos.slots.filter((s) => s.src || s.externalId);
// Leere Slots nur in Entwicklung/Staging zeigen; oeffentlich nie ein
// erfundenes Testimonial als Ersatz.
const slots = showDrafts ? videos.slots : filled;
if (slots.length === 0) return null;
/**
* Drei Reels im Hochformat. Leere Slots erscheinen nur in Entwicklung und
* Staging; oeffentlich nie ein erfundenes Testimonial als Ersatz.
*/
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
const { reels: content } = site;
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 (
<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">
<h2 id="stimmen-title" className="section-title">{videos.title}</h2>
<ul className="mt-12 grid gap-10 md:grid-cols-3">
{slots.map((slot, index) => (
<div className="flex flex-wrap items-end justify-between gap-6">
<h2 id="reels-title" className="section-title">{content.title}</h2>
<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}>
<VideoSlot slot={slot} index={index} provider={provider} showDrafts={showDrafts} />
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
</li>
))}
</ul>
+35 -33
View File
@@ -4,20 +4,47 @@ import { formatEuroShort } from "@/lib/money";
import type { PublicPricing } from "@/lib/public-page";
import { BookButton } from "../booking/BookingProvider";
// Bewusst ohne Startdatum des Verkaufs (Wunsch des Auftraggebers).
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.",
CAMP_STARTED: "Die Buchung ist geschlossen.",
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 }) {
const included = site.services.included.filter((s) => s.confirmed);
const title = pricing.phase === "REGULAR" ? "Jetzt Platz sichern" : "Jetzt Platz sichern mit Early Bird";
return (
<section id="preis" aria-labelledby="preis-title" className="bg-paper py-20 md:py-32">
<div className="container-edge grid gap-14 md:grid-cols-12">
<div className="md:col-span-7">
<h2 id="preis-title" className="section-title">Preis und Buchung</h2>
<h2 id="preis-title" className="section-title">{title}</h2>
{pricing.mode === "test" && (
<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">
{pricing.phase === "EARLY_BIRD" && pricing.currentCents !== null && (
<>
<p className="text-stone">
Regulärer Preis <s className="tabular-nums">{formatEuroShort(pricing.regularCents)}</s>
</p>
<p className="display mt-2 text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
<p className="mt-3 text-lg">Early-Bird-Preis pro Person, gültig bis einschließlich 31. Dezember 2026.</p>
</>
)}
{pricing.phase === "REGULAR" && pricing.currentCents !== null && (
<>
<p className="display text-[clamp(4rem,12vw,8rem)] leading-none tabular-nums">{formatEuroShort(pricing.currentCents)}</p>
<p className="mt-3 text-lg">pro Person</p>
</>
)}
{pricing.phase === "PREVIEW" && (
<dl className="grid gap-6 sm:grid-cols-2">
<div className="border-t border-ink pt-4">
<dt className="text-stone">Early Bird, 1. November bis 31. Dezember 2026</dt>
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.earlyBirdCents)}</dd>
</div>
<div className="border-t border-line pt-4">
<dt className="text-stone">Regulär, ab 1. Januar 2027</dt>
<dd className="display mt-2 text-6xl tabular-nums">{formatEuroShort(pricing.regularCents)}</dd>
</div>
</dl>
)}
<PriceDisplay pricing={pricing} />
</div>
<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">
{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>
)}
@@ -91,12 +92,13 @@ export function PricingSection({ pricing, facts, showDrafts }: { pricing: Public
<li key={item.text} className="py-3">{item.text}</li>
))}
</ul>
<p className="mt-6 text-[0.9375rem] text-stone">
Nicht enthalten: {site.services.excluded.join(", ")}.
</p>
<p className="mt-6 text-[0.9375rem] text-stone">Nicht enthalten: {site.services.excluded.join(", ")}.</p>
<p className="mt-4 text-[0.9375rem] text-stone">
Vor der Zahlung siehst du Gesamtpreis, heute fälligen Betrag, alle weiteren Raten mit Terminen und die Beschreibung der Unterbringung auf einen Blick.
</p>
<p className="mt-4 text-[0.9375rem] text-stone">
Hinweise zu deinem <a href="/widerruf" className="link-quiet">Widerrufsrecht</a>.
</p>
</aside>
</div>
</section>