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

+11 -4
View File
@@ -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"}`);
+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;
}