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

+53
View File
@@ -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:[email protected]/")).toBeNull();
expect(safeGoogleUrl("javascript:alert(1)")).toBeNull();
expect(safeGoogleUrl("kein link")).toBeNull();
expect(safeGoogleUrl(undefined)).toBeNull();
});
});