fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s
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:
1 parent
33580b3290
commit
7fe1a01550
3 files changed
+96
-21
No files matched your search
@@ -167,13 +167,24 @@ Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag),
|
||||
|
||||
## Google-Rezensionen
|
||||
|
||||
Die „Bewertungen"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig:
|
||||
Die Bewertungen-Sektion der Startseite zeigt echte Google-Rezensionen, sobald `GOOGLE_PLACES_API_KEY` (Secret) und `GOOGLE_PLACE_ID` (ConfigMap) gesetzt sind. Ohne diese Werte greifen die statischen Beispiel-Stimmen.
|
||||
|
||||
1. In der [Google Cloud Console](https://console.cloud.google.com/) ein Projekt anlegen (oder ein bestehendes verwenden) und darin die **„Places API"** aktivieren.
|
||||
2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als `GOOGLE_PLACES_API_KEY` in `.env` eintragen. Empfehlenswert: den Key auf die Places API einschränken.
|
||||
3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id) ermitteln und als `GOOGLE_PLACE_ID` eintragen.
|
||||
**Einrichtung:** Google-Cloud-Projekt anlegen, darin die **Places API** aktivieren, einen API-Schlüssel erzeugen und ihn auf diese eine API einschränken. Der Schlüssel wird ausschließlich serverseitig verwendet und erscheint nie im Browser — eine Referrer-Beschränkung greift daher nicht; sinnvoll ist eine IP-Beschränkung auf die ausgehende Adresse des Clusters. Die Place-ID des eigenen Eintrags findet sich über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id).
|
||||
|
||||
Die Rezensionen werden serverseitig eine Stunde gecacht (`src/lib/google-reviews.ts`), um API-Kosten gering zu halten.
|
||||
**Fehler werden jetzt im Server-Log benannt.** Das ist wichtig, weil jeder Fehlerfall auf der Startseite gleich aussieht — nämlich nach den Beispiel-Stimmen:
|
||||
|
||||
| Meldung im Log | Bedeutung |
|
||||
|----------------|-----------|
|
||||
| `REQUEST_DENIED` | Schlüssel ungültig, oder die Places API ist im Projekt nicht aktiviert |
|
||||
| `INVALID_REQUEST` | Parameter falsch |
|
||||
| `NOT_FOUND` / `ZERO_RESULTS` | Die Place-ID passt zu keinem Eintrag |
|
||||
| `OVER_QUERY_LIMIT` | Kontingent erschöpft oder keine Abrechnung hinterlegt |
|
||||
|
||||
Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die Kosten niedrig hält, und nach fünf Sekunden abgebrochen, damit eine nicht antwortende Google-API nicht die Startseite aufhält.
|
||||
|
||||
**Zuordnung:** Angezeigte Rezensionen verlinken auf ihre Quelle bei Google — die Nutzungsbedingungen der Places API verlangen das. Rezensionen ohne Text (reine Sternebewertungen) werden übersprungen, sie wären sonst leere Sprechblasen.
|
||||
|
||||
> **Legacy-Hinweis:** Genutzt wird der klassische `place/details`-Endpunkt. Google führt ihn als *Legacy* und stellt neue Projekte auf die Places API (New) um (`places.googleapis.com/v1`). Meldet das Log dauerhaft `REQUEST_DENIED`, obwohl Schlüssel und Abrechnung stimmen, ist das der wahrscheinliche Grund — dann ist die Umstellung auf die neue API nötig.
|
||||
|
||||
## E-Mail-Versand
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user