From cc4412d151891c8e13966db7e23f79d61ae977d7 Mon Sep 17 00:00:00 2001
From: gpatruno
Date: Wed, 8 Jul 2026 13:28:51 +0200
Subject: [PATCH] =?UTF-8?q?update=20esth=C3=A9tique?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 8 +-
docker-compose.server.yml | 2 +
docker-compose.yml | 2 +
.../migration.sql | 24 ++
prisma/schema.prisma | 25 ++
src/app/page.tsx | 5 +
src/components/site/ReviewCard.tsx | 55 ++++
src/components/site/ReviewStars.tsx | 33 +++
src/components/site/ReviewsCarousel.tsx | 167 +++++++++++
src/components/site/ReviewsSection.tsx | 62 +++++
src/components/site/SiteHeader.tsx | 6 +
src/components/site/WeekScheduleCalendar.tsx | 20 +-
src/lib/constants.ts | 2 +
src/lib/google-reviews.ts | 259 ++++++++++++++++++
14 files changed, 663 insertions(+), 7 deletions(-)
create mode 100644 prisma/migrations/20260615210000_google_reviews/migration.sql
create mode 100644 src/components/site/ReviewCard.tsx
create mode 100644 src/components/site/ReviewStars.tsx
create mode 100644 src/components/site/ReviewsCarousel.tsx
create mode 100644 src/components/site/ReviewsSection.tsx
create mode 100644 src/lib/google-reviews.ts
diff --git a/README.md b/README.md
index 59d617c..043ee98 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Site vitrine et back-office pour **Gaufrement Bon** (gaufres artisanales à Bagn
## Fonctionnalités
-- **Site public** : hero, menu par catégories (sucrées, salées, du mois), galerie, horaires et contact.
+- **Site public** : hero, menu par catégories (sucrées, salées, du mois), galerie, avis Google (synchronisés en base), horaires et contact.
- **Administration** (`/admin`) : connexion par session, gestion du menu, de la galerie et des textes / images du site.
- **Upload d’images** : redimensionnement via Sharp, stockage dans `public/uploads/`.
- **Base SQLite** : Prisma 7, migrations versionnées, seed au premier démarrage Docker.
@@ -40,6 +40,8 @@ Sans fichier `.env`, les valeurs par défaut suivantes s’appliquent :
| `AUTH_SECRET` | secret de dev (à changer en prod) |
| `ADMIN_EMAIL` | `admin@lagaufredor.local` |
| `ADMIN_PASSWORD` | `changeme123` |
+| `GOOGLE_PLACES_API_KEY` | *(aucun — section avis masquée)* |
+| `GOOGLE_PLACE_ID` | ID par défaut dans `src/lib/constants.ts` |
Exemple `.env` :
@@ -48,6 +50,9 @@ DATABASE_URL="file:./dev.db"
AUTH_SECRET="remplacer-par-une-longue-chaine-aleatoire"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="mot-de-passe-fort"
+# Optionnel — avis Google Maps (Places API New)
+# GOOGLE_PLACES_API_KEY=
+# GOOGLE_PLACE_ID=ChIJN_OqSb2ltRIRhA21X2loXQI
# Optionnel — rate limiting distribué
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=
@@ -123,6 +128,7 @@ docker/ # Entrypoint conteneur
- Changer `ADMIN_EMAIL` et `ADMIN_PASSWORD` avant le premier seed, ou créer l’admin puis modifier le mot de passe.
- Servir le site derrière HTTPS (Nginx ou équivalent).
- Configurer Upstash si plusieurs instances partagent le rate limiting.
+- Pour afficher les avis Google sous la galerie : activer la [Places API (New)](https://developers.google.com/maps/documentation/places/web-service/overview) sur un projet Google Cloud, créer une clé API, et définir `GOOGLE_PLACES_API_KEY`. L’ID de lieu (`GOOGLE_PLACE_ID`) est préconfiguré pour Gaufrement Bon ; vérifiez-le avec l’[outil Place ID](https://developers.google.com/maps/documentation/javascript/place-id) si besoin.
## Licence
diff --git a/docker-compose.server.yml b/docker-compose.server.yml
index 6900916..6efb17e 100644
--- a/docker-compose.server.yml
+++ b/docker-compose.server.yml
@@ -21,6 +21,8 @@ services:
NODE_ENV: production
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
+ GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
+ GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID:-}
volumes:
- gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads
diff --git a/docker-compose.yml b/docker-compose.yml
index 4dcf1a9..b73b309 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,8 @@ services:
# Optionnel : rate limiting distribué (sinon fallback mémoire)
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
+ GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
+ GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID:-}
volumes:
- gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads
diff --git a/prisma/migrations/20260615210000_google_reviews/migration.sql b/prisma/migrations/20260615210000_google_reviews/migration.sql
new file mode 100644
index 0000000..7412372
--- /dev/null
+++ b/prisma/migrations/20260615210000_google_reviews/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "GoogleReviewsMeta" (
+ "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
+ "placeId" TEXT NOT NULL,
+ "rating" REAL,
+ "reviewCount" INTEGER,
+ "googleMapsUri" TEXT NOT NULL,
+ "syncedAt" DATETIME NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "GoogleReview" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "authorName" TEXT NOT NULL,
+ "authorUri" TEXT,
+ "authorPhotoUri" TEXT,
+ "rating" REAL NOT NULL,
+ "text" TEXT NOT NULL,
+ "relativeTime" TEXT NOT NULL DEFAULT '',
+ "googleMapsUri" TEXT,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c56db88..9319412 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -103,3 +103,28 @@ model TruckEventRecurrence {
@@index([dayOfWeek])
}
+
+/** Métadonnées des avis Google (note globale, lien Maps). Une seule ligne (id = default). */
+model GoogleReviewsMeta {
+ id String @id @default("default")
+ placeId String
+ rating Float?
+ reviewCount Int?
+ googleMapsUri String
+ syncedAt DateTime @updatedAt
+}
+
+/** Avis Google Maps synchronisés depuis l’API Places. */
+model GoogleReview {
+ id String @id
+ authorName String
+ authorUri String?
+ authorPhotoUri String?
+ rating Float
+ text String
+ relativeTime String @default("")
+ googleMapsUri String?
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3f270df..27e41f6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,6 @@
import { Footer } from "@/components/site/Footer";
import { GallerySection } from "@/components/site/GallerySection";
+import { ReviewsSection } from "@/components/site/ReviewsSection";
import { ScheduleSection } from "@/components/site/ScheduleSection";
import { Hero } from "@/components/site/Hero";
import { HeroIntro } from "@/components/site/HeroIntro";
@@ -7,6 +8,7 @@ import { MenuSection } from "@/components/site/MenuSection";
import { SiteHeader } from "@/components/site/SiteHeader";
import { prisma } from "@/lib/prisma";
import { getCustomWaffleConfig } from "@/lib/custom-waffle-content";
+import { getGoogleReviews } from "@/lib/google-reviews";
import { getPublicSiteContent } from "@/lib/site-content";
import { getPublicTruckEvents } from "@/lib/truck-event";
import { getPublicTruckRecurrences } from "@/lib/truck-recurrence";
@@ -24,6 +26,7 @@ export default async function Home() {
customWaffle,
truckEvents,
truckRecurrences,
+ googleReviews,
] = await Promise.all([
prisma.waffle.findMany({ include: waffleIngredientInclude }),
prisma.ingredient.findMany({
@@ -37,6 +40,7 @@ export default async function Home() {
getCustomWaffleConfig(),
getPublicTruckEvents(),
getPublicTruckRecurrences(),
+ getGoogleReviews(),
]);
return (
@@ -69,6 +73,7 @@ export default async function Home() {
intro={site.scheduleIntro}
/>
+ {googleReviews ? : null}
>
diff --git a/src/components/site/ReviewCard.tsx b/src/components/site/ReviewCard.tsx
new file mode 100644
index 0000000..2c171cb
--- /dev/null
+++ b/src/components/site/ReviewCard.tsx
@@ -0,0 +1,55 @@
+import type { GoogleReview } from "@/lib/google-reviews";
+import { ReviewStars } from "./ReviewStars";
+
+export function ReviewCard({ review }: { review: GoogleReview }) {
+ const author = review.authorUri ? (
+
+ {review.authorName}
+
+ ) : (
+ {review.authorName}
+ );
+
+ return (
+
+
+ {review.authorPhotoUri ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ {review.authorName.charAt(0).toUpperCase()}
+
+ )}
+
+
+ {author}
+ {review.relativeTime ? (
+ · {review.relativeTime}
+ ) : null}
+
+
+
+
+
+ {review.text}
+
+
+ );
+}
diff --git a/src/components/site/ReviewStars.tsx b/src/components/site/ReviewStars.tsx
new file mode 100644
index 0000000..f908f1e
--- /dev/null
+++ b/src/components/site/ReviewStars.tsx
@@ -0,0 +1,33 @@
+type Props = {
+ rating: number;
+ size?: "sm" | "md";
+};
+
+function StarIcon({ filled }: { filled: boolean }) {
+ return (
+
+ );
+}
+
+export function ReviewStars({ rating, size = "md" }: Props) {
+ const rounded = Math.round(rating);
+ const sizeClass = size === "sm" ? "text-sm" : "text-base";
+
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/site/ReviewsCarousel.tsx b/src/components/site/ReviewsCarousel.tsx
new file mode 100644
index 0000000..606bbf7
--- /dev/null
+++ b/src/components/site/ReviewsCarousel.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import type { GoogleReview } from "@/lib/google-reviews";
+import { ReviewCard } from "./ReviewCard";
+
+const INTERVAL_MS = 5000;
+const GAP_PX = 16;
+const SLIDE_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)";
+
+type Props = {
+ reviews: GoogleReview[];
+};
+
+type CarouselMetrics = {
+ itemW: number;
+ step: number;
+ maxVisible: number;
+};
+
+function getMaxVisible(width: number): number {
+ if (width >= 1024) return 3;
+ if (width >= 640) return 2;
+ return 1;
+}
+
+function positionCount(len: number, maxVisible: number) {
+ return Math.max(0, len - maxVisible + 1);
+}
+
+export function ReviewsCarousel({ reviews }: Props) {
+ const viewportRef = useRef(null);
+ const [startIndex, setStartIndex] = useState(0);
+ const [paused, setPaused] = useState(false);
+ const [metrics, setMetrics] = useState({
+ itemW: 0,
+ step: 0,
+ maxVisible: 1,
+ });
+
+ const len = reviews.length;
+ const { maxVisible, itemW, step } = metrics;
+ const positions = positionCount(len, maxVisible);
+
+ useLayoutEffect(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+
+ function measure() {
+ const node = viewportRef.current;
+ if (!node) return;
+ const w = node.clientWidth;
+ if (w <= 0) return;
+ const visible = getMaxVisible(w);
+ const nextItemW = (w - (visible - 1) * GAP_PX) / visible;
+ const nextStep = nextItemW + GAP_PX;
+ setMetrics((m) =>
+ m.itemW === nextItemW &&
+ m.step === nextStep &&
+ m.maxVisible === visible
+ ? m
+ : { itemW: nextItemW, step: nextStep, maxVisible: visible }
+ );
+ }
+
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ const advance = useCallback(() => {
+ if (len <= maxVisible || positions <= 1) return;
+ setStartIndex((i) => (i + 1) % positions);
+ }, [len, maxVisible, positions]);
+
+ useEffect(() => {
+ if (len <= maxVisible || paused || positions <= 1) return undefined;
+ const t = window.setInterval(advance, INTERVAL_MS);
+ return () => window.clearInterval(t);
+ }, [len, maxVisible, paused, positions, advance]);
+
+ useEffect(() => {
+ if (startIndex >= positions) setStartIndex(0);
+ }, [startIndex, positions]);
+
+ function goToPosition(i: number) {
+ if (i < 0 || i >= positions || i === startIndex) return;
+ setStartIndex(i);
+ }
+
+ if (len <= 3) {
+ const gridClass =
+ len === 1
+ ? "mx-auto grid max-w-md grid-cols-1 gap-4"
+ : len === 2
+ ? "mx-auto grid max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2"
+ : "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
+
+ return (
+
+ {reviews.map((review) => (
+
+ ))}
+
+ );
+ }
+
+ const translateX = step > 0 ? -startIndex * step : 0;
+ const fallbackWidth =
+ maxVisible === 1
+ ? "100%"
+ : maxVisible === 2
+ ? "calc((100% - 16px) / 2)"
+ : "calc((100% - 32px) / 3)";
+
+ return (
+ setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ >
+
+
+ {reviews.map((review) => (
+
0
+ ? { width: itemW, minWidth: itemW }
+ : { width: fallbackWidth, minWidth: fallbackWidth }
+ }
+ >
+
+
+ ))}
+
+
+
+ {positions > 1 ? (
+
+ {Array.from({ length: positions }).map((_, i) => (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/site/ReviewsSection.tsx b/src/components/site/ReviewsSection.tsx
new file mode 100644
index 0000000..a18ea12
--- /dev/null
+++ b/src/components/site/ReviewsSection.tsx
@@ -0,0 +1,62 @@
+import type { GoogleReviewsSummary } from "@/lib/google-reviews";
+import { ReviewStars } from "./ReviewStars";
+import { ReviewsCarousel } from "./ReviewsCarousel";
+
+function formatRating(rating: number) {
+ return rating.toLocaleString("fr-FR", {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+}
+
+function formatReviewCount(count: number) {
+ return count.toLocaleString("fr-FR");
+}
+
+export function ReviewsSection({ data }: { data: GoogleReviewsSummary }) {
+ const { rating, reviewCount, googleMapsUri, reviews } = data;
+
+ return (
+
+
+
+
+ Avis clients
+
+
+ Ce que disent nos clients sur Google.
+
+ {rating != null ? (
+
+
+ {formatRating(rating)}
+
+
+ {reviewCount != null ? (
+
+ ({formatReviewCount(reviewCount)} avis)
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/site/SiteHeader.tsx b/src/components/site/SiteHeader.tsx
index 5845ad6..4a114a0 100644
--- a/src/components/site/SiteHeader.tsx
+++ b/src/components/site/SiteHeader.tsx
@@ -42,6 +42,12 @@ export function SiteHeader({ logoUrl = SITE.logoUrl }: Props) {
>
Galerie
+
+ Avis
+
{rangeLabel}
-
+
{days.map((day) => {
const dayEvents = byDate.get(day.date) ?? [];
return (
@@ -112,7 +112,11 @@ function DayColumn({
day.isToday
? "border-gaufre/40 bg-white shadow-[0_8px_28px_-10px_rgba(243,156,18,0.22)] ring-1 ring-gaufre/20 hover:shadow-[0_12px_36px_-10px_rgba(243,156,18,0.28)]"
: "border-chocolat/12 bg-white shadow-[0_4px_20px_-8px_rgba(61,43,31,0.12)] hover:shadow-[0_8px_28px_-8px_rgba(61,43,31,0.16)]"
- } ${hasEvents ? "min-h-[200px] lg:min-h-[220px]" : "min-h-[100px] lg:min-h-[120px]"}`}
+ } ${
+ hasEvents
+ ? "min-h-[200px] lg:min-h-[220px]"
+ : "self-start lg:min-h-[220px] lg:self-stretch"
+ }`}
>
{hasEvents ? (
events.map((ev) => )
) : (
- - —
+ -
+ —
+
)}
@@ -164,7 +172,7 @@ function EventCard({ event: ev }: { event: PublicTruckEvent }) {
return (
{ev.imageUrl ? (
-
+
) : null}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index d58d239..da6ff3e 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -7,6 +7,8 @@ export const SITE = {
email: "gaufrementbon30@gmail.com",
mapsEmbedUrl:
"https://www.google.com/maps?q=Impasse+du+moulin+de+la+tour,+30200+Bagnols-sur-C%C3%A8ze&hl=fr&z=16&output=embed",
+ /** ID Google Maps de la fiche établissement (Places API). */
+ googlePlaceId: "ChIJN_OqSb2ltRIRhA21X2loXQI",
hours: [
{ days: "Lun — Ven", hours: "11h — 22h" },
{ days: "Sam — Dim", hours: "10h — 23h" },
diff --git a/src/lib/google-reviews.ts b/src/lib/google-reviews.ts
new file mode 100644
index 0000000..003cdf0
--- /dev/null
+++ b/src/lib/google-reviews.ts
@@ -0,0 +1,259 @@
+import { unstable_noStore as noStore } from "next/cache";
+import { SITE } from "./constants";
+import { prisma } from "./prisma";
+
+export type GoogleReview = {
+ id: string;
+ authorName: string;
+ authorUri: string | null;
+ authorPhotoUri: string | null;
+ rating: number;
+ text: string;
+ relativeTime: string;
+ googleMapsUri: string | null;
+};
+
+export type GoogleReviewsSummary = {
+ rating: number | null;
+ reviewCount: number | null;
+ googleMapsUri: string;
+ reviews: GoogleReview[];
+};
+
+type LocalizedText = {
+ text?: string;
+ languageCode?: string;
+};
+
+type PlacesReview = {
+ name?: string;
+ rating?: number;
+ relativePublishTimeDescription?: string;
+ text?: LocalizedText;
+ originalText?: LocalizedText;
+ authorAttribution?: {
+ displayName?: string;
+ uri?: string;
+ photoUri?: string;
+ };
+ googleMapsUri?: string;
+};
+
+type PlacesDetailsResponse = {
+ rating?: number;
+ userRatingCount?: number;
+ googleMapsUri?: string;
+ reviews?: PlacesReview[];
+};
+
+const META_ID = "default";
+const SYNC_MAX_AGE_MS = 60 * 60 * 1000;
+
+function googleMapsPlaceUrl(placeId: string) {
+ return `https://www.google.com/maps/search/?api=1&query=Google&query_place_id=${encodeURIComponent(placeId)}`;
+}
+
+function pickReviewText(review: PlacesReview): string | undefined {
+ const original = review.originalText?.text?.trim();
+ const localized = review.text?.text?.trim();
+ return original || localized;
+}
+
+function parseReview(review: PlacesReview): GoogleReview | null {
+ const text = pickReviewText(review);
+ const authorName = review.authorAttribution?.displayName?.trim();
+ if (!text || !authorName || !review.rating) return null;
+
+ return {
+ id: review.name ?? `${authorName}-${review.relativePublishTimeDescription ?? ""}`,
+ authorName,
+ authorUri: review.authorAttribution?.uri ?? null,
+ authorPhotoUri: review.authorAttribution?.photoUri ?? null,
+ rating: review.rating,
+ text,
+ relativeTime: review.relativePublishTimeDescription ?? "",
+ googleMapsUri: review.googleMapsUri ?? null,
+ };
+}
+
+function toSummary(
+ meta: {
+ rating: number | null;
+ reviewCount: number | null;
+ googleMapsUri: string;
+ },
+ reviews: GoogleReview[]
+): GoogleReviewsSummary | null {
+ if (reviews.length === 0) return null;
+ return {
+ rating: meta.rating,
+ reviewCount: meta.reviewCount,
+ googleMapsUri: meta.googleMapsUri,
+ reviews,
+ };
+}
+
+async function readReviewsFromDb(): Promise
{
+ const [meta, rows] = await Promise.all([
+ prisma.googleReviewsMeta.findUnique({ where: { id: META_ID } }),
+ prisma.googleReview.findMany({ orderBy: { sortOrder: "asc" } }),
+ ]);
+
+ if (!meta || rows.length === 0) return null;
+
+ const reviews: GoogleReview[] = rows.map((row) => ({
+ id: row.id,
+ authorName: row.authorName,
+ authorUri: row.authorUri,
+ authorPhotoUri: row.authorPhotoUri,
+ rating: row.rating,
+ text: row.text,
+ relativeTime: row.relativeTime,
+ googleMapsUri: row.googleMapsUri,
+ }));
+
+ return toSummary(meta, reviews);
+}
+
+async function saveReviewsToDb(
+ placeId: string,
+ data: {
+ rating: number | null;
+ reviewCount: number | null;
+ googleMapsUri: string;
+ reviews: GoogleReview[];
+ }
+) {
+ const [meta, existing] = await Promise.all([
+ prisma.googleReviewsMeta.findUnique({ where: { id: META_ID } }),
+ prisma.googleReview.findMany({ select: { id: true, sortOrder: true } }),
+ ]);
+
+ const placeChanged = meta != null && meta.placeId !== placeId;
+ const sortOrderById = new Map(
+ (placeChanged ? [] : existing).map((row) => [row.id, row.sortOrder])
+ );
+ let nextSortOrder =
+ (placeChanged ? [] : existing).reduce(
+ (max, row) => Math.max(max, row.sortOrder),
+ -1
+ ) + 1;
+
+ await prisma.$transaction([
+ prisma.googleReviewsMeta.upsert({
+ where: { id: META_ID },
+ create: {
+ id: META_ID,
+ placeId,
+ rating: data.rating,
+ reviewCount: data.reviewCount,
+ googleMapsUri: data.googleMapsUri,
+ },
+ update: {
+ placeId,
+ rating: data.rating,
+ reviewCount: data.reviewCount,
+ googleMapsUri: data.googleMapsUri,
+ },
+ }),
+ ...(placeChanged ? [prisma.googleReview.deleteMany({})] : []),
+ ...data.reviews.map((review) => {
+ const sortOrder = sortOrderById.get(review.id) ?? nextSortOrder++;
+ return prisma.googleReview.upsert({
+ where: { id: review.id },
+ create: {
+ id: review.id,
+ authorName: review.authorName,
+ authorUri: review.authorUri,
+ authorPhotoUri: review.authorPhotoUri,
+ rating: review.rating,
+ text: review.text,
+ relativeTime: review.relativeTime,
+ googleMapsUri: review.googleMapsUri,
+ sortOrder,
+ },
+ update: {
+ authorName: review.authorName,
+ authorUri: review.authorUri,
+ authorPhotoUri: review.authorPhotoUri,
+ rating: review.rating,
+ text: review.text,
+ relativeTime: review.relativeTime,
+ googleMapsUri: review.googleMapsUri,
+ },
+ });
+ }),
+ ]);
+}
+
+async function fetchReviewsFromGoogle(
+ apiKey: string,
+ placeId: string
+): Promise {
+ const url = new URL(
+ `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`
+ );
+ url.searchParams.set("languageCode", "fr");
+ url.searchParams.set("regionCode", "FR");
+ const fallbackMapsUri = googleMapsPlaceUrl(placeId);
+
+ const res = await fetch(url.toString(), {
+ headers: {
+ "Content-Type": "application/json",
+ "X-Goog-Api-Key": apiKey,
+ "X-Goog-FieldMask": "reviews,rating,userRatingCount,googleMapsUri",
+ },
+ });
+
+ if (!res.ok) {
+ console.error("Google Places API error:", res.status, await res.text());
+ return null;
+ }
+
+ const data = (await res.json()) as PlacesDetailsResponse;
+ const reviews = (data.reviews ?? [])
+ .map(parseReview)
+ .filter((r): r is GoogleReview => r !== null);
+
+ if (reviews.length === 0) return null;
+
+ return {
+ rating: data.rating ?? null,
+ reviewCount: data.userRatingCount ?? null,
+ googleMapsUri: data.googleMapsUri ?? fallbackMapsUri,
+ reviews,
+ };
+}
+
+function shouldSync(meta: { syncedAt: Date; placeId: string } | null, placeId: string) {
+ if (!meta) return true;
+ if (meta.placeId !== placeId) return true;
+ return Date.now() - meta.syncedAt.getTime() > SYNC_MAX_AGE_MS;
+}
+
+/** Avis Google Maps : synchronise depuis l’API, accumule les nouveaux en base, puis lit tout. */
+export async function getGoogleReviews(): Promise {
+ noStore();
+ const apiKey = process.env.GOOGLE_PLACES_API_KEY?.trim();
+ const placeId =
+ process.env.GOOGLE_PLACE_ID?.trim() ?? SITE.googlePlaceId?.trim();
+
+ if (!placeId) return readReviewsFromDb();
+
+ const meta = await prisma.googleReviewsMeta.findUnique({
+ where: { id: META_ID },
+ });
+ const storedReviewCount = await prisma.googleReview.count();
+
+ if (
+ apiKey &&
+ (shouldSync(meta, placeId) || storedReviewCount === 0)
+ ) {
+ const fetched = await fetchReviewsFromGoogle(apiKey, placeId);
+ if (fetched) {
+ await saveReviewsToDb(placeId, fetched);
+ }
+ }
+
+ return readReviewsFromDb();
+}