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
|
## 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 d’images** : redimensionnement via Sharp, stockage dans `public/uploads/`.
|
- **Upload d’images** : 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 s’appliquent :
|
|||||||
| `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 l’admin puis modifier le mot de passe.
|
- 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).
|
- 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`. 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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 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 { 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} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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