fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s

Die Anfrage an die Places API setzte "fields=review". Richtig ist "reviews"
(Plural). Google antwortet auf den falschen Wert mit INVALID_REQUEST, die
Funktion gab daraufhin null zurueck, und die Startseite fiel auf die
statischen Beispiel-Stimmen zurueck.

Nach aussen sah das voellig normal aus — eine Bewertungen-Sektion mit
Zitaten und Sternen war ja da. Nur waren es nie die echten. Genau deshalb
blieb der Tippfehler unbemerkt.

Damit das nicht wieder passieren kann, meldet die Anbindung jetzt, WARUM sie
nichts liefert. Vorher fuehrten alle Fehlerfaelle stumm zum selben Bild:

  [google-reviews] Places API meldet "REQUEST_DENIED": ...

REQUEST_DENIED (Schluessel ungueltig oder Places API im Projekt nicht
aktiviert), NOT_FOUND (Place-ID stimmt nicht) und OVER_QUERY_LIMIT
(Kontingent erschoepft oder keine Abrechnung hinterlegt) sind damit von
aussen unterscheidbar.

Weitere Korrekturen:

- Zeitlimit von fuenf Sekunden. Ohne das haengt die Startseite an einer
  nicht antwortenden fremden API — dieselbe Absicherung wie beim
  Passwort-Leak-Abgleich.
- Rezensionen verlinken auf ihre Quelle bei Google. Die Nutzungsbedingungen
  der Places API verlangen die Zuordnung; bisher stand ein fremdes Zitat
  unbelegt auf der Seite. author_url und profile_photo_url wurden aus der
  Antwort schlicht verworfen.
- Rezensionen ohne Text werden uebersprungen. Reine Sternebewertungen sind
  in der Antwort enthalten und waeren als Zitat eine leere Sprechblase; "text"
  ist laut Google optional, war hier aber als Pflichtfeld typisiert.

README: Einrichtung, Bedeutung der Statuscodes und der Hinweis, dass Google
den genutzten place/details-Endpunkt als Legacy fuehrt — bleibt
REQUEST_DENIED trotz gueltigem Schluessel bestehen, ist die Umstellung auf
die Places API (New) faellig.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 19:17:13 +02:00
1 parent 33580b3290
commit 7fe1a01550
3 files changed
+96 -21

No files matched your search

