Compare commits

..
5 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 6a9c6c8e50 docs: Gestaltung um Rezensionen, Platzhalter-Tafeln und Formregeln ergaenzt
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:46:47 +02:00
Nick SzaunigandClaude Opus 5.5 2ef92db9ed style: Hero, Leistungen und Abschnittsrhythmus ueberarbeitet
- Hero: Schlagzeile zweizeilig direkt unter dem Masthead, Buchung im
  ersten Bildschirm; Termin, Ort und Preis als Cover-Line-Leiste
- Leistungen: Inklusivleistungen in Bodoni ohne Linie unter jeder Zeile,
  Beispielablauf ruhiger, Hinweis unter statt neben der Ueberschrift
- Unterkunft ohne Split-Header, Ueber ARTISTA kompakter
- FAQ-Symbol gezeichnet statt Textzeichen, groesserer Abschnittsabstand
- Header auf Mobil ohne doppeltes "Modelcamp 2027"

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:46:30 +02:00
Nick SzaunigandClaude Opus 5.5 00c7118c10 style: Platzhalter als typografische Tafeln statt Verlaufsflaechen
Beige Verlaeufe mit Bogen- und Silhouettenformen wirkten wie generische
Fuellbilder. Jetzt: ruhiger Palettenton, feine Papierstruktur,
Passepartout und ein angeschnittener Bodoni-Buchstabe aus "ARTISTA"
(Cover: Jahreszahl), skaliert ueber Container-Einheiten. Eindeutig kein
Foto, in Produktion ohne Label. Galerie etwas flacher.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:46:29 +02:00
Nick SzaunigandClaude Opus 5.5 f74a08fa68 feat: Google-Rezensionen als eigener Abschnitt auf der Onepage
- ReviewsSection (Server Component) mit Durchschnitt, zugaenglichen
  Sternen, Initialen statt Profilbildern, relativer Zeit, Links zur
  Rezension und zum Maps-Eintrag sowie Hinweis "Rezensionen von Google"
- lange Texte optisch gekuerzt, "Weiterlesen" mit aria-expanded
- ohne Daten ausgeblendet; in Entwicklung/Staging ein Einrichtungshinweis
- Laden parallel zu loadPublicPage, Fehler blenden nur den Abschnitt aus
- Links aus der API-Antwort nur zu HTTPS-Google-Hosts (safeGoogleUrl)
- Linkstile und Browser-Oberflaechen (Scrollbar, Caret, Unterstreichung)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:46:19 +02:00
Nick SzaunigandClaude Opus 5.5 b4eafbe433 feat: Google-Rezensionen ueber Places API (New) abrufen
Muster wie Wabenhain (Feldmaske, 1 h Cache, 5 s Timeout, Schluessel im
Header). Ohne Konfiguration oder bei Fehler null, keine Ersatz-Stimmen.
Keine Profilbilder (kein IP-Transfer der Besucherinnen an Google),
Quellenlinks nur als https. Neue Variablen GOOGLE_PLACES_API_KEY und
GOOGLE_PLACE_ID in .env.example und Secret-Vorlage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 17:30:30 +02:00
19 changed files with 593 additions and 127 deletions

No files matched your search

+6
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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} />
+41
View File
@@ -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>
)}
</>
);
}
+1 -1
View File
@@ -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
View File
@@ -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>
); );
+29 -46
View File
@@ -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>
)} )}
+2 -2
View File
@@ -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">
+34
View File
@@ -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>
);
}
+29 -35
View File
@@ -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">
+5 -4
View File
@@ -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>
+5 -5
View File
@@ -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>)
) : ( ) : (
+126
View File
@@ -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
View File
@@ -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: {
+110
View File
@@ -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;
}
}
+39
View File
@@ -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;
}
+53
View File
@@ -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();
});
});