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>
);
}