update esthétique

This commit is contained in:
gpatruno
2026-07-08 13:28:51 +02:00
parent e45e9f154e
commit cc4412d151
14 changed files with 663 additions and 7 deletions
+7 -1
View File
@@ -4,7 +4,7 @@ Site vitrine et back-office pour **Gaufrement Bon** (gaufres artisanales à Bagn
## Fonctionnalités ## 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. - **Administration** (`/admin`) : connexion par session, gestion du menu, de la galerie et des textes / images du site.
- **Upload dimages** : redimensionnement via Sharp, stockage dans `public/uploads/`. - **Upload dimages** : redimensionnement via Sharp, stockage dans `public/uploads/`.
- **Base SQLite** : Prisma 7, migrations versionnées, seed au premier démarrage Docker. - **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 sappliquent :
| `AUTH_SECRET` | secret de dev (à changer en prod) | | `AUTH_SECRET` | secret de dev (à changer en prod) |
| `ADMIN_EMAIL` | `admin@lagaufredor.local` | | `ADMIN_EMAIL` | `admin@lagaufredor.local` |
| `ADMIN_PASSWORD` | `changeme123` | | `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` : Exemple `.env` :
@@ -48,6 +50,9 @@ DATABASE_URL="file:./dev.db"
AUTH_SECRET="remplacer-par-une-longue-chaine-aleatoire" AUTH_SECRET="remplacer-par-une-longue-chaine-aleatoire"
ADMIN_EMAIL="admin@example.com" ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="mot-de-passe-fort" 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é # Optionnel — rate limiting distribué
# UPSTASH_REDIS_REST_URL= # UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN= # UPSTASH_REDIS_REST_TOKEN=
@@ -123,6 +128,7 @@ docker/ # Entrypoint conteneur
- Changer `ADMIN_EMAIL` et `ADMIN_PASSWORD` avant le premier seed, ou créer ladmin puis modifier le mot de passe. - Changer `ADMIN_EMAIL` et `ADMIN_PASSWORD` avant le premier seed, ou créer ladmin puis modifier le mot de passe.
- Servir le site derrière HTTPS (Nginx ou équivalent). - Servir le site derrière HTTPS (Nginx ou équivalent).
- Configurer Upstash si plusieurs instances partagent le rate limiting. - 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`. LID 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 ## Licence
+2
View File
@@ -21,6 +21,8 @@ services:
NODE_ENV: production NODE_ENV: production
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-} UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-} UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID:-}
volumes: volumes:
- gauffre-data:/app/data - gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads - gauffre-uploads:/app/public/uploads
+2
View File
@@ -12,6 +12,8 @@ services:
# Optionnel : rate limiting distribué (sinon fallback mémoire) # Optionnel : rate limiting distribué (sinon fallback mémoire)
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-} UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-} UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID:-}
volumes: volumes:
- gauffre-data:/app/data - gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads - gauffre-uploads:/app/public/uploads
@@ -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
);
+25
View File
@@ -103,3 +103,28 @@ model TruckEventRecurrence {
@@index([dayOfWeek]) @@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 lAPI 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
}
+5
View File
@@ -1,5 +1,6 @@
import { Footer } from "@/components/site/Footer"; import { Footer } from "@/components/site/Footer";
import { GallerySection } from "@/components/site/GallerySection"; import { GallerySection } from "@/components/site/GallerySection";
import { ReviewsSection } from "@/components/site/ReviewsSection";
import { ScheduleSection } from "@/components/site/ScheduleSection"; import { ScheduleSection } from "@/components/site/ScheduleSection";
import { Hero } from "@/components/site/Hero"; import { Hero } from "@/components/site/Hero";
import { HeroIntro } from "@/components/site/HeroIntro"; import { HeroIntro } from "@/components/site/HeroIntro";
@@ -7,6 +8,7 @@ import { MenuSection } from "@/components/site/MenuSection";
import { SiteHeader } from "@/components/site/SiteHeader"; import { SiteHeader } from "@/components/site/SiteHeader";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCustomWaffleConfig } from "@/lib/custom-waffle-content"; import { getCustomWaffleConfig } from "@/lib/custom-waffle-content";
import { getGoogleReviews } from "@/lib/google-reviews";
import { getPublicSiteContent } from "@/lib/site-content"; import { getPublicSiteContent } from "@/lib/site-content";
import { getPublicTruckEvents } from "@/lib/truck-event"; import { getPublicTruckEvents } from "@/lib/truck-event";
import { getPublicTruckRecurrences } from "@/lib/truck-recurrence"; import { getPublicTruckRecurrences } from "@/lib/truck-recurrence";
@@ -24,6 +26,7 @@ export default async function Home() {
customWaffle, customWaffle,
truckEvents, truckEvents,
truckRecurrences, truckRecurrences,
googleReviews,
] = await Promise.all([ ] = await Promise.all([
prisma.waffle.findMany({ include: waffleIngredientInclude }), prisma.waffle.findMany({ include: waffleIngredientInclude }),
prisma.ingredient.findMany({ prisma.ingredient.findMany({
@@ -37,6 +40,7 @@ export default async function Home() {
getCustomWaffleConfig(), getCustomWaffleConfig(),
getPublicTruckEvents(), getPublicTruckEvents(),
getPublicTruckRecurrences(), getPublicTruckRecurrences(),
getGoogleReviews(),
]); ]);
return ( return (
@@ -69,6 +73,7 @@ export default async function Home() {
intro={site.scheduleIntro} intro={site.scheduleIntro}
/> />
<GallerySection images={galleryImages} intro={site.galleryIntro} /> <GallerySection images={galleryImages} intro={site.galleryIntro} />
{googleReviews ? <ReviewsSection data={googleReviews} /> : null}
</main> </main>
<Footer hours={site.hours} /> <Footer hours={site.hours} />
</> </>
+55
View File
@@ -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 ? (
<a
href={review.authorUri}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-chocolat hover:text-gaufre"
>
{review.authorName}
</a>
) : (
<span className="font-semibold text-chocolat">{review.authorName}</span>
);
return (
<article className="flex h-full w-full flex-col rounded-3xl border border-chocolat/10 bg-white/80 p-5 sm:p-6">
<div className="flex items-start gap-3">
{review.authorPhotoUri ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={review.authorPhotoUri}
alt=""
width={44}
height={44}
className="h-11 w-11 shrink-0 rounded-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gaufre/20 text-sm font-bold text-chocolat"
aria-hidden
>
{review.authorName.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{author}
{review.relativeTime ? (
<span className="text-sm text-muted">· {review.relativeTime}</span>
) : null}
</div>
<ReviewStars rating={review.rating} size="sm" />
</div>
</div>
<p className="mt-4 flex-1 text-sm leading-relaxed text-chocolat/90">
{review.text}
</p>
</article>
);
}
+33
View File
@@ -0,0 +1,33 @@
type Props = {
rating: number;
size?: "sm" | "md";
};
function StarIcon({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 20 20"
aria-hidden
className={`h-[1em] w-[1em] ${filled ? "text-gaufre" : "text-chocolat/20"}`}
fill="currentColor"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
);
}
export function ReviewStars({ rating, size = "md" }: Props) {
const rounded = Math.round(rating);
const sizeClass = size === "sm" ? "text-sm" : "text-base";
return (
<span
className={`inline-flex items-center gap-0.5 ${sizeClass}`}
aria-label={`${rating.toFixed(1).replace(".", ",")} sur 5`}
>
{Array.from({ length: 5 }).map((_, i) => (
<StarIcon key={i} filled={i < rounded} />
))}
</span>
);
}
+167
View File
@@ -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<HTMLDivElement>(null);
const [startIndex, setStartIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [metrics, setMetrics] = useState<CarouselMetrics>({
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 (
<div className={gridClass}>
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
}
const translateX = step > 0 ? -startIndex * step : 0;
const fallbackWidth =
maxVisible === 1
? "100%"
: maxVisible === 2
? "calc((100% - 16px) / 2)"
: "calc((100% - 32px) / 3)";
return (
<div
className="relative"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div ref={viewportRef} className="overflow-hidden">
<div
className="flex flex-row flex-nowrap items-stretch gap-4 will-change-transform"
style={{
transform: `translateX(${translateX}px)`,
transition: `transform 650ms ${SLIDE_EASE}`,
}}
>
{reviews.map((review) => (
<div
key={review.id}
className="flex shrink-0 self-stretch"
style={
itemW > 0
? { width: itemW, minWidth: itemW }
: { width: fallbackWidth, minWidth: fallbackWidth }
}
>
<ReviewCard review={review} />
</div>
))}
</div>
</div>
{positions > 1 ? (
<div className="mt-6 flex flex-wrap justify-center gap-1.5">
{Array.from({ length: positions }).map((_, i) => (
<button
key={`dot-${i}`}
type="button"
aria-label={`Avis ${i + 1} sur ${positions}`}
aria-current={i === startIndex}
onClick={() => goToPosition(i)}
className={`h-2 rounded-full transition-all duration-500 ease-out ${
i === startIndex
? "w-8 bg-gaufre"
: "w-2 bg-chocolat/30 hover:bg-chocolat/50"
}`}
/>
))}
</div>
) : null}
</div>
);
}
+62
View File
@@ -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 (
<section
id="avis"
className="scroll-mt-24 border-t border-chocolat/8 bg-cream px-4 py-20 sm:px-6"
>
<div className="mx-auto max-w-6xl">
<div className="mb-12 text-center">
<h2 className="text-3xl font-bold text-chocolat sm:text-4xl">
Avis clients
</h2>
<p className="mx-auto mt-3 max-w-2xl text-muted">
Ce que disent nos clients sur Google.
</p>
{rating != null ? (
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
<span className="text-3xl font-bold text-chocolat">
{formatRating(rating)}
</span>
<ReviewStars rating={rating} />
{reviewCount != null ? (
<span className="text-sm text-muted">
({formatReviewCount(reviewCount)} avis)
</span>
) : null}
</div>
) : null}
</div>
<ReviewsCarousel reviews={reviews} />
<div className="mt-10 flex flex-col items-center gap-3 text-center">
<a
href={googleMapsUri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-gaufre px-6 py-3 text-sm font-semibold text-white shadow-[0_8px_24px_-8px_rgba(243,156,18,0.55)] transition hover:bg-gaufre/90"
>
Voir tous les avis sur Google
</a>
</div>
</div>
</section>
);
}
+6
View File
@@ -42,6 +42,12 @@ export function SiteHeader({ logoUrl = SITE.logoUrl }: Props) {
> >
Galerie Galerie
</Link> </Link>
<Link
href="/#avis"
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
>
Avis
</Link>
<Link <Link
href="/#infos" href="/#infos"
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15" className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
+14 -6
View File
@@ -77,7 +77,7 @@ export function WeekScheduleCalendar({ events, recurrences }: Props) {
<p className="text-sm font-medium capitalize text-chocolat">{rangeLabel}</p> <p className="text-sm font-medium capitalize text-chocolat">{rangeLabel}</p>
</div> </div>
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2 lg:flex lg:items-stretch lg:gap-2.5 xl:gap-3"> <div className="grid auto-rows-auto gap-3 sm:grid-cols-2 lg:flex lg:items-stretch lg:gap-2.5 xl:gap-3">
{days.map((day) => { {days.map((day) => {
const dayEvents = byDate.get(day.date) ?? []; const dayEvents = byDate.get(day.date) ?? [];
return ( return (
@@ -112,7 +112,11 @@ function DayColumn({
day.isToday 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-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)]" : "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"
}`}
> >
<header <header
className={`shrink-0 border-b px-3 py-2.5 text-center ${ className={`shrink-0 border-b px-3 py-2.5 text-center ${
@@ -144,14 +148,18 @@ function DayColumn({
</header> </header>
<ul <ul
className={`flex min-h-0 flex-1 flex-col ${ className={`flex min-h-0 flex-col ${
hasEvents ? "gap-3 p-2.5" : "items-center justify-center p-3" hasEvents
? "flex-1 gap-3 p-2.5"
: "px-2.5 py-2 lg:flex-1 lg:items-center lg:justify-center lg:p-2.5"
}`} }`}
> >
{hasEvents ? ( {hasEvents ? (
events.map((ev) => <EventCard key={ev.id} event={ev} />) events.map((ev) => <EventCard key={ev.id} event={ev} />)
) : ( ) : (
<li className="text-center text-xs text-muted"></li> <li className="text-center text-xs text-muted" aria-hidden>
</li>
)} )}
</ul> </ul>
</article> </article>
@@ -164,7 +172,7 @@ function EventCard({ event: ev }: { event: PublicTruckEvent }) {
return ( return (
<li className="w-full min-w-0 overflow-hidden rounded-xl border border-chocolat/10 bg-white shadow-sm ring-1 ring-chocolat/5"> <li className="w-full min-w-0 overflow-hidden rounded-xl border border-chocolat/10 bg-white shadow-sm ring-1 ring-chocolat/5">
{ev.imageUrl ? ( {ev.imageUrl ? (
<div className="relative aspect-[16/10] w-full bg-chocolat/5"> <div className="relative aspect-[5/2] w-full bg-chocolat/5 lg:aspect-[16/10]">
<WaffleMenuImage src={ev.imageUrl} alt={ev.location} /> <WaffleMenuImage src={ev.imageUrl} alt={ev.location} />
</div> </div>
) : null} ) : null}
+2
View File
@@ -7,6 +7,8 @@ export const SITE = {
email: "gaufrementbon30@gmail.com", email: "gaufrementbon30@gmail.com",
mapsEmbedUrl: 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", "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: [ hours: [
{ days: "Lun — Ven", hours: "11h — 22h" }, { days: "Lun — Ven", hours: "11h — 22h" },
{ days: "Sam — Dim", hours: "10h — 23h" }, { days: "Sam — Dim", hours: "10h — 23h" },
+259
View File
@@ -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<GoogleReviewsSummary | null> {
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<GoogleReviewsSummary | null> {
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 lAPI, accumule les nouveaux en base, puis lit tout. */
export async function getGoogleReviews(): Promise<GoogleReviewsSummary | null> {
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();
}