From f74a08fa68b5f037f500e901fa83680339925fbc Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 28 Sep 2026 17:46:19 +0200 Subject: [PATCH] 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) --- src/app/globals.css | 54 +++++++++ src/app/page.tsx | 10 +- src/components/ExpandableText.tsx | 41 +++++++ src/components/Stars.tsx | 34 ++++++ src/components/sections/ReviewsSection.tsx | 126 +++++++++++++++++++++ src/lib/google-reviews.ts | 15 ++- src/lib/review-format.ts | 39 +++++++ tests/review-format.test.ts | 53 +++++++++ 8 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/components/ExpandableText.tsx create mode 100644 src/components/Stars.tsx create mode 100644 src/components/sections/ReviewsSection.tsx create mode 100644 src/lib/review-format.ts create mode 100644 tests/review-format.test.ts diff --git a/src/app/globals.css b/src/app/globals.css index 957f0cf..7f8e356 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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,"); +} + /* Masthead: der eine laute Moment der Seite. */ .masthead { font-family: var(--font-display); diff --git a/src/app/page.tsx b/src/app/page.tsx index de82652..873e60f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( @@ -26,6 +33,7 @@ export default async function HomePage() { + diff --git a/src/components/ExpandableText.tsx b/src/components/ExpandableText.tsx new file mode 100644 index 0000000..3fcdb93 --- /dev/null +++ b/src/components/ExpandableText.tsx @@ -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 ( + <> +

+ {text} +

+ {clamp && ( + + )} + + ); +} diff --git a/src/components/Stars.tsx b/src/components/Stars.tsx new file mode 100644 index 0000000..5c7fb7d --- /dev/null +++ b/src/components/Stars.tsx @@ -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 ( + + {[0, 1, 2, 3, 4].map((i) => ( + + + + ))} + + ); +} + +/** + * 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 ( + + + + + + + ); +} diff --git a/src/components/sections/ReviewsSection.tsx b/src/components/sections/ReviewsSection.tsx new file mode 100644 index 0000000..f509a06 --- /dev/null +++ b/src/components/sections/ReviewsSection.tsx @@ -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 ( +
+ {/* Initialen statt Profilbild: kein Abruf von Google-Servern. */} + + {initialsOf(review.author)} + + + {review.author} + + {review.relativeTime && <>{review.relativeTime}, } + {review.authorUrl ? ( + + Auf Google ansehen (Rezension von {review.author}, öffnet in neuem Tab) + + ) : ( + "auf Google" + )} + + +
+ ); +} + +function Summary({ data }: { data: GoogleReviewSummary }) { + return ( +
+ {data.rating !== null && ( +

+ {formatRating(data.rating)} + + + + Durchschnitt + {data.totalCount !== null && <> aus {reviewCountLabel(data.totalCount)}} + + +

+ )} +

Rezensionen von Google. Angezeigt werden die Rezensionen, die Google für diesen Eintrag ausliefert.

+ {data.mapsUrl && ( +

+ + Alle Rezensionen auf Google Maps (öffnet in neuem Tab) + +

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

+ Entwurf + Google-Rezensionen erscheinen, sobald GOOGLE_PLACES_API_KEY und GOOGLE_PLACE_ID gesetzt sind. +

+
+ ); + } + + const [lead, ...rest] = data.reviews; + return ( +
+
+

+ Was andere über ARTISTA sagen +

+
+
+
+
+ +
+
+ +
+ {lead && ( +
+ +
+ +
+ +
+ )} + {rest.length > 0 && ( +
    + {rest.map((review, index) => ( +
  • +
    + +
    + +
    + +
    +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/lib/google-reviews.ts b/src/lib/google-reviews.ts index b996920..817d70b 100644 --- a/src/lib/google-reviews.ts +++ b/src/lib/google-reviews.ts @@ -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 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 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"}`); diff --git a/src/lib/review-format.ts b/src/lib/review-format.ts new file mode 100644 index 0000000..d6a6fe8 --- /dev/null +++ b/src/lib/review-format.ts @@ -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; +} diff --git a/tests/review-format.test.ts b/tests/review-format.test.ts new file mode 100644 index 0000000..b33e532 --- /dev/null +++ b/tests/review-format.test.ts @@ -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:pw@www.google.com/")).toBeNull(); + expect(safeGoogleUrl("javascript:alert(1)")).toBeNull(); + expect(safeGoogleUrl("kein link")).toBeNull(); + expect(safeGoogleUrl(undefined)).toBeNull(); + }); +});