This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { appUrl } from "@/lib/env";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
/**
|
||||
* Strukturierte Veranstaltungsdaten (schema.org/Event) — nur, wenn Termin,
|
||||
* Ort und Veranstalter bestaetigt sind und der Live-Verkauf laeuft.
|
||||
* Unbestaetigte Angaben erscheinen nie in Suchmaschinen.
|
||||
*/
|
||||
export function EventJsonLd({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
if (!facts.startDate || !facts.location || !facts.organizer || pricing.mode !== "live" || !pricing.checkoutOpen || pricing.currentCents === null) {
|
||||
return null;
|
||||
}
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: "ARTISTA MODELCAMP 2027",
|
||||
startDate: facts.startDate,
|
||||
...(facts.endDate ? { endDate: facts.endDate } : {}),
|
||||
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||
eventStatus: "https://schema.org/EventScheduled",
|
||||
location: { "@type": "Place", name: facts.location, address: { "@type": "PostalAddress", addressCountry: "IT", ...(facts.region ? { addressRegion: facts.region } : {}) } },
|
||||
organizer: { "@type": "Organization", name: facts.organizer.name, email: facts.organizer.email },
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: (pricing.currentCents / 100).toFixed(2),
|
||||
priceCurrency: "EUR",
|
||||
availability: "https://schema.org/InStock",
|
||||
url: appUrl("/#preis"),
|
||||
},
|
||||
};
|
||||
// JSON.stringify + Ersetzen von "<" verhindert, dass Inhalte das
|
||||
// Script-Element vorzeitig schliessen.
|
||||
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
import { MediaFrame } from "./MediaFrame";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
function describe(media: Media): string {
|
||||
return media.kind === "image" ? media.alt : `Platzhalter: ${media.label}`;
|
||||
}
|
||||
|
||||
/** Bildmosaik der Unterkunft mit Vollbildansicht (Pfeiltasten, Esc). */
|
||||
export function Gallery({ images, showLabels }: { images: readonly Media[]; showLabels: boolean }) {
|
||||
const [current, setCurrent] = useState<number | null>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggers = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (current !== null && !dialog.open) dialog.showModal();
|
||||
if (current === null && dialog.open) dialog.close();
|
||||
}, [current]);
|
||||
|
||||
const close = () => {
|
||||
const index = current;
|
||||
setCurrent(null);
|
||||
if (index !== null) requestAnimationFrame(() => triggers.current[index]?.focus());
|
||||
};
|
||||
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
|
||||
|
||||
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
|
||||
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
|
||||
const aspect = (index: number) =>
|
||||
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[4/3] md:aspect-[21/8]" : "aspect-[4/3]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
|
||||
{images.map((media, index) => (
|
||||
<li key={index} className={layout[index] ?? "md:col-span-4"}>
|
||||
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
|
||||
<button
|
||||
ref={(el) => {
|
||||
triggers.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setCurrent(index)}
|
||||
aria-label={`${describe(media)} vergrößern`}
|
||||
className={`block w-full ${index === 0 ? "md:h-full" : ""}`}
|
||||
>
|
||||
<MediaFrame
|
||||
media={media}
|
||||
aspect={aspect(index)}
|
||||
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
|
||||
showLabel={showLabels}
|
||||
className="w-full"
|
||||
/>
|
||||
</button>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Bildansicht Unterkunft"
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight") step(1);
|
||||
if (e.key === "ArrowLeft") step(-1);
|
||||
}}
|
||||
className="booking-dialog m-auto w-full max-w-5xl bg-transparent p-4"
|
||||
>
|
||||
{current !== null && (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className="relative">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={current}
|
||||
initial={reduce ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<MediaFrame media={images[current]!} aspect="aspect-[3/2]" sizes="90vw" showLabel={showLabels} />
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-paper">
|
||||
<p aria-live="polite" className="text-sm">
|
||||
Bild {current + 1} von {images.length}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(-1)}>
|
||||
Vorheriges
|
||||
</button>
|
||||
<button type="button" className="btn btn-quiet min-h-11 py-2 text-paper" onClick={() => step(1)}>
|
||||
Nächstes
|
||||
</button>
|
||||
<button type="button" className="btn bg-paper min-h-11 py-2 text-ink" onClick={close} autoFocus>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
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";
|
||||
if (!facts.endDate) return `ab ${formatIsoDateLong(facts.startDate)}`;
|
||||
return `${formatIsoDateLong(facts.startDate)} bis ${formatIsoDateLong(facts.endDate)}`;
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
||||
* Termin, Ort und Preis stehen als Cover-Lines daneben.
|
||||
*/
|
||||
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
||||
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
|
||||
return (
|
||||
<section id="top" aria-labelledby="hero-title" className="relative pb-16 pt-4 md:pb-24">
|
||||
<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-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.2vw] grid gap-8 md:grid-cols-12 md:gap-10">
|
||||
<div className="hero-image md:col-span-7 lg:col-span-8">
|
||||
<MediaFrame
|
||||
media={site.hero.media}
|
||||
aspect="aspect-[4/5] md:aspect-[5/4] lg:aspect-[16/11]"
|
||||
sizes="(min-width: 1024px) 66vw, (min-width: 768px) 58vw, 100vw"
|
||||
showLabel={showDrafts}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hero-fade flex flex-col justify-end md:col-span-5 lg:col-span-4">
|
||||
<p className="display text-[2.25rem] leading-[1.05] md:text-[2.75rem]">{site.hero.headline}</p>
|
||||
<p className="mt-5 max-w-md text-lg text-ink/85">{site.hero.subline}</p>
|
||||
<dl className="mt-8 divide-y divide-line border-y border-line text-[0.9375rem]">
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Termin</dt>
|
||||
<dd className="text-right">{dateLine(facts)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Ort</dt>
|
||||
<dd className="text-right">{facts.location ?? "Italien, genauer Ort folgt"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-3">
|
||||
<dt className="text-stone">Preis</dt>
|
||||
<dd className="text-right">{priceLine(pricing)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary">Platz buchen</BookButton>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-primary">
|
||||
Preis und Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#camp" className="btn btn-quiet">
|
||||
Mehr über das Camp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** Rahmen fuer die rechtlichen Seiten. Inhalte ausschliesslich freigegeben. */
|
||||
export function LegalPage({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="container-edge max-w-3xl py-12 md:py-20">
|
||||
<Link href="/" className="display text-2xl">
|
||||
ARTISTA <span className="font-sans text-sm text-stone">Modelcamp 2027</span>
|
||||
</Link>
|
||||
<h1 className="section-title mt-12">{title}</h1>
|
||||
<div className="prose-body mt-8 space-y-4 text-ink/90">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PendingLegalNotice({ what }: { what: string }) {
|
||||
return (
|
||||
<p className="rounded-xl border border-line bg-paper p-5">
|
||||
{what} veröffentlichen wir vor dem Verkaufsstart. Bis dahin ist keine verbindliche Buchung möglich.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Image from "next/image";
|
||||
import type { Media, PlaceholderTone } from "@/content/site";
|
||||
|
||||
const TONES: Record<PlaceholderTone, { background: string; shape: "arch" | "cypress" | "window" | "figure" }> = {
|
||||
villa: { background: "linear-gradient(172deg, #efe2cc 0%, #e0c7a2 52%, #c49a6c 100%)", shape: "arch" },
|
||||
terrace: { background: "linear-gradient(185deg, #e9dcc6 0%, #d7c29d 60%, #9f8b62 100%)", shape: "cypress" },
|
||||
room: { background: "linear-gradient(160deg, #f3ebdf 0%, #e3d3bb 60%, #cdb592 100%)", shape: "window" },
|
||||
garden: { background: "linear-gradient(190deg, #e6e0cc 0%, #c9c3a0 55%, #8c8a64 100%)", shape: "cypress" },
|
||||
portrait: { background: "linear-gradient(170deg, #eadccb 0%, #d4b99c 60%, #a8805f 100%)", shape: "figure" },
|
||||
studio: { background: "linear-gradient(175deg, #e4ddd3 0%, #cabdac 60%, #8f8273 100%)", shape: "figure" },
|
||||
};
|
||||
|
||||
function Shape({ shape }: { shape: (typeof TONES)[PlaceholderTone]["shape"] }) {
|
||||
const common = { fill: "currentColor", className: "absolute inset-0 h-full w-full text-ink/10" } as const;
|
||||
switch (shape) {
|
||||
case "arch":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M120 500V250a80 80 0 0 1 160 0v250z" />
|
||||
<path d="M20 500V330a40 40 0 0 1 80 0v170zM300 500V330a40 40 0 0 1 80 0v170z" opacity=".6" />
|
||||
</svg>
|
||||
);
|
||||
case "cypress":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<path d="M90 500c-22-90-18-230 12-330 26 100 30 240 10 330z" />
|
||||
<path d="M290 500c-18-70-15-180 10-260 21 80 24 190 8 260z" opacity=".7" />
|
||||
<rect x="0" y="440" width="400" height="60" opacity=".5" />
|
||||
</svg>
|
||||
);
|
||||
case "window":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMid slice" aria-hidden {...common}>
|
||||
<path d="M150 90h100v190H150z" opacity=".7" />
|
||||
<rect x="60" y="360" width="280" height="70" rx="6" />
|
||||
</svg>
|
||||
);
|
||||
case "figure":
|
||||
return (
|
||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
||||
<circle cx="200" cy="185" r="62" />
|
||||
<path d="M80 500c0-120 54-200 120-200s120 80 120 200z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
media: Media;
|
||||
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
|
||||
aspect: string;
|
||||
sizes: string;
|
||||
showLabel: boolean;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
|
||||
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
|
||||
* uebersehen werden.
|
||||
*/
|
||||
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
|
||||
if (media.kind === "image") {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
|
||||
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tone = TONES[media.tone];
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Platzhalter: ${media.label}`}
|
||||
className={`relative overflow-hidden ${aspect} ${className}`}
|
||||
style={{ background: tone.background }}
|
||||
>
|
||||
<Shape shape={tone.shape} />
|
||||
{showLabel && (
|
||||
<span className="absolute bottom-3 left-3 rounded-full bg-paper/85 px-3 py-1 text-xs font-semibold text-ink">
|
||||
Platzhalter: {media.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Einmaliges Aufdecken eines Bildes beim Hineinscrollen (Clip von unten).
|
||||
* Nur fuer Bildflaechen — Text erscheint sofort, damit nichts auf eine
|
||||
* Animation warten muss.
|
||||
*/
|
||||
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
|
||||
const reduce = useReducedMotion();
|
||||
if (reduce) return <div className={className}>{children}</div>;
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
className={className}
|
||||
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
|
||||
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
|
||||
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
|
||||
export function SiteFooter({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
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="mt-1 text-stone">Modelcamp 2027</p>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<h2 className="font-semibold">Kontakt</h2>
|
||||
{organizer ? (
|
||||
<address className="mt-3 not-italic leading-relaxed">
|
||||
{organizer.name}
|
||||
<br />
|
||||
<span className="whitespace-pre-line">{organizer.address}</span>
|
||||
<br />
|
||||
<a href={`mailto:${organizer.email}`} className="underline underline-offset-4">
|
||||
{organizer.email}
|
||||
</a>
|
||||
{organizer.phone && (
|
||||
<>
|
||||
<br />
|
||||
<a href={`tel:${organizer.phone.replace(/\s+/g, "")}`}>{organizer.phone}</a>
|
||||
</>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p className="mt-3 text-stone">
|
||||
{showDrafts ? <span className="draft-flag">Veranstalter- und Kontaktdaten folgen</span> : "Kontaktdaten folgen in Kürze."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<nav aria-label="Rechtliches" className="md:col-span-3">
|
||||
<h2 className="font-semibold">Rechtliches</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NAV_ITEMS } from "@/content/site";
|
||||
|
||||
export function SiteHeader() {
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuButton = useRef<HTMLButtonElement>(null);
|
||||
const firstLink = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Markiert den Abschnitt, der gerade die Bildschirmmitte kreuzt.
|
||||
useEffect(() => {
|
||||
const sections = NAV_ITEMS.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id);
|
||||
},
|
||||
{ rootMargin: "-45% 0px -50% 0px" },
|
||||
);
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
firstLink.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
menuButton.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-40 transition-colors duration-300 ${scrolled ? "bg-plaster/92 shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "bg-transparent"}`}
|
||||
>
|
||||
<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">
|
||||
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="font-sans text-sm text-stone">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
|
||||
</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>
|
||||
</div>
|
||||
{menuOpen && (
|
||||
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster lg:hidden">
|
||||
<ul className="container-edge py-4">
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
ref={index === 0 ? firstLink : undefined}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="display block border-b border-line/70 py-3 text-2xl"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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" };
|
||||
|
||||
return (
|
||||
<figure>
|
||||
<div className="relative aspect-[4/5] overflow-hidden bg-ink">
|
||||
{stage === "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" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStage(external && !slot.src ? "consent" : "playing")}
|
||||
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"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
|
||||
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
|
||||
</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>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Kleine, gemeinsame Bausteine fuer die Admin-Seiten. */
|
||||
|
||||
export function Flash({ ok, fehler }: { ok?: string; fehler?: string }) {
|
||||
if (!ok && !fehler) return null;
|
||||
return (
|
||||
<p role={fehler ? "alert" : "status"} className={`mb-6 rounded-lg border p-3 font-medium ${fehler ? "border-rosso/40 text-rosso" : "border-line bg-plaster"}`}>
|
||||
{fehler ?? ok}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<h1 className="display text-4xl">{children}</h1>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-line bg-plaster/60 p-5">
|
||||
<h2 className="mb-3 font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValue({ items }: { items: [string, React.ReactNode][] }) {
|
||||
return (
|
||||
<dl className="divide-y divide-line">
|
||||
{items.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-4 py-1.5">
|
||||
<dt className="text-stone">{k}</dt>
|
||||
<dd className="text-right tabular-nums">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function Checkbox({ name, label, defaultChecked, hint }: { name: string; label: string; defaultChecked: boolean; hint?: string }) {
|
||||
return (
|
||||
<label className="flex gap-3 py-1.5">
|
||||
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]" />
|
||||
<span>
|
||||
{label}
|
||||
{hint && <span className="block text-sm text-stone">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
type = "text",
|
||||
hint,
|
||||
multiline,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string | number | null | undefined;
|
||||
type?: string;
|
||||
hint?: string;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`f-${name}`} className="field-label">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea id={`f-${name}`} name={name} defaultValue={defaultValue ?? ""} rows={4} className="field" />
|
||||
) : (
|
||||
<input id={`f-${name}`} name={name} type={type} defaultValue={defaultValue ?? ""} className="field" />
|
||||
)}
|
||||
{hint && <p className="mt-1 text-sm text-stone">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Voucher } from "@/generated/prisma/client";
|
||||
import { saveVoucherAction } from "@/app/admin/actions";
|
||||
import { berlinDateString } from "@/lib/time";
|
||||
import { Checkbox, Field } from "./AdminUi";
|
||||
|
||||
/** Anlegen und Bearbeiten eines Stammmodel-Codes. Alle Felder bleiben aenderbar. */
|
||||
export function VoucherForm({ voucher }: { voucher: Voucher | null }) {
|
||||
return (
|
||||
<form action={saveVoucherAction} className="space-y-4">
|
||||
{voucher && <input type="hidden" name="voucherId" value={voucher.id} />}
|
||||
<Field name="code" label="Code" defaultValue={voucher?.code} hint="Wird in Großbuchstaben gespeichert. Keine öffentlich erratbaren Codes verwenden." />
|
||||
<Checkbox name="active" label="Aktiv" defaultChecked={voucher?.active ?? true} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field name="validFrom" label="Gültig ab (optional)" type="date" defaultValue={voucher?.validFrom ? berlinDateString(voucher.validFrom) : ""} />
|
||||
<Field
|
||||
name="validUntil"
|
||||
label="Gültig bis einschließlich (optional)"
|
||||
type="date"
|
||||
defaultValue={voucher?.validUntil ? berlinDateString(new Date(voucher.validUntil.getTime() - 1)) : ""}
|
||||
/>
|
||||
</div>
|
||||
<Field name="maxRedemptions" label="Maximale Einlösungen" type="number" defaultValue={voucher?.maxRedemptions ?? 1} />
|
||||
<Field name="boundEmail" label="Nur für diese E-Mail-Adresse (optional)" type="email" defaultValue={voucher?.boundEmail} />
|
||||
<Field name="internalNote" label="Interne Zuordnung" defaultValue={voucher?.internalNote} hint="Nur hier sichtbar, z. B. Name des Stammmodels." />
|
||||
<button type="submit" className="btn btn-primary">{voucher ? "Speichern" : "Code anlegen"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import type { QuoteData } from "@/lib/quote";
|
||||
|
||||
type PlanType = "FULL" | "TWO_INSTALLMENTS" | "THREE_INSTALLMENTS";
|
||||
|
||||
const eur = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
const money = (cents: number) => eur.format(cents / 100);
|
||||
const dateFmt = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" });
|
||||
const isoDate = (iso: string) => dateFmt.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
const STEPS = ["Deine Daten", "Zahlungsweise", "Prüfen und bezahlen"] as const;
|
||||
|
||||
type Participant = { firstName: string; lastName: string; email: string; phone: string; country: string; notes: string };
|
||||
type Consents = { adult: boolean; terms: boolean; accommodation: boolean; privacy: boolean; installmentMandate: boolean };
|
||||
type FieldErrors = Record<string, string>;
|
||||
|
||||
const EMPTY: Participant = { firstName: "", lastName: "", email: "", phone: "", country: "Deutschland", notes: "" };
|
||||
|
||||
type ApiResult<T> = { success: true; data: T } | { success: false; error: { code: string; message: string; fields?: FieldErrors } };
|
||||
|
||||
async function post<T>(url: string, body: unknown): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
return (await res.json()) as ApiResult<T>;
|
||||
} catch {
|
||||
return { success: false, error: { code: "NETWORK", message: "Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut." } };
|
||||
}
|
||||
}
|
||||
|
||||
function validateParticipant(p: Participant): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
if (!p.firstName.trim()) errors["participant.firstName"] = "Bitte gib deinen Vornamen an.";
|
||||
if (!p.lastName.trim()) errors["participant.lastName"] = "Bitte gib deinen Nachnamen an.";
|
||||
if (!/^\S+@\S+\.\S+$/.test(p.email.trim())) errors["participant.email"] = "Bitte gib eine gültige E-Mail-Adresse an.";
|
||||
if (!/^[+0-9 ()/-]{6,40}$/.test(p.phone.trim())) errors["participant.phone"] = "Bitte gib eine Telefonnummer mit mindestens sechs Ziffern an.";
|
||||
if (p.country.trim().length < 2) errors["participant.country"] = "Bitte gib dein Land an.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function BookingDialog({
|
||||
open,
|
||||
onClose,
|
||||
pricing,
|
||||
facts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
const titleId = useId();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
||||
const [voucherInput, setVoucherInput] = useState("");
|
||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||
const [consents, setConsents] = useState<Consents>({ adult: false, terms: false, accommodation: false, privacy: false, installmentMandate: false });
|
||||
const [quote, setQuote] = useState<QuoteData | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Idempotenz: identische Absendungen (Doppelklick, Wiederholung nach
|
||||
// Netzfehler) tragen denselben Schluessel, jede geaenderte Angabe einen neuen.
|
||||
const lastSubmission = useRef<{ fingerprint: string; key: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadQuote = useCallback(
|
||||
async (voucherCode: string | null) => {
|
||||
const result = await post<QuoteData>("/api/booking/quote", {
|
||||
voucherCode: voucherCode ?? undefined,
|
||||
email: participant.email.trim() || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setQuote(result.data);
|
||||
return result.data;
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
return null;
|
||||
},
|
||||
[participant.email],
|
||||
);
|
||||
|
||||
// Erste Preisvorschau beim Oeffnen.
|
||||
useEffect(() => {
|
||||
if (!open || quote) return;
|
||||
let cancelled = false;
|
||||
post<QuoteData>("/api/booking/quote", {}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success) setQuote(result.data);
|
||||
else setFormError(result.error.message);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, quote]);
|
||||
|
||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
||||
|
||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
||||
|
||||
function updateParticipant(field: keyof Participant, value: string) {
|
||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`participant.${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
setFormError(null);
|
||||
if (step === 0) {
|
||||
const found = validateParticipant(participant);
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
requestAnimationFrame(() => (dialogRef.current?.querySelector('[aria-invalid="true"]') as HTMLElement | null)?.focus());
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
function back() {
|
||||
setFormError(null);
|
||||
setStep((s) => Math.max(s - 1, 0));
|
||||
requestAnimationFrame(() => headingRef.current?.focus());
|
||||
}
|
||||
|
||||
async function applyVoucher() {
|
||||
const code = voucherInput.trim();
|
||||
if (!code) return;
|
||||
setBusy(true);
|
||||
const data = await loadQuote(code);
|
||||
setBusy(false);
|
||||
if (!data) return;
|
||||
if (data.voucher.state === "applied") {
|
||||
setAppliedVoucher(code);
|
||||
setVoucherMessage("Gutschein angewendet.");
|
||||
} else {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherMessage(data.voucher.message ?? "Dieser Gutscheincode kann leider nicht angewendet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeVoucher() {
|
||||
setAppliedVoucher(null);
|
||||
setVoucherInput("");
|
||||
setVoucherMessage(null);
|
||||
await loadQuote(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setFormError(null);
|
||||
const missing: FieldErrors = {};
|
||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||
if (!consents.accommodation) missing["consents.accommodation"] = "Bitte bestätige die Unterbringung.";
|
||||
if (!consents.privacy) missing["consents.privacy"] = "Bitte bestätige die Datenschutzhinweise.";
|
||||
if (planType !== "FULL" && !consents.installmentMandate) missing["consents.installmentMandate"] = "Bitte stimme dem Einzug der weiteren Raten zu.";
|
||||
setErrors(missing);
|
||||
if (Object.keys(missing).length > 0) return;
|
||||
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
participant: { ...participant, notes: participant.notes || undefined },
|
||||
planType,
|
||||
voucherCode: appliedVoucher ?? undefined,
|
||||
consents,
|
||||
};
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
if (lastSubmission.current?.fingerprint !== fingerprint) {
|
||||
lastSubmission.current = { fingerprint, key: crypto.randomUUID() };
|
||||
}
|
||||
const result = await post<{ url: string }>("/api/booking/checkout", { idempotencyKey: lastSubmission.current.key, ...payload });
|
||||
if (result.success) {
|
||||
window.location.assign(result.data.url);
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
if (result.error.fields) {
|
||||
setErrors(result.error.fields);
|
||||
if (Object.keys(result.error.fields).some((k) => k.startsWith("participant."))) setStep(0);
|
||||
}
|
||||
setFormError(result.error.message);
|
||||
if (result.error.code === "VOUCHER_REJECTED") {
|
||||
setAppliedVoucher(null);
|
||||
void loadQuote(null);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = (field: keyof Participant) => ({
|
||||
id: `bk-${field}`,
|
||||
value: participant[field],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => updateParticipant(field, e.target.value),
|
||||
"aria-invalid": errors[`participant.${field}`] ? true : undefined,
|
||||
"aria-describedby": errors[`participant.${field}`] ? `bk-${field}-error` : undefined,
|
||||
className: "field",
|
||||
});
|
||||
|
||||
const fieldError = (key: string, id: string) =>
|
||||
errors[key] ? (
|
||||
<p id={id} className="mt-1.5 text-sm font-medium text-rosso">
|
||||
{errors[key]}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
const today = selectedPlan?.installments[0]?.amountCents ?? null;
|
||||
const organizer = facts.organizer?.name ?? "den Veranstalter";
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
className="booking-dialog m-0 h-dvh max-h-dvh w-full max-w-full bg-plaster p-0 text-ink backdrop:bg-transparent md:m-auto md:h-auto md:max-h-[92dvh] md:max-w-3xl md:rounded-2xl"
|
||||
>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.div
|
||||
initial={reduce ? false : { opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="flex h-full max-h-[inherit] flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line px-5 pb-4 pt-5 md:px-8">
|
||||
<div>
|
||||
<p id={titleId} className="text-sm text-stone">
|
||||
Buchung ARTISTA Modelcamp 2027
|
||||
{pricing.mode === "test" && <span className="ml-2 draft-flag">Testmodus</span>}
|
||||
</p>
|
||||
<h2 ref={headingRef} tabIndex={-1} className="display mt-1 text-3xl outline-none md:text-4xl">
|
||||
{STEPS[step]}
|
||||
</h2>
|
||||
<ol className="mt-3 flex gap-2" aria-label="Fortschritt">
|
||||
{STEPS.map((label, index) => (
|
||||
<li key={label} aria-current={index === step ? "step" : undefined} className={`h-1 w-10 rounded-full ${index <= step ? "bg-rosso" : "bg-line"}`}>
|
||||
<span className="sr-only">
|
||||
Schritt {index + 1} von {STEPS.length}: {label}
|
||||
{index < step ? " (erledigt)" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-ink/30 px-4 py-2 text-sm font-semibold">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6 md:px-8">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<m.div
|
||||
key={step}
|
||||
initial={reduce ? false : { opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{step === 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-firstName">Vorname</label>
|
||||
<input {...fieldProps("firstName")} autoComplete="given-name" required />
|
||||
{fieldError("participant.firstName", "bk-firstName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-lastName">Nachname</label>
|
||||
<input {...fieldProps("lastName")} autoComplete="family-name" required />
|
||||
{fieldError("participant.lastName", "bk-lastName-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-email">E-Mail</label>
|
||||
<input {...fieldProps("email")} type="email" autoComplete="email" inputMode="email" required />
|
||||
{fieldError("participant.email", "bk-email-error")}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-phone">Telefon</label>
|
||||
<input {...fieldProps("phone")} type="tel" autoComplete="tel" required />
|
||||
{fieldError("participant.phone", "bk-phone-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="bk-country">Land</label>
|
||||
<input {...fieldProps("country")} autoComplete="country-name" required />
|
||||
{fieldError("participant.country", "bk-country-error")}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-8">
|
||||
<fieldset>
|
||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||
<div className="mt-2 space-y-3">
|
||||
{quote?.plans.map((plan) => (
|
||||
<label
|
||||
key={plan.type}
|
||||
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="planType"
|
||||
value={plan.type}
|
||||
checked={planType === plan.type}
|
||||
disabled={!plan.available}
|
||||
onChange={() => {
|
||||
setPlanType(plan.type);
|
||||
}}
|
||||
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block font-semibold">{plan.label}</span>
|
||||
{plan.available ? (
|
||||
<span className="mt-1 block text-[0.9375rem] text-ink/80">
|
||||
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="bk-voucher">Gutscheincode (optional)</label>
|
||||
{appliedVoucher ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full bg-paper px-4 py-2 font-semibold">{appliedVoucher.toUpperCase()}</span>
|
||||
<button type="button" className="text-sm font-semibold underline" onClick={removeVoucher}>
|
||||
Gutschein entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="bk-voucher"
|
||||
className="field"
|
||||
value={voucherInput}
|
||||
onChange={(e) => setVoucherInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
aria-describedby={voucherMessage ? "bk-voucher-msg" : undefined}
|
||||
/>
|
||||
<button type="button" className="btn btn-quiet shrink-0" onClick={applyVoucher} disabled={busy || !voucherInput.trim()}>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p id="bk-voucher-msg" role="status" className={`mt-1.5 text-sm ${appliedVoucher ? "text-ink" : "text-rosso"}`}>
|
||||
{voucherMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && quote?.price && selectedPlan && (
|
||||
<div className="space-y-8">
|
||||
<section aria-labelledby="sum-price">
|
||||
<h3 id="sum-price" className="font-semibold">Preis und Zahlungsplan</h3>
|
||||
<PriceBreakdown quote={quote} />
|
||||
<table className="mt-4 w-full text-[0.9375rem]">
|
||||
<caption className="sr-only">Zahlungsplan</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-stone">
|
||||
<th scope="col" className="py-1 font-normal">Rate</th>
|
||||
<th scope="col" className="py-1 font-normal">Fällig</th>
|
||||
<th scope="col" className="py-1 text-right font-normal">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedPlan.installments.map((i) => (
|
||||
<tr key={i.seq} className="border-t border-line">
|
||||
<td className="py-2">{selectedPlan.installments.length > 1 ? `Rate ${i.seq}` : "Einmalzahlung"}</td>
|
||||
<td className="py-2">{i.dueDate ? isoDate(i.dueDate) : "heute, bei Buchung"}</td>
|
||||
<td className="py-2 text-right tabular-nums">{money(i.amountCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{today !== null && (
|
||||
<p className="mt-3 font-semibold">
|
||||
Heute fällig: {money(today)}
|
||||
{selectedPlan.installments.length > 1 && `, danach noch ${money(quote.price.totalCents - today)}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">
|
||||
Der Preis ist ab Klick auf „Zahlungspflichtig buchen“ für {quote.priceLockMinutes} Minuten reserviert. Wird die Zahlung in dieser Zeit
|
||||
nicht abgeschlossen, verfällt die Reservierung.
|
||||
</p>
|
||||
{quote.taxNote ? <p className="mt-2 text-sm text-stone">{quote.taxNote}</p> : quote.mode === "test" && <p className="mt-2"><span className="draft-flag">Steuerlicher Hinweis folgt vor Verkaufsstart</span></p>}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="sum-stay">
|
||||
<h3 id="sum-stay" className="font-semibold">Unterkunft und Leistungen</h3>
|
||||
<p className="mt-2">Teilnahme pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-2 whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-2">
|
||||
<span className="draft-flag">Testmodus</span>{" "}
|
||||
<span className="text-stone">Die verbindliche Zimmer- und Bettbeschreibung wird vor dem Live-Verkauf ergänzt.</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone">Nicht enthalten: Anreise nach Italien und zurück, persönliche Ausgaben vor Ort, Reiserücktrittsversicherung.</p>
|
||||
</section>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="font-semibold">Erklärungen</legend>
|
||||
<Consent id="adult" checked={consents.adult} error={errors["consents.adult"]} onChange={(v) => setConsents((c) => ({ ...c, adult: v }))}>
|
||||
Ich bin volljährig.
|
||||
</Consent>
|
||||
<Consent id="accommodation" checked={consents.accommodation} error={errors["consents.accommodation"]} onChange={(v) => setConsents((c) => ({ ...c, accommodation: v }))}>
|
||||
Ich habe die Beschreibung der Unterbringung gelesen und buche einen Platz mit geteilter Unterbringung.
|
||||
</Consent>
|
||||
<Consent id="terms" checked={consents.terms} error={errors["consents.terms"]} onChange={(v) => setConsents((c) => ({ ...c, terms: v }))}>
|
||||
Ich akzeptiere die{" "}
|
||||
<a href={facts.termsUrl ?? "/buchungsbedingungen"} target="_blank" rel="noopener" className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
.
|
||||
</Consent>
|
||||
<Consent id="privacy" checked={consents.privacy} error={errors["consents.privacy"]} onChange={(v) => setConsents((c) => ({ ...c, privacy: v }))}>
|
||||
Ich habe die{" "}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener" className="underline">
|
||||
Datenschutzhinweise
|
||||
</a>{" "}
|
||||
gelesen.
|
||||
</Consent>
|
||||
{planType !== "FULL" && (
|
||||
<Consent
|
||||
id="installmentMandate"
|
||||
checked={consents.installmentMandate}
|
||||
error={errors["consents.installmentMandate"]}
|
||||
onChange={(v) => setConsents((c) => ({ ...c, installmentMandate: v }))}
|
||||
>
|
||||
Ich ermächtige {organizer}, die weiteren Raten zu den oben genannten Terminen über die bei der Zahlung gespeicherte Zahlungsmethode
|
||||
einzuziehen. Nach der letzten Rate wird die Zahlungsmethode gelöst; weitere Abbuchungen finden nicht statt.
|
||||
</Consent>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
{formError && (
|
||||
<p role="alert" className="mt-6 rounded-lg border border-rosso/40 bg-paper p-4 text-[0.9375rem] font-medium text-rosso">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-line px-5 py-4 md:px-8">
|
||||
{step > 0 ? (
|
||||
<button type="button" className="btn btn-quiet" onClick={back} disabled={busy}>
|
||||
Zurück
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy || !quote?.checkoutOpen}>
|
||||
{busy ? "Einen Moment …" : "Zahlungspflichtig buchen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||
const p = quote.price!;
|
||||
return (
|
||||
<dl className="mt-3 space-y-1.5 text-[0.9375rem]">
|
||||
<div className="flex justify-between">
|
||||
<dt>Regulärer Preis</dt>
|
||||
<dd className="tabular-nums">{money(p.basePriceCents)}</dd>
|
||||
</div>
|
||||
{p.phaseDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Early-Bird-Vorteil (20 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.phaseDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{p.voucherDiscountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<dt>Gutschein (10 %)</dt>
|
||||
<dd className="tabular-nums">−{money(p.voucherDiscountCents)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between border-t border-ink pt-2 text-lg font-semibold">
|
||||
<dt>Gesamtpreis pro Person</dt>
|
||||
<dd className="tabular-nums">{money(p.totalCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Consent({
|
||||
id,
|
||||
checked,
|
||||
error,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
error?: string;
|
||||
onChange: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={`bk-c-${id}`} className="flex cursor-pointer gap-3 text-[0.9375rem] leading-snug">
|
||||
<input
|
||||
id={`bk-c-${id}`}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `bk-c-${id}-error` : undefined}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 accent-[var(--color-rosso)]"
|
||||
/>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p id={`bk-c-${id}-error`} className="ml-8 mt-1 text-sm font-medium text-rosso">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
|
||||
// Der Dialog (Formular, Motion, Preislogik-Anzeige) wird erst beim ersten
|
||||
// Oeffnen geladen und belastet den ersten Seitenaufbau nicht.
|
||||
const BookingDialog = dynamic(() => import("./BookingDialog").then((m) => m.BookingDialog), { ssr: false });
|
||||
|
||||
type BookingContextValue = { open: () => void; pricing: PublicPricing };
|
||||
const BookingContext = createContext<BookingContextValue | null>(null);
|
||||
|
||||
export function useBooking(): BookingContextValue {
|
||||
const ctx = useContext(BookingContext);
|
||||
if (!ctx) throw new Error("useBooking ausserhalb von BookingProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function BookingProvider({
|
||||
pricing,
|
||||
facts,
|
||||
children,
|
||||
}: {
|
||||
pricing: PublicPricing;
|
||||
facts: PublicCampFacts;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const trigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
trigger.current = document.activeElement as HTMLElement | null;
|
||||
setMounted(true);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
// Fokus zurueck auf den ausloesenden Button.
|
||||
requestAnimationFrame(() => trigger.current?.focus());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookingContext.Provider value={{ open, pricing }}>
|
||||
{children}
|
||||
{mounted && <BookingDialog open={isOpen} onClose={close} pricing={pricing} facts={facts} />}
|
||||
</BookingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookButton({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const { open, pricing } = useBooking();
|
||||
return (
|
||||
<button type="button" className={className} onClick={open} disabled={!pricing.checkoutOpen} aria-haspopup="dialog">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookButton, useBooking } from "./BookingProvider";
|
||||
|
||||
/**
|
||||
* Buchungsleiste fuer kleine Bildschirme. Erscheint erst, wenn der Hero
|
||||
* verlassen ist, und verschwindet im Preisbereich und am Seitenende, damit
|
||||
* sie dort keine Inhalte oder Bedienelemente verdeckt.
|
||||
*/
|
||||
export function MobileBookingBar({ priceLabel }: { priceLabel: string }) {
|
||||
const { pricing } = useBooking();
|
||||
const [heroVisible, setHeroVisible] = useState(true);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hero = document.getElementById("top");
|
||||
const blockers = ["preis", "site-footer"].map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
|
||||
const visible = new Set<string>();
|
||||
const heroObserver = new IntersectionObserver(([entry]) => setHeroVisible(entry?.isIntersecting ?? false));
|
||||
const blockObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) visible.add(entry.target.id);
|
||||
else visible.delete(entry.target.id);
|
||||
}
|
||||
setBlocked(visible.size > 0);
|
||||
});
|
||||
if (hero) heroObserver.observe(hero);
|
||||
blockers.forEach((b) => blockObserver.observe(b));
|
||||
return () => {
|
||||
heroObserver.disconnect();
|
||||
blockObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const show = !heroVisible && !blocked;
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<m.div
|
||||
className="mobile-book-bar fixed inset-x-0 bottom-0 z-30 border-t border-line bg-plaster/95 backdrop-blur lg:hidden"
|
||||
data-visible="true"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="container-edge flex items-center justify-between gap-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<p className="text-sm leading-tight">
|
||||
<span className="block font-semibold">Modelcamp 2027</span>
|
||||
<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>
|
||||
) : (
|
||||
<a href="#preis" className="btn btn-quiet min-h-11 px-5 py-2 text-sm">
|
||||
Zum Preis
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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-20 md:py-32">
|
||||
<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-20 text-paper md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="leistungen-title" className="section-title max-w-3xl">{services.title}</h2>
|
||||
<div className="mt-14 grid gap-12 md:grid-cols-2 md:gap-16">
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Im Preis enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{included.map((item) => (
|
||||
<li key={item.text} className="py-4 text-lg">
|
||||
{item.text}
|
||||
{!item.confirmed && <DraftFlag show />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!showDrafts && included.length < services.included.length && (
|
||||
<p className="mt-4 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Nicht enthalten</h3>
|
||||
<ul className="mt-2 divide-y divide-paper/15">
|
||||
{services.excluded.map((item) => (
|
||||
<li key={item} className="py-4 text-lg text-paper/85">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 border-t border-paper/30 pt-10">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h3 className="display text-3xl">So könnte ein Tag im Camp aussehen</h3>
|
||||
<p className="max-w-md text-[0.9375rem] text-paper/75">{services.exampleSchedule.note}</p>
|
||||
</div>
|
||||
{/* Nummeriert, weil es eine Abfolge ist — ausdruecklich als Beispiel gekennzeichnet. */}
|
||||
<ol className="mt-10 grid gap-8 md:grid-cols-3">
|
||||
{services.exampleSchedule.steps.map((step, index) => (
|
||||
<li key={step.title} className="border-t border-paper/30 pt-5">
|
||||
<span className="display text-5xl text-paper/40">{index + 1}</span>
|
||||
<p className="mt-3 text-lg font-semibold">{step.title}</p>
|
||||
<p className="mt-2 text-paper/80">{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const { accommodation } = site;
|
||||
return (
|
||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<div className="grid gap-8 md:grid-cols-12">
|
||||
<h2 id="unterkunft-title" className="section-title md:col-span-6">{accommodation.title}</h2>
|
||||
<p className="text-lg text-ink/85 md:col-span-5 md:col-start-8 md:self-end">{accommodation.lead}</p>
|
||||
</div>
|
||||
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||
<div className="md:col-span-7">
|
||||
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
|
||||
<p className="mt-3 max-w-2xl">
|
||||
Gebucht wird ein Platz pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.
|
||||
</p>
|
||||
{facts.accommodationText ? (
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
|
||||
) : (
|
||||
<p className="mt-3 max-w-2xl text-stone">
|
||||
Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-4 md:col-start-9">
|
||||
{accommodation.bookingUrl ? (
|
||||
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
|
||||
Unterkunft ansehen (externe Seite)
|
||||
</a>
|
||||
) : (
|
||||
showDrafts && <span className="draft-flag">Link zur Unterkunft folgt</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
type Faq = { q: string; a: React.ReactNode };
|
||||
|
||||
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.",
|
||||
},
|
||||
{
|
||||
q: "Wie lange gilt der angezeigte Preis?",
|
||||
a: `Es gilt der Preis zum Zeitpunkt, an dem du die Buchung startest. Er bleibt ${pricing.reservationMinutes} Minuten für dich reserviert. Schließt du die Zahlung in dieser Zeit nicht ab, verfällt die Reservierung, und ein neuer Buchungsversuch wird zum dann gültigen Preis berechnet. Der Early-Bird-Preis endet am 31. Dezember 2026 um 24 Uhr und wird nicht verlängert.`,
|
||||
},
|
||||
{
|
||||
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."
|
||||
: "Ob eine Ratenzahlung möglich ist, geben wir bekannt, sobald der Camp-Termin feststeht.",
|
||||
},
|
||||
{
|
||||
q: "Welche Zahlungsarten gibt es?",
|
||||
a: "Die verfügbaren Zahlungsarten siehst du auf der Zahlungsseite von Stripe. Kartendaten gibst du nur dort ein; wir speichern sie nicht.",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
{
|
||||
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."}`,
|
||||
},
|
||||
{
|
||||
q: "Was ist nicht im Preis enthalten?",
|
||||
a: `${site.services.excluded.join(", ")}.`,
|
||||
},
|
||||
{
|
||||
q: "Was gilt, wenn ich nicht teilnehmen kann?",
|
||||
a: facts.termsUrl ? (
|
||||
<>
|
||||
Das regeln unsere{" "}
|
||||
<a href={facts.termsUrl} className="underline">
|
||||
Buchungs-, Storno- und Zahlungsbedingungen
|
||||
</a>
|
||||
. Eine Reiserücktrittsversicherung ist nicht enthalten.
|
||||
</>
|
||||
) : (
|
||||
"Die Buchungs-, Storno- und Zahlungsbedingungen veröffentlichen wir vor dem Verkaufsstart. Eine Reiserücktrittsversicherung ist nicht enthalten."
|
||||
),
|
||||
},
|
||||
{
|
||||
q: "Ich habe einen Gutscheincode. Wo gebe ich ihn ein?",
|
||||
a: "Im zweiten Schritt der Buchung. Pro Buchung lässt sich ein Gutschein einlösen.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||
return (
|
||||
<section id="fragen" aria-labelledby="fragen-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
||||
<h2 id="fragen-title" className="section-title md:col-span-4">{site.faq.title}</h2>
|
||||
<div className="md:col-span-8">
|
||||
{faqs(facts, pricing).map((item) => (
|
||||
<details key={item.q} className="group border-b border-line py-1 first:border-t">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-6 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden">
|
||||
{item.q}
|
||||
<span aria-hidden className="text-2xl font-normal text-rosso transition-transform duration-200 group-open:rotate-45">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClosingSection({ pricing }: { pricing: PublicPricing }) {
|
||||
return (
|
||||
<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.
|
||||
</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>
|
||||
) : (
|
||||
<a href="#preis" className="btn bg-paper px-8 text-lg text-ink hover:bg-plaster">
|
||||
Zum Buchungsstart
|
||||
</a>
|
||||
)}
|
||||
<a href="#fragen" className="btn btn-quiet text-paper">
|
||||
Fragen ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { site } from "@/content/site";
|
||||
import { MediaFrame } from "../MediaFrame";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { VideoSlot } from "../VideoSlot";
|
||||
|
||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { teachers } = site;
|
||||
return (
|
||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-20 md:py-32">
|
||||
<div className="container-edge">
|
||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
||||
{teachers.people.map((person, index) => (
|
||||
<article
|
||||
key={index}
|
||||
aria-label={person.name ?? `Dozentin ${index + 1}`}
|
||||
className={index === 0 ? "md:col-span-5" : "md:col-span-5 md:col-start-8 md:mt-40"}
|
||||
>
|
||||
<Reveal>
|
||||
<MediaFrame media={person.portrait} aspect="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
|
||||
</Reveal>
|
||||
<h3 className="display mt-6 text-3xl">
|
||||
{person.name ?? (showDrafts ? `Dozentin ${index + 1}: Name folgt` : "Wird in Kürze vorgestellt")}
|
||||
</h3>
|
||||
{person.role && <p className="mt-1 text-stone">{person.role}</p>}
|
||||
{person.bio ? (
|
||||
<p className="mt-4 max-w-md">{person.bio}</p>
|
||||
) : (
|
||||
showDrafts && <p className="mt-4"><span className="draft-flag">Kurzbeschreibung folgt</span></p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
||||
const { about } = site;
|
||||
return (
|
||||
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-32">
|
||||
<div className="container-edge grid gap-8 md:grid-cols-12">
|
||||
<h2 id="artista-title" className="section-title md:col-span-5">{about.title}</h2>
|
||||
<div className="prose-body md:col-span-6 md:col-start-7">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{about.sourceUrl && (
|
||||
<p className="mt-6">
|
||||
<a href={about.sourceUrl} target="_blank" rel="noopener noreferrer" className="font-semibold underline underline-offset-4">
|
||||
Zur ARTISTA-Stammmodel-Seite
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return (
|
||||
<section id="stimmen" aria-labelledby="stimmen-title" className="border-t border-line py-20 md:py-28">
|
||||
<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) => (
|
||||
<li key={index}>
|
||||
<VideoSlot slot={slot} index={index} provider={provider} showDrafts={showDrafts} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { site } from "@/content/site";
|
||||
import type { PublicCampFacts } from "@/lib/camp";
|
||||
import { formatEuroShort } from "@/lib/money";
|
||||
import type { PublicPricing } from "@/lib/public-page";
|
||||
import { BookButton } from "../booking/BookingProvider";
|
||||
|
||||
const CLOSED_TEXT: Record<Exclude<PublicPricing["closedReason"], null>, string> = {
|
||||
PREVIEW: "Die Buchung öffnet am 1. November 2026.",
|
||||
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.",
|
||||
};
|
||||
|
||||
export function PricingSection({ pricing, facts, showDrafts }: { pricing: PublicPricing; facts: PublicCampFacts; showDrafts: boolean }) {
|
||||
const included = site.services.included.filter((s) => s.confirmed);
|
||||
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>
|
||||
|
||||
{pricing.mode === "test" && (
|
||||
<p className="mt-6 rounded-lg border border-line bg-plaster p-4 text-[0.9375rem]">
|
||||
<span className="draft-flag">Testmodus</span> Zahlungen laufen über Stripe-Testdaten, es wird kein Geld eingezogen.
|
||||
{pricing.phaseSimulated && " Die Preisphase ist für Tests simuliert."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 max-w-xl space-y-2 text-[0.9375rem] text-ink/85">
|
||||
<li>Gesamtpreis pro Person für die Teilnahme mit geteilter Unterbringung.</li>
|
||||
<li>
|
||||
{pricing.installmentsAllowed
|
||||
? "Bezahlen kannst du auf einmal oder in bis zu drei Raten. Die erste Rate ist bei Buchung fällig, die letzte spätestens 30 Tage vor Anreise."
|
||||
: "Bezahlt wird bei Buchung. Ob zusätzlich Ratenzahlung möglich ist, geben wir bekannt, sobald der Termin feststeht."}
|
||||
</li>
|
||||
<li>Einen Gutscheincode kannst du im Buchungsschritt eingeben.</li>
|
||||
{facts.taxNote ? (
|
||||
<li>{facts.taxNote}</li>
|
||||
) : (
|
||||
showDrafts && (
|
||||
<li>
|
||||
<span className="draft-flag">Steuerlicher Preishinweis folgt vor Verkaufsstart</span>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="mt-10">
|
||||
{pricing.checkoutOpen ? (
|
||||
<BookButton className="btn btn-primary px-8 text-lg">Platz buchen</BookButton>
|
||||
) : (
|
||||
<p className="max-w-xl text-lg font-semibold">{CLOSED_TEXT[pricing.closedReason ?? "NOT_CONFIGURED"]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside aria-labelledby="preis-umfang" className="md:col-span-4 md:col-start-9 md:pt-24">
|
||||
<h3 id="preis-umfang" className="text-lg font-semibold">Was du buchst</h3>
|
||||
<ul className="mt-4 divide-y divide-line border-y border-line">
|
||||
{included.map((item) => (
|
||||
<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-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>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user