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

@@ -73,6 +73,14 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro
<Field name="termsUrl" label="Link zu den Bedingungen" defaultValue={camp.termsUrl} hint="https://… oder /pfad" />
</div>
<Checkbox name="termsApproved" label="Buchungs-, Storno- und Zahlungsbedingungen freigegeben" defaultChecked={camp.termsApproved} />
<Field
name="withdrawalText"
label="Widerrufsbelehrung"
defaultValue={camp.withdrawalText}
multiline
hint="Geprüfter Text, erscheint unter /widerruf. Absätze durch Leerzeilen trennen."
/>
<Checkbox name="withdrawalApproved" label="Widerrufsbelehrung freigegeben" defaultChecked={camp.withdrawalApproved} />
<Checkbox
name="travelLawChecked"
label="Reiserechtliche Einordnung geprüft"
+2
View File
@@ -136,6 +136,8 @@ export async function updateCampAction(form: FormData) {
termsVersion: str(form, "termsVersion"),
termsUrl: str(form, "termsUrl"),
termsApproved: bool(form, "termsApproved"),
withdrawalText: str(form, "withdrawalText"),
withdrawalApproved: bool(form, "withdrawalApproved"),
travelLawChecked: bool(form, "travelLawChecked"),
installmentsApproved: bool(form, "installmentsApproved"),
installmentRuleApproved: bool(form, "installmentRuleApproved"),
+41 -48
View File
@@ -16,8 +16,8 @@
--color-rosso-deep: #6f2c20;
--color-sand: #c9b79c;
--font-display: var(--font-bodoni), "Bodoni 72", "Didot", Georgia, serif;
--font-sans: var(--font-hanken), "Helvetica Neue", Arial, sans-serif;
--font-display: var(--font-figtree), "Helvetica Neue", Arial, sans-serif;
--font-sans: var(--font-figtree), "Helvetica Neue", Arial, sans-serif;
--ease-editorial: cubic-bezier(0.22, 1, 0.36, 1);
}
@@ -101,17 +101,17 @@
.display {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.01em;
line-height: 1.02;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.08;
}
.section-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.25rem, 1.4rem + 3.6vw, 4.25rem);
line-height: 1.02;
letter-spacing: -0.015em;
font-weight: 700;
font-size: clamp(2.25rem, 1.4rem + 3.2vw, 3.75rem);
line-height: 1.05;
letter-spacing: -0.025em;
}
.prose-body {
@@ -229,64 +229,57 @@
background-image: url("data:image/svg+xml;utf8,<svg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%27160%27><filter id=%27n%27><feTurbulence type=%27fractalNoise%27 baseFrequency=%270.9%27 numOctaves=%272%27 stitchTiles=%27stitch%27/><feColorMatrix values=%270 0 0 0 0.45 0 0 0 0 0.4 0 0 0 0 0.35 0 0 0 0.22 0%27/></filter><rect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23n)%27/></svg>");
}
/* Masthead: der eine laute Moment der Seite. */
.masthead {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(4.25rem, 21vw, 19rem);
line-height: 0.8;
letter-spacing: -0.035em;
font-variation-settings: "opsz" 96;
}
/*
* Einzige orchestrierte Eroeffnung: Masthead steigt auf, das Leitbild setzt
* sich, die Cover-Lines folgen. Reines CSS, kein JavaScript noetig; bei
* reduzierter Bewegung greift die Regel in @layer base.
* Eroeffnung: Headerbild zoomt langsam heran, Titel, Text und Buttons
* blenden nacheinander ein. Reines CSS; bei reduzierter Bewegung greift
* die Regel in @layer base, das Headervideo wird dann ausgeblendet.
*/
@keyframes hero-rise {
@keyframes hero-zoom {
from {
opacity: 0;
transform: translateY(0.18em);
clip-path: inset(0 0 100% 0);
transform: scale(1.12);
}
to {
opacity: 1;
transform: none;
clip-path: inset(0 0 0 0);
}
}
@keyframes hero-settle {
from {
opacity: 0;
transform: scale(1.035);
}
to {
opacity: 1;
transform: none;
transform: scale(1);
}
}
@keyframes hero-fade {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}
.hero-rise {
animation: hero-rise 1.1s var(--ease-editorial) both;
@keyframes hero-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(6px);
}
}
.hero-image {
overflow: hidden;
}
.hero-image > * {
animation: hero-settle 1.5s var(--ease-editorial) 0.15s both;
.hero-zoom {
animation: hero-zoom 9s cubic-bezier(0.2, 0.6, 0.3, 1) both;
}
.hero-fade {
animation: hero-fade 0.9s var(--ease-editorial) 0.5s both;
animation: hero-fade 0.9s var(--ease-editorial) 0.2s both;
}
.hero-fade-late {
animation-delay: 0.4s;
}
.hero-fade-later {
animation-delay: 0.6s;
}
.hero-bounce {
animation: hero-bounce 2.4s ease-in-out 1.5s infinite;
}
@media (prefers-reduced-motion: reduce) {
.hero-video {
display: none;
}
}
/* Mobile Buchungsleiste: Inhalt am Seitenende nie verdecken. */
+9 -14
View File
@@ -1,33 +1,28 @@
import type { Metadata, Viewport } from "next";
import { Bodoni_Moda, Hanken_Grotesk } from "next/font/google";
import { Figtree } from "next/font/google";
import { env } from "@/lib/env";
import "./globals.css";
// next/font laedt die Schriften zur Build-Zeit herunter und liefert sie
// selbst aus: Besucherinnen stellen keine Verbindung zu Google her.
const bodoni = Bodoni_Moda({
// Eine Schriftfamilie fuer alles, wie auf der Filmcamp-Referenz: runde,
// klare Grotesk, Ueberschriften im Halbfett.
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-bodoni",
display: "swap",
axes: ["opsz"],
style: ["normal", "italic"],
});
const hanken = Hanken_Grotesk({
subsets: ["latin"],
variable: "--font-hanken",
variable: "--font-figtree",
display: "swap",
weight: ["400", "500", "600", "700", "800"],
});
const TITLE = "ARTISTA MODELCAMP 2027";
const DESCRIPTION =
"Das ARTISTA Modelcamp 2027 in Italien: lernen, arbeiten und wohnen unter einem Dach. Alle Infos zu Unterkunft, Team, Preis und Buchung.";
"Das ARTISTA Modelcamp 2027 in der Toskana: eine Woche lernen, arbeiten und wohnen in der Villa La Chiesina. Alle Infos zu Ablauf, Unterkunft, Team und Buchung.";
export function generateMetadata(): Metadata {
const { APP_URL, APP_ENV } = env();
return {
metadataBase: new URL(APP_URL),
title: { default: TITLE, template: `%s – ${TITLE}` },
title: { default: TITLE, template: `%s | ${TITLE}` },
description: DESCRIPTION,
alternates: { canonical: "/" },
openGraph: {
@@ -52,7 +47,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" className={`${bodoni.variable} ${hanken.variable}`}>
<html lang="de" className={figtree.variable}>
<body>{children}</body>
</html>
);
+3
View File
@@ -17,6 +17,7 @@ const TYPES: Record<string, string> = {
".png": "image/png",
".webp": "image/webp",
".avif": "image/avif",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".webm": "video/webm",
".vtt": "text/vtt",
@@ -48,6 +49,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ path
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=86400",
"X-Content-Type-Options": "nosniff",
// SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen.
...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),
};
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get("range") ?? "");
+1 -1
View File
@@ -1 +1 @@
ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
+24 -14
View File
@@ -1,10 +1,11 @@
import { connection } from "next/server";
import { BookingProvider } from "@/components/booking/BookingProvider";
import { MobileBookingBar } from "@/components/booking/MobileBookingBar";
import { Hero, priceLine } from "@/components/Hero";
import { AccommodationSection, CampSection, ServicesSection } from "@/components/sections/CampSections";
import { DateBar, Hero, priceLine } from "@/components/Hero";
import { AccommodationSection } from "@/components/sections/CampSections";
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
import { AboutSection, TeamSection, VoicesSection } from "@/components/sections/PeopleSections";
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
import { PricingSection } from "@/components/sections/PricingSection";
import { ReviewsSection } from "@/components/sections/ReviewsSection";
import { SiteFooter } from "@/components/SiteFooter";
@@ -12,34 +13,43 @@ import { SiteHeader } from "@/components/SiteHeader";
import { EventJsonLd } from "@/components/EventJsonLd";
import { fetchGoogleReviews } from "@/lib/google-reviews";
import { loadPublicPage } from "@/lib/public-page";
import { loadSiteMedia } from "@/lib/site-media";
/*
* Gliederung nach der Filmcamp-Referenz: Headerbild, Termin, was dich
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
* Fragen, Abschluss.
*/
export default async function HomePage() {
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
// Preisphase, Verkaufsfreigabe und Medien haengen von Uhrzeit, Datenbank
// und Medienverzeichnis ab.
await connection();
// Rezensionen parallel laden; ein Fehler dort liefert null und blendet
// nur den Abschnitt aus, nie die Seite.
const [{ facts, pricing, showDrafts, externalVideoProvider }, reviews] = await Promise.all([
const [{ facts, pricing, showDrafts }, media, reviews] = await Promise.all([
loadPublicPage(),
loadSiteMedia(),
fetchGoogleReviews().catch(() => null),
]);
return (
<BookingProvider pricing={pricing} facts={facts}>
<SiteHeader />
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
<main id="inhalt">
<Hero facts={facts} pricing={pricing} showDrafts={showDrafts} />
<CampSection showDrafts={showDrafts} />
<ServicesSection showDrafts={showDrafts} />
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
<DateBar facts={facts} />
<ExpectSection />
<ScheduleSection facts={facts} />
<AccommodationSection facts={facts} showDrafts={showDrafts} />
<TeamSection showDrafts={showDrafts} />
<AboutSection showDrafts={showDrafts} />
<ReviewsSection data={reviews} showDrafts={showDrafts} />
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
<TeamSection showDrafts={showDrafts} portraits={media.portraits} />
<ReelsSection showDrafts={showDrafts} reels={media.reels} />
<ReviewsSection data={reviews} showDrafts={showDrafts} />
<AboutSection showDrafts={showDrafts} />
<FaqSection facts={facts} pricing={pricing} />
<ClosingSection pricing={pricing} />
</main>
<SiteFooter facts={facts} showDrafts={showDrafts} />
<SiteFooter facts={facts} showDrafts={showDrafts} logo={media.logoDark} />
<MobileBookingBar priceLabel={priceLine(pricing)} />
<EventJsonLd facts={facts} pricing={pricing} />
</BookingProvider>
+1 -1
View File
@@ -1 +1 @@
ARTISTA Modelcamp 2027 – Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
ARTISTA Modelcamp 2027: Villa La Chiesina in der Toskana, Luftaufnahme zwischen Zypressen
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { connection } from "next/server";
import { LegalPage, PendingLegalNotice } from "@/components/LegalPage";
import { getCamp, publicCampFacts } from "@/lib/camp";
import { getDb } from "@/lib/db";
export const metadata: Metadata = { title: "Widerrufsrecht", alternates: { canonical: "/widerruf" } };
/**
* Die Widerrufsbelehrung wird im Adminbereich gepflegt und freigegeben.
* Hier wird kein Rechtstext formuliert: ohne Freigabe erscheint nur der
* Hinweis, dass der Text vor dem Verkaufsstart veroeffentlicht wird.
*/
export default async function WithdrawalPage() {
await connection();
const facts = publicCampFacts(await getCamp(getDb()));
return (
<LegalPage title="Widerrufsrecht">
{facts.withdrawalText ? (
facts.withdrawalText
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map((paragraph) => (
<p key={paragraph} className="whitespace-pre-line">
{paragraph}
</p>
))
) : (
<PendingLegalNotice what="Die Belehrung zu deinem Widerrufsrecht" />
)}
</LegalPage>
);
}
+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>
+82 -62
View File
@@ -8,14 +8,16 @@
* und Staging sichtbar als solche markiert und werden im Produktivbetrieb
* (APP_ENV=production) ausgeblendet oder durch einen neutralen Hinweis
* ersetzt.
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis und
* Bedingungen pflegt der Adminbereich (Datenbank), weil sie den
* Live-Verkauf freischalten. Hier steht alles Uebrige.
* - Termin, Ort, Zimmer-/Bettbeschreibung, Veranstalter, Steuerhinweis,
* Bedingungen und Widerrufsbelehrung pflegt der Adminbereich (Datenbank),
* weil sie den Live-Verkauf freischalten. Hier steht alles Uebrige.
* - Bilder: nur mit bestaetigter Nutzungsberechtigung und immer als lokale
* Datei, nie per Hotlinking. Feste Bilder liegen in public/images/,
* austauschbare Medien im Medienverzeichnis (MEDIA_DIR, /media/…).
* Datei, nie per Hotlinking. Feste Bilder liegen in public/images/.
* Logo, Headerbild, Portraets und Reels kommen ueber feste Dateinamen aus
* dem Medienverzeichnis (src/lib/site-media.ts, docs/MEDIEN.md).
* Die Unterkunftsbilder stammen aus dem Booking.com-Eintrag der Villa
* und wurden vom Auftraggeber am 01.10.2026 zur Nutzung freigegeben.
* - Keine Gedankenstriche in Texten (Wunsch des Auftraggebers).
*
* Offene Punkte: docs/OFFENE-PUNKTE.md
*/
@@ -28,49 +30,75 @@ export type Media =
export type Confirmable<T> = T & { confirmed: boolean };
export type ScheduleDay = { title: string | null; items: string[] };
const portrait = (name: string, initials: string): Media => ({ kind: "placeholder", label: `Porträt ${name}`, tone: "portrait", glyph: initials });
/** Lokales Bild aus public/images/unterkunft (1280 x 853 px). */
const villa = (file: string, alt: string): Media => ({ kind: "image", src: `/images/unterkunft/${file}.jpg`, alt, width: 1280, height: 853 });
export const site = {
hero: {
title: "Modelcamp Toskana 2027",
// Entwurf zur Freigabe: bewusst ohne Versprechen zu Shootings,
// Vermittlung oder Ergebnissen.
headline: "Gemeinsam wachsen. In Italien.",
subline:
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
"Eine Woche in einer Villa in der Toskana. Lernen vor und hinter der Kamera, mit einem Team aus Fotografen, Models und Content Creators, und Abende, an denen alle zusammen am Tisch sitzen.",
cta: "Jetzt Platz sichern",
// Standardbild, solange kein header.jpg im Medienverzeichnis liegt.
media: villa("villa-luftbild", "Luftaufnahme der Villa La Chiesina zwischen Zypressen und Wiesen in der Toskana"),
},
camp: {
title: "Das Camp",
lead: "Ein paar Tage, an denen sich alles um deine Arbeit als Model dreht – in Ruhe, in einer kleinen Gruppe und mit Zeit füreinander.",
expect: {
title: "Was dich erwartet",
lead: "Eine Woche in der Villa La Chiesina, mitten in der Toskana.",
paragraphs: [
"Das Modelcamp verbindet gemeinsames Arbeiten mit dem Leben unter einem Dach. Du wohnst mit den anderen Teilnehmerinnen in einer Unterkunft in Italien, lernst von zwei Dozentinnen und tauschst dich mit Menschen aus, die dieselbe Leidenschaft teilen.",
"Das Programm wird gerade zusammengestellt. Was bereits feststeht, findest du unten unter Leistungen – alles andere ergänzen wir, sobald es verbindlich ist.",
"Die Villa liegt zwischen Zypressen oberhalb von Arcidosso, mit Blick über die Hügel am Monte Amiata. Hier wohnen, arbeiten und essen wir eine Woche lang zusammen.",
"Garten, Pool, alte Steintreppen und Räume mit Holzbalkendecken: Die Villa bietet viele Orte zum Fotografieren und zum Durchatmen.",
],
audience: {
title: "Für wen das Camp gedacht ist",
items: [
"Models, die bereits mit ARTISTA arbeiten",
"Models, die ARTISTA und die Arbeit vor der Kamera näher kennenlernen möchten",
"Alle, die Lust auf eine konzentrierte Zeit in einer kleinen Gruppe haben",
],
confirmed: false,
},
// Ausdruecklich KEINE Aussagen zu Agenturvermittlung, Auftraegen,
// Verdienst oder Karriereerfolg (Briefing Abschnitt 4).
highlights: ["Eigenes Bett im geteilten Zimmer", "Abholung am Bahnhof in Florenz", "Kleine Gruppe, ein ganzes Team"],
slides: [
villa("villa-eingang", "Die Villa La Chiesina mit Steintreppe und Rasen im Frühling"),
villa("pool-zypressen", "Pool zwischen hohen Zypressen, im Hintergrund die Hügel der Toskana"),
villa("wohnzimmer-kamin", "Wohnzimmer mit Holzbalkendecke, Natursteinwand und Kamin"),
villa("terrasse", "Sitzplatz auf der Gartenterrasse"),
villa("anwesen-luftbild", "Luftaufnahme des Anwesens mit Zypressenallee, Pool und Tennisplatz"),
] as Media[],
},
schedule: {
title: "Tagesablauf",
note: "Programmentwurf. Das finale Programm kann sich noch ändern.",
// Ein Eintrag pro Tag ab Anreisetag; das Datum kommt aus dem
// Adminbereich. Das Programm liefert der Auftraggeber als PDF nach.
// Leere Tage zeigen "Programm folgt". Nichts erfinden.
days: [
{
title: "Anreise",
items: [
"Eigene Anreise nach Florenz",
"Abholung am Bahnhof Firenze Santa Maria Novella und gemeinsame Fahrt zur Villa",
"Mit dem Auto? Dann fährst du direkt zur Villa",
],
},
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: null, items: [] },
{ title: "Abreise", items: [] },
] as ScheduleDay[],
},
services: {
title: "Erlebnisse und Leistungen",
included: [
// Bestaetigt durch das Buchungsprodukt selbst:
// Bestaetigt durch das Buchungsprodukt bzw. den Auftraggeber:
{ text: "Teilnahme am Modelcamp 2027", confirmed: true },
{ text: "Unterbringung in einer geteilten Unterkunft in Italien", confirmed: true },
{ text: "Sieben Nächte in der Villa La Chiesina, eigenes Bett im geteilten Zimmer", confirmed: true },
{ text: "Abholung am Anreisetag vom Bahnhof Firenze Santa Maria Novella", confirmed: true },
// Entwuerfe: erst nach Bestaetigung (Admin: "Leistungen bestaetigt")
// auf confirmed: true setzen.
{ text: "Workshops mit den beiden Dozentinnen", confirmed: false },
{ text: "Workshops und Coachings mit dem ARTISTA-Team", confirmed: false },
{ text: "Verpflegung vor Ort (Umfang folgt)", confirmed: false },
] satisfies Confirmable<{ text: string }>[],
excluded: [
@@ -79,26 +107,15 @@ export const site = {
"Persönliche Ausgaben vor Ort",
"Reiserücktrittsversicherung",
],
exampleSchedule: {
// Beispiel, KEIN verbindliches Programm.
note: "Beispielhafter Ablauf – das verbindliche Programm veröffentlichen wir, sobald alle Punkte bestätigt sind.",
steps: [
{ title: "Ankommen", text: "Anreise, Zimmer beziehen, erstes Kennenlernen der Gruppe." },
{ title: "Arbeiten", text: "Gemeinsame Einheiten mit den Dozentinnen und Zeit für Fragen." },
{ title: "Abschied", text: "Rückblick, Austausch und individuelle Abreise." },
],
confirmed: false,
},
},
accommodation: {
title: "Die Unterkunft",
lead: "Eine Villa zwischen Zypressen, mit Blick über die Hügel am Monte Amiata. Wohnen, arbeiten und abends zusammensitzen – alles an einem Ort.",
lead: "Eine Villa zwischen Zypressen, mit Blick über die Hügel am Monte Amiata. Wohnen, arbeiten und abends zusammensitzen, alles an einem Ort.",
name: "Villa La Chiesina",
place: "Arcidosso, Toskana",
// Ohne Tracking-Parameter; Termin und Personenzahl waehlt man dort selbst.
bookingUrl: "https://www.booking.com/hotel/it/villa-la-chiesina-arcidosso.de.html" as string | null,
// Ausstattung laut Unterkunftsbeschreibung (Booking.com, Stand 01.10.2026).
// Die Booking-Seite selbst wird bewusst nicht verlinkt.
features: [
"Eigener Außenpool (saisonal)",
"Großer Garten mit Terrasse",
@@ -122,13 +139,13 @@ export const site = {
},
team: {
title: "Das Team",
title: "Mit dabei",
lead: "Fotografie, Modelcoaching, Content und eine Küche, die alle zusammenbringt: Diese Menschen sind beim Modelcamp 2027 dabei.",
// Namen und Rollen vom Auftraggeber (01.10.2026). Porträts folgen:
// Datei in public/images/team/ legen und "portrait" ersetzen, z. B.
// { kind: "image", src: "/images/team/eric-heitz.jpg", alt: "Porträt Eric Heitz", width: 1200, height: 1500 }.
// Namen und Rollen vom Auftraggeber (01.10.2026). Portraets kommen ueber
// das Medienverzeichnis als team/<slug>.jpg (docs/MEDIEN.md).
// Kurzbeschreibungen nur aus freigegebenen Angaben, keine erfundenen Referenzen.
head: {
slug: "eric-heitz",
name: "Eric Heitz",
title: "Head of Modelcamp",
role: "Fotograf · Content Creator · Modelcoach",
@@ -136,14 +153,14 @@ export const site = {
portrait: portrait("Eric Heitz", "EH"),
},
people: [
{ name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") },
{ name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
{ name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
{ name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
{ name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") },
{ name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
{ name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
{ name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
{ slug: "michele-ietro", name: "Michele Ietro", role: "Editorial- und Casual-Fotograf", bio: null as string | null, portrait: portrait("Michele Ietro", "MI") },
{ slug: "lola-stoll", name: "Lola Stoll", role: "Editorial Model · UGC · GNTM 2026", bio: null as string | null, portrait: portrait("Lola Stoll", "LS") },
{ slug: "eileen-kaupp", name: "Eileen Kaupp", role: "Editorial Model · Fashion · GNTM 2026", bio: null as string | null, portrait: portrait("Eileen Kaupp", "EK") },
{ slug: "gabriela-pronkin", name: "Gabriela Pronkin", role: "Commercial · Casual Model · UGC", bio: null as string | null, portrait: portrait("Gabriela Pronkin", "GP") },
{ slug: "stefan-hoechtl", name: "Stefan Höchtl", role: "Social Media Content Creator", bio: null as string | null, portrait: portrait("Stefan Höchtl", "SH") },
{ slug: "saskia-sirowatka", name: "Saskia Sirowatka", role: "Social Media Managerin", bio: null as string | null, portrait: portrait("Saskia Sirowatka", "SS") },
{ slug: "andre-hartshauser", name: "André Hartshauser", role: "Workshop-Organisation · Assistenz Küche", bio: null as string | null, portrait: portrait("André Hartshauser", "AH") },
{ slug: "ante-rejo", name: "Ante Rejo", role: "Chefkoch", bio: null as string | null, portrait: portrait("Ante Rejo", "AR") },
],
},
@@ -155,13 +172,16 @@ export const site = {
paragraphs: [] as string[],
},
videos: {
title: "Stimmen",
// Genau drei Slots. Nur echte, freigegebene Videos eintragen.
reels: {
title: "Reels",
lead: "Eindrücke und Stimmen in drei kurzen Clips.",
// Genau drei Slots. Video und Vorschaubild kommen ueber das
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
// Name und Begleittext nur mit Freigabe der gezeigten Person.
slots: [
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
{ name: null as string | null, caption: null as string | null, poster: null as Media | null, src: null as string | null, externalId: null as string | null },
{ name: null as string | null, caption: null as string | null },
{ name: null as string | null, caption: null as string | null },
{ name: null as string | null, caption: null as string | null },
],
},
@@ -170,12 +190,12 @@ export const site = {
},
} as const;
/** Links in der Kopfzeile: die ersten drei links vom Logo, der Rest rechts. */
export const NAV_ITEMS = [
{ id: "camp", label: "Das Camp" },
{ id: "leistungen", label: "Leistungen" },
{ id: "erwartet", label: "Was dich erwartet" },
{ id: "ablauf", label: "Tagesablauf" },
{ id: "unterkunft", label: "Unterkunft" },
{ id: "team", label: "Team" },
{ id: "artista", label: "Über ARTISTA" },
{ id: "preis", label: "Preis" },
{ id: "team", label: "Team" },
{ id: "fragen", label: "Fragen" },
] as const;
+2
View File
@@ -220,6 +220,8 @@ export const campInputSchema = z.object({
termsVersion: text(60),
termsUrl: text(500).refine((v) => v === null || v.startsWith("/") || v.startsWith("https://"), "Nur https:// oder ein Pfad dieser Seite"),
termsApproved: z.boolean(),
withdrawalText: text(10000),
withdrawalApproved: z.boolean(),
travelLawChecked: z.boolean(),
installmentsApproved: z.boolean(),
installmentRuleApproved: z.boolean(),
+4
View File
@@ -37,6 +37,8 @@ export function toGateFields(camp: Camp): CampGateFields {
taxConfirmed: camp.taxConfirmed,
termsVersion: camp.termsVersion,
termsApproved: camp.termsApproved,
withdrawalText: camp.withdrawalText,
withdrawalApproved: camp.withdrawalApproved,
travelLawChecked: camp.travelLawChecked,
installmentsApproved: camp.installmentsApproved,
installmentRuleApproved: camp.installmentRuleApproved,
@@ -63,6 +65,7 @@ export type PublicCampFacts = {
organizer: { name: string; address: string; email: string; phone: string | null } | null;
termsUrl: string | null;
termsVersion: string | null;
withdrawalText: string | null;
};
export function publicCampFacts(camp: Camp): PublicCampFacts {
@@ -84,5 +87,6 @@ export function publicCampFacts(camp: Camp): PublicCampFacts {
: null,
termsUrl: camp.termsApproved ? camp.termsUrl : null,
termsVersion: camp.termsApproved ? camp.termsVersion : null,
withdrawalText: camp.withdrawalApproved ? camp.withdrawalText : null,
};
}
+2 -2
View File
@@ -20,7 +20,7 @@ import type { PaymentGateway } from "./payment-gateway";
import { statusTokenFor } from "./tokens";
import { formatDate, formatDateTime } from "./time";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027 – Teilnahme";
export const PRODUCT_NAME = "ARTISTA MODELCAMP 2027, Teilnahme";
export const PRODUCT_DESCRIPTION = "Teilnahme pro Person mit geteilter Unterbringung";
/** Stripe akzeptiert expires_at erst ab 30 Minuten in der Zukunft. */
const MIN_STRIPE_SESSION_MS = 30 * 60_000;
@@ -43,7 +43,7 @@ export function planSummary(installments: Pick<Installment, "seq" | "amountCents
return [
`Gesamtpreis ${formatEuro(totalCents)}.`,
today,
later ? `Weitere Raten: ${later} – automatisch über die hier gespeicherte Zahlungsmethode.` : "",
later ? `Weitere Raten: ${later}, automatisch über die hier gespeicherte Zahlungsmethode.` : "",
]
.filter(Boolean)
.join(" ");
+6 -6
View File
@@ -26,7 +26,7 @@ function planLines(booking: BookingWithInstallments): string[] {
const due = i.dueAt ? `fällig am ${formatDate(i.dueAt)}` : "bei Buchung";
const state =
i.status === "PAID" ? "bezahlt" : i.status === "CANCELED" ? "storniert" : i.status === "PROCESSING" ? "in Bearbeitung" : "offen";
return `Rate ${i.seq}: ${formatEuro(i.amountCents)} – ${due} – ${state}`;
return `Rate ${i.seq}: ${formatEuro(i.amountCents)}, ${due}, ${state}`;
});
}
@@ -43,12 +43,12 @@ ${link ? `<p><a href="${escapeHtml(link.href)}" style="display:inline-block;back
}
function draft(key: string, booking: Booking, subject: string, body: { text: string; html: string }): EmailDraft {
return { dedupeKey: key, to: booking.email, subject: `${subject} – ${BRAND}`, bookingId: booking.id, ...body };
return { dedupeKey: key, to: booking.email, subject: `${subject} | ${BRAND}`, bookingId: booking.id, ...body };
}
export function bookingConfirmedEmail(booking: BookingWithInstallments, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt – erste Rate erhalten";
const subject = fullyPaid ? "Deine Buchung ist bestätigt und bezahlt" : "Deine Buchung ist bestätigt, erste Rate erhalten";
const lines = [
`Hallo ${booking.firstName},`,
`vielen Dank! Deine Buchung ${booking.reference} ist bestätigt.`,
@@ -96,7 +96,7 @@ export function paymentFailedEmail(booking: Booking): EmailDraft {
export function installmentPaidEmail(booking: BookingWithInstallments, installment: Installment, token: string): EmailDraft {
const fullyPaid = booking.paidCents >= booking.totalCents;
const subject = fullyPaid ? "Vollständig bezahlt – danke!" : `Rate ${installment.seq} erhalten`;
const subject = fullyPaid ? "Vollständig bezahlt. Danke!" : `Rate ${installment.seq} erhalten`;
return draft(
`installment-paid:${installment.id}`,
booking,
@@ -129,7 +129,7 @@ export function installmentActionEmail(booking: Booking, installment: Installmen
requiresAction
? `deine Bank möchte die Zahlung von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) zusätzlich bestätigen lassen.`
: `der Einzug von Rate ${installment.seq} (${formatEuro(installment.amountCents)}) ist leider fehlgeschlagen.`,
"Du kannst die Rate über den folgenden Link selbst begleichen – auch mit einer anderen Zahlungsmethode.",
"Du kannst die Rate über den folgenden Link selbst begleichen, auch mit einer anderen Zahlungsmethode.",
],
{ href: statusLink(token), label: "Rate jetzt bezahlen" },
),
@@ -147,7 +147,7 @@ export function adminReviewEmail(to: string, booking: Booking, reason: string):
}
export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft {
const subject = "Deine Zahlung ist eingegangen – wir melden uns";
const subject = "Deine Zahlung ist eingegangen, wir melden uns";
return draft(
`review-customer:${booking.id}`,
booking,
+5
View File
@@ -34,6 +34,8 @@ export type CampGateFields = {
taxConfirmed: boolean;
termsVersion: string | null;
termsApproved: boolean;
withdrawalText: string | null;
withdrawalApproved: boolean;
travelLawChecked: boolean;
installmentsApproved: boolean;
installmentRuleApproved: boolean;
@@ -49,6 +51,7 @@ export type ReleaseBlocker =
| "ORGANIZER"
| "TAX"
| "TERMS"
| "WITHDRAWAL"
| "TRAVEL_LAW"
| "CAPACITY"
| "STAMMMODEL_QUOTA";
@@ -62,6 +65,7 @@ export const BLOCKER_TEXT: Record<ReleaseBlocker, string> = {
ORGANIZER: "Veranstalter- und Kontaktdaten fehlen oder sind nicht bestätigt.",
TAX: "Steuerlich korrekte Preisangabe ist nicht konfiguriert.",
TERMS: "Buchungs-, Storno- und Zahlungsbedingungen sind nicht freigegeben.",
WITHDRAWAL: "Widerrufsbelehrung ist nicht eingetragen oder nicht freigegeben.",
TRAVEL_LAW: "Reiserechtliche Einordnung (inkl. ggf. Absicherung) ist nicht geprüft.",
CAPACITY: "Verkaufbare Platzzahl ist nicht bestätigt.",
STAMMMODEL_QUOTA: "Kontingent für Stammmodel-Codes ist nicht bestätigt.",
@@ -93,6 +97,7 @@ export function releaseBlockers(camp: CampGateFields): ReleaseBlocker[] {
}
if (!camp.taxNote || !camp.taxConfirmed) blockers.push("TAX");
if (!camp.termsVersion || !camp.termsApproved) blockers.push("TERMS");
if (!camp.withdrawalText || !camp.withdrawalApproved) blockers.push("WITHDRAWAL");
if (!camp.travelLawChecked) blockers.push("TRAVEL_LAW");
if (!camp.capacityConfirmed) blockers.push("CAPACITY");
if (!camp.stammmodelQuotaConfirmed) blockers.push("STAMMMODEL_QUOTA");
+84
View File
@@ -0,0 +1,84 @@
/**
* Austauschbare Medien ueber feste Dateinamen im Medienverzeichnis
* (MEDIA_DIR, im Cluster ein PersistentVolume, ausgeliefert unter /media/…).
*
* Liegt eine Datei unter dem erwarteten Namen dort, erscheint sie ohne
* Neustart und ohne Codeaenderung auf der Seite. Fehlt sie, bleibt der
* Platzhalter bzw. das Standardbild. Die Liste aller Namen steht in
* docs/MEDIEN.md.
*
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
*/
import { stat } from "node:fs/promises";
import path from "node:path";
import { site, type Media } from "@/content/site";
import { env } from "./env";
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
const LOGO_EXTS = [".svg", ".png", ".webp"];
const VIDEO_EXTS = [".mp4", ".webm"];
type Found = { src: string; ext: string };
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
const dir = path.resolve(env().MEDIA_DIR);
for (const ext of exts) {
try {
const info = await stat(path.join(dir, `${base}${ext}`));
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
} catch {
// Datei fehlt: naechste Endung probieren.
}
}
return null;
}
async function image(base: string, alt: string): Promise<Media | null> {
const found = await find(base, IMAGE_EXTS);
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
}
export type Reel = { src: string | null; poster: Media | null };
export type SiteMedia = {
/** Logo fuer helle Flaechen (dunkles Logo). */
logoDark: string | null;
/** Logo auf Foto oder dunklem Grund (helles Logo). */
logoLight: string | null;
header: Media;
headerVideo: string | null;
portraits: Record<string, Media>;
reels: Reel[];
};
export async function loadSiteMedia(): Promise<SiteMedia> {
const people = [site.team.head, ...site.team.people];
const [logoDark, logoLight, header, headerVideo, portraitList, reels] = await Promise.all([
find("logo", LOGO_EXTS),
find("logo-hell", LOGO_EXTS),
image("header", "ARTISTA Modelcamp in der Toskana"),
find("header", VIDEO_EXTS),
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
Promise.all(
[1, 2, 3].map(async (n): Promise<Reel> => {
const [video, poster] = await Promise.all([find(`reels/reel-${n}`, VIDEO_EXTS), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
return { src: video?.src ?? null, poster };
}),
),
]);
const portraits: Record<string, Media> = {};
people.forEach((p, index) => {
portraits[p.slug] = portraitList[index] ?? p.portrait;
});
return {
logoDark: logoDark?.src ?? logoLight?.src ?? null,
logoLight: logoLight?.src ?? logoDark?.src ?? null,
header: header ?? site.hero.media,
headerVideo: headerVideo?.src ?? null,
portraits,
reels,
};
}
+12
View File
@@ -130,3 +130,15 @@ export function formatIsoDateLong(isoDate: string): string {
function pad(n: number): string {
return String(n).padStart(2, "0");
}
const weekdayDateFormatter = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
weekday: "long",
day: "numeric",
month: "long",
});
/** "Samstag, 8. Mai" fuer reine Datumswerte (YYYY-MM-DD). */
export function formatIsoWeekdayDate(isoDate: string): string {
return weekdayDateFormatter.format(isoToDbDate(isoDate));
}