diff --git a/README.md b/README.md index 291be2e..ce00b68 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/home-sections-b.tsx b/src/components/home-sections-b.tsx index 6ffbccd..c5afa12 100644 --- a/src/components/home-sections-b.tsx +++ b/src/components/home-sections-b.tsx @@ -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 (
@@ -192,7 +197,22 @@ export async function Reviews() {

„{review.quote}“

-

{review.author}

+ {/* 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 ? ( + + {review.author} + + ) : ( +

{review.author}

+ )} ))} diff --git a/src/lib/google-reviews.ts b/src/lib/google-reviews.ts index 1b51809..1a106c3 100644 --- a/src/lib/google-reviews.ts +++ b/src/lib/google-reviews.ts @@ -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; } }