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:
1 parent
3cba3e2341
commit
7203cd1e6e
37 files changed
+962
-517
No files matched your search
+75
-77
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user