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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:46:19 +02:00
1 parent b4eafbe433
commit f74a08fa68
8 files changed
+367 -5

No files matched your search

+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>
)}
</>
);
}
+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>
);
}
+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>
);
}