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]>
This commit is contained in:
1 parent
b4eafbe433
commit
f74a08fa68
8 files changed
+367
-5
No files matched your search
@@ -27,6 +27,8 @@
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scrollbar-color: var(--color-stone) var(--color-plaster);
|
||||
accent-color: var(--color-rosso);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -37,6 +39,22 @@
|
||||
line-height: 1.6;
|
||||
text-rendering: optimizeLegibility;
|
||||
-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 {
|
||||
@@ -164,6 +182,28 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
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 {
|
||||
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 { AboutSection, TeachersSection, VoicesSection } from "@/components/sections/PeopleSections";
|
||||
import { PricingSection } from "@/components/sections/PricingSection";
|
||||
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { EventJsonLd } from "@/components/EventJsonLd";
|
||||
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||
import { loadPublicPage } from "@/lib/public-page";
|
||||
|
||||
export default async function HomePage() {
|
||||
// Preisphase und Verkaufsfreigabe haengen von Uhrzeit und Datenbank ab.
|
||||
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 (
|
||||
<BookingProvider pricing={pricing} facts={facts}>
|
||||
@@ -26,6 +33,7 @@ export default async function HomePage() {
|
||||
<AccommodationSection facts={facts} showDrafts={showDrafts} />
|
||||
<TeachersSection showDrafts={showDrafts} />
|
||||
<AboutSection showDrafts={showDrafts} />
|
||||
<ReviewsSection data={reviews} showDrafts={showDrafts} />
|
||||
<VoicesSection showDrafts={showDrafts} provider={externalVideoProvider} />
|
||||
<PricingSection pricing={pricing} facts={facts} showDrafts={showDrafts} />
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -47,11 +47,18 @@ type PlacesResponse = {
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
function safeHttpsUrl(value: string | undefined): string | null {
|
||||
// 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);
|
||||
return url.protocol === "https:" ? url.toString() : null;
|
||||
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;
|
||||
}
|
||||
@@ -85,7 +92,7 @@ export async function fetchGoogleReviews(): Promise<GoogleReviewSummary | null>
|
||||
rating: Math.max(0, Math.min(5, Math.round(r.rating ?? 0))),
|
||||
text: (r.text?.text ?? r.originalText?.text ?? "").trim(),
|
||||
relativeTime: r.relativePublishTimeDescription ?? "",
|
||||
authorUrl: safeHttpsUrl(r.authorAttribution?.uri),
|
||||
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);
|
||||
@@ -94,7 +101,7 @@ export async function fetchGoogleReviews(): Promise<GoogleReviewSummary | null>
|
||||
reviews,
|
||||
rating: typeof data.rating === "number" ? data.rating : null,
|
||||
totalCount: typeof data.userRatingCount === "number" ? data.userRatingCount : null,
|
||||
mapsUrl: safeHttpsUrl(data.googleMapsUri),
|
||||
mapsUrl: safeGoogleUrl(data.googleMapsUri),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.warn(`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user