+23 -3
View File
@@ -164,8 +164,13 @@ export async function Reviews() {
// uebersetzt oder umformuliert — nur die eigenen Fallback-Testimonials
// (ohne konfigurierte Google-Anbindung) existieren in beiden Sprachen.
const items = google && google.reviews.length > 0
? google.reviews.map((r) => ({ quote: r.text, author: r.author, rating: r.rating }))
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5 }));
? google.reviews.map((r) => ({
quote: r.text,
author: r.author,
rating: r.rating,
authorUrl: r.authorUrl,
}))
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5, authorUrl: null }));
return (
<section className="py-24 bg-forest text-cream">
@@ -192,7 +197,22 @@ export async function Reviews() {
<p className="mt-4 text-cream/85 leading-relaxed italic">
„{review.quote}“
</p>
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
{/* Zuordnung zur Quelle: die Nutzungsbedingungen der Places API
verlangen, dass eine angezeigte Rezension zu Google
zurueckfuehrt. Bei den eigenen Beispiel-Stimmen gibt es
nichts zu verlinken — dort bleibt es beim reinen Namen. */}
{review.authorUrl ? (
<a
href={review.authorUrl}
target="_blank"
rel="noopener noreferrer nofollow"
className="mt-4 inline-block text-sm text-cream/70 underline decoration-cream/30 hover:decoration-cream"
>
{review.author}
</a>
) : (
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
)}
</ScrollReveal>
))}
</div>
+57 -13
View File
@@ -3,8 +3,18 @@ export interface GoogleReview {
rating: number;
text: string;
relativeTime: string;
/**
* Link auf die Rezension bei Google. Die Nutzungsbedingungen der Places API
* verlangen, dass angezeigte Rezensionen der Quelle zugeordnet bleiben —
* ohne diesen Link steht ein fremdes Zitat unbelegt auf unserer Seite.
*/
authorUrl: string | null;
profilePhotoUrl: string | null;
}
/** Google antwortet auch bei Fehlern mit HTTP 200 — die Aussage steckt in "status". */
const GOOGLE_TIMEOUT_MS = 5000;
interface PlacesDetailsResponse {
result?: {
rating?: number;
@@ -12,11 +22,14 @@ interface PlacesDetailsResponse {
reviews?: {
author_name: string;
rating: number;
text: string;
text?: string;
relative_time_description: string;
author_url?: string;
profile_photo_url?: string;
}[];
};
status: string;
error_message?: string;
}
/**
@@ -40,7 +53,11 @@ export async function fetchGoogleReviews(): Promise<{
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.set("place_id", placeId);
url.searchParams.set("fields", "review,rating,user_ratings_total");
// "reviews" (Plural!) — mit "review" antwortet Google mit INVALID_REQUEST,
// und weil jeder Fehlerfall hier still auf die Beispiel-Stimmen
// zurueckfaellt, sah die Startseite dabei voellig normal aus. Genau so
// blieb der Tippfehler unbemerkt.
url.searchParams.set("fields", "reviews,rating,user_ratings_total");
url.searchParams.set("language", "de");
url.searchParams.set("reviews_sort", "newest");
url.searchParams.set("key", apiKey);
@@ -48,27 +65,54 @@ export async function fetchGoogleReviews(): Promise<{
try {
// 1 Stunde Cache: haelt die Seite schnell und reduziert API-Kosten,
// ohne die Rezensionen spuerbar veralten zu lassen.
const response = await fetch(url, { next: { revalidate: 3600 } });
if (!response.ok) return null;
const response = await fetch(url, {
next: { revalidate: 3600 },
// Ohne Zeitlimit haengt die Startseite an einer nicht antwortenden
// fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich.
signal: AbortSignal.timeout(GOOGLE_TIMEOUT_MS),
});
if (!response.ok) {
console.warn(`[google-reviews] Places API antwortete mit HTTP ${response.status}.`);
return null;
}
const data = (await response.json()) as PlacesDetailsResponse;
if (data.status !== "OK" || !data.result) return null;
if (data.status !== "OK" || !data.result) {
// Laut melden statt still zurueckfallen: "REQUEST_DENIED" (Schluessel
// ungueltig oder Places API nicht aktiviert), "INVALID_REQUEST"
// (Parameter falsch) und "NOT_FOUND" (Place-ID stimmt nicht) sehen auf
// der Startseite alle gleich aus — naemlich nach den Beispiel-Stimmen.
console.warn(
`[google-reviews] Places API meldet "${data.status}"${data.error_message ? `: ${data.error_message}` : ""}.`
);
return null;
}
const reviews: GoogleReview[] = (data.result.reviews ?? []).map((r) => ({
author: r.author_name,
rating: r.rating,
text: r.text,
relativeTime: r.relative_time_description,
}));
const reviews: GoogleReview[] = (data.result.reviews ?? [])
// Rezensionen ohne Text sind reine Sternebewertungen — als Zitat auf der
// Startseite waeren sie eine leere Sprechblase.
.filter((r) => r.text?.trim())
.map((r) => ({
author: r.author_name,
rating: r.rating,
text: r.text!.trim(),
relativeTime: r.relative_time_description,
authorUrl: r.author_url ?? null,
profilePhotoUrl: r.profile_photo_url ?? null,
}));
return {
reviews,
rating: data.result.rating ?? null,
totalCount: data.result.user_ratings_total ?? null,
};
} catch {
} catch (error: unknown) {
// Netzwerk-/API-Fehler duerfen die Startseite nicht zum Absturz bringen —
// stattdessen greift der statische Fallback.
// stattdessen greift der statische Fallback. Geloggt wird trotzdem, sonst
// ist eine dauerhaft kaputte Anbindung von aussen nicht zu erkennen.
console.warn(
`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
);
return null;
}
}