diff --git a/.env.example b/.env.example index 5486ac1..47da156 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,9 @@ MEDIA_DIR=./media # Datenschutzkonzept fuer externe Videos: none | youtube-nocookie | vimeo # Externe Player laden nur nach Klick und ausdruecklicher Zustimmung. EXTERNAL_VIDEO_PROVIDER=none + +# --- Google-Rezensionen (optional) ------------------------------------------ +# "Places API (New)" aktivieren, Abrechnung an, API-Schluessel (kein OAuth). +# Ohne Werte wird der Rezensionen-Abschnitt ausgeblendet. +GOOGLE_PLACES_API_KEY= +GOOGLE_PLACE_ID= diff --git a/k8s/02-secret.yaml.example b/k8s/02-secret.yaml.example index e7a2b3a..f1ffa70 100644 --- a/k8s/02-secret.yaml.example +++ b/k8s/02-secret.yaml.example @@ -16,6 +16,9 @@ stringData: SMTP_HOST: "smtp.example.org" SMTP_USER: "benutzer" SMTP_PASSWORD: "passwort" + # Optional: Google-Rezensionen (Places API New). Leer lassen = Abschnitt aus. + GOOGLE_PLACES_API_KEY: "" + GOOGLE_PLACE_ID: "" --- # Zugang zur SMB-Freigabe (fuer 03-storage.yaml) apiVersion: v1 diff --git a/src/lib/google-reviews.ts b/src/lib/google-reviews.ts new file mode 100644 index 0000000..b996920 --- /dev/null +++ b/src/lib/google-reviews.ts @@ -0,0 +1,103 @@ +/** + * Echte Google-Rezensionen ueber die "Places API (New)" (gleiches Muster wie + * im Wabenhain-Projekt). + * + * - Ohne GOOGLE_PLACES_API_KEY/GOOGLE_PLACE_ID oder bei Fehlern: null. Die + * Seite blendet den Abschnitt dann aus — es gibt bewusst KEINE + * Beispiel-Stimmen als Ersatz (keine erfundenen Testimonials). + * - Profilbilder werden nicht geladen: sie kaemen direkt von Google-Servern + * und wuerden die IP-Adressen der Besucherinnen dorthin uebertragen. + * - Zuordnung zur Quelle (Nutzungsbedingungen): Autorenname, Link zur + * Rezension und zum Google-Maps-Eintrag. + * + * Einrichtung: in der Google Cloud Console "Places API (New)" aktivieren, + * Abrechnung aktivieren, API-Schluessel erzeugen (kein OAuth-Client) und + * GOOGLE_PLACES_API_KEY sowie GOOGLE_PLACE_ID setzen. + */ +export type GoogleReview = { + author: string; + rating: number; + text: string; + relativeTime: string; + authorUrl: string | null; +}; + +export type GoogleReviewSummary = { + reviews: GoogleReview[]; + rating: number | null; + totalCount: number | null; + mapsUrl: string | null; +}; + +const TIMEOUT_MS = 5000; +// Abgerechnet wird nach angeforderten Feldern — nur das Noetige anfordern. +const FIELD_MASK = "rating,userRatingCount,reviews,googleMapsUri"; + +type PlacesResponse = { + rating?: number; + userRatingCount?: number; + googleMapsUri?: string; + reviews?: { + rating?: number; + relativePublishTimeDescription?: string; + text?: { text?: string }; + originalText?: { text?: string }; + authorAttribution?: { displayName?: string; uri?: string }; + }[]; + error?: { message?: string }; +}; + +function safeHttpsUrl(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +export async function fetchGoogleReviews(): Promise { + const apiKey = process.env.GOOGLE_PLACES_API_KEY; + const placeId = process.env.GOOGLE_PLACE_ID; + if (!apiKey || !placeId) return null; + + const resource = placeId.startsWith("places/") ? placeId : `places/${placeId}`; + const url = new URL(`https://places.googleapis.com/v1/${encodeURI(resource)}`); + url.searchParams.set("languageCode", "de"); + + try { + const response = await fetch(url, { + // Schluessel im Header, nicht in der URL (keine fremden Zugriffsprotokolle). + headers: { "X-Goog-Api-Key": apiKey, "X-Goog-FieldMask": FIELD_MASK }, + // 1 Stunde Cache: schnell und guenstig, ohne spuerbar zu veralten. + next: { revalidate: 3600 }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const data = (await response.json().catch(() => null)) as PlacesResponse | null; + if (!response.ok || !data || data.error) { + console.warn(`[google-reviews] Abruf abgelehnt: ${data?.error?.message ?? `HTTP ${response.status}`}`); + return null; + } + const reviews = (data.reviews ?? []) + .map((r) => ({ + author: r.authorAttribution?.displayName?.trim() ?? "", + 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), + })) + // Ohne Text keine Stimme, ohne Namen keine Zuordnung zur Quelle. + .filter((r) => r.text.length > 0 && r.author.length > 0); + if (reviews.length === 0) return null; + return { + reviews, + rating: typeof data.rating === "number" ? data.rating : null, + totalCount: typeof data.userRatingCount === "number" ? data.userRatingCount : null, + mapsUrl: safeHttpsUrl(data.googleMapsUri), + }; + } catch (error: unknown) { + console.warn(`[google-reviews] Abruf fehlgeschlagen: ${error instanceof Error ? error.message : "unbekannt"}`); + return null; + } +}