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
@@ -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