Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a9c6c8e50 | ||
|
|
2ef92db9ed | ||
|
|
00c7118c10 | ||
|
|
f74a08fa68 | ||
|
|
b4eafbe433 |
No files matched your search
@@ -53,3 +53,9 @@ MEDIA_DIR=./media
|
|||||||
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
# Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo
|
||||||
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
# Externe Player laden nur nach Klick und ausdruecklicher Zustimmung.
|
||||||
EXTERNAL_VIDEO_PROVIDER=none
|
EXTERNAL_VIDEO_PROVIDER=none
|
||||||
|
|
||||||
|
# --- Google-Rezensionen (optional) ------------------------------------------
|
||||||
|
# "Places API (New)" aktivieren, Abrechnung an, API-Schluessel (kein OAuth).
|
||||||
|
# Ohne Werte wird der Rezensionen-Abschnitt ausgeblendet.
|
||||||
|
GOOGLE_PLACES_API_KEY=
|
||||||
|
GOOGLE_PLACE_ID=
|
||||||
+6
-3
@@ -6,17 +6,20 @@
|
|||||||
|
|
||||||
## Gestaltung
|
## Gestaltung
|
||||||
|
|
||||||
- **Idee:** Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild, Termin, Ort und Preis stehen als Cover-Lines daneben. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
|
- **Idee:** Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild. Schlagzeile und Buchung stehen direkt unter dem Masthead und bleiben im ersten Bildschirm; Termin, Ort und Preis folgen als Cover-Line-Leiste unter dem Bild. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
|
||||||
- **Farbe:** Putz-Weiß `#F2ECE3`, Papier `#FAF7F1`, Anthrazit `#23211F`, Stein `#676058`, Linie `#D8CEC0`. Der Akzent ist Pompeji-Rot `#8E3B2B` statt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1.
|
- **Farbe:** Putz-Weiß `#F2ECE3`, Papier `#FAF7F1`, Anthrazit `#23211F`, Stein `#676058`, Linie `#D8CEC0`. Der Akzent ist Pompeji-Rot `#8E3B2B` statt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1.
|
||||||
- **Schrift:** Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über `next/font` selbst ausgeliefert, es gibt keine Verbindung zu Google.
|
- **Schrift:** Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über `next/font` selbst ausgeliefert, es gibt keine Verbindung zu Google.
|
||||||
- **Layout:** asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
|
- **Layout:** asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster und keine Versal-Labels über Überschriften. Überschrift und Einleitung stehen untereinander (kein „Split-Header“). Listen werden über Abstand gegliedert, nicht über eine Linie unter jeder Zeile. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
|
||||||
|
- **Formen:** Bedienelemente (Buttons) sind Pillen, Bild- und Tafelflächen haben rechte Winkel. Textlinks sind fein unterstrichen (`.link-quiet`), der wichtigste Link eines Abschnitts trägt eine rote Unterstreichung (`.link-strong`).
|
||||||
|
- **Rezensionen:** echte Google-Rezensionen (Places API) als Zitatstrecke: ein führendes Zitat in Bodoni, weitere in zwei Spalten, daneben Durchschnitt und Anzahl. Initialen statt Profilbildern (keine Verbindung zu Google-Servern im Browser), Links nur zu Google-Hosts. Ohne Daten entfällt der Abschnitt; es gibt keine Ersatzstimmen.
|
||||||
- **Bewegung:**
|
- **Bewegung:**
|
||||||
- eine orchestrierte Hero-Eröffnung in reinem CSS
|
- eine orchestrierte Hero-Eröffnung in reinem CSS
|
||||||
- Clip-Reveal nur auf Bildern
|
- Clip-Reveal nur auf Bildern
|
||||||
- Nav-Indikator
|
- Nav-Indikator
|
||||||
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
|
- Dialog-Übergänge und mobile Leiste über Motion (`LazyMotion`)
|
||||||
- `prefers-reduced-motion` wird überall respektiert, es gibt keine Scroll-Übernahme.
|
- `prefers-reduced-motion` wird überall respektiert, es gibt keine Scroll-Übernahme.
|
||||||
- **Platzhalter:** Fehlende Bilder erscheinen als gestaltete Flächen im Mittelmeer-Ton. In Entwicklung und Staging tragen sie das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
|
- **Platzhalter:** Fehlende Bilder erscheinen als typografische Tafeln: ruhiger Palettenton, feine Papierstruktur, Passepartout und ein angeschnittener Bodoni-Buchstabe aus „ARTISTA“ (im Cover die Jahreszahl). Die früheren Verläufe mit Bogen- und Silhouettenformen wurden ersetzt, weil sie wie generische Füllbilder wirkten; die Tafeln sind eindeutig kein Foto und damit als Platzhalter erkennbar. In Entwicklung und Staging tragen sie zusätzlich das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
|
||||||
|
- **Hell-Modus:** Die Seite hat bewusst nur ein helles Druckbild (Magazin); die dunklen Flächen (Leistungen, Schluss) sind gesetzte Kontrastseiten, kein Dunkelmodus.
|
||||||
|
|
||||||
## Stack und Laufzeit
|
## Stack und Laufzeit
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ stringData:
|
|||||||
SMTP_HOST: "smtp.example.org"
|
SMTP_HOST: "smtp.example.org"
|
||||||
SMTP_USER: "benutzer"
|
SMTP_USER: "benutzer"
|
||||||
SMTP_PASSWORD: "passwort"
|
SMTP_PASSWORD: "passwort"
|
||||||
|
# Optional: Google-Rezensionen (Places API New). Leer lassen = Abschnitt aus.
|
||||||
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
GOOGLE_PLACE_ID: ""
|
||||||
---
|
---
|
||||||
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
|
# Zugang zur SMB-Freigabe (fuer 03-storage.yaml)
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 5rem;
|
scroll-padding-top: 5rem;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scrollbar-color: var(--color-stone) var(--color-plaster);
|
||||||
|
accent-color: var(--color-rosso);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -37,6 +39,22 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
caret-color: var(--color-rosso);
|
||||||
|
font-variant-numeric: lining-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-underline-offset: 0.22em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -164,6 +182,28 @@
|
|||||||
border-color: var(--color-rosso);
|
border-color: var(--color-rosso);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Textlinks: ruhig unterstrichen, beim Zeigen kraeftiger. */
|
||||||
|
.link-quiet {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||||
|
text-underline-offset: 0.22em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
transition: text-decoration-color 160ms var(--ease-editorial);
|
||||||
|
}
|
||||||
|
.link-quiet:hover {
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
.link-strong {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: var(--color-rosso);
|
||||||
|
text-underline-offset: 0.3em;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
transition: color 160ms var(--ease-editorial);
|
||||||
|
}
|
||||||
|
.link-strong:hover {
|
||||||
|
color: var(--color-rosso);
|
||||||
|
}
|
||||||
|
|
||||||
.draft-flag {
|
.draft-flag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -175,6 +215,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Papierstruktur der Platzhalter-Tafeln (inline SVG als data-URI, CSP:
|
||||||
|
* img-src data: ist erlaubt). Statisch, keine Animation.
|
||||||
|
*/
|
||||||
|
.placeholder-plate::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.35;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
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: der eine laute Moment der Seite. */
|
||||||
.masthead {
|
.masthead {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
|||||||
+9
-1
@@ -6,15 +6,22 @@ import { AccommodationSection, CampSection, ServicesSection } from "@/components
|
|||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
import { EventJsonLd } from "@/components/EventJsonLd";
|
import { EventJsonLd } from "@/components/EventJsonLd";
|
||||||
|
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||||
import { loadPublicPage } from "@/lib/public-page";
|
import { loadPublicPage } from "@/lib/public-page";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
||||||
await connection();
|
await connection();
|
||||||
const { facts, pricing, showDrafts, externalVideoProvider } = await loadPublicPage();
|
// 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([
|
||||||
|
loadPublicPage(),
|
||||||
|
fetchGoogleReviews().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BookingProvider pricing={pricing} facts={facts}>
|
<BookingProvider pricing={pricing} facts={facts}>
|
||||||
@@ -26,6 +33,7 @@ export default async function HomePage() {
|
|||||||
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
||||||
<TeachersSection showDrafts={showDrafts} />
|
<TeachersSection showDrafts={showDrafts} />
|
||||||
<AboutSection showDrafts={showDrafts} />
|
<AboutSection showDrafts={showDrafts} />
|
||||||
|
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||||
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
||||||
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
||||||
<FaqSection facts={facts} pricing={pricing} />
|
<FaqSection facts={facts} pricing={pricing} />
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Langer Text, der zunaechst auf wenige Zeilen begrenzt ist. Der volle Text
|
||||||
|
* steht immer im DOM (Screenreader lesen ihn ganz); gekuerzt wird nur
|
||||||
|
* optisch. Der Schalter meldet seinen Zustand ueber aria-expanded.
|
||||||
|
*/
|
||||||
|
export function ExpandableText({
|
||||||
|
text,
|
||||||
|
clamp,
|
||||||
|
className = "",
|
||||||
|
clampClass = "line-clamp-6",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
clamp: boolean;
|
||||||
|
className?: string;
|
||||||
|
clampClass?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p id={id} className={`whitespace-pre-line ${className} ${clamp && !open ? clampClass : ""}`}>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{clamp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="link-quiet mt-3 text-[0.9375rem] font-semibold"
|
||||||
|
>
|
||||||
|
{open ? "Weniger anzeigen" : "Weiterlesen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
|
|||||||
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
|
// 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 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) =>
|
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]";
|
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-[3/2]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+39
-28
@@ -22,66 +22,77 @@ export function priceLine(pricing: PublicPricing): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
* Hero als Magazin-Cover: der Masthead "ARTISTA" ueberlappt das Leitbild,
|
||||||
* Termin, Ort und Preis stehen als Cover-Lines daneben.
|
* Schlagzeile und Buchung stehen daneben und bleiben im ersten Bildschirm.
|
||||||
|
* Termin, Ort und Preis folgen als Cover-Lines in einer eigenen Leiste.
|
||||||
*/
|
*/
|
||||||
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
export function Hero({ facts, pricing, showDrafts }: { facts: PublicCampFacts; pricing: PublicPricing; showDrafts: boolean }) {
|
||||||
const place = facts.location ? `${facts.location}${facts.region ? `, ${facts.region}` : ""}` : "Italien";
|
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) },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<section id="top" aria-labelledby="hero-title" className="relative pb-16 pt-4 md:pb-24">
|
<section id="top" aria-labelledby="hero-title" className="relative pb-20 pt-3 md:pb-28">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
|
<p className="hero-fade flex items-baseline justify-between border-b border-ink pb-2 text-sm">
|
||||||
<span>Modelcamp 2027</span>
|
<span>Modelcamp 2027</span>
|
||||||
<span>{place}</span>
|
<span>{place}</span>
|
||||||
</p>
|
</p>
|
||||||
<h1 id="hero-title" className="relative z-10 mt-3">
|
<h1 id="hero-title" className="relative z-10 mt-2 md:mt-3">
|
||||||
<span className="masthead hero-rise block" aria-hidden>
|
<span className="masthead hero-rise block" aria-hidden>
|
||||||
ARTISTA
|
ARTISTA
|
||||||
</span>
|
</span>
|
||||||
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
|
<span className="sr-only">ARTISTA Modelcamp 2027 – {site.hero.headline}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-[-3.2vw] grid gap-8 md:grid-cols-12 md:gap-10">
|
<div className="mt-[-3.4vw] grid gap-9 md:grid-cols-12 md:gap-10">
|
||||||
<div className="hero-image md:col-span-7 lg:col-span-8">
|
<div className="hero-image md:col-span-7">
|
||||||
<MediaFrame
|
<MediaFrame
|
||||||
media={site.hero.media}
|
media={site.hero.media}
|
||||||
aspect="aspect-[4/5] md:aspect-[5/4] lg:aspect-[16/11]"
|
aspect="aspect-[4/3] md:aspect-[4/5] lg:aspect-[7/6]"
|
||||||
sizes="(min-width: 1024px) 66vw, (min-width: 768px) 58vw, 100vw"
|
sizes="(min-width: 768px) 58vw, 100vw"
|
||||||
showLabel={showDrafts}
|
showLabel={showDrafts}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hero-fade flex flex-col justify-end md:col-span-5 lg:col-span-4">
|
<div className="hero-fade flex flex-col md:col-span-5 md:pt-[5.5vw] lg:col-start-8">
|
||||||
<p className="display text-[2.25rem] leading-[1.05] md:text-[2.75rem]">{site.hero.headline}</p>
|
<p className="display text-[2.5rem] leading-[1.02] tracking-[-0.02em] md:text-[clamp(2.25rem,3.6vw,3.375rem)]">
|
||||||
<p className="mt-5 max-w-md text-lg text-ink/85">{site.hero.subline}</p>
|
{/* Jeder Satz eine Zeile: bricht nie mitten im Gedanken um. */}
|
||||||
<dl className="mt-8 divide-y divide-line border-y border-line text-[0.9375rem]">
|
{site.hero.headline.split(/(?<=\.)\s+/).map((sentence) => (
|
||||||
<div className="flex justify-between gap-4 py-3">
|
<span key={sentence} className="block">
|
||||||
<dt className="text-stone">Termin</dt>
|
{sentence}
|
||||||
<dd className="text-right">{dateLine(facts)}</dd>
|
</span>
|
||||||
</div>
|
))}
|
||||||
<div className="flex justify-between gap-4 py-3">
|
</p>
|
||||||
<dt className="text-stone">Ort</dt>
|
<p className="mt-6 max-w-[34ch] text-lg leading-relaxed text-ink/85">{site.hero.subline}</p>
|
||||||
<dd className="text-right">{facts.location ?? "Italien, genauer Ort folgt"}</dd>
|
<div className="mt-9 flex flex-wrap items-center gap-x-7 gap-y-4">
|
||||||
</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 ? (
|
{pricing.checkoutOpen ? (
|
||||||
<BookButton className="btn btn-primary">Platz buchen</BookButton>
|
<BookButton className="btn btn-primary px-7">Platz buchen</BookButton>
|
||||||
) : (
|
) : (
|
||||||
<a href="#preis" className="btn btn-primary">
|
<a href="#preis" className="btn btn-primary px-7">
|
||||||
Preis und Buchungsstart
|
Preis und Buchungsstart
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a href="#camp" className="btn btn-quiet">
|
<a href="#camp" className="link-quiet font-semibold">
|
||||||
Mehr über das Camp
|
Mehr über das Camp
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +1,25 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Media, PlaceholderTone } from "@/content/site";
|
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" },
|
* Platzhalter als typografische Tafeln: ruhiger Farbton aus der Palette,
|
||||||
terrace: { background: "linear-gradient(185deg, #e9dcc6 0%, #d7c29d 60%, #9f8b62 100%)", shape: "cypress" },
|
* ein angeschnittener Bodoni-Buchstabe aus "ARTISTA" (bzw. die Jahreszahl
|
||||||
room: { background: "linear-gradient(160deg, #f3ebdf 0%, #e3d3bb 60%, #cdb592 100%)", shape: "window" },
|
* im Cover) und ein feines Passepartout. Sie wirken gestaltet, sind aber
|
||||||
garden: { background: "linear-gradient(190deg, #e6e0cc 0%, #c9c3a0 55%, #8c8a64 100%)", shape: "cypress" },
|
* eindeutig kein Foto und damit als Platzhalter erkennbar.
|
||||||
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" },
|
type Plate = { background: string; glyph: string; italic?: boolean; style: React.CSSProperties };
|
||||||
};
|
|
||||||
|
|
||||||
function Shape({ shape }: { shape: (typeof TONES)[PlaceholderTone]["shape"] }) {
|
// Groessen und Lagen in Container-Einheiten (cqh/cqw): der Anschnitt sieht
|
||||||
const common = { fill: "currentColor", className: "absolute inset-0 h-full w-full text-ink/10" } as const;
|
// in jeder Tafelgroesse gleich aus.
|
||||||
switch (shape) {
|
const TONES: Record<PlaceholderTone, Plate> = {
|
||||||
case "arch":
|
cover: { background: "#ddd0bd", glyph: "27", italic: true, style: { fontSize: "92cqh", right: "-3cqw", bottom: "-20cqh" } },
|
||||||
return (
|
villa: { background: "#e2d6c4", glyph: "A", style: { fontSize: "105cqh", left: "5cqw", bottom: "-22cqh" } },
|
||||||
<svg viewBox="0 0 400 500" preserveAspectRatio="xMidYMax slice" aria-hidden {...common}>
|
terrace: { background: "#d6ccb8", glyph: "R", italic: true, style: { fontSize: "118cqh", right: "-4cqw", bottom: "-30cqh" } },
|
||||||
<path d="M120 500V250a80 80 0 0 1 160 0v250z" />
|
room: { background: "#e8dfd2", glyph: "T", style: { fontSize: "112cqh", left: "22cqw", top: "-34cqh" } },
|
||||||
<path d="M20 500V330a40 40 0 0 1 80 0v170zM300 500V330a40 40 0 0 1 80 0v170z" opacity=".6" />
|
garden: { background: "#cfcab6", glyph: "S", italic: true, style: { fontSize: "150cqh", right: "7cqw", bottom: "-58cqh" } },
|
||||||
</svg>
|
portrait: { background: "#dccfbe", glyph: "R", style: { fontSize: "92cqh", left: "-10cqw", bottom: "-14cqh" } },
|
||||||
);
|
studio: { background: "#d2c9bd", glyph: "A", italic: true, style: { fontSize: "96cqh", right: "-16cqw", bottom: "-16cqh" } },
|
||||||
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 = {
|
type Props = {
|
||||||
media: Media;
|
media: Media;
|
||||||
@@ -73,12 +49,19 @@ export function MediaFrame({ media, aspect, sizes, showLabel, priority, classNam
|
|||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Platzhalter: ${media.label}`}
|
aria-label={`Platzhalter: ${media.label}`}
|
||||||
className={`relative overflow-hidden ${aspect} ${className}`}
|
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${className}`}
|
||||||
style={{ background: tone.background }}
|
style={{ backgroundColor: tone.background, containerType: "size" }}
|
||||||
>
|
>
|
||||||
<Shape shape={tone.shape} />
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`display pointer-events-none absolute select-none leading-none text-ink/[0.07] ${tone.italic ? "italic" : ""}`}
|
||||||
|
style={tone.style}
|
||||||
|
>
|
||||||
|
{tone.glyph}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="absolute bottom-3 left-3 rounded-full bg-paper/85 px-3 py-1 text-xs font-semibold text-ink">
|
<span className="absolute bottom-6 left-6 bg-paper/90 px-2.5 py-1 text-xs font-semibold text-ink md:bottom-7 md:left-7">
|
||||||
Platzhalter: {media.label}
|
Platzhalter: {media.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export function SiteHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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"}`}
|
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"}`}
|
||||||
>
|
>
|
||||||
<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">
|
||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
<div className="container-edge flex h-16 items-center justify-between gap-6">
|
<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">
|
<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>
|
ARTISTA <span className="hidden font-sans text-sm text-stone sm:inline">Modelcamp 2027</span>
|
||||||
</a>
|
</a>
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<nav aria-label="Abschnitte" className="hidden lg:block">
|
<nav aria-label="Abschnitte" className="hidden lg:block">
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { starsLabel } from "@/lib/review-format";
|
||||||
|
|
||||||
|
const STAR_PATH = "M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8z";
|
||||||
|
|
||||||
|
function Row({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`flex gap-[0.2em] ${className}`}>
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<svg key={i} viewBox="0 0 24 24" className="h-[1em] w-[1em] shrink-0" aria-hidden focusable="false">
|
||||||
|
<path d={STAR_PATH} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sternebewertung von 0 bis 5, auch mit Nachkommastelle (Durchschnitt).
|
||||||
|
* Die gefuellte Reihe wird auf den Anteil beschnitten; Screenreader lesen
|
||||||
|
* nur die Textalternative, z. B. "4 von 5 Sternen".
|
||||||
|
*/
|
||||||
|
export function Stars({ rating, className = "" }: { rating: number; className?: string }) {
|
||||||
|
const clamped = Math.max(0, Math.min(5, rating));
|
||||||
|
// Naeherung: die Abstaende zwischen den Sternen zaehlen zur Breite.
|
||||||
|
const percent = (clamped / 5) * 100;
|
||||||
|
return (
|
||||||
|
<span role="img" aria-label={starsLabel(Math.round(clamped * 10) / 10)} className={`relative inline-flex ${className}`}>
|
||||||
|
<Row className="text-line" />
|
||||||
|
<span className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${percent}%` }}>
|
||||||
|
<Row className="text-rosso" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export function CampSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
const { camp } = site;
|
const { camp } = site;
|
||||||
const showAudience = camp.audience.confirmed || showDrafts;
|
const showAudience = camp.audience.confirmed || showDrafts;
|
||||||
return (
|
return (
|
||||||
<section id="camp" aria-labelledby="camp-title" className="py-20 md:py-32">
|
<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="container-edge grid gap-10 md:grid-cols-12">
|
||||||
<div className="md:col-span-6 lg:col-span-5">
|
<div className="md:col-span-6 lg:col-span-5">
|
||||||
<h2 id="camp-title" className="section-title">{camp.title}</h2>
|
<h2 id="camp-title" className="section-title">{camp.title}</h2>
|
||||||
@@ -48,48 +48,44 @@ export function ServicesSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
const { services } = site;
|
const { services } = site;
|
||||||
const included = services.included.filter((s) => s.confirmed || showDrafts);
|
const included = services.included.filter((s) => s.confirmed || showDrafts);
|
||||||
return (
|
return (
|
||||||
<section id="leistungen" aria-labelledby="leistungen-title" className="on-dark bg-ink py-20 text-paper md:py-32">
|
<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="container-edge">
|
||||||
<h2 id="leistungen-title" className="section-title max-w-3xl">{services.title}</h2>
|
<div className="grid gap-14 md:grid-cols-12 md:gap-10">
|
||||||
<div className="mt-14 grid gap-12 md:grid-cols-2 md:gap-16">
|
<h2 id="leistungen-title" className="section-title md:col-span-5">{services.title}</h2>
|
||||||
<div>
|
<div className="md:col-span-7 md:col-start-6 lg:col-span-6 lg:col-start-7">
|
||||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Im Preis enthalten</h3>
|
<h3 className="text-[0.9375rem] font-semibold text-paper/75">Im Preis enthalten</h3>
|
||||||
<ul className="mt-2 divide-y divide-paper/15">
|
<ul className="mt-5 space-y-4">
|
||||||
{included.map((item) => (
|
{included.map((item) => (
|
||||||
<li key={item.text} className="py-4 text-lg">
|
<li key={item.text} className="display text-[1.625rem] leading-[1.2] md:text-[2rem]">
|
||||||
{item.text}
|
{item.text}
|
||||||
{!item.confirmed && <DraftFlag show />}
|
{!item.confirmed && <DraftFlag show />}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{!showDrafts && included.length < services.included.length && (
|
{!showDrafts && included.length < services.included.length && (
|
||||||
<p className="mt-4 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
|
<p className="mt-6 text-paper/75">Weitere Leistungen veröffentlichen wir, sobald sie verbindlich feststehen.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div>
|
<h3 className="mt-14 text-[0.9375rem] font-semibold text-paper/75">Nicht enthalten</h3>
|
||||||
<h3 className="border-b border-paper/30 pb-3 text-lg font-semibold">Nicht enthalten</h3>
|
<p className="mt-3 max-w-xl text-lg text-paper/85">{services.excluded.join(", ")}.</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-20 border-t border-paper/30 pt-10">
|
<div className="mt-24 md:mt-32">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
<h3 className="display max-w-2xl text-[2rem] leading-[1.1] md:text-[2.5rem]">So könnte ein Tag im Camp aussehen</h3>
|
||||||
<h3 className="display text-3xl">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>
|
||||||
<p className="max-w-md text-[0.9375rem] text-paper/75">{services.exampleSchedule.note}</p>
|
{/* Nummeriert, weil es eine Abfolge ist; ausdruecklich als Beispiel gekennzeichnet. */}
|
||||||
</div>
|
<ol className="mt-12 grid border-t border-paper/30 md:grid-cols-3">
|
||||||
{/* 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) => (
|
{services.exampleSchedule.steps.map((step, index) => (
|
||||||
<li key={step.title} className="border-t border-paper/30 pt-5">
|
<li
|
||||||
<span className="display text-5xl text-paper/40">{index + 1}</span>
|
key={step.title}
|
||||||
<p className="mt-3 text-lg font-semibold">{step.title}</p>
|
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="mt-2 text-paper/80">{step.text}</p>
|
>
|
||||||
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -102,12 +98,10 @@ export function ServicesSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampFacts; showDrafts: boolean }) {
|
||||||
const { accommodation } = site;
|
const { accommodation } = site;
|
||||||
return (
|
return (
|
||||||
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-20 md:py-32">
|
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<div className="grid gap-8 md:grid-cols-12">
|
<h2 id="unterkunft-title" className="section-title">{accommodation.title}</h2>
|
||||||
<h2 id="unterkunft-title" className="section-title md:col-span-6">{accommodation.title}</h2>
|
<p className="display mt-6 max-w-2xl text-[1.5rem] leading-[1.25] text-ink/85 md:text-[1.875rem]">{accommodation.lead}</p>
|
||||||
<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} />
|
<Gallery images={accommodation.images} showLabels={showDrafts} />
|
||||||
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
<div className="mt-12 grid gap-8 md:grid-cols-12">
|
||||||
<div className="md:col-span-7">
|
<div className="md:col-span-7">
|
||||||
|
|||||||
@@ -62,16 +62,17 @@ function faqs(facts: PublicCampFacts, pricing: PublicPricing): Faq[] {
|
|||||||
|
|
||||||
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
export function FaqSection({ facts, pricing }: { facts: PublicCampFacts; pricing: PublicPricing }) {
|
||||||
return (
|
return (
|
||||||
<section id="fragen" aria-labelledby="fragen-title" className="py-20 md:py-32">
|
<section id="fragen" aria-labelledby="fragen-title" className="py-24 md:py-36">
|
||||||
<div className="container-edge grid gap-10 md:grid-cols-12">
|
<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>
|
<h2 id="fragen-title" className="section-title md:col-span-4">{site.faq.title}</h2>
|
||||||
<div className="md:col-span-8">
|
<div className="md:col-span-8">
|
||||||
{faqs(facts, pricing).map((item) => (
|
{faqs(facts, pricing).map((item) => (
|
||||||
<details key={item.q} className="group border-b border-line py-1 first:border-t">
|
<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">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-6 py-5 text-lg font-semibold transition-colors hover:text-rosso [&::-webkit-details-marker]:hidden">
|
||||||
{item.q}
|
{item.q}
|
||||||
<span aria-hidden className="text-2xl font-normal text-rosso transition-transform duration-200 group-open:rotate-45">
|
<span aria-hidden className="relative h-4 w-4 shrink-0 text-rosso transition-transform duration-300 ease-[var(--ease-editorial)] group-open:rotate-45">
|
||||||
+
|
<span className="absolute inset-x-0 top-1/2 h-[1.5px] -translate-y-1/2 bg-current" />
|
||||||
|
<span className="absolute inset-y-0 left-1/2 w-[1.5px] -translate-x-1/2 bg-current" />
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
<div className="max-w-2xl pb-5 text-ink/85">{item.a}</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { VideoSlot } from "../VideoSlot";
|
|||||||
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
||||||
const { teachers } = site;
|
const { teachers } = site;
|
||||||
return (
|
return (
|
||||||
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-20 md:py-32">
|
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-24 md:py-36">
|
||||||
<div className="container-edge">
|
<div className="container-edge">
|
||||||
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
|
||||||
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
|
||||||
@@ -39,10 +39,10 @@ export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
||||||
const { about } = site;
|
const { about } = site;
|
||||||
return (
|
return (
|
||||||
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-32">
|
<section id="artista" aria-labelledby="artista-title" className="py-20 md:py-28">
|
||||||
<div className="container-edge grid gap-8 md:grid-cols-12">
|
<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="section-title md:col-span-5">{about.title}</h2>
|
<h2 id="artista-title" className="display text-[2.25rem] leading-none md:col-span-5 md:text-[3rem]">{about.title}</h2>
|
||||||
<div className="prose-body md:col-span-6 md:col-start-7">
|
<div className="prose-body text-lg md:col-span-6 md:col-start-7 md:pt-2">
|
||||||
{about.paragraphs.length > 0 ? (
|
{about.paragraphs.length > 0 ? (
|
||||||
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
about.paragraphs.map((p) => <p key={p}>{p}</p>)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ExpandableText } from "@/components/ExpandableText";
|
||||||
|
import { Stars } from "@/components/Stars";
|
||||||
|
import type { GoogleReview, GoogleReviewSummary } from "@/lib/google-reviews";
|
||||||
|
import { formatRating, initialsOf, needsClamp, reviewCountLabel } from "@/lib/review-format";
|
||||||
|
|
||||||
|
const EXTERNAL = { target: "_blank", rel: "noopener noreferrer" } as const;
|
||||||
|
|
||||||
|
function Attribution({ review }: { review: GoogleReview }) {
|
||||||
|
return (
|
||||||
|
<figcaption className="mt-6 flex items-center gap-4">
|
||||||
|
{/* Initialen statt Profilbild: kein Abruf von Google-Servern. */}
|
||||||
|
<span aria-hidden className="display flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-ink/25 text-lg">
|
||||||
|
{initialsOf(review.author)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-[0.9375rem] leading-snug">
|
||||||
|
<cite className="block font-semibold not-italic">{review.author}</cite>
|
||||||
|
<span className="text-stone">
|
||||||
|
{review.relativeTime && <>{review.relativeTime}, </>}
|
||||||
|
{review.authorUrl ? (
|
||||||
|
<a href={review.authorUrl} {...EXTERNAL} className="link-quiet">
|
||||||
|
Auf Google ansehen<span className="sr-only"> (Rezension von {review.author}, öffnet in neuem Tab)</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"auf Google"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ data }: { data: GoogleReviewSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-6 md:mt-10">
|
||||||
|
{data.rating !== null && (
|
||||||
|
<p className="flex items-end gap-5">
|
||||||
|
<span className="display text-[5.5rem] leading-[0.8] tabular-nums md:text-[7rem]">{formatRating(data.rating)}</span>
|
||||||
|
<span className="pb-1 text-[0.9375rem] leading-snug text-stone">
|
||||||
|
<Stars rating={data.rating} className="mb-2 text-xl" />
|
||||||
|
<span className="block">
|
||||||
|
Durchschnitt
|
||||||
|
{data.totalCount !== null && <> aus {reviewCountLabel(data.totalCount)}</>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-8 text-[0.9375rem] text-stone">Rezensionen von Google. Angezeigt werden die Rezensionen, die Google für diesen Eintrag ausliefert.</p>
|
||||||
|
{data.mapsUrl && (
|
||||||
|
<p className="mt-4">
|
||||||
|
<a href={data.mapsUrl} {...EXTERNAL} className="link-strong text-[0.9375rem] font-semibold">
|
||||||
|
Alle Rezensionen auf Google Maps<span className="sr-only"> (öffnet in neuem Tab)</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echte Google-Rezensionen (Places API). Ohne Daten erscheint der Abschnitt
|
||||||
|
* nicht; in Entwicklung/Staging steht dort ein Hinweis zur Einrichtung.
|
||||||
|
* Keine Ersatz- oder Beispielstimmen.
|
||||||
|
*/
|
||||||
|
export function ReviewsSection({ data, showDrafts }: { data: GoogleReviewSummary | null; showDrafts: boolean }) {
|
||||||
|
if (!data) {
|
||||||
|
if (!showDrafts) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="Google-Rezensionen (Entwurf)" className="border-t border-line py-10">
|
||||||
|
<p className="container-edge text-[0.9375rem] text-stone">
|
||||||
|
<span className="draft-flag mr-2">Entwurf</span>
|
||||||
|
Google-Rezensionen erscheinen, sobald GOOGLE_PLACES_API_KEY und GOOGLE_PLACE_ID gesetzt sind.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lead, ...rest] = data.reviews;
|
||||||
|
return (
|
||||||
|
<section id="rezensionen" aria-labelledby="rezensionen-title" className="py-24 md:py-36">
|
||||||
|
<div className="container-edge">
|
||||||
|
<h2 id="rezensionen-title" className="section-title max-w-4xl">
|
||||||
|
Was andere über ARTISTA sagen
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="container-edge mt-4 grid gap-14 md:mt-6 md:grid-cols-12 md:gap-10">
|
||||||
|
<div className="md:col-span-5 lg:col-span-4">
|
||||||
|
<div className="md:sticky md:top-28">
|
||||||
|
<Summary data={data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7 md:col-start-6 md:pt-10 lg:col-span-7 lg:col-start-6 lg:pt-12">
|
||||||
|
{lead && (
|
||||||
|
<figure>
|
||||||
|
<Stars rating={lead.rating} className="text-base" />
|
||||||
|
<blockquote className="mt-5">
|
||||||
|
<ExpandableText
|
||||||
|
text={lead.text}
|
||||||
|
clamp={needsClamp(lead.text)}
|
||||||
|
clampClass="line-clamp-7"
|
||||||
|
className="display text-[1.625rem] leading-[1.28] md:text-[2.125rem] md:leading-[1.24]"
|
||||||
|
/>
|
||||||
|
</blockquote>
|
||||||
|
<Attribution review={lead} />
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<ul className="mt-16 grid gap-x-10 gap-y-12 border-t border-ink pt-10 sm:grid-cols-2">
|
||||||
|
{rest.map((review, index) => (
|
||||||
|
<li key={`${review.author}-${index}`}>
|
||||||
|
<figure>
|
||||||
|
<Stars rating={review.rating} className="text-sm" />
|
||||||
|
<blockquote className="mt-4">
|
||||||
|
<ExpandableText text={review.text} clamp={needsClamp(review.text)} className="text-[1.0625rem] leading-relaxed text-ink/90" />
|
||||||
|
</blockquote>
|
||||||
|
<Attribution review={review} />
|
||||||
|
</figure>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
* Offene Punkte: docs/OFFENE-PUNKTE.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type PlaceholderTone = "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
export type PlaceholderTone = "cover" | "villa" | "garden" | "room" | "terrace" | "portrait" | "studio";
|
||||||
|
|
||||||
export type Media =
|
export type Media =
|
||||||
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
| { kind: "placeholder"; label: string; tone: PlaceholderTone }
|
||||||
@@ -35,7 +35,7 @@ export const site = {
|
|||||||
headline: "Gemeinsam wachsen. In Italien.",
|
headline: "Gemeinsam wachsen. In Italien.",
|
||||||
subline:
|
subline:
|
||||||
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
"Das ARTISTA Modelcamp 2027 bringt Models an einem Ort in Italien zusammen – zum Lernen, Arbeiten und Wohnen unter einem Dach.",
|
||||||
media: placeholder("Leitmotiv: Bild oder Video folgt", "villa"),
|
media: placeholder("Leitmotiv: Bild oder Video folgt", "cover"),
|
||||||
},
|
},
|
||||||
|
|
||||||
camp: {
|
camp: {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Echte Google-Rezensionen ueber die "Places API (New)" (gleiches Muster wie
|
||||||
|
* im Wabenhain-Projekt).
|
||||||
|
*
|
||||||
|
* - Ohne GOOGLE_PLACES_API_KEY/GOOGLE_PLACE_ID oder bei Fehlern: null. Die
|
||||||
|
* Seite blendet den Abschnitt dann aus — es gibt bewusst KEINE
|
||||||
|
* Beispiel-Stimmen als Ersatz (keine erfundenen Testimonials).
|
||||||
|
* - Profilbilder werden nicht geladen: sie kaemen direkt von Google-Servern
|
||||||
|
* und wuerden die IP-Adressen der Besucherinnen dorthin uebertragen.
|
||||||
|
* - Zuordnung zur Quelle (Nutzungsbedingungen): Autorenname, Link zur
|
||||||
|
* Rezension und zum Google-Maps-Eintrag.
|
||||||
|
*
|
||||||
|
* Einrichtung: in der Google Cloud Console "Places API (New)" aktivieren,
|
||||||
|
* Abrechnung aktivieren, API-Schluessel erzeugen (kein OAuth-Client) und
|
||||||
|
* GOOGLE_PLACES_API_KEY sowie GOOGLE_PLACE_ID setzen.
|
||||||
|
*/
|
||||||
|
export type GoogleReview = {
|
||||||
|
author: string;
|
||||||
|
rating: number;
|
||||||
|
text: string;
|
||||||
|
relativeTime: string;
|
||||||
|
authorUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoogleReviewSummary = {
|
||||||
|
reviews: GoogleReview[];
|
||||||
|
rating: number | null;
|
||||||
|
totalCount: number | null;
|
||||||
|
mapsUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
// Abgerechnet wird nach angeforderten Feldern — nur das Noetige anfordern.
|
||||||
|
const FIELD_MASK = "rating,userRatingCount,reviews,googleMapsUri";
|
||||||
|
|
||||||
|
type PlacesResponse = {
|
||||||
|
rating?: number;
|
||||||
|
userRatingCount?: number;
|
||||||
|
googleMapsUri?: string;
|
||||||
|
reviews?: {
|
||||||
|
rating?: number;
|
||||||
|
relativePublishTimeDescription?: string;
|
||||||
|
text?: { text?: string };
|
||||||
|
originalText?: { text?: string };
|
||||||
|
authorAttribution?: { displayName?: string; uri?: string };
|
||||||
|
}[];
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Links aus der Antwort fuehren nur zu Google: eine fehlerhafte oder
|
||||||
|
// manipulierte Antwort kann so keine fremden Ziele auf die Seite bringen.
|
||||||
|
const GOOGLE_HOSTS = ["google.com", "google.de", "goo.gl"];
|
||||||
|
|
||||||
|
export function safeGoogleUrl(value: string | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password) return null;
|
||||||
|
const host = url.hostname.toLowerCase();
|
||||||
|
const allowed = GOOGLE_HOSTS.some((base) => host === base || host.endsWith(`.${base}`));
|
||||||
|
return allowed ? url.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGoogleReviews(): Promise<GoogleReviewSummary | null> {
|
||||||
|
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||||
|
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||||
|
if (!apiKey || !placeId) return null;
|
||||||
|
|
||||||
|
const resource = placeId.startsWith("places/") ? placeId : `places/${placeId}`;
|
||||||
|
const url = new URL(`https://places.googleapis.com/v1/${encodeURI(resource)}`);
|
||||||
|
url.searchParams.set("languageCode", "de");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
// Schluessel im Header, nicht in der URL (keine fremden Zugriffsprotokolle).
|
||||||
|
headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": FIELD_MASK },
|
||||||
|
// 1 Stunde Cache: schnell und guenstig, ohne spuerbar zu veralten.
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const data = (await response.json().catch(() => null)) as PlacesResponse | null;
|
||||||
|
if (!response.ok || !data || data.error) {
|
||||||
|
console.warn(`[google-reviews] Abruf abgelehnt: ${data?.error?.message ?? `HTTP ${response.status}`}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const reviews = (data.reviews ?? [])
|
||||||
|
.map((r) => ({
|
||||||
|
author: r.authorAttribution?.displayName?.trim() ?? "",
|
||||||
|
rating: Math.max(0, Math.min(5, Math.round(r.rating ?? 0))),
|
||||||
|
text: (r.text?.text ?? r.originalText?.text ?? "").trim(),
|
||||||
|
relativeTime: r.relativePublishTimeDescription ?? "",
|
||||||
|
authorUrl: safeGoogleUrl(r.authorAttribution?.uri),
|
||||||
|
}))
|
||||||
|
// Ohne Text keine Stimme, ohne Namen keine Zuordnung zur Quelle.
|
||||||
|
.filter((r) => r.text.length > 0 && r.author.length > 0);
|
||||||
|
if (reviews.length === 0) return null;
|
||||||
|
return {
|
||||||
|
reviews,
|
||||||
|
rating: typeof data.rating === "number" ? data.rating : null,
|
||||||
|
totalCount: typeof data.userRatingCount === "number" ? data.userRatingCount : null,
|
||||||
|
mapsUrl: safeGoogleUrl(data.googleMapsUri),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.warn(`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Anzeigehilfen fuer Google-Rezensionen. Reine Funktionen ohne
|
||||||
|
* Seiteneffekte, damit sie ohne Netzwerk testbar sind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ab dieser Laenge wird eine Rezension zunaechst gekuerzt angezeigt. */
|
||||||
|
export const REVIEW_CLAMP_CHARS = 320;
|
||||||
|
|
||||||
|
/** Initialen statt Profilbild: bis zu zwei Buchstaben aus dem Anzeigenamen. */
|
||||||
|
export function initialsOf(name: string): string {
|
||||||
|
const letters = name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => Array.from(part.replace(/[^\p{L}\p{N}]/gu, ""))[0] ?? "")
|
||||||
|
.filter(Boolean);
|
||||||
|
if (letters.length === 0) return "?";
|
||||||
|
const pick = letters.length === 1 ? [letters[0]] : [letters[0], letters[letters.length - 1]];
|
||||||
|
return pick.join("").toLocaleUpperCase("de-DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4,8" – Durchschnitt mit einer Nachkommastelle im deutschen Format. */
|
||||||
|
export function formatRating(rating: number): string {
|
||||||
|
return rating.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zugaenglicher Text fuer eine Sternebewertung, z. B. "4 von 5 Sternen". */
|
||||||
|
export function starsLabel(rating: number): string {
|
||||||
|
const value = Number.isInteger(rating) ? String(rating) : formatRating(rating);
|
||||||
|
return `${value} von 5 Sternen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "1 Rezension" bzw. "12 Rezensionen". */
|
||||||
|
export function reviewCountLabel(count: number): string {
|
||||||
|
return `${count.toLocaleString("de-DE")} ${count === 1 ? "Rezension" : "Rezensionen"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsClamp(text: string): boolean {
|
||||||
|
return text.length > REVIEW_CLAMP_CHARS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { safeGoogleUrl } from "@/lib/google-reviews";
|
||||||
|
import { formatRating, initialsOf, needsClamp, REVIEW_CLAMP_CHARS, reviewCountLabel, starsLabel } from "@/lib/review-format";
|
||||||
|
|
||||||
|
// Bewusst neutrale Platzhalternamen: keine echt wirkenden Personen.
|
||||||
|
describe("initialsOf", () => {
|
||||||
|
it("nimmt den ersten und letzten Namensteil", () => {
|
||||||
|
expect(initialsOf("Testperson Mittel Beispiel")).toBe("TB");
|
||||||
|
});
|
||||||
|
it("kommt mit einem einzelnen Namen und Sonderzeichen aus", () => {
|
||||||
|
expect(initialsOf(" ärger ")).toBe("Ä");
|
||||||
|
expect(initialsOf("(Test) Person.")).toBe("TP");
|
||||||
|
});
|
||||||
|
it("liefert ein Fragezeichen ohne verwertbare Zeichen", () => {
|
||||||
|
expect(initialsOf(" ... ")).toBe("?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bewertungstexte", () => {
|
||||||
|
it("formatiert den Durchschnitt deutsch mit einer Nachkommastelle", () => {
|
||||||
|
expect(formatRating(4.84)).toBe("4,8");
|
||||||
|
expect(formatRating(5)).toBe("5,0");
|
||||||
|
});
|
||||||
|
it("beschreibt Sterne zugaenglich", () => {
|
||||||
|
expect(starsLabel(4)).toBe("4 von 5 Sternen");
|
||||||
|
expect(starsLabel(4.6)).toBe("4,6 von 5 Sternen");
|
||||||
|
});
|
||||||
|
it("unterscheidet Einzahl und Mehrzahl", () => {
|
||||||
|
expect(reviewCountLabel(1)).toBe("1 Rezension");
|
||||||
|
expect(reviewCountLabel(1200)).toBe("1.200 Rezensionen");
|
||||||
|
});
|
||||||
|
it("kuerzt erst oberhalb der Schwelle", () => {
|
||||||
|
expect(needsClamp("x".repeat(REVIEW_CLAMP_CHARS))).toBe(false);
|
||||||
|
expect(needsClamp("x".repeat(REVIEW_CLAMP_CHARS + 1))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safeGoogleUrl", () => {
|
||||||
|
it("laesst nur HTTPS-Links zu Google-Hosts durch", () => {
|
||||||
|
expect(safeGoogleUrl("https://www.google.com/maps/contrib/123")).toBe("https://www.google.com/maps/contrib/123");
|
||||||
|
expect(safeGoogleUrl("https://maps.google.com/?cid=1")).toBe("https://maps.google.com/?cid=1");
|
||||||
|
expect(safeGoogleUrl("https://maps.app.goo.gl/abc")).toBe("https://maps.app.goo.gl/abc");
|
||||||
|
});
|
||||||
|
it("verwirft fremde Hosts, HTTP, Zugangsdaten und Unsinn", () => {
|
||||||
|
expect(safeGoogleUrl("https://google.com.example.org/")).toBeNull();
|
||||||
|
expect(safeGoogleUrl("https://evilgoogle.com/")).toBeNull();
|
||||||
|
expect(safeGoogleUrl("http://www.google.com/")).toBeNull();
|
||||||
|
expect(safeGoogleUrl("https://user:[email protected]/")).toBeNull();
|
||||||
|
expect(safeGoogleUrl("javascript:alert(1)")).toBeNull();
|
||||||
|
expect(safeGoogleUrl("kein link")).toBeNull();
|
||||||
|
expect(safeGoogleUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user