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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user