update esthétique
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<GallerySection images={galleryImages} intro={site.galleryIntro} />
|
||||
{googleReviews ? <ReviewsSection data={googleReviews} /> : null}
|
||||
</main>
|
||||
<Footer hours={site.hours} />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,12 @@ export function SiteHeader({ logoUrl = SITE.logoUrl }: Props) {
|
||||
>
|
||||
Galerie
|
||||
</Link>
|
||||
<Link
|
||||
href="/#avis"
|
||||
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
|
||||
>
|
||||
Avis
|
||||
</Link>
|
||||
<Link
|
||||
href="/#infos"
|
||||
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
|
||||
|
||||
@@ -77,7 +77,7 @@ export function WeekScheduleCalendar({ events, recurrences }: Props) {
|
||||
<p className="text-sm font-medium capitalize text-chocolat">{rangeLabel}</p>
|
||||
</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) => {
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<header
|
||||
className={`shrink-0 border-b px-3 py-2.5 text-center ${
|
||||
@@ -144,14 +148,18 @@ function DayColumn({
|
||||
</header>
|
||||
|
||||
<ul
|
||||
className={`flex min-h-0 flex-1 flex-col ${
|
||||
hasEvents ? "gap-3 p-2.5" : "items-center justify-center p-3"
|
||||
className={`flex min-h-0 flex-col ${
|
||||
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 ? (
|
||||
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>
|
||||
</article>
|
||||
@@ -164,7 +172,7 @@ function EventCard({ event: ev }: { event: PublicTruckEvent }) {
|
||||
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">
|
||||
{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} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 l’API, 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();
|
||||
}
|
||||
Reference in New Issue
Block a user