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

+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>