This commit is contained in:
gpatruno
2026-06-11 17:30:13 +02:00
parent 36b4acb355
commit e45e9f154e
108 changed files with 9163 additions and 167 deletions
+25
View File
@@ -0,0 +1,25 @@
node_modules
.next
.git
.gitignore
*.md
.env
.env.*
!.env.example
Dockerfile*
docker-compose*.yml
.dockerignore
*.db
*.db-journal
coverage
.vscode
.idea
src/generated/prisma
# Uploads (volume en prod) — pas les embarquer dans limage
public/uploads/*.png
public/uploads/*.jpg
public/uploads/*.jpeg
public/uploads/**/*.png
public/uploads/**/*.jpg
public/uploads/**/*.jpeg
+10
View File
@@ -33,9 +33,19 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# sqlite local
*.db
*.db-journal
# photos uploadées (fichiers générés)
/public/uploads/*
!/public/uploads/.gitkeep
# vercel # vercel
.vercel .vercel
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
FROM node:22-bookworm-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="file:./build-placeholder.db"
RUN npm rebuild better-sqlite3
RUN npx prisma generate
RUN npx prisma migrate deploy
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/lib/password.ts ./src/lib/password.ts
COPY --from=builder /app/src/lib/constants.ts ./src/lib/constants.ts
COPY --from=builder /app/src/lib/site-content-defaults.ts ./src/lib/site-content-defaults.ts
COPY --from=builder /app/src/lib/week-calendar.ts ./src/lib/week-calendar.ts
COPY --from=builder /app/src/generated/prisma ./src/generated/prisma
COPY docker/entrypoint.sh /entrypoint.sh
RUN npm install prisma@7.5.0 dotenv@17.3.1 --omit=dev \
&& npm install -g tsx@4.21.0 \
&& chmod +x /entrypoint.sh \
&& mkdir -p /app/data /app/public/uploads \
&& chown -R nextjs:nodejs /app
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/entrypoint.sh"]
+113 -20
View File
@@ -1,36 +1,129 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Gaufrement Bon — site vitrine
## Getting Started Site vitrine et back-office pour **Gaufrement Bon** (gaufres artisanales à Bagnols-sur-Cèze) : page daccueil, carte des gaufres, galerie photo, contenus éditables, et interface dadministration.
First, run the development server: ## Fonctionnalités
- **Site public** : hero, menu par catégories (sucrées, salées, du mois), galerie, horaires et contact.
- **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/`.
- **Base SQLite** : Prisma 7, migrations versionnées, seed au premier démarrage Docker.
## Stack
- [Next.js 15](https://nextjs.org) (App Router, Turbopack, sortie `standalone`)
- React 19, Tailwind CSS 4
- Prisma + SQLite (`better-sqlite3`)
- Auth JWT en cookie (`jose`), rate limiting (mémoire ou [Upstash Redis](https://upstash.com))
## Prérequis
- Node.js 22+
- npm
## Développement local
```bash ```bash
npm install
npx prisma migrate deploy # crée dev.db si besoin
npm run db:seed # optionnel : données de démo + compte admin
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Ouvrir [http://localhost:3000](http://localhost:3000). Ladmin est sur [http://localhost:3000/admin](http://localhost:3000/admin).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. Sans fichier `.env`, les valeurs par défaut suivantes sappliquent :
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. | Variable | Défaut local |
|----------|----------------|
| `DATABASE_URL` | `file:./dev.db` |
| `AUTH_SECRET` | secret de dev (à changer en prod) |
| `ADMIN_EMAIL` | `admin@lagaufredor.local` |
| `ADMIN_PASSWORD` | `changeme123` |
## Learn More Exemple `.env` :
To learn more about Next.js, take a look at the following resources: ```env
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 — rate limiting distribué
# UPSTASH_REDIS_REST_URL=
# UPSTASH_REDIS_REST_TOKEN=
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## Scripts npm
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | Commande | Description |
|----------|-------------|
| `npm run dev` | Serveur de dev (port 3000) |
| `npm run build` | Build production (migrations + generate Prisma) |
| `npm run start` | Serveur Next en production |
| `npm run db:seed` | Seed manuel (menu, galerie, contenus, admin) |
| `npm run lint` | ESLint |
## Deploy on Vercel ## Docker
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Image publiée : `foufure/gauffre:latest`.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### Compose local (build + run)
```bash
export AUTH_SECRET="$(openssl rand -base64 32)"
docker compose up -d --build
```
Application sur [http://localhost:3007](http://localhost:3007) (port hôte `3007` → conteneur `3000`).
Volumes : base SQLite (`gauffre-data`) et fichiers uploadés (`gauffre-uploads`).
### Déploiement serveur
Fichier dédié : `docker-compose.server.yml` (HTTPS assuré par Nginx + Certbot sur lhôte).
```bash
export AUTH_SECRET="$(openssl rand -base64 32)"
export ADMIN_EMAIL="admin@example.com"
export ADMIN_PASSWORD="mot-de-passe-fort"
docker compose -f docker-compose.server.yml up -d
```
Exemple de proxy Nginx : `proxy_pass http://127.0.0.1:3007;`
Port hôte configurable via `HOST_PORT` (défaut `3007`).
Au démarrage du conteneur, `docker/entrypoint.sh` exécute les migrations Prisma et le seed, puis lance `node server.js`.
### Publier limage sur Docker Hub
```bash
docker login
./scripts/docker-hub-push.sh # tag latest
./scripts/docker-hub-push.sh v1.0.0 # tag personnalisé
```
Variables optionnelles : `DOCKER_USER` (défaut `foufure`), `IMAGE_NAME` (défaut `gauffre`).
## Structure (aperçu)
```
src/app/ # Pages et routes API (public + /admin)
src/components/site/ # UI vitrine
src/components/admin/ # UI back-office
src/lib/ # Prisma, auth, uploads, contenus
prisma/ # Schéma, migrations, seed
public/gallery/ # Images statiques par défaut
public/uploads/ # Images uploadées (persistées en Docker)
docker/ # Entrypoint conteneur
```
## Sécurité en production
- Définir un `AUTH_SECRET` long et aléatoire (`openssl rand -base64 32`).
- 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).
- Configurer Upstash si plusieurs instances partagent le rate limiting.
## Licence
Projet privé.
+31
View File
@@ -0,0 +1,31 @@
# Déploiement serveur : app seule ; HTTPS géré par Nginx + Certbot sur lhôte.
# Exemple Nginx : proxy_pass http://127.0.0.1:3007;
#
# Usage :
# export AUTH_SECRET="$(openssl rand -base64 32)"
# docker compose -f docker-compose.server.yml up -d
name: gauffre-server
services:
app:
image: foufure/gauffre:latest
build: .
ports:
- "${HOST_PORT:-3007}:3000"
environment:
DATABASE_URL: file:/app/data/app.db
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production-use-long-random-string}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@lagaufredor.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme123}
NODE_ENV: production
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
volumes:
- gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads
restart: unless-stopped
volumes:
gauffre-data:
gauffre-uploads:
+22
View File
@@ -0,0 +1,22 @@
services:
app:
image: foufure/gauffre:latest
build: .
ports:
- "3007:3000"
environment:
DATABASE_URL: file:/app/data/app.db
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production-use-long-random-string}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@lagaufredor.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme123}
# Optionnel : rate limiting distribué (sinon fallback mémoire)
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
volumes:
- gauffre-data:/app/data
- gauffre-uploads:/app/public/uploads
restart: unless-stopped
volumes:
gauffre-data:
gauffre-uploads:
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
if [ "$(id -u)" = 0 ]; then
mkdir -p /app/data /app/public/uploads
chown -R nextjs:nodejs /app/data /app/public/uploads
exec gosu nextjs:nodejs "$0" "$@"
fi
if [ -z "$DATABASE_URL" ]; then
echo "DATABASE_URL doit être défini (ex. file:/app/data/app.db)" >&2
exit 1
fi
case "$DATABASE_URL" in
file:*)
dbpath="${DATABASE_URL#file:}"
dir=$(dirname "$dbpath")
if [ "$dir" != "." ] && [ -n "$dir" ]; then
mkdir -p "$dir"
fi
;;
esac
npx prisma migrate deploy
tsx prisma/seed.ts
exec node server.js
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+1997 -17
View File
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -4,24 +4,43 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"prebuild": "prisma migrate deploy && prisma generate",
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:seed": "tsx prisma/seed.ts",
"postinstall": "prisma generate"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.5.0",
"@prisma/client": "^7.5.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"jose": "^6.2.2",
"next": "15.5.14",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.5.14" "sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.14", "eslint-config-next": "15.5.14",
"@eslint/eslintrc": "^3" "prisma": "^7.5.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
} }
} }
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Waffle" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"ingredients" TEXT NOT NULL,
"price" REAL NOT NULL,
"isNew" BOOLEAN NOT NULL DEFAULT false,
"category" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Waffle" ADD COLUMN "imageUrl" TEXT;
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "GalleryImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"imageUrl" TEXT NOT NULL,
"alt" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "SiteContent" (
"id" TEXT NOT NULL PRIMARY KEY,
"heroEyebrow" TEXT NOT NULL,
"heroTitle" TEXT NOT NULL,
"heroSubtitle" TEXT NOT NULL,
"menuIntro" TEXT NOT NULL,
"galleryIntro" TEXT NOT NULL,
"hours" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL
);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "SiteContent" ADD COLUMN "heroBackgroundImageUrl" TEXT;
@@ -0,0 +1,4 @@
-- Add configurable default upload image resolution.
ALTER TABLE "SiteContent" ADD COLUMN "uploadImageWidth" INTEGER NOT NULL DEFAULT 1920;
ALTER TABLE "SiteContent" ADD COLUMN "uploadImageHeight" INTEGER NOT NULL DEFAULT 1080;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SiteContent" ADD COLUMN "heroTextOverImage" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "SiteContent" ADD COLUMN "scheduleIntro" TEXT NOT NULL DEFAULT '';
-- CreateTable
CREATE TABLE "TruckEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventDate" TEXT NOT NULL,
"timeLabel" TEXT NOT NULL DEFAULT '',
"location" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "TruckEvent_eventDate_idx" ON "TruckEvent"("eventDate");
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "TruckEventRecurrence" (
"id" TEXT NOT NULL PRIMARY KEY,
"dayOfWeek" INTEGER NOT NULL,
"timeLabel" TEXT NOT NULL DEFAULT '',
"location" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "TruckEventRecurrence_dayOfWeek_idx" ON "TruckEventRecurrence"("dayOfWeek");
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Ingredient" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"price" REAL NOT NULL,
"imageUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "_IngredientToWaffle" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_IngredientToWaffle_A_fkey" FOREIGN KEY ("A") REFERENCES "Ingredient" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_IngredientToWaffle_B_fkey" FOREIGN KEY ("B") REFERENCES "Waffle" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "_IngredientToWaffle_AB_unique" ON "_IngredientToWaffle"("A", "B");
-- CreateIndex
CREATE INDEX "_IngredientToWaffle_B_index" ON "_IngredientToWaffle"("B");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SiteContent" ADD COLUMN "logoUrl" TEXT;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "SiteContent" ADD COLUMN "customWaffleTitle" TEXT NOT NULL DEFAULT 'La gaufre sur mesure';
ALTER TABLE "SiteContent" ADD COLUMN "customWaffleDescription" TEXT NOT NULL DEFAULT 'Composez votre gaufre en choisissant parmi nos ingrédients : chacun avec sa photo et son prix.';
ALTER TABLE "SiteContent" ADD COLUMN "customWaffleBasePrice" REAL NOT NULL DEFAULT 3;
ALTER TABLE "SiteContent" ADD COLUMN "customWaffleImageUrl" TEXT;
ALTER TABLE "SiteContent" ADD COLUMN "customWaffleVisible" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TruckEvent" ADD COLUMN "imageUrl" TEXT;
ALTER TABLE "TruckEventRecurrence" ADD COLUMN "imageUrl" TEXT;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+105
View File
@@ -0,0 +1,105 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
}
model Ingredient {
id String @id @default(cuid())
name String
price Float
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
waffles Waffle[]
}
model Waffle {
id String @id @default(cuid())
name String
/** Description textuelle affichée sur la fiche menu. */
ingredients String
price Float
isNew Boolean @default(false)
category String
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ingredientItems Ingredient[]
}
model GalleryImage {
id String @id @default(cuid())
imageUrl String
alt String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/** Une seule ligne (id = default) : textes affichés sur la page daccueil et horaires. */
model SiteContent {
id String @id @default("default")
heroEyebrow String
heroTitle String
heroSubtitle String
heroBackgroundImageUrl String?
/** Logo affiché dans len-tête du site public */
logoUrl String?
/** true : texte sur limage (héros) ; false : image seule, texte au-dessus du menu */
heroTextOverImage Boolean @default(true)
menuIntro String
galleryIntro String
scheduleIntro String @default("")
customWaffleTitle String @default("La gaufre sur mesure")
customWaffleDescription String @default("Composez votre gaufre en choisissant parmi nos ingrédients : chacun avec sa photo et son prix.")
customWaffleBasePrice Float @default(3)
customWaffleImageUrl String?
customWaffleVisible Boolean @default(true)
uploadImageWidth Int @default(1920)
uploadImageHeight Int @default(1080)
hours Json
updatedAt DateTime @updatedAt
}
/** Présence du food truck (calendrier « Où me trouver »). */
model TruckEvent {
id String @id @default(cuid())
eventDate String
timeLabel String @default("")
location String
note String @default("")
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([eventDate])
}
/** Règle hebdomadaire : répétée chaque semaine (0 = lundi … 6 = dimanche). */
model TruckEventRecurrence {
id String @id @default(cuid())
dayOfWeek Int
timeLabel String @default("")
location String
note String @default("")
imageUrl String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([dayOfWeek])
}
+178
View File
@@ -0,0 +1,178 @@
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../src/generated/prisma/client";
import { hashPassword } from "../src/lib/password";
import { DEFAULT_PUBLIC_SITE_COPY } from "../src/lib/site-content-defaults";
import { WAFFLE_CATEGORIES } from "../src/lib/constants";
import { formatDateIso } from "../src/lib/week-calendar";
const url = process.env.DATABASE_URL ?? "file:./dev.db";
const adapter = new PrismaBetterSqlite3({ url });
const prisma = new PrismaClient({ adapter });
async function main() {
const email =
process.env.ADMIN_EMAIL?.trim().toLowerCase() || "admin@lagaufredor.local";
const password = process.env.ADMIN_PASSWORD || "changeme123";
const existing = await prisma.user.findUnique({ where: { email } });
if (!existing) {
await prisma.user.create({
data: {
email,
passwordHash: await hashPassword(password),
},
});
console.log(
`Compte admin créé : ${email} (mot de passe : variable ADMIN_PASSWORD ou défaut changeme123)`
);
}
const count = await prisma.waffle.count();
if (count === 0) {
await prisma.waffle.createMany({
data: [
{
name: "Coco-Belge",
ingredients:
"Pâte Liège, perles de sucre, noix de coco râpée, touche de vanille Bourbon.",
price: 6.5,
isNew: true,
category: WAFFLE_CATEGORIES.DU_MOIS,
imageUrl: "/gallery/hero-coco.png",
},
{
name: "Classique dorée",
ingredients: "Sucre perlé, beurre AOP, sirop d’érable ou chocolat.",
price: 5.9,
isNew: false,
category: WAFFLE_CATEGORIES.SUCREE,
imageUrl: "/gallery/gaufres-sucrees.png",
},
{
name: "Bubble chantilly",
ingredients: "Bubble waffle, chantilly maison, copeaux de chocolat noir.",
price: 7.2,
isNew: false,
category: WAFFLE_CATEGORIES.SUCREE,
imageUrl: "/gallery/bubble-waffle.png",
},
{
name: "Montagnarde",
ingredients:
"Pâte aux herbes, comté fondant, jambon cru, poivre noir concassé.",
price: 8.5,
isNew: false,
category: WAFFLE_CATEGORIES.SALEE,
imageUrl: "/gallery/gaufre-salee.png",
},
{
name: "Bacon & ciboulette",
ingredients: "Crème légère, bacon croustillant, ciboulette fraîche.",
price: 7.9,
isNew: false,
category: WAFFLE_CATEGORIES.SALEE,
imageUrl: "/gallery/gaufre-salee.png",
},
],
});
console.log("Gaufres dexemple insérées.");
}
const galleryCount = await prisma.galleryImage.count();
if (galleryCount === 0) {
await prisma.galleryImage.createMany({
data: [
{
imageUrl: "/gallery/hero-coco.png",
alt: "Gaufres belges caramélisées sur ardoise, noix de coco et drapeau belge.",
sortOrder: 0,
},
{
imageUrl: "/gallery/gaufres-sucrees.png",
alt: "Stack de gaufres dorées, sucre glace et sirop.",
sortOrder: 1,
},
{
imageUrl: "/gallery/bubble-waffle.png",
alt: "Bubble waffles en cornet, chantilly et chocolat.",
sortOrder: 2,
},
{
imageUrl: "/gallery/gaufre-salee.png",
alt: "Gaufre salée aux herbes, crème, bacon et ciboulette.",
sortOrder: 3,
},
],
});
console.log("Galerie dexemple insérée.");
}
await prisma.siteContent.upsert({
where: { id: "default" },
create: {
id: "default",
...DEFAULT_PUBLIC_SITE_COPY,
},
update: {},
});
const truckCount = await prisma.truckEvent.count();
if (truckCount === 0) {
const start = new Date();
const day = start.getDay();
const diff = day === 0 ? -6 : 1 - day;
start.setDate(start.getDate() + diff);
const dateAt = (offset: number) => {
const d = new Date(start);
d.setDate(start.getDate() + offset);
return formatDateIso(d);
};
await prisma.truckEvent.createMany({
data: [
{
eventDate: dateAt(1),
timeLabel: "9h — 13h",
location: "Marché de Bagnols-sur-Cèze",
note: "Place du marché",
},
{
eventDate: dateAt(3),
timeLabel: "11h — 19h",
location: "Festival gourmand — Uzès",
note: "",
},
{
eventDate: dateAt(5),
timeLabel: "10h — 18h",
location: "Place de la République, Alès",
note: "Food truck zone nord",
},
],
});
console.log("Événements food truck dexemple insérés.");
}
const recurCount = await prisma.truckEventRecurrence.count();
if (recurCount === 0) {
await prisma.truckEventRecurrence.create({
data: {
dayOfWeek: 1,
timeLabel: "9h — 13h",
location: "Marché hebdomadaire — Bagnols-sur-Cèze",
note: "Place du marché",
},
});
console.log("Récurrence food truck dexemple insérée.");
}
}
main()
.then(() => prisma.$disconnect())
.catch((e) => {
console.error(e);
prisma.$disconnect();
process.exit(1);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

View File
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Publie limage sur Docker Hub (compte par défaut : foufure/gauffre).
# Prérequis : docker login (une fois)
# Usage :
# ./scripts/docker-hub-push.sh # tag latest
# ./scripts/docker-hub-push.sh v1.0.0 # tag personnalisé
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
DOCKER_USER="${DOCKER_USER:-foufure}"
IMAGE_NAME="${IMAGE_NAME:-gauffre}"
TAG="${1:-latest}"
FULL_IMAGE="${DOCKER_USER}/${IMAGE_NAME}:${TAG}"
echo "Build : ${FULL_IMAGE}"
docker build -t "${FULL_IMAGE}" .
echo "Push : ${FULL_IMAGE}"
docker push "${FULL_IMAGE}"
echo "Terminé : ${FULL_IMAGE}"
@@ -0,0 +1,5 @@
import { AdminGallery } from "@/components/admin/AdminGallery";
export default function AdminGalleryPage() {
return <AdminGallery />;
}
@@ -0,0 +1,5 @@
import { AdminIngredients } from "@/components/admin/AdminIngredients";
export default function AdminIngredientsPage() {
return <AdminIngredients />;
}
+14
View File
@@ -0,0 +1,14 @@
import { AdminNav } from "@/components/admin/AdminNav";
export default function AdminDashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-cream">
<AdminNav />
{children}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminDashboard } from "@/components/admin/AdminDashboard";
export default function AdminPage() {
return <AdminDashboard />;
}
@@ -0,0 +1,5 @@
import { AdminTruckSchedule } from "@/components/admin/AdminTruckSchedule";
export default function AdminSchedulePage() {
return <AdminTruckSchedule />;
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminSiteContent } from "@/components/admin/AdminSiteContent";
export default function AdminSitePage() {
return <AdminSiteContent />;
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { createSessionToken, SESSION_COOKIE } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
import { getClientIp, rateLimitFailedLogin } from "@/lib/rate-limit";
import {
clearLegacyRootSessionCookie,
sessionCookieBase,
} from "@/lib/session-cookie";
export async function POST(request: Request) {
const ip = getClientIp(request);
let body: { email?: string; password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const email = String(body.email ?? "")
.trim()
.toLowerCase();
const password = String(body.password ?? "");
if (!email || !password) {
return NextResponse.json(
{ error: "Email et mot de passe requis" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({ where: { email } });
const valid =
!!user && (await verifyPassword(password, user.passwordHash));
if (!valid) {
const limited = await rateLimitFailedLogin(ip);
if (!limited.ok) {
return NextResponse.json(
{
error: "Trop de tentatives. Réessayez plus tard.",
retryAfterSec: limited.retryAfterSec,
},
{
status: 429,
headers: { "Retry-After": String(limited.retryAfterSec) },
}
);
}
return NextResponse.json({ error: "Identifiants invalides" }, { status: 401 });
}
const token = await createSessionToken(user.id);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
...sessionCookieBase(),
maxAge: 60 * 60 * 24 * 7,
});
clearLegacyRootSessionCookie(res);
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { clearAllSessionCookieVariants } from "@/lib/session-cookie";
export async function POST() {
const res = NextResponse.json({ ok: true });
clearAllSessionCookieVariants(res);
return res;
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ user: null });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!user) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user: { email: user.email } });
}
+66
View File
@@ -0,0 +1,66 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import {
getCustomWaffleConfig,
parseCustomWafflePayload,
} from "@/lib/custom-waffle-content";
import { DEFAULT_PUBLIC_SITE_COPY, SITE_CONTENT_ID } from "@/lib/site-content-defaults";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
return NextResponse.json(await getCustomWaffleConfig());
}
export async function PUT(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const parsed = parseCustomWafflePayload(body as Record<string, unknown>);
if (parsed.error || !parsed.data) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
const { data } = parsed;
await prisma.siteContent.upsert({
where: { id: SITE_CONTENT_ID },
create: {
id: SITE_CONTENT_ID,
...DEFAULT_PUBLIC_SITE_COPY,
customWaffleTitle: data.title,
customWaffleDescription: data.description,
customWaffleBasePrice: data.basePrice,
customWaffleImageUrl: data.imageUrl,
customWaffleVisible: data.visible,
},
update: {
customWaffleTitle: data.title,
customWaffleDescription: data.description,
customWaffleBasePrice: data.basePrice,
customWaffleImageUrl: data.imageUrl,
customWaffleVisible: data.visible,
},
});
revalidatePath("/");
return NextResponse.json({ ok: true });
}
+81
View File
@@ -0,0 +1,81 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: { imageUrl?: string; alt?: string; sortOrder?: number };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const imageUrl = normalizeImageUrl(body.imageUrl);
const alt = String(body.alt ?? "").trim();
const sortOrder = Number.isFinite(Number(body.sortOrder))
? Number(body.sortOrder)
: 0;
if (!imageUrl || !alt) {
return NextResponse.json(
{ error: "Image et texte alternatif requis" },
{ status: 400 }
);
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
try {
const previous = await prisma.galleryImage.findUnique({
where: { id },
select: { imageUrl: true },
});
const row = await prisma.galleryImage.update({
where: { id },
data: { imageUrl, alt, sortOrder },
});
if (previous?.imageUrl && previous.imageUrl !== row.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.galleryImage.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const images = await prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(images);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: { imageUrl?: string; alt?: string; sortOrder?: number };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const imageUrl = normalizeImageUrl(body.imageUrl);
const alt = String(body.alt ?? "").trim();
const sortOrder = Number.isFinite(Number(body.sortOrder))
? Number(body.sortOrder)
: 0;
if (!imageUrl || !alt) {
return NextResponse.json(
{ error: "Image et texte alternatif requis" },
{ status: 400 }
);
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const row = await prisma.galleryImage.create({
data: { imageUrl, alt, sortOrder },
});
revalidatePath("/");
return NextResponse.json(row);
}
@@ -0,0 +1,76 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: { name?: string; price?: number; imageUrl?: string | null };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const price = Number(body.price);
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
try {
const previous = await prisma.ingredient.findUnique({
where: { id },
select: { imageUrl: true },
});
const ingredient = await prisma.ingredient.update({
where: { id },
data: { name, price, imageUrl },
});
if (previous?.imageUrl && previous.imageUrl !== ingredient.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(ingredient);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.ingredient.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const ingredients = await prisma.ingredient.findMany({
orderBy: { name: "asc" },
});
return NextResponse.json(ingredients);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: { name?: string; price?: number; imageUrl?: string | null };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const price = Number(body.price);
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const ingredient = await prisma.ingredient.create({
data: { name, price, imageUrl },
});
revalidatePath("/");
return NextResponse.json(ingredient);
}
+123
View File
@@ -0,0 +1,123 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import {
DEFAULT_PUBLIC_SITE_COPY,
SITE_CONTENT_ID,
} from "@/lib/site-content-defaults";
import { parseHoursPayload } from "@/lib/site-content";
import { normalizeImageUrl } from "@/lib/image-url";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const row = await prisma.siteContent.findUnique({
where: { id: SITE_CONTENT_ID },
});
if (!row) {
return NextResponse.json({
...DEFAULT_PUBLIC_SITE_COPY,
heroTextOverImage: DEFAULT_PUBLIC_SITE_COPY.heroTextOverImage,
uploadImageWidth: 1920,
uploadImageHeight: 1080,
});
}
return NextResponse.json({
logoUrl: row.logoUrl ?? DEFAULT_PUBLIC_SITE_COPY.logoUrl,
heroEyebrow: row.heroEyebrow,
heroTitle: row.heroTitle,
heroSubtitle: row.heroSubtitle,
heroBackgroundImageUrl:
row.heroBackgroundImageUrl ?? DEFAULT_PUBLIC_SITE_COPY.heroBackgroundImageUrl,
heroTextOverImage: row.heroTextOverImage ?? true,
menuIntro: row.menuIntro,
galleryIntro: row.galleryIntro,
scheduleIntro: row.scheduleIntro ?? "",
uploadImageWidth: row.uploadImageWidth ?? 1920,
uploadImageHeight: row.uploadImageHeight ?? 1080,
hours: row.hours,
});
}
export async function PUT(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const b = body as Record<string, unknown>;
const hours = parseHoursPayload(b.hours);
if (hours === null) {
return NextResponse.json(
{ error: "Horaires : tableau de { days, hours } attendu" },
{ status: 400 }
);
}
const rawW = typeof b.uploadImageWidth === "number"
? b.uploadImageWidth
: Number(String(b.uploadImageWidth ?? ""));
const rawH = typeof b.uploadImageHeight === "number"
? b.uploadImageHeight
: Number(String(b.uploadImageHeight ?? ""));
const uploadImageWidth = Number.isFinite(rawW) ? Math.trunc(rawW) : 1920;
const uploadImageHeight = Number.isFinite(rawH) ? Math.trunc(rawH) : 1080;
if (
uploadImageWidth < 320 ||
uploadImageWidth > 8000 ||
uploadImageHeight < 240 ||
uploadImageHeight > 8000
) {
return NextResponse.json(
{ error: "Résolution upload invalide (ex: 1920×1080)" },
{ status: 400 }
);
}
const data = {
logoUrl:
normalizeImageUrl(String(b.logoUrl ?? "")) ??
DEFAULT_PUBLIC_SITE_COPY.logoUrl,
heroEyebrow: String(b.heroEyebrow ?? "").trim(),
heroTitle: String(b.heroTitle ?? "").trim(),
heroSubtitle: String(b.heroSubtitle ?? "").trim(),
heroBackgroundImageUrl:
normalizeImageUrl(String(b.heroBackgroundImageUrl ?? "")) ??
DEFAULT_PUBLIC_SITE_COPY.heroBackgroundImageUrl,
heroTextOverImage: b.heroTextOverImage !== false,
menuIntro: String(b.menuIntro ?? "").trim(),
galleryIntro: String(b.galleryIntro ?? "").trim(),
scheduleIntro: String(b.scheduleIntro ?? "").trim(),
uploadImageWidth,
uploadImageHeight,
hours,
};
await prisma.siteContent.upsert({
where: { id: SITE_CONTENT_ID },
create: {
id: SITE_CONTENT_ID,
...data,
},
update: data,
});
revalidatePath("/");
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,64 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckEventPayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckEventPayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Date (AAAA-MM-JJ) et lieu requis" },
{ status: 400 }
);
}
try {
const row = await prisma.truckEvent.update({
where: { id },
data,
});
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
await prisma.truckEvent.delete({ where: { id } });
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckEventPayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const events = await prisma.truckEvent.findMany({
orderBy: [{ eventDate: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(events);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckEventPayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Date (AAAA-MM-JJ) et lieu requis" },
{ status: 400 }
);
}
const row = await prisma.truckEvent.create({ data });
revalidatePath("/");
return NextResponse.json(row);
}
@@ -0,0 +1,64 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckRecurrencePayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckRecurrencePayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Jour de la semaine et lieu requis" },
{ status: 400 }
);
}
try {
const row = await prisma.truckEventRecurrence.update({
where: { id },
data,
});
revalidatePath("/");
return NextResponse.json(row);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
await prisma.truckEventRecurrence.delete({ where: { id } });
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
@@ -0,0 +1,47 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { parseTruckRecurrencePayload } from "@/lib/truck-schedule-shared";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const rows = await prisma.truckEventRecurrence.findMany({
orderBy: [{ dayOfWeek: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(rows);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
if (body === null || typeof body !== "object") {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const data = parseTruckRecurrencePayload(body as Record<string, unknown>);
if (!data) {
return NextResponse.json(
{ error: "Jour de la semaine et lieu requis" },
{ status: 400 }
);
}
const row = await prisma.truckEventRecurrence.create({ data });
revalidatePath("/");
return NextResponse.json(row);
}
+106
View File
@@ -0,0 +1,106 @@
import { randomBytes } from "crypto";
import { mkdir, writeFile } from "fs/promises";
import { join } from "path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { detectImageMime } from "@/lib/image-magic";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
import { SITE_CONTENT_ID } from "@/lib/site-content-defaults";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
["image/gif", ".gif"],
]);
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json(
{ error: "Fichier trop volumineux (max 10 Mo)" },
{ status: 400 }
);
}
const ab = await file.arrayBuffer();
const originalBuf: Buffer = Buffer.from(new Uint8Array(ab));
const detected = detectImageMime(originalBuf);
if (!detected || !ALLOWED.has(detected)) {
return NextResponse.json(
{
error:
"Fichier non reconnu comme image (JPEG, PNG, WebP ou GIF attendu)",
},
{ status: 400 }
);
}
const ext = ALLOWED.get(detected)!;
const settings = await prisma.siteContent.findUnique({
where: { id: SITE_CONTENT_ID },
select: { uploadImageWidth: true, uploadImageHeight: true },
});
const width =
typeof settings?.uploadImageWidth === "number" && settings.uploadImageWidth > 0
? settings.uploadImageWidth
: 1920;
const height =
typeof settings?.uploadImageHeight === "number" && settings.uploadImageHeight > 0
? settings.uploadImageHeight
: 1080;
let buf: Buffer = originalBuf;
if (detected !== "image/gif") {
try {
let pipeline = sharp(originalBuf, { failOn: "none" }).rotate();
pipeline = pipeline.resize({
width,
height,
fit: "cover",
withoutEnlargement: true,
});
if (detected === "image/jpeg") {
buf = await pipeline.jpeg({ quality: 82 }).toBuffer();
} else if (detected === "image/png") {
buf = await pipeline.png({ compressionLevel: 9 }).toBuffer();
} else if (detected === "image/webp") {
buf = await pipeline.webp({ quality: 82 }).toBuffer();
} else {
buf = await pipeline.toBuffer();
}
} catch {
return NextResponse.json(
{ error: "Impossible de traiter cette image" },
{ status: 400 }
);
}
}
const name = `${randomBytes(16).toString("hex")}${ext}`;
const dir = join(process.cwd(), "public", "uploads");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, name), buf);
const url = `/uploads/${name}`;
return NextResponse.json({ url });
}
+123
View File
@@ -0,0 +1,123 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { WAFFLE_CATEGORIES } from "@/lib/constants";
import { normalizeImageUrl } from "@/lib/image-url";
import { parseStrictBoolean } from "@/lib/json-fields";
import { prisma } from "@/lib/prisma";
import { prismaMutationErrorResponse } from "@/lib/prisma-route-error";
import { getSessionUserId } from "@/lib/session";
import { deleteUploadAssetIfAny } from "@/lib/upload-asset";
import {
parseIngredientIds,
validateIngredientIds,
waffleIngredientInclude,
} from "@/lib/waffle-ingredients";
const CATEGORIES: Set<string> = new Set(Object.values(WAFFLE_CATEGORIES));
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
let body: {
name?: string;
ingredients?: string;
price?: number;
isNew?: boolean;
category?: string;
imageUrl?: string | null;
ingredientIds?: unknown;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const ingredients = String(body.ingredients ?? "").trim();
const price = Number(body.price);
const isNewParsed = parseStrictBoolean(body.isNew, "isNew", false);
if (!isNewParsed.ok) {
return NextResponse.json({ error: isNewParsed.error }, { status: 400 });
}
const isNew = isNewParsed.value;
const category = String(body.category ?? "").trim();
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || !ingredients || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (!CATEGORIES.has(category)) {
return NextResponse.json({ error: "Catégorie invalide" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const parsedIds = parseIngredientIds(body.ingredientIds);
if (parsedIds !== null && !(await validateIngredientIds(parsedIds))) {
return NextResponse.json({ error: "Ingrédient inconnu" }, { status: 400 });
}
try {
const previous = await prisma.waffle.findUnique({
where: { id },
select: { imageUrl: true },
});
const waffle = await prisma.waffle.update({
where: { id },
data: {
name,
ingredients,
price,
isNew,
category,
imageUrl,
...(parsedIds !== null
? {
ingredientItems: {
set: parsedIds.map((ingredientId) => ({ id: ingredientId })),
},
}
: {}),
},
include: waffleIngredientInclude,
});
if (previous?.imageUrl && previous.imageUrl !== waffle.imageUrl) {
await deleteUploadAssetIfAny(previous.imageUrl);
}
revalidatePath("/");
return NextResponse.json(waffle);
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
export async function DELETE(_request: Request, { params }: Params) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const { id } = await params;
try {
const row = await prisma.waffle.delete({ where: { id } });
await deleteUploadAssetIfAny(row.imageUrl);
revalidatePath("/");
return NextResponse.json({ ok: true });
} catch (e) {
return prismaMutationErrorResponse(e);
}
}
+96
View File
@@ -0,0 +1,96 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { WAFFLE_CATEGORIES } from "@/lib/constants";
import { normalizeImageUrl } from "@/lib/image-url";
import { parseStrictBoolean } from "@/lib/json-fields";
import { prisma } from "@/lib/prisma";
import { getSessionUserId } from "@/lib/session";
import {
parseIngredientIds,
validateIngredientIds,
waffleIngredientInclude,
} from "@/lib/waffle-ingredients";
const CATEGORIES: Set<string> = new Set(Object.values(WAFFLE_CATEGORIES));
export async function GET() {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
const waffles = await prisma.waffle.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
include: waffleIngredientInclude,
});
return NextResponse.json(waffles);
}
export async function POST(request: Request) {
const userId = await getSessionUserId();
if (!userId) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
let body: {
name?: string;
ingredients?: string;
price?: number;
isNew?: boolean;
category?: string;
imageUrl?: string | null;
ingredientIds?: unknown;
};
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Corps invalide" }, { status: 400 });
}
const name = String(body.name ?? "").trim();
const ingredients = String(body.ingredients ?? "").trim();
const price = Number(body.price);
const isNewParsed = parseStrictBoolean(body.isNew, "isNew", false);
if (!isNewParsed.ok) {
return NextResponse.json({ error: isNewParsed.error }, { status: 400 });
}
const isNew = isNewParsed.value;
const category = String(body.category ?? "").trim();
const imageUrl = normalizeImageUrl(body.imageUrl);
if (!name || !ingredients || Number.isNaN(price) || price < 0) {
return NextResponse.json({ error: "Données invalides" }, { status: 400 });
}
if (!CATEGORIES.has(category)) {
return NextResponse.json({ error: "Catégorie invalide" }, { status: 400 });
}
if (
body.imageUrl != null &&
String(body.imageUrl).trim() !== "" &&
imageUrl === null
) {
return NextResponse.json({ error: "URL dimage invalide" }, { status: 400 });
}
const ingredientIds = parseIngredientIds(body.ingredientIds) ?? [];
if (!(await validateIngredientIds(ingredientIds))) {
return NextResponse.json({ error: "Ingrédient inconnu" }, { status: 400 });
}
const waffle = await prisma.waffle.create({
data: {
name,
ingredients,
price,
isNew,
category,
imageUrl,
...(ingredientIds.length > 0
? { ingredientItems: { connect: ingredientIds.map((id) => ({ id })) } }
: {}),
},
include: waffleIngredientInclude,
});
revalidatePath("/");
return NextResponse.json(waffle);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useState } from "react";
import { ADMIN_API } from "@/lib/constants";
function formatLockoutRemaining(totalSec: number): string {
if (totalSec < 60) return `${totalSec} s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (s === 0) return `${m} min`;
return `${m} min ${s} s`;
}
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/** Compte à rebours anti force brute (secondes restantes), null si pas bloqué. */
const [lockoutRemainingSec, setLockoutRemainingSec] = useState<number | null>(
null
);
useEffect(() => {
fetch(`${ADMIN_API}/auth/me`, { credentials: "include" })
.then((r) => r.json())
.then((d) => {
if (d.user) router.replace("/admin");
})
.catch(() => {});
}, [router]);
useEffect(() => {
if (lockoutRemainingSec == null || lockoutRemainingSec <= 0) return;
const t = setTimeout(() => {
setLockoutRemainingSec((r) =>
r != null && r > 1 ? r - 1 : null
);
}, 1000);
return () => clearTimeout(t);
}, [lockoutRemainingSec]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`${ADMIN_API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 429) {
const fromBody =
typeof data.retryAfterSec === "number" &&
Number.isFinite(data.retryAfterSec)
? Math.max(0, Math.ceil(data.retryAfterSec))
: null;
const headerRaw = res.headers.get("Retry-After");
const fromHeader = headerRaw
? Math.max(0, parseInt(headerRaw, 10))
: NaN;
const sec =
fromBody ??
(Number.isFinite(fromHeader) ? fromHeader : null);
if (sec != null && sec > 0) {
setLockoutRemainingSec(sec);
setError(null);
} else {
setLockoutRemainingSec(null);
setError(
typeof data.error === "string"
? data.error
: "Trop de tentatives. Réessayez plus tard."
);
}
} else {
setLockoutRemainingSec(null);
setError(
typeof data.error === "string"
? data.error
: "Connexion impossible"
);
}
return;
}
setLockoutRemainingSec(null);
window.location.href = "/admin";
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-cream px-4 py-12">
<div className="w-full max-w-md rounded-[2rem] border border-chocolat/10 bg-white p-8 shadow-[0_20px_50px_-15px_rgba(61,43,31,0.2)]">
<h1 className="text-center text-2xl font-bold text-chocolat">
Administration
</h1>
<p className="mt-2 text-center text-sm text-muted">
Connexion réservée au gérant
</p>
<form onSubmit={onSubmit} className="mt-8 flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm font-medium text-chocolat">
Email
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-3 text-foreground outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm font-medium text-chocolat">
Mot de passe
<span className="relative">
<input
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-2xl border border-chocolat/15 bg-cream/50 py-3 pl-4 pr-12 text-foreground outline-none ring-gaufre/40 focus:ring-2"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-lg p-1 text-chocolat/50 transition hover:text-chocolat"
aria-label={
showPassword
? "Masquer le mot de passe"
: "Afficher le mot de passe"
}
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</span>
</label>
{lockoutRemainingSec != null && lockoutRemainingSec > 0 && (
<div
className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"
role="status"
>
<p className="font-semibold">Connexion temporairement bloquée</p>
<p className="mt-1 text-amber-900/90">
Trop de tentatives infructueuses. Temps restant avant un nouvel
essai :{" "}
<span className="font-mono font-semibold tabular-nums">
{formatLockoutRemaining(lockoutRemainingSec)}
</span>
</p>
</div>
)}
{error && (
<p className="rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={
loading ||
(lockoutRemainingSec != null && lockoutRemainingSec > 0)
}
className="mt-2 rounded-full bg-gaufre py-3.5 text-base font-semibold text-chocolat shadow-lg shadow-chocolat/15 transition hover:brightness-105 disabled:opacity-60"
>
{loading ? "Connexion…" : "Se connecter"}
</button>
</form>
<p className="mt-6 text-center text-xs text-muted">
<Link href="/" className="underline hover:text-chocolat">
Retour au site
</Link>
</p>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/** Données vitrine uniquement (pas de métadonnées internes). */
export async function GET() {
const rows = await prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
imageUrl: true,
alt: true,
sortOrder: true,
},
});
return NextResponse.json(rows);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { waffleIngredientInclude } from "@/lib/waffle-ingredients";
/** Données vitrine uniquement (pas de métadonnées internes). */
export async function GET() {
const rows = await prisma.waffle.findMany({
orderBy: [{ category: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
ingredients: true,
price: true,
isNew: true,
category: true,
imageUrl: true,
ingredientItems: waffleIngredientInclude.ingredientItems,
},
});
return NextResponse.json(rows);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+13 -12
View File
@@ -1,26 +1,27 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #fff8f0;
--foreground: #171717; --foreground: #3d2b1f;
--muted: #6b5344;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --color-muted: var(--muted);
--font-mono: var(--font-geist-mono); --color-gaufre: #f39c12;
} --color-chocolat: #3d2b1f;
--color-cream: #fff8f0;
@media (prefers-color-scheme: dark) { --font-sans: var(--font-outfit);
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans), system-ui, sans-serif;
}
html {
scroll-behavior: smooth;
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+9 -14
View File
@@ -1,20 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Outfit } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const outfit = Outfit({
variable: "--font-geist-sans", variable: "--font-outfit",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Gaufrement Bon",
description: "Generated by create next app", description:
"Gaufres sucrées et salées artisanales. Horaires, adresse et menu à Lille.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,10 +20,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="fr">
<body <body className={`${outfit.variable} font-sans antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children} {children}
</body> </body>
</html> </html>
+69 -96
View File
@@ -1,103 +1,76 @@
import Image from "next/image"; import { Footer } from "@/components/site/Footer";
import { GallerySection } from "@/components/site/GallerySection";
import { ScheduleSection } from "@/components/site/ScheduleSection";
import { Hero } from "@/components/site/Hero";
import { HeroIntro } from "@/components/site/HeroIntro";
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 { getPublicSiteContent } from "@/lib/site-content";
import { getPublicTruckEvents } from "@/lib/truck-event";
import { getPublicTruckRecurrences } from "@/lib/truck-recurrence";
import { waffleIngredientInclude } from "@/lib/waffle-ingredients";
/** Page mise en cache (ISR) ; invalidation via `revalidatePath("/")` après modifs admin. */
export const revalidate = 60;
export default async function Home() {
const [
waffles,
ingredientCatalog,
galleryImages,
site,
customWaffle,
truckEvents,
truckRecurrences,
] = await Promise.all([
prisma.waffle.findMany({ include: waffleIngredientInclude }),
prisma.ingredient.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, price: true, imageUrl: true },
}),
prisma.galleryImage.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}),
getPublicSiteContent(),
getCustomWaffleConfig(),
getPublicTruckEvents(),
getPublicTruckRecurrences(),
]);
export default function Home() {
return ( return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> <>
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <SiteHeader logoUrl={site.logoUrl} />
<Image <main>
className="dark:invert" <Hero
src="/next.svg" eyebrow={site.heroEyebrow}
alt="Next.js logo" title={site.heroTitle}
width={180} subtitle={site.heroSubtitle}
height={38} backgroundImageUrl={site.heroBackgroundImageUrl}
priority textOverImage={site.heroTextOverImage}
/> />
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left"> {!site.heroTextOverImage ? (
<li className="mb-2 tracking-[-.01em]"> <HeroIntro
Get started by editing{" "} eyebrow={site.heroEyebrow}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded"> title={site.heroTitle}
src/app/page.tsx subtitle={site.heroSubtitle}
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/> />
Deploy now ) : null}
</a> <MenuSection
<a waffles={waffles}
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]" intro={site.menuIntro}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" ingredientCatalog={ingredientCatalog}
target="_blank" customWaffle={customWaffle}
rel="noopener noreferrer" />
> <ScheduleSection
Read our docs events={truckEvents}
</a> recurrences={truckRecurrences}
</div> intro={site.scheduleIntro}
/>
<GallerySection images={galleryImages} intro={site.galleryIntro} />
</main> </main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"> <Footer hours={site.hours} />
<a </>
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
); );
} }
+716
View File
@@ -0,0 +1,716 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
ADMIN_API,
CATEGORY_LABELS,
WAFFLE_CATEGORIES,
} from "@/lib/constants";
import { DEFAULT_CUSTOM_WAFFLE } from "@/lib/custom-waffle";
import { isUploadAssetPath } from "@/lib/image-url";
import type { Ingredient, Waffle } from "@/generated/prisma/client";
const categories = Object.values(WAFFLE_CATEGORIES);
type AdminWaffle = Waffle & {
ingredientItems: Pick<Ingredient, "id" | "name">[];
};
export function AdminDashboard() {
const router = useRouter();
const [waffles, setWaffles] = useState<AdminWaffle[]>([]);
const [catalog, setCatalog] = useState<Ingredient[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [customWaffleForm, setCustomWaffleForm] = useState({
title: DEFAULT_CUSTOM_WAFFLE.title,
description: DEFAULT_CUSTOM_WAFFLE.description,
basePrice: String(DEFAULT_CUSTOM_WAFFLE.basePrice),
visible: DEFAULT_CUSTOM_WAFFLE.visible,
});
const [customWaffleLoaded, setCustomWaffleLoaded] = useState(false);
const [savingCustomWaffle, setSavingCustomWaffle] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<{
name: string;
ingredients: string;
price: string;
category: string;
isNew: boolean;
imageUrl: string;
ingredientIds: string[];
}>({
name: "",
ingredients: "",
price: "",
category: WAFFLE_CATEGORIES.SUCREE,
isNew: false,
imageUrl: "",
ingredientIds: [],
});
const load = useCallback(async () => {
const [wafflesRes, ingredientsRes, customWaffleRes] = await Promise.all([
fetch(`${ADMIN_API}/waffles`, { credentials: "include" }),
fetch(`${ADMIN_API}/ingredients`, { credentials: "include" }),
fetch(`${ADMIN_API}/custom-waffle`, { credentials: "include" }),
]);
if (
wafflesRes.status === 401 ||
ingredientsRes.status === 401 ||
customWaffleRes.status === 401
) {
router.replace("/admin/login");
return;
}
const wafflesData = await wafflesRes.json();
const ingredientsData = await ingredientsRes.json();
const customWaffleData = await customWaffleRes.json().catch(() => null);
setWaffles(Array.isArray(wafflesData) ? wafflesData : []);
setCatalog(Array.isArray(ingredientsData) ? ingredientsData : []);
if (customWaffleData && typeof customWaffleData === "object") {
setCustomWaffleForm({
title: String(customWaffleData.title ?? DEFAULT_CUSTOM_WAFFLE.title),
description: String(
customWaffleData.description ?? DEFAULT_CUSTOM_WAFFLE.description
),
basePrice: String(
customWaffleData.basePrice ?? DEFAULT_CUSTOM_WAFFLE.basePrice
),
visible: customWaffleData.visible !== false,
});
}
setCustomWaffleLoaded(true);
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function resetForm() {
setEditingId(null);
setForm({
name: "",
ingredients: "",
price: "",
category: WAFFLE_CATEGORIES.SUCREE,
isNew: false,
imageUrl: "",
ingredientIds: [],
});
}
function toggleIngredient(id: string) {
setForm((f) => ({
...f,
ingredientIds: f.ingredientIds.includes(id)
? f.ingredientIds.filter((x) => x !== id)
: [...f.ingredientIds, id],
}));
}
function startEdit(w: AdminWaffle) {
setEditingId(w.id);
setForm({
name: w.name,
ingredients: w.ingredients,
price: String(w.price),
category: w.category,
isNew: w.isNew,
imageUrl: w.imageUrl ?? "",
ingredientIds: w.ingredientItems.map((i) => i.id),
});
}
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) setForm((f) => ({ ...f, imageUrl: data.url }));
} finally {
setUploading(false);
e.target.value = "";
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const price = parseFloat(form.price.replace(",", "."));
if (Number.isNaN(price) || price < 0) return;
setSaving(true);
try {
const body = {
name: form.name.trim(),
ingredients: form.ingredients.trim(),
price,
category: form.category,
isNew: form.isNew,
imageUrl: form.imageUrl.trim() || null,
ingredientIds: form.ingredientIds,
};
if (editingId) {
const res = await fetch(`${ADMIN_API}/waffles/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) return;
} else {
const res = await fetch(`${ADMIN_API}/waffles`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) return;
}
resetForm();
await load();
} finally {
setSaving(false);
}
}
async function saveCustomWaffle(e: React.FormEvent) {
e.preventDefault();
const basePrice = parseFloat(customWaffleForm.basePrice.replace(",", "."));
if (Number.isNaN(basePrice) || basePrice < 0) return;
setSavingCustomWaffle(true);
try {
const res = await fetch(`${ADMIN_API}/custom-waffle`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title: customWaffleForm.title.trim(),
description: customWaffleForm.description.trim(),
basePrice,
visible: customWaffleForm.visible,
}),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(typeof data.error === "string" ? data.error : "Échec de lenregistrement");
}
} finally {
setSavingCustomWaffle(false);
}
}
async function remove(id: string) {
if (!confirm("Supprimer cette gaufre ?")) return;
const res = await fetch(`${ADMIN_API}/waffles/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (editingId === id) resetForm();
await load();
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
router.refresh();
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat">Menu à laffiche</h1>
<p className="text-sm text-muted">
Ajoutez une photo (fichier ou lien), modifiez ou retirez les gaufres
visibles sur le site.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
<form
onSubmit={onSubmit}
className="mb-10 rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]"
>
<h2 className="text-lg font-semibold text-chocolat">
{editingId ? "Modifier la gaufre" : "Nouvelle gaufre"}
</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Nom
<input
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Description
<textarea
value={form.ingredients}
onChange={(e) =>
setForm((f) => ({ ...f, ingredients: e.target.value }))
}
required
rows={3}
className="resize-y rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">
Ingrédients de la gaufre{" "}
<span className="font-normal text-muted">(facultatif)</span>
</span>
{catalog.length === 0 ? (
<p className="text-sm text-muted">
Aucun ingrédient en base.{" "}
<a
href="/admin/ingredients"
className="font-medium text-chocolat underline"
>
Créer des ingrédients
</a>
</p>
) : (
<ul className="max-h-48 space-y-2 overflow-y-auto rounded-2xl border border-chocolat/15 bg-cream/30 p-3">
{catalog.map((ing) => {
const checked = form.ingredientIds.includes(ing.id);
return (
<li key={ing.id}>
<label className="flex cursor-pointer items-center gap-3 rounded-xl px-2 py-1.5 hover:bg-white/80">
<input
type="checkbox"
checked={checked}
onChange={() => toggleIngredient(ing.id)}
className="size-4 rounded border-chocolat/30 text-gaufre focus:ring-gaufre"
/>
{ing.imageUrl ? (
<span className="relative h-8 w-10 shrink-0 overflow-hidden rounded-lg border border-chocolat/10 bg-chocolat/5">
{ing.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={ing.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={ing.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(ing.imageUrl)}
className="object-cover"
sizes="40px"
/>
)}
</span>
) : null}
<span className="min-w-0 flex-1 text-sm text-chocolat">
{ing.name}
</span>
<span className="shrink-0 text-xs font-semibold text-gaufre">
{ing.price.toFixed(2).replace(".", ",")}
</span>
</label>
</li>
);
})}
</ul>
)}
</div>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Prix ()
<input
type="text"
inputMode="decimal"
value={form.price}
onChange={(e) =>
setForm((f) => ({ ...f, price: e.target.value }))
}
required
placeholder="6,50"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Catégorie
<select
value={form.category}
onChange={(e) =>
setForm((f) => ({ ...f, category: e.target.value }))
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
>
{categories.map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">Photo</span>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={uploadFile}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">ou colle une URL ci-dessous</span>
</div>
<input
type="text"
value={form.imageUrl}
onChange={(e) =>
setForm((f) => ({ ...f, imageUrl: e.target.value }))
}
placeholder="https://… ou /uploads/… ou /gallery/…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
<p className="text-xs text-muted">
Chemin local (ex. <code className="rounded bg-chocolat/5 px-1">/gallery/nom.png</code>) ou lien https.
</p>
{form.imageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{form.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.imageUrl}
alt="Aperçu"
className="h-full w-full object-cover"
/>
) : (
<Image
src={form.imageUrl}
alt="Aperçu"
fill
unoptimized={isUploadAssetPath(form.imageUrl)}
className="object-cover"
sizes="144px"
/>
)}
</div>
<button
type="button"
onClick={() => setForm((f) => ({ ...f, imageUrl: "" }))}
className="text-sm text-red-700 underline"
>
Retirer la photo
</button>
</div>
) : null}
</div>
<label className="flex items-center gap-2 text-sm font-medium text-chocolat sm:col-span-2">
<input
type="checkbox"
checked={form.isNew}
onChange={(e) =>
setForm((f) => ({ ...f, isNew: e.target.checked }))
}
className="size-4 rounded border-chocolat/30 text-gaufre focus:ring-gaufre"
/>
Badge « Nouveauté »
</label>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={saving || uploading}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingId
? "Mettre à jour"
: "Ajouter"}
</button>
{editingId && (
<button
type="button"
onClick={resetForm}
className="rounded-full border border-chocolat/20 px-6 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
)}
</div>
</form>
{loading ? (
<p className="text-muted">Chargement</p>
) : (
<>
{customWaffleLoaded ? (
<form
onSubmit={saveCustomWaffle}
className="mb-8 rounded-[2rem] border border-gaufre/30 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)] ring-1 ring-gaufre/20"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-chocolat">
Gaufre sur mesure
</h2>
<p className="mt-1 text-sm text-muted">
Carte affichée en tête de la section « Gaufre du moment » sur
le site public.
</p>
</div>
<div
className="flex items-center gap-3"
role="group"
aria-label="Visibilité sur le site"
>
<span
className={`text-xs font-semibold uppercase tracking-wide ${
customWaffleForm.visible ? "text-muted" : "text-chocolat"
}`}
>
OFF
</span>
<button
type="button"
role="switch"
aria-checked={customWaffleForm.visible}
onClick={() =>
setCustomWaffleForm((f) => ({
...f,
visible: !f.visible,
}))
}
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors ${
customWaffleForm.visible ? "bg-gaufre" : "bg-chocolat/25"
}`}
>
<span
className={`absolute top-1 left-1 h-6 w-6 rounded-full bg-white shadow transition-transform ${
customWaffleForm.visible
? "translate-x-6"
: "translate-x-0"
}`}
/>
</button>
<span
className={`text-xs font-semibold uppercase tracking-wide ${
customWaffleForm.visible ? "text-chocolat" : "text-muted"
}`}
>
ON
</span>
</div>
</div>
<p className="mt-2 text-xs text-muted">
{customWaffleForm.visible
? "ON : la carte est visible sur le menu public."
: "OFF : la carte est masquée sur le site."}
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Titre
<input
value={customWaffleForm.title}
onChange={(e) =>
setCustomWaffleForm((f) => ({
...f,
title: e.target.value,
}))
}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Description
<textarea
value={customWaffleForm.description}
onChange={(e) =>
setCustomWaffleForm((f) => ({
...f,
description: e.target.value,
}))
}
required
rows={3}
className="resize-y rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Prix minimum ()
<input
type="text"
inputMode="decimal"
value={customWaffleForm.basePrice}
onChange={(e) =>
setCustomWaffleForm((f) => ({
...f,
basePrice: e.target.value,
}))
}
required
placeholder="3,00"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
</div>
<div className="mt-6">
<button
type="submit"
disabled={savingCustomWaffle}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{savingCustomWaffle
? "Enregistrement…"
: "Enregistrer la gaufre sur mesure"}
</button>
</div>
</form>
) : null}
<ul className="space-y-3">
{waffles.map((w) => (
<li
key={w.id}
className="flex flex-col gap-3 rounded-2xl border border-chocolat/10 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex min-w-0 gap-3">
{w.imageUrl ? (
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{w.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={w.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={w.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(w.imageUrl)}
className="object-cover"
sizes="96px"
/>
)}
</div>
) : null}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-chocolat">{w.name}</span>
{w.isNew && (
<span className="rounded-full bg-gaufre/20 px-2 py-0.5 text-xs font-bold text-chocolat">
Nouveauté
</span>
)}
<span className="text-sm text-muted">
{CATEGORY_LABELS[w.category] ?? w.category}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted">
{w.ingredients}
</p>
{w.ingredientItems.length > 0 ? (
<p className="mt-1 text-xs text-chocolat/80">
Ingrédients :{" "}
{w.ingredientItems.map((i) => i.name).join(", ")}
</p>
) : null}
<p className="mt-1 text-sm font-bold text-gaufre">
{w.price.toFixed(2).replace(".", ",")}
</p>
</div>
</div>
<div className="flex shrink-0 gap-2 sm:pt-0">
<button
type="button"
onClick={() => startEdit(w)}
className="rounded-full border border-chocolat/20 px-4 py-1.5 text-sm font-medium text-chocolat hover:bg-cream"
>
Modifier
</button>
<button
type="button"
onClick={() => remove(w.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-1.5 text-sm font-medium text-red-800 hover:bg-red-100"
>
Supprimer
</button>
</div>
</li>
))}
{waffles.length === 0 && (
<li className="rounded-2xl border border-dashed border-chocolat/20 p-8 text-center text-muted">
Aucune gaufre pour le moment. Ajoutez-en une ci-dessus.
</li>
)}
</ul>
</>
)}
</div>
</div>
);
}
+360
View File
@@ -0,0 +1,360 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { GalleryImage } from "@/generated/prisma/client";
import { isUploadAssetPath } from "@/lib/image-url";
import { ADMIN_API } from "@/lib/constants";
export function AdminGallery() {
const router = useRouter();
const [items, setItems] = useState<GalleryImage[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
imageUrl: "",
alt: "",
sortOrder: "0",
});
const load = useCallback(async () => {
const res = await fetch(`${ADMIN_API}/gallery`, { credentials: "include" });
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const data = await res.json();
setItems(Array.isArray(data) ? data : []);
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function resetForm() {
setEditingId(null);
setForm({ imageUrl: "", alt: "", sortOrder: "0" });
}
function startEdit(g: GalleryImage) {
setEditingId(g.id);
setForm({
imageUrl: g.imageUrl,
alt: g.alt,
sortOrder: String(g.sortOrder),
});
}
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) setForm((f) => ({ ...f, imageUrl: data.url }));
} finally {
setUploading(false);
e.target.value = "";
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const sortOrder = parseInt(form.sortOrder, 10);
if (Number.isNaN(sortOrder)) return;
setSaving(true);
try {
const body = {
imageUrl: form.imageUrl.trim() || null,
alt: form.alt.trim(),
sortOrder,
};
if (editingId) {
const res = await fetch(`${ADMIN_API}/gallery/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
} else {
const res = await fetch(`${ADMIN_API}/gallery`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
}
resetForm();
await load();
} finally {
setSaving(false);
}
}
async function remove(id: string) {
if (!confirm("Supprimer cette photo de la galerie ?")) return;
const res = await fetch(`${ADMIN_API}/gallery/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (editingId === id) resetForm();
await load();
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
router.refresh();
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat">Galerie du site</h1>
<p className="text-sm text-muted">
Ordre daffichage (nombre le plus petit en premier). Sur la page
daccueil, jusquà 3 photos visibles ; défilement toutes les 5 s si
plus de 3 images.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
<form
onSubmit={onSubmit}
className="mb-10 rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]"
>
<h2 className="text-lg font-semibold text-chocolat">
{editingId ? "Modifier la photo" : "Ajouter une photo"}
</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Texte alternatif (accessibilité)
<input
value={form.alt}
onChange={(e) => setForm((f) => ({ ...f, alt: e.target.value }))}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Ordre (0 = premier)
<input
type="text"
inputMode="numeric"
value={form.sortOrder}
onChange={(e) =>
setForm((f) => ({ ...f, sortOrder: e.target.value }))
}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">Image</span>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={uploadFile}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">ou chemin / URL ci-dessous</span>
</div>
<input
type="text"
value={form.imageUrl}
onChange={(e) =>
setForm((f) => ({ ...f, imageUrl: e.target.value }))
}
required
placeholder="/uploads/… ou https://…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{form.imageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{form.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={form.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(form.imageUrl)}
className="object-cover"
sizes="144px"
/>
)}
</div>
<button
type="button"
onClick={() => setForm((f) => ({ ...f, imageUrl: "" }))}
className="text-sm text-red-700 underline"
>
Retirer limage
</button>
</div>
) : null}
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={saving || uploading}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingId
? "Mettre à jour"
: "Ajouter"}
</button>
{editingId && (
<button
type="button"
onClick={resetForm}
className="rounded-full border border-chocolat/20 px-6 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
)}
</div>
</form>
{loading ? (
<p className="text-muted">Chargement</p>
) : (
<ul className="space-y-3">
{items.map((g) => (
<li
key={g.id}
className="flex flex-col gap-3 rounded-2xl border border-chocolat/10 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex min-w-0 gap-3">
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{g.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={g.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={g.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(g.imageUrl)}
className="object-cover"
sizes="112px"
/>
)}
</div>
<div className="min-w-0">
<p className="font-medium text-chocolat">{g.alt}</p>
<p className="mt-1 truncate text-xs text-muted">{g.imageUrl}</p>
<p className="mt-1 text-sm text-muted">Ordre : {g.sortOrder}</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(g)}
className="rounded-full border border-chocolat/20 px-4 py-1.5 text-sm font-medium text-chocolat hover:bg-cream"
>
Modifier
</button>
<button
type="button"
onClick={() => remove(g.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-1.5 text-sm font-medium text-red-800 hover:bg-red-100"
>
Supprimer
</button>
</div>
</li>
))}
{items.length === 0 && (
<li className="rounded-2xl border border-dashed border-chocolat/20 p-8 text-center text-muted">
Aucune image. Ajoutez-en une ci-dessus.
</li>
)}
</ul>
)}
</div>
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ADMIN_API } from "@/lib/constants";
import { isUploadAssetPath } from "@/lib/image-url";
import type { Ingredient } from "@/generated/prisma/client";
export function AdminIngredients() {
const router = useRouter();
const [items, setItems] = useState<Ingredient[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: "",
price: "",
imageUrl: "",
});
const load = useCallback(async () => {
const res = await fetch(`${ADMIN_API}/ingredients`, {
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const data = await res.json();
setItems(Array.isArray(data) ? data : []);
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function resetForm() {
setEditingId(null);
setForm({ name: "", price: "", imageUrl: "" });
}
function startEdit(ing: Ingredient) {
setEditingId(ing.id);
setForm({
name: ing.name,
price: String(ing.price),
imageUrl: ing.imageUrl ?? "",
});
}
async function uploadFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) setForm((f) => ({ ...f, imageUrl: data.url }));
} finally {
setUploading(false);
e.target.value = "";
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const price = parseFloat(form.price.replace(",", "."));
if (Number.isNaN(price) || price < 0) return;
setSaving(true);
try {
const body = {
name: form.name.trim(),
price,
imageUrl: form.imageUrl.trim() || null,
};
if (editingId) {
const res = await fetch(`${ADMIN_API}/ingredients/${editingId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) return;
} else {
const res = await fetch(`${ADMIN_API}/ingredients`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (!res.ok) return;
}
resetForm();
await load();
} finally {
setSaving(false);
}
}
async function remove(id: string) {
if (!confirm("Supprimer cet ingrédient ?")) return;
const res = await fetch(`${ADMIN_API}/ingredients/${id}`, {
method: "DELETE",
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
if (editingId === id) resetForm();
await load();
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
router.refresh();
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat">Ingrédients</h1>
<p className="text-sm text-muted">
Créez les ingrédients réutilisables (titre, prix, photo), puis
associez-les aux gaufres depuis le menu.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
<form
onSubmit={onSubmit}
className="mb-10 rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]"
>
<h2 className="text-lg font-semibold text-chocolat">
{editingId ? "Modifier lingrédient" : "Nouvel ingrédient"}
</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Titre
<input
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
required
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Prix ()
<input
type="text"
inputMode="decimal"
value={form.price}
onChange={(e) =>
setForm((f) => ({ ...f, price: e.target.value }))
}
required
placeholder="0,50"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">Photo</span>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={uploadFile}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">ou colle une URL ci-dessous</span>
</div>
<input
type="text"
value={form.imageUrl}
onChange={(e) =>
setForm((f) => ({ ...f, imageUrl: e.target.value }))
}
placeholder="https://… ou /uploads/… ou /gallery/…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{form.imageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{form.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.imageUrl}
alt="Aperçu"
className="h-full w-full object-cover"
/>
) : (
<Image
src={form.imageUrl}
alt="Aperçu"
fill
unoptimized={isUploadAssetPath(form.imageUrl)}
className="object-cover"
sizes="144px"
/>
)}
</div>
<button
type="button"
onClick={() => setForm((f) => ({ ...f, imageUrl: "" }))}
className="text-sm text-red-700 underline"
>
Retirer la photo
</button>
</div>
) : null}
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="submit"
disabled={saving || uploading}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingId
? "Mettre à jour"
: "Ajouter"}
</button>
{editingId && (
<button
type="button"
onClick={resetForm}
className="rounded-full border border-chocolat/20 px-6 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
)}
</div>
</form>
{loading ? (
<p className="text-muted">Chargement</p>
) : (
<ul className="space-y-3">
{items.map((ing) => (
<li
key={ing.id}
className="flex flex-col gap-3 rounded-2xl border border-chocolat/10 bg-white p-4 shadow-sm sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex min-w-0 gap-3">
{ing.imageUrl ? (
<div className="relative h-16 w-24 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{ing.imageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={ing.imageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={ing.imageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(ing.imageUrl)}
className="object-cover"
sizes="96px"
/>
)}
</div>
) : null}
<div className="min-w-0">
<span className="font-semibold text-chocolat">{ing.name}</span>
<p className="mt-1 text-sm font-bold text-gaufre">
{ing.price.toFixed(2).replace(".", ",")}
</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(ing)}
className="rounded-full border border-chocolat/20 px-4 py-1.5 text-sm font-medium text-chocolat hover:bg-cream"
>
Modifier
</button>
<button
type="button"
onClick={() => remove(ing.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-1.5 text-sm font-medium text-red-800 hover:bg-red-100"
>
Supprimer
</button>
</div>
</li>
))}
{items.length === 0 && (
<li className="rounded-2xl border border-dashed border-chocolat/20 p-8 text-center text-muted">
Aucun ingrédient pour le moment. Ajoutez-en un ci-dessus.
</li>
)}
</ul>
)}
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
type NavLink = { href: string; label: string; exact?: boolean };
const LINKS: NavLink[] = [
{ href: "/admin", label: "Menu", exact: true },
{ href: "/admin/ingredients", label: "Ingrédients" },
{ href: "/admin/gallery", label: "Galerie" },
{ href: "/admin/site", label: "Textes du site" },
{ href: "/admin/schedule", label: "Où me trouver" },
];
function isActive(pathname: string, href: string, exact?: boolean): boolean {
if (exact) return pathname === href;
return pathname === href || pathname.startsWith(`${href}/`);
}
function linkClassName(active: boolean): string {
const base =
"rounded-full px-3 py-1.5 text-sm font-medium text-chocolat transition-colors";
return active
? `${base} bg-gaufre/30 font-semibold shadow-sm ring-1 ring-gaufre/40`
: `${base} hover:bg-gaufre/15`;
}
export function AdminNav() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-10 border-b border-chocolat/10 bg-white/90 px-4 py-3 backdrop-blur-sm">
<div className="mx-auto flex max-w-4xl flex-wrap items-center gap-2 sm:gap-6">
<span className="text-sm font-semibold text-chocolat">Administration</span>
{LINKS.map(({ href, label, exact }) => {
const active = isActive(pathname, href, exact);
return (
<Link key={href} href={href} className={linkClassName(active)}>
{label}
</Link>
);
})}
</div>
</nav>
);
}
+612
View File
@@ -0,0 +1,612 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ADMIN_API } from "@/lib/constants";
import { isUploadAssetPath } from "@/lib/image-url";
import type { SiteHoursRow } from "@/lib/site-content-defaults";
type FormState = {
logoUrl: string;
heroEyebrow: string;
heroTitle: string;
heroSubtitle: string;
heroBackgroundImageUrl: string;
heroTextOverImage: boolean;
menuIntro: string;
galleryIntro: string;
uploadImageWidth: number;
uploadImageHeight: number;
hours: SiteHoursRow[];
};
export function AdminSiteContent() {
const router = useRouter();
const [form, setForm] = useState<FormState | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const load = useCallback(async () => {
const res = await fetch(`${ADMIN_API}/site-content`, {
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const data = await res.json().catch(() => null);
if (!data || typeof data !== "object") {
setLoading(false);
return;
}
const h = data.hours;
const hours: SiteHoursRow[] = Array.isArray(h)
? h
.filter(
(x: unknown) =>
x !== null &&
typeof x === "object" &&
typeof (x as { days?: string }).days === "string" &&
typeof (x as { hours?: string }).hours === "string"
)
.map((x: { days: string; hours: string }) => ({
days: x.days,
hours: x.hours,
}))
: [];
setForm({
logoUrl: String(data.logoUrl ?? ""),
heroEyebrow: String(data.heroEyebrow ?? ""),
heroTitle: String(data.heroTitle ?? ""),
heroSubtitle: String(data.heroSubtitle ?? ""),
heroBackgroundImageUrl: String(data.heroBackgroundImageUrl ?? ""),
heroTextOverImage: data.heroTextOverImage !== false,
menuIntro: String(data.menuIntro ?? ""),
galleryIntro: String(data.galleryIntro ?? ""),
uploadImageWidth: Number(data.uploadImageWidth ?? 1920) || 1920,
uploadImageHeight: Number(data.uploadImageHeight ?? 1080) || 1080,
hours,
});
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function setHour(i: number, field: keyof SiteHoursRow, value: string) {
setForm((f) => {
if (!f) return f;
const next = [...f.hours];
next[i] = { ...next[i], [field]: value };
return { ...f, hours: next };
});
}
function addHourRow() {
setForm((f) =>
f ? { ...f, hours: [...f.hours, { days: "", hours: "" }] } : f
);
}
function removeHourRow(i: number) {
setForm((f) => {
if (!f) return f;
return { ...f, hours: f.hours.filter((_, j) => j !== i) };
});
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
setSaving(true);
try {
const res = await fetch(`${ADMIN_API}/site-content`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(form),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const err = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
} finally {
setSaving(false);
}
}
async function uploadImage(
e: React.ChangeEvent<HTMLInputElement>,
field: "logoUrl" | "heroBackgroundImageUrl",
setBusy: (v: boolean) => void
) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) {
setForm((f) => (f ? { ...f, [field]: String(data.url) } : f));
}
} finally {
setBusy(false);
e.target.value = "";
}
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
router.refresh();
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat">Textes du site</h1>
<p className="text-sm text-muted">
Accueil, menu, galerie et horaires affichés sur la page publique.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
{loading || !form ? (
<p className="text-muted">Chargement</p>
) : (
<form
onSubmit={onSubmit}
className="space-y-8 rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]"
>
<section>
<h2 className="text-lg font-semibold text-chocolat">Logo</h2>
<p className="mt-1 text-xs text-muted">
Mascotte affichée à côté du nom dans len-tête du site.
</p>
<div className="mt-4 flex flex-col gap-2">
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) => uploadImage(e, "logoUrl", setUploadingLogo)}
disabled={uploadingLogo}
/>
{uploadingLogo ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">
ou chemin / URL ci-dessous
</span>
</div>
<input
type="text"
value={form.logoUrl}
onChange={(e) =>
setForm((f) => (f ? { ...f, logoUrl: e.target.value } : f))
}
placeholder="/logo.png ou /uploads/…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{form.logoUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-cream/50">
{form.logoUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logoUrl}
alt=""
className="h-full w-full object-contain"
/>
) : (
<Image
src={form.logoUrl}
alt=""
fill
unoptimized={isUploadAssetPath(form.logoUrl)}
className="object-contain"
sizes="64px"
/>
)}
</div>
<button
type="button"
onClick={() =>
setForm((f) => (f ? { ...f, logoUrl: "" } : f))
}
className="text-sm text-red-700 underline"
>
Retirer le logo
</button>
</div>
) : null}
</div>
</section>
<section>
<h2 className="text-lg font-semibold text-chocolat">
Bloc accueil (héros)
</h2>
<p className="mt-1 text-xs text-muted">
Image daccueil et textes affichés sur la page publique.
</p>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm font-medium text-chocolat">
L&apos;image au premier plan
</span>
<div
className="flex items-center gap-3"
role="group"
aria-label="Disposition image et texte"
>
<span
className={`text-xs font-semibold uppercase tracking-wide ${
form.heroTextOverImage ? "text-muted" : "text-chocolat"
}`}
>
OFF
</span>
<button
type="button"
role="switch"
aria-checked={form.heroTextOverImage}
onClick={() =>
setForm((f) =>
f ? { ...f, heroTextOverImage: !f.heroTextOverImage } : f
)
}
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors ${
form.heroTextOverImage ? "bg-gaufre" : "bg-chocolat/25"
}`}
>
<span
className={`absolute top-1 left-1 h-6 w-6 rounded-full bg-white shadow transition-transform ${
form.heroTextOverImage ? "translate-x-6" : "translate-x-0"
}`}
/>
</button>
<span
className={`text-xs font-semibold uppercase tracking-wide ${
form.heroTextOverImage ? "text-chocolat" : "text-muted"
}`}
>
ON
</span>
</div>
</div>
<p className="mt-1 text-xs text-muted">
{form.heroTextOverImage
? "ON : texte sur limage (comme actuellement)."
: "OFF : image seule en haut, texte noir au-dessus du menu."}
</p>
<div className="mt-4 grid gap-4">
<fieldset className="rounded-2xl border border-chocolat/12 bg-cream/30 p-4">
<legend className="px-1 text-sm font-semibold text-chocolat">
Texte au premier plan
</legend>
<div className="mt-3 grid gap-4">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Surtitre (ex. Artisanal · Belge)
<input
value={form.heroEyebrow}
onChange={(e) =>
setForm((f) =>
f ? { ...f, heroEyebrow: e.target.value } : f
)
}
className="rounded-2xl border border-chocolat/15 bg-white px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Titre principal
<textarea
value={form.heroTitle}
onChange={(e) =>
setForm((f) =>
f ? { ...f, heroTitle: e.target.value } : f
)
}
rows={2}
className="resize-y rounded-2xl border border-chocolat/15 bg-white px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Texte sous le titre
<textarea
value={form.heroSubtitle}
onChange={(e) =>
setForm((f) =>
f ? { ...f, heroSubtitle: e.target.value } : f
)
}
rows={3}
className="resize-y rounded-2xl border border-chocolat/15 bg-white px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-chocolat">
Image de fond (accueil)
</span>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Redimensionnement upload (largeur)
<input
type="number"
min={320}
max={8000}
value={form.uploadImageWidth}
onChange={(e) =>
setForm((f) =>
f
? {
...f,
uploadImageWidth:
Number(e.target.value || 0) || 0,
}
: f
)
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Redimensionnement upload (hauteur)
<input
type="number"
min={240}
max={8000}
value={form.uploadImageHeight}
onChange={(e) =>
setForm((f) =>
f
? {
...f,
uploadImageHeight:
Number(e.target.value || 0) || 0,
}
: f
)
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
</div>
<p className="text-xs text-muted">
Cette résolution est appliquée aux prochaines images envoyées
via ladmin (ex: 1920×1080).
</p>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) =>
uploadImage(e, "heroBackgroundImageUrl", setUploading)
}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">
ou chemin / URL ci-dessous
</span>
</div>
<input
type="text"
value={form.heroBackgroundImageUrl}
onChange={(e) =>
setForm((f) =>
f ? { ...f, heroBackgroundImageUrl: e.target.value } : f
)
}
placeholder="/uploads/… ou https://…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{form.heroBackgroundImageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<div className="relative h-24 w-36 shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5">
{form.heroBackgroundImageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.heroBackgroundImageUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Image
src={form.heroBackgroundImageUrl}
alt=""
fill
unoptimized={isUploadAssetPath(
form.heroBackgroundImageUrl
)}
className="object-cover"
sizes="144px"
/>
)}
</div>
<button
type="button"
onClick={() =>
setForm((f) =>
f ? { ...f, heroBackgroundImageUrl: "" } : f
)
}
className="text-sm text-red-700 underline"
>
Retirer limage
</button>
</div>
) : null}
</div>
</div>
</section>
<section>
<h2 className="text-lg font-semibold text-chocolat">Section Menu</h2>
<p className="mt-1 text-xs text-muted">
Texte sous le titre « Menu ».
</p>
<label className="mt-4 flex flex-col gap-1 text-sm font-medium text-chocolat">
Texte sous « Menu »
<textarea
value={form.menuIntro}
onChange={(e) =>
setForm((f) =>
f ? { ...f, menuIntro: e.target.value } : f
)
}
rows={2}
className="resize-y rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
</section>
<section>
<h2 className="text-lg font-semibold text-chocolat">
Section Galerie
</h2>
<p className="mt-1 text-xs text-muted">
Texte optionnel affiché sous le titre « Galerie », avant les
photos.
</p>
<label className="mt-4 flex flex-col gap-1 text-sm font-medium text-chocolat">
Texte sous « Galerie »
<textarea
value={form.galleryIntro}
onChange={(e) =>
setForm((f) =>
f ? { ...f, galleryIntro: e.target.value } : f
)
}
rows={3}
placeholder="Laissez vide pour masquer ce bloc."
className="resize-y rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
</section>
<section>
<h2 className="text-lg font-semibold text-chocolat">Horaires</h2>
<p className="mt-1 text-xs text-muted">
Affichés dans le pied de page. Supprimez toutes les lignes pour
masquer le bloc horaires.
</p>
{form.hours.length === 0 ? (
<p className="mt-4 rounded-2xl border border-dashed border-chocolat/20 bg-cream/40 px-4 py-6 text-sm text-muted">
Aucune ligne : le bloc « Horaires » sera masqué sur le site.
</p>
) : (
<ul className="mt-4 space-y-3">
{form.hours.map((row, i) => (
<li
key={i}
className="flex flex-col gap-2 rounded-2xl border border-chocolat/10 bg-cream/30 p-4 sm:flex-row sm:items-end"
>
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm font-medium text-chocolat">
Jours
<input
value={row.days}
onChange={(e) => setHour(i, "days", e.target.value)}
placeholder="Lun — Ven"
className="rounded-2xl border border-chocolat/15 bg-white px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm font-medium text-chocolat">
Créneau
<input
value={row.hours}
onChange={(e) => setHour(i, "hours", e.target.value)}
placeholder="11h — 22h"
className="rounded-2xl border border-chocolat/15 bg-white px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<button
type="button"
onClick={() => removeHourRow(i)}
className="shrink-0 rounded-full border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-800"
>
Retirer
</button>
</li>
))}
</ul>
)}
<button
type="button"
onClick={addHourRow}
className="mt-3 rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-cream"
>
Ajouter une ligne
</button>
</section>
<div className="flex flex-wrap gap-3 border-t border-chocolat/10 pt-6">
<button
type="submit"
disabled={saving || uploading || uploadingLogo}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat shadow-md disabled:opacity-60"
>
{saving ? "Enregistrement…" : "Enregistrer"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
+857
View File
@@ -0,0 +1,857 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { TruckEvent, TruckEventRecurrence } from "@/generated/prisma/client";
import { ADMIN_API } from "@/lib/constants";
import { isUploadAssetPath } from "@/lib/image-url";
import { WEEKDAY_LABELS } from "@/lib/truck-schedule-shared";
import { formatDateIso } from "@/lib/week-calendar";
type EventMode = "once" | "recurring";
type OnceForm = {
eventDate: string;
timeLabel: string;
location: string;
note: string;
imageUrl: string;
};
type RecurForm = {
dayOfWeek: string;
timeLabel: string;
location: string;
note: string;
imageUrl: string;
};
const emptyOnceForm = (): OnceForm => ({
eventDate: "",
timeLabel: "",
location: "",
note: "",
imageUrl: "",
});
const emptyRecurForm = (): RecurForm => ({
dayOfWeek: "0",
timeLabel: "",
location: "",
note: "",
imageUrl: "",
});
export function AdminTruckSchedule() {
const router = useRouter();
const [events, setEvents] = useState<TruckEvent[]>([]);
const [recurrences, setRecurrences] = useState<TruckEventRecurrence[]>([]);
const [scheduleIntro, setScheduleIntro] = useState("");
const [loading, setLoading] = useState(true);
const [eventMode, setEventMode] = useState<EventMode>("once");
const [saving, setSaving] = useState(false);
const [savingIntro, setSavingIntro] = useState(false);
const [uploadingOnceImage, setUploadingOnceImage] = useState(false);
const [uploadingRecurImage, setUploadingRecurImage] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingRecurId, setEditingRecurId] = useState<string | null>(null);
const [onceForm, setOnceForm] = useState<OnceForm>(emptyOnceForm);
const [recurForm, setRecurForm] = useState<RecurForm>(emptyRecurForm);
const { upcomingEvents, pastEvents } = useMemo(() => {
const today = formatDateIso(new Date());
const upcoming: TruckEvent[] = [];
const past: TruckEvent[] = [];
for (const ev of events) {
if (ev.eventDate < today) past.push(ev);
else upcoming.push(ev);
}
past.sort((a, b) => b.eventDate.localeCompare(a.eventDate));
return { upcomingEvents: upcoming, pastEvents: past };
}, [events]);
const load = useCallback(async () => {
const [eventsRes, recurRes, siteRes] = await Promise.all([
fetch(`${ADMIN_API}/truck-events`, { credentials: "include" }),
fetch(`${ADMIN_API}/truck-recurrences`, { credentials: "include" }),
fetch(`${ADMIN_API}/site-content`, { credentials: "include" }),
]);
if (
eventsRes.status === 401 ||
recurRes.status === 401 ||
siteRes.status === 401
) {
router.replace("/admin/login");
return;
}
const eventsData = await eventsRes.json().catch(() => []);
const recurData = await recurRes.json().catch(() => []);
const siteData = await siteRes.json().catch(() => ({}));
setEvents(Array.isArray(eventsData) ? eventsData : []);
setRecurrences(Array.isArray(recurData) ? recurData : []);
setScheduleIntro(String(siteData.scheduleIntro ?? ""));
setLoading(false);
}, [router]);
useEffect(() => {
load();
}, [load]);
function resetOnceForm() {
setEditingId(null);
setOnceForm(emptyOnceForm());
}
function resetRecurForm() {
setEditingRecurId(null);
setRecurForm(emptyRecurForm());
}
function startEditOnce(ev: TruckEvent) {
setEventMode("once");
setEditingId(ev.id);
resetRecurForm();
setOnceForm({
eventDate: ev.eventDate,
timeLabel: ev.timeLabel,
location: ev.location,
note: ev.note,
imageUrl: ev.imageUrl ?? "",
});
}
function startEditRecur(r: TruckEventRecurrence) {
setEventMode("recurring");
setEditingRecurId(r.id);
resetOnceForm();
setRecurForm({
dayOfWeek: String(r.dayOfWeek),
timeLabel: r.timeLabel,
location: r.location,
note: r.note,
imageUrl: r.imageUrl ?? "",
});
}
async function uploadImage(
e: React.ChangeEvent<HTMLInputElement>,
onUrl: (url: string) => void,
setUploading: (v: boolean) => void
) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${ADMIN_API}/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof data.error === "string" ? data.error : "Échec de lenvoi");
return;
}
if (data.url) onUrl(data.url);
} finally {
setUploading(false);
e.target.value = "";
}
}
async function onSubmitOnce(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const url = editingId
? `${ADMIN_API}/truck-events/${editingId}`
: `${ADMIN_API}/truck-events`;
const res = await fetch(url, {
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(onceForm),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const err = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
resetOnceForm();
await load();
} finally {
setSaving(false);
}
}
async function onSubmitRecur(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
const url = editingRecurId
? `${ADMIN_API}/truck-recurrences/${editingRecurId}`
: `${ADMIN_API}/truck-recurrences`;
const res = await fetch(url, {
method: editingRecurId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
...recurForm,
dayOfWeek: Number(recurForm.dayOfWeek),
active: true,
}),
});
if (res.status === 401) {
router.replace("/admin/login");
return;
}
const err = await res.json().catch(() => ({}));
if (!res.ok) {
alert(typeof err.error === "string" ? err.error : "Erreur");
return;
}
resetRecurForm();
await load();
} finally {
setSaving(false);
}
}
async function removeOnce(id: string) {
if (!confirm("Supprimer cet événement ponctuel ?")) return;
const res = await fetch(`${ADMIN_API}/truck-events/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
alert("Suppression impossible");
return;
}
if (editingId === id) resetOnceForm();
await load();
}
async function removeRecur(id: string) {
if (!confirm("Supprimer cette récurrence ?")) return;
const res = await fetch(`${ADMIN_API}/truck-recurrences/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
alert("Suppression impossible");
return;
}
if (editingRecurId === id) resetRecurForm();
await load();
}
async function toggleRecurActive(r: TruckEventRecurrence) {
const res = await fetch(`${ADMIN_API}/truck-recurrences/${r.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
dayOfWeek: r.dayOfWeek,
timeLabel: r.timeLabel,
location: r.location,
note: r.note,
imageUrl: r.imageUrl ?? "",
active: !r.active,
}),
});
if (!res.ok) {
alert("Mise à jour impossible");
return;
}
await load();
}
async function saveIntro() {
setSavingIntro(true);
try {
const siteRes = await fetch(`${ADMIN_API}/site-content`, {
credentials: "include",
});
if (siteRes.status === 401) {
router.replace("/admin/login");
return;
}
const site = await siteRes.json();
const res = await fetch(`${ADMIN_API}/site-content`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ...site, scheduleIntro }),
});
if (!res.ok) {
alert("Erreur lors de lenregistrement du texte");
}
} finally {
setSavingIntro(false);
}
}
async function logout() {
const res = await fetch(`${ADMIN_API}/auth/logout`, {
method: "POST",
credentials: "include",
});
if (res.ok) {
window.location.href = "/admin/login";
return;
}
router.replace("/admin/login");
}
return (
<div className="px-4 py-10 sm:px-6">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-chocolat"> me trouver</h1>
<p className="text-sm text-muted">
Calendrier hebdomadaire des présences du food truck.
</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/#ou-me-trouver"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Voir sur le site
</a>
<button
type="button"
onClick={() => logout()}
className="rounded-full bg-chocolat px-4 py-2 text-sm font-medium text-cream hover:opacity-90"
>
Déconnexion
</button>
</div>
</div>
{loading ? (
<p className="text-muted">Chargement</p>
) : (
<div className="space-y-8">
<section className="rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]">
<h2 className="text-lg font-semibold text-chocolat">
Texte de la section
</h2>
<label className="mt-4 flex flex-col gap-1 text-sm font-medium text-chocolat">
Introduction sous le titre « me trouver »
<textarea
value={scheduleIntro}
onChange={(e) => setScheduleIntro(e.target.value)}
rows={2}
placeholder="Ex. Retrouvez-nous sur les marchés de la semaine…"
className="resize-y rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<button
type="button"
onClick={() => saveIntro()}
disabled={savingIntro}
className="mt-4 rounded-full bg-gaufre px-5 py-2 text-sm font-semibold text-chocolat disabled:opacity-60"
>
{savingIntro ? "Enregistrement…" : "Enregistrer le texte"}
</button>
</section>
<section className="rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]">
<h2 className="text-lg font-semibold text-chocolat">
Ajouter une présence
</h2>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
setEventMode("once");
resetRecurForm();
}}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
eventMode === "once"
? "bg-gaufre text-chocolat"
: "border border-chocolat/20 text-chocolat hover:bg-cream"
}`}
>
Événement ponctuel
</button>
<button
type="button"
onClick={() => {
setEventMode("recurring");
resetOnceForm();
}}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
eventMode === "recurring"
? "bg-gaufre text-chocolat"
: "border border-chocolat/20 text-chocolat hover:bg-cream"
}`}
>
Événement récurrent
</button>
</div>
<p className="mt-2 text-xs text-muted">
{eventMode === "once"
? "Une date précise (marché exceptionnel, festival…)."
: "Répété chaque semaine le même jour (ex. tous les mardis)."}
</p>
{eventMode === "once" ? (
<form
onSubmit={onSubmitOnce}
className="mt-4 grid gap-4 sm:grid-cols-2"
>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Date
<input
type="date"
required
value={onceForm.eventDate}
onChange={(e) =>
setOnceForm((f) => ({ ...f, eventDate: e.target.value }))
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Horaires (optionnel)
<input
value={onceForm.timeLabel}
onChange={(e) =>
setOnceForm((f) => ({ ...f, timeLabel: e.target.value }))
}
placeholder="11h — 18h"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Lieu / événement
<input
required
value={onceForm.location}
onChange={(e) =>
setOnceForm((f) => ({ ...f, location: e.target.value }))
}
placeholder="Marché de Bagnols-sur-Cèze"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Note (optionnel)
<input
value={onceForm.note}
onChange={(e) =>
setOnceForm((f) => ({ ...f, note: e.target.value }))
}
placeholder="Place du marché, stand n°3…"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<EventImageField
imageUrl={onceForm.imageUrl}
uploading={uploadingOnceImage}
onUpload={(e) =>
uploadImage(
e,
(url) => setOnceForm((f) => ({ ...f, imageUrl: url })),
setUploadingOnceImage
)
}
onUrlChange={(imageUrl) =>
setOnceForm((f) => ({ ...f, imageUrl }))
}
onClear={() => setOnceForm((f) => ({ ...f, imageUrl: "" }))}
/>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<button
type="submit"
disabled={saving}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingId
? "Mettre à jour"
: "Ajouter"}
</button>
{editingId ? (
<button
type="button"
onClick={() => resetOnceForm()}
className="rounded-full border border-chocolat/20 px-4 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
) : null}
</div>
</form>
) : (
<form
onSubmit={onSubmitRecur}
className="mt-4 grid gap-4 sm:grid-cols-2"
>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Jour de la semaine
<select
required
value={recurForm.dayOfWeek}
onChange={(e) =>
setRecurForm((f) => ({ ...f, dayOfWeek: e.target.value }))
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
>
{WEEKDAY_LABELS.map((label, i) => (
<option key={label} value={String(i)}>
{label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat">
Horaires (optionnel)
<input
value={recurForm.timeLabel}
onChange={(e) =>
setRecurForm((f) => ({ ...f, timeLabel: e.target.value }))
}
placeholder="9h — 13h"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Lieu / événement
<input
required
value={recurForm.location}
onChange={(e) =>
setRecurForm((f) => ({ ...f, location: e.target.value }))
}
placeholder="Marché de Bagnols-sur-Cèze"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-chocolat sm:col-span-2">
Note (optionnel)
<input
value={recurForm.note}
onChange={(e) =>
setRecurForm((f) => ({ ...f, note: e.target.value }))
}
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 outline-none ring-gaufre/40 focus:ring-2"
/>
</label>
<EventImageField
imageUrl={recurForm.imageUrl}
uploading={uploadingRecurImage}
onUpload={(e) =>
uploadImage(
e,
(url) => setRecurForm((f) => ({ ...f, imageUrl: url })),
setUploadingRecurImage
)
}
onUrlChange={(imageUrl) =>
setRecurForm((f) => ({ ...f, imageUrl }))
}
onClear={() => setRecurForm((f) => ({ ...f, imageUrl: "" }))}
/>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<button
type="submit"
disabled={saving}
className="rounded-full bg-gaufre px-6 py-2.5 text-sm font-semibold text-chocolat disabled:opacity-60"
>
{saving
? "Enregistrement…"
: editingRecurId
? "Mettre à jour"
: "Enregistrer la récurrence"}
</button>
{editingRecurId ? (
<button
type="button"
onClick={() => resetRecurForm()}
className="rounded-full border border-chocolat/20 px-4 py-2.5 text-sm font-medium text-chocolat"
>
Annuler
</button>
) : null}
</div>
</form>
)}
</section>
<section className="rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]">
<h2 className="text-lg font-semibold text-chocolat">
Événements récurrents
</h2>
<p className="mt-1 text-xs text-muted">
Affichés automatiquement chaque semaine sur le calendrier public.
</p>
{recurrences.length === 0 ? (
<p className="mt-4 text-sm text-muted">Aucune récurrence.</p>
) : (
<ul className="mt-4 space-y-3">
{recurrences.map((r) => (
<li
key={r.id}
className={`flex flex-col gap-3 rounded-2xl border p-4 sm:flex-row sm:items-center sm:justify-between ${
r.active
? "border-chocolat/10 bg-cream/30"
: "border-dashed border-chocolat/20 bg-cream/10 opacity-70"
}`}
>
<div className="flex min-w-0 gap-3">
{r.imageUrl ? (
<EventListThumb src={r.imageUrl} alt={r.location} />
) : null}
<div className="min-w-0">
<p className="text-sm font-semibold text-chocolat">
Chaque {WEEKDAY_LABELS[r.dayOfWeek]}
{r.timeLabel ? ` · ${r.timeLabel}` : ""}
{!r.active ? " (désactivé)" : ""}
</p>
<p className="font-medium text-chocolat">{r.location}</p>
{r.note ? (
<p className="text-sm text-muted">{r.note}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
type="button"
onClick={() => toggleRecurActive(r)}
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
{r.active ? "Désactiver" : "Activer"}
</button>
<button
type="button"
onClick={() => startEditRecur(r)}
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Modifier
</button>
<button
type="button"
onClick={() => removeRecur(r.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-800"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
</section>
<section className="rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]">
<h2 className="text-lg font-semibold text-chocolat">
Événements ponctuels
</h2>
<p className="mt-1 text-xs text-muted">
Événements à venir (aujourdhui et après).
</p>
{upcomingEvents.length === 0 ? (
<p className="mt-4 text-sm text-muted">
Aucun événement ponctuel à venir.
</p>
) : (
<OnceEventList
items={upcomingEvents}
onEdit={startEditOnce}
onRemove={removeOnce}
/>
)}
</section>
{pastEvents.length > 0 ? (
<section className="rounded-[2rem] border border-chocolat/10 bg-white p-6 shadow-[0_12px_40px_-12px_rgba(61,43,31,0.12)]">
<h2 className="text-lg font-semibold text-chocolat">
Événements Historique
</h2>
<p className="mt-1 text-xs text-muted">
Événements ponctuels déjà passés.
</p>
<OnceEventList
items={pastEvents}
muted
onEdit={startEditOnce}
onRemove={removeOnce}
/>
</section>
) : null}
</div>
)}
</div>
</div>
);
}
function OnceEventList({
items,
muted,
onEdit,
onRemove,
}: {
items: TruckEvent[];
muted?: boolean;
onEdit: (ev: TruckEvent) => void;
onRemove: (id: string) => void;
}) {
return (
<ul className="mt-4 space-y-3">
{items.map((ev) => (
<li
key={ev.id}
className={`flex flex-col gap-3 rounded-2xl border p-4 sm:flex-row sm:items-center sm:justify-between ${
muted
? "border-chocolat/10 bg-cream/20 opacity-80"
: "border-chocolat/10 bg-cream/30"
}`}
>
<div className="flex min-w-0 gap-3">
{ev.imageUrl ? (
<EventListThumb src={ev.imageUrl} alt={ev.location} />
) : null}
<div className="min-w-0">
<p className="text-sm font-semibold text-chocolat">
{formatEventDate(ev.eventDate)}
{ev.timeLabel ? ` · ${ev.timeLabel}` : ""}
</p>
<p className="font-medium text-chocolat">{ev.location}</p>
{ev.note ? (
<p className="text-sm text-muted">{ev.note}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => onEdit(ev)}
className="rounded-full border border-chocolat/20 px-4 py-2 text-sm font-medium text-chocolat hover:bg-white"
>
Modifier
</button>
<button
type="button"
onClick={() => onRemove(ev.id)}
className="rounded-full border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-800"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
);
}
function EventImageField({
imageUrl,
uploading,
onUpload,
onUrlChange,
onClear,
}: {
imageUrl: string;
uploading: boolean;
onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
onUrlChange: (url: string) => void;
onClear: () => void;
}) {
return (
<div className="flex flex-col gap-2 sm:col-span-2">
<span className="text-sm font-medium text-chocolat">
Photo <span className="font-normal text-muted">(optionnel)</span>
</span>
<div className="flex flex-wrap items-end gap-3">
<label className="inline-flex cursor-pointer items-center rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm font-medium text-chocolat hover:bg-cream">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={onUpload}
disabled={uploading}
/>
{uploading ? "Envoi…" : "Choisir un fichier"}
</label>
<span className="text-xs text-muted">ou colle une URL ci-dessous</span>
</div>
<input
type="text"
value={imageUrl}
onChange={(e) => onUrlChange(e.target.value)}
placeholder="https://… ou /uploads/… ou /gallery/…"
autoComplete="off"
className="rounded-2xl border border-chocolat/15 bg-cream/50 px-4 py-2.5 text-sm outline-none ring-gaufre/40 focus:ring-2"
/>
{imageUrl.trim() ? (
<div className="mt-2 flex items-start gap-3">
<EventListThumb src={imageUrl} alt="Aperçu" large />
<button
type="button"
onClick={onClear}
className="text-sm text-red-700 underline"
>
Retirer la photo
</button>
</div>
) : null}
</div>
);
}
function EventListThumb({
src,
alt,
large,
}: {
src: string;
alt: string;
large?: boolean;
}) {
return (
<div
className={`relative shrink-0 overflow-hidden rounded-xl border border-chocolat/10 bg-chocolat/5 ${
large ? "h-20 w-32" : "h-14 w-20"
}`}
>
{src.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className="h-full w-full object-cover" />
) : (
<Image
src={src}
alt={alt}
fill
unoptimized={isUploadAssetPath(src)}
className="object-cover"
sizes={large ? "128px" : "80px"}
/>
)}
</div>
);
}
function formatEventDate(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, m - 1, d).toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
formatEuro,
type CustomWaffleConfig,
type IngredientPickerItem,
} from "@/lib/custom-waffle";
import { isUploadAssetPath } from "@/lib/image-url";
import { WaffleMenuImage } from "./WaffleMenuImage";
function IngredientThumb({ src, alt }: { src: string; alt: string }) {
if (src.startsWith("http")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className="h-full w-full object-cover" />
);
}
return (
<Image
src={src}
alt={alt}
fill
unoptimized={isUploadAssetPath(src)}
className="object-cover"
sizes="64px"
/>
);
}
export function CustomWaffleCard({
config,
ingredients,
}: {
config: CustomWaffleConfig;
ingredients: IngredientPickerItem[];
}) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const selectedList = useMemo(
() => ingredients.filter((ing) => selected.has(ing.id)),
[ingredients, selected]
);
const extrasTotal = useMemo(
() => selectedList.reduce((sum, ing) => sum + ing.price, 0),
[selectedList]
);
const total = config.basePrice + extrasTotal;
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, close]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<>
<li className="overflow-hidden rounded-3xl border border-gaufre/30 bg-white shadow-[0_8px_30px_-8px_rgba(61,43,31,0.12)] ring-1 ring-gaufre/20 transition hover:shadow-[0_12px_40px_-8px_rgba(61,43,31,0.18)]">
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full flex-col text-left"
>
<div className="relative aspect-[16/10] w-full bg-chocolat/5">
<WaffleMenuImage src={config.imageUrl} alt={config.title} />
<span className="absolute left-4 top-4 rounded-full bg-gaufre px-3 py-1 text-xs font-bold uppercase tracking-wide text-chocolat shadow-sm">
Sur mesure
</span>
</div>
<div className="p-6">
<div className="mb-2">
<span className="font-semibold text-chocolat">
{config.title}
</span>
</div>
<p className="text-sm leading-relaxed text-muted">
{config.description}
</p>
{selectedList.length > 0 ? (
<p className="mt-2 text-xs font-medium text-chocolat">
{selectedList.map((i) => i.name).join(", ")}
</p>
) : (
<p className="mt-3 text-sm font-semibold text-gaufre">
Cliquez pour composer
</p>
)}
</div>
</button>
</li>
{open ? (
<div
className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center"
role="presentation"
onClick={close}
>
<div
className="absolute inset-0 bg-chocolat/50 backdrop-blur-[2px]"
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="custom-waffle-title"
className="relative flex max-h-[min(90vh,720px)] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-chocolat/10 bg-cream shadow-[0_24px_60px_-12px_rgba(61,43,31,0.35)]"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-chocolat/10 bg-white px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4
id="custom-waffle-title"
className="text-lg font-bold text-chocolat"
>
{config.title}
</h4>
<p className="mt-1 text-sm text-muted">
Base {formatEuro(config.basePrice)} ajoutez vos
ingrédients
</p>
</div>
<button
type="button"
onClick={close}
className="rounded-full p-2 text-chocolat/70 hover:bg-chocolat/5 hover:text-chocolat"
aria-label="Fermer"
>
<span className="text-xl leading-none">×</span>
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{ingredients.length === 0 ? (
<p className="rounded-2xl border border-dashed border-chocolat/20 p-6 text-center text-sm text-muted">
Aucun ingrédient disponible pour le moment.
</p>
) : (
<ul className="space-y-2">
{ingredients.map((ing) => {
const isOn = selected.has(ing.id);
return (
<li key={ing.id}>
<button
type="button"
onClick={() => toggle(ing.id)}
className={`flex w-full items-center gap-3 rounded-2xl border p-3 text-left transition ${
isOn
? "border-gaufre bg-gaufre/15 ring-1 ring-gaufre/40"
: "border-chocolat/10 bg-white hover:border-chocolat/20"
}`}
>
<span
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-xl border bg-chocolat/5 ${
isOn
? "border-gaufre/50"
: "border-chocolat/10"
}`}
>
{ing.imageUrl ? (
<IngredientThumb
src={ing.imageUrl}
alt={ing.name}
/>
) : (
<span className="flex h-full w-full items-center justify-center text-lg text-chocolat/25">
</span>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium text-chocolat">
{ing.name}
</span>
<span className="text-sm font-semibold text-gaufre">
+{formatEuro(ing.price)}
</span>
</span>
<span
className={`flex size-6 shrink-0 items-center justify-center rounded-full border-2 text-xs font-bold ${
isOn
? "border-gaufre bg-gaufre text-chocolat"
: "border-chocolat/20 text-transparent"
}`}
aria-hidden
>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
<div className="border-t border-chocolat/10 bg-white px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-sm text-muted">
{selectedList.length === 0
? "Aucun ingrédient sélectionné"
: `${selectedList.length} ingrédient${selectedList.length > 1 ? "s" : ""}`}
</span>
<span className="text-xl font-bold text-gaufre">
{formatEuro(total)}
</span>
</div>
<button
type="button"
onClick={close}
className="w-full rounded-full bg-gaufre py-3 text-sm font-semibold text-chocolat shadow-md hover:opacity-95"
>
Valider ma composition
</button>
</div>
</div>
</div>
) : null}
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { SITE } from "@/lib/constants";
import type { SiteHoursRow } from "@/lib/site-content-defaults";
export function Footer({ hours }: { hours: SiteHoursRow[] }) {
return (
<footer id="infos" className="scroll-mt-24 border-t border-chocolat/10 bg-chocolat text-cream">
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<div className="grid gap-12 lg:grid-cols-2">
<div>
<h2 className="text-2xl font-bold text-white">{SITE.name}</h2>
<p className="mt-4 text-sm leading-relaxed text-white/80">
{SITE.address}
</p>
<p className="mt-2 text-sm text-white/80">
<a href={`tel:${SITE.phone.replace(/\s/g, "")}`} className="hover:text-gaufre">
{SITE.phone}
</a>
{" · "}
<a href={`mailto:${SITE.email}`} className="hover:text-gaufre">
{SITE.email}
</a>
</p>
{hours.length > 0 ? (
<>
<h3 className="mt-8 text-sm font-bold uppercase tracking-wider text-gaufre">
Horaires
</h3>
<ul className="mt-3 space-y-2 text-sm text-white/85">
{hours.map((h, i) => (
<li
key={`hour-${i}-${h.days}`}
className="flex justify-between gap-4 max-w-xs"
>
<span>{h.days}</span>
<span className="font-medium text-white">{h.hours}</span>
</li>
))}
</ul>
</>
) : null}
</div>
<div className="overflow-hidden rounded-3xl shadow-[0_16px_50px_-12px_rgba(0,0,0,0.45)]">
<iframe
title="Plan — Gaufrement Bon"
src={SITE.mapsEmbedUrl}
className="aspect-[4/3] h-[min(320px,50vh)] w-full border-0 lg:aspect-auto lg:h-full lg:min-h-[280px]"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
</div>
<p className="mt-12 border-t border-white/10 pt-8 text-center text-xs text-white/50">
© {new Date().getFullYear()} {SITE.name}. Tous droits réservés.
</p>
</div>
</footer>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { GalleryImage } from "@/generated/prisma/client";
import { WaffleMenuImage } from "./WaffleMenuImage";
const INTERVAL_MS = 5000;
const MAX_VISIBLE = 3;
/** gap-3 = 0.75rem — garder aligné avec les classes Tailwind */
const GAP_PX = 12;
const SLIDE_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)";
type Props = {
images: GalleryImage[];
};
/** Nombre de positions « première image à gauche » : 0 … len-3 → len-2 positions */
function positionCount(len: number) {
return Math.max(0, len - 2);
}
export function GalleryCarousel({ images }: Props) {
const viewportRef = useRef<HTMLDivElement>(null);
const [startIndex, setStartIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [metrics, setMetrics] = useState({ itemW: 0, step: 0 });
const len = images.length;
const positions = positionCount(len);
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 itemW = (w - 2 * GAP_PX) / MAX_VISIBLE;
const step = itemW + GAP_PX;
setMetrics((m) =>
m.itemW === itemW && m.step === step ? m : { itemW, step }
);
}
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const advance = useCallback(() => {
if (len <= MAX_VISIBLE || positions <= 1) return;
setStartIndex((i) => (i + 1) % positions);
}, [len, positions]);
useEffect(() => {
if (len <= MAX_VISIBLE || paused || positions <= 1) return undefined;
const t = window.setInterval(advance, INTERVAL_MS);
return () => window.clearInterval(t);
}, [len, 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 === 0) {
return (
<p className="rounded-3xl border border-dashed border-chocolat/20 bg-cream/50 px-6 py-12 text-center text-muted">
Photos à venir ajoutez-en depuis ladministration (section Galerie).
</p>
);
}
if (len <= MAX_VISIBLE) {
const gridClass =
len === 1
? "mx-auto grid max-w-md grid-cols-1 gap-3 sm:gap-4"
: len === 2
? "mx-auto grid max-w-2xl grid-cols-2 gap-3 sm:gap-4"
: "grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4";
return (
<div className={gridClass}>
{images.map((img) => (
<figure
key={img.id}
className="group relative aspect-square overflow-hidden rounded-3xl shadow-[0_10px_40px_-10px_rgba(61,43,31,0.2)]"
>
<div className="relative h-full w-full origin-center transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.035]">
<WaffleMenuImage src={img.imageUrl} alt={img.alt} />
</div>
</figure>
))}
</div>
);
}
const translateX = metrics.step > 0 ? -startIndex * metrics.step : 0;
return (
<div
className="relative"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div
ref={viewportRef}
className="overflow-hidden rounded-3xl"
>
<div
className="flex flex-row flex-nowrap gap-3 will-change-transform"
style={{
transform: `translateX(${translateX}px)`,
transition: `transform 650ms ${SLIDE_EASE}`,
}}
>
{images.map((img) => (
<div
key={img.id}
className="shrink-0"
style={
metrics.itemW > 0
? { width: metrics.itemW, minWidth: metrics.itemW }
: { width: "calc((100% - 24px) / 3)", minWidth: "calc((100% - 24px) / 3)" }
}
>
<figure className="group relative aspect-square overflow-hidden rounded-3xl shadow-[0_10px_40px_-10px_rgba(61,43,31,0.2)]">
<div className="relative h-full w-full origin-center transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.035]">
<WaffleMenuImage src={img.imageUrl} alt={img.alt} />
</div>
</figure>
</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={`Position ${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>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { GalleryImage } from "@/generated/prisma/client";
import { GalleryCarousel } from "./GalleryCarousel";
export function GallerySection({
images,
intro,
}: {
images: GalleryImage[];
intro: string;
}) {
return (
<section id="galerie" className="scroll-mt-24 bg-white/60 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">
Galerie
</h2>
{intro.trim() ? (
<p className="mx-auto mt-3 max-w-2xl text-muted">{intro}</p>
) : null}
</div>
<GalleryCarousel images={images} />
</div>
</section>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Image from "next/image";
import { isUploadAssetPath } from "@/lib/image-url";
import { MenuTextureButton } from "./MenuTextureButton";
type Props = {
eyebrow: string;
title: string;
subtitle: string;
backgroundImageUrl: string;
/** false : image seule, sans texte ni dégradé fort */
textOverImage?: boolean;
};
export function Hero({
eyebrow,
title,
subtitle,
backgroundImageUrl,
textOverImage = true,
}: Props) {
return (
<section
id="accueil"
className="relative flex min-h-[min(92vh,820px)] flex-col justify-end overflow-hidden rounded-b-[2.5rem] shadow-[0_24px_60px_-12px_rgba(61,43,31,0.35)]"
>
{backgroundImageUrl.startsWith("http") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={backgroundImageUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center"
/>
) : (
<Image
src={backgroundImageUrl}
alt=""
fill
priority
unoptimized={isUploadAssetPath(backgroundImageUrl)}
className="object-cover object-center"
sizes="100vw"
/>
)}
<div
className={
textOverImage
? "absolute inset-0 bg-gradient-to-t from-chocolat via-chocolat/75 to-chocolat/25"
: "absolute inset-0 bg-gradient-to-t from-chocolat/30 via-transparent to-transparent"
}
aria-hidden
/>
{textOverImage ? (
<div className="relative mx-auto max-w-6xl px-4 pb-16 pt-32 sm:px-6 sm:pb-24">
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-gaufre">
{eyebrow}
</p>
<h1 className="mb-4 max-w-3xl text-4xl font-bold leading-tight tracking-tight text-white drop-shadow-sm sm:text-5xl md:text-6xl">
{title}
</h1>
<p className="mb-10 max-w-xl text-lg text-white/90">{subtitle}</p>
<div className="flex flex-wrap gap-4">
<MenuTextureButton href="/#menu">Voir le menu</MenuTextureButton>
<MenuTextureButton href="/#ou-me-trouver">
me trouver
</MenuTextureButton>
</div>
</div>
) : null}
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { MenuTextureButton } from "./MenuTextureButton";
type Props = {
eyebrow: string;
title: string;
subtitle: string;
};
/** Texte daccueil affiché sous le héros (mode image seule), au-dessus du menu. */
export function HeroIntro({ eyebrow, title, subtitle }: Props) {
return (
<section
aria-labelledby="hero-intro-title"
className="border-b border-chocolat/8 bg-cream"
>
<div className="mx-auto max-w-6xl px-4 py-12 text-center sm:px-6 sm:py-16">
<p className="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-gaufre">
{eyebrow}
</p>
<h1
id="hero-intro-title"
className="mx-auto mb-4 max-w-3xl text-4xl font-bold leading-tight tracking-tight text-chocolat sm:text-5xl md:text-6xl"
>
{title}
</h1>
<p className="mx-auto mb-10 max-w-xl text-lg text-chocolat">{subtitle}</p>
<div className="flex flex-wrap justify-center gap-4">
<MenuTextureButton href="/#menu">Voir le menu</MenuTextureButton>
<MenuTextureButton href="/#ou-me-trouver">
me trouver
</MenuTextureButton>
</div>
</div>
</section>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { CATEGORY_LABELS, WAFFLE_CATEGORIES } from "@/lib/constants";
import type { CustomWaffleConfig, IngredientPickerItem } from "@/lib/custom-waffle";
import { CustomWaffleCard } from "./CustomWaffleCard";
import type { Waffle } from "@/generated/prisma/client";
import {
WaffleIngredientList,
type MenuIngredient,
} from "./WaffleIngredientList";
import { WaffleMenuImage } from "./WaffleMenuImage";
export type WaffleForMenu = Waffle & {
ingredientItems: MenuIngredient[];
};
const CATEGORY_ORDER = ["DU_MOIS", "SUCREE", "SALEE"] as const;
function sortWaffles(waffles: WaffleForMenu[]) {
const rank = (c: string) => {
const i = CATEGORY_ORDER.indexOf(c as (typeof CATEGORY_ORDER)[number]);
return i === -1 ? 99 : i;
};
return [...waffles].sort(
(a, b) => rank(a.category) - rank(b.category) || a.name.localeCompare(b.name)
);
}
function groupByCategory(waffles: WaffleForMenu[]) {
const map = new Map<string, WaffleForMenu[]>();
for (const w of sortWaffles(waffles)) {
const list = map.get(w.category) ?? [];
list.push(w);
map.set(w.category, list);
}
return map;
}
export function MenuSection({
waffles,
intro,
ingredientCatalog,
customWaffle,
}: {
waffles: WaffleForMenu[];
intro: string;
ingredientCatalog: IngredientPickerItem[];
customWaffle: CustomWaffleConfig;
}) {
const grouped = groupByCategory(waffles);
return (
<section id="menu" className="scroll-mt-24 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">Menu</h2>
{intro.trim() ? (
<p className="mt-3 text-muted">{intro}</p>
) : null}
</div>
<div className="flex flex-col gap-16">
{CATEGORY_ORDER.map((key) => {
const items = grouped.get(key) ?? [];
const showCustomWaffle =
key === WAFFLE_CATEGORIES.DU_MOIS && customWaffle.visible;
if (!items.length && !showCustomWaffle) return null;
return (
<div key={key}>
<h3 className="mb-6 flex items-center gap-3 text-xl font-bold text-chocolat">
<span className="h-1 w-10 rounded-full bg-gaufre" />
{CATEGORY_LABELS[key] ?? key}
</h3>
<ul className="grid gap-6 sm:grid-cols-2">
{showCustomWaffle ? (
<CustomWaffleCard
config={customWaffle}
ingredients={ingredientCatalog}
/>
) : null}
{items.map((w) => (
<li
key={w.id}
className="overflow-hidden rounded-3xl border border-chocolat/10 bg-white shadow-[0_8px_30px_-8px_rgba(61,43,31,0.12)] transition hover:shadow-[0_12px_40px_-8px_rgba(61,43,31,0.18)]"
>
{w.imageUrl ? (
<div className="relative aspect-[16/10] w-full bg-chocolat/5">
<WaffleMenuImage src={w.imageUrl} alt={w.name} />
</div>
) : null}
<div className="p-6">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<span className="font-semibold text-chocolat">
{w.name}
</span>
<div className="flex items-center gap-2">
{w.isNew && (
<span className="rounded-full bg-gaufre/20 px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-chocolat">
Nouveauté
</span>
)}
<span className="whitespace-nowrap text-lg font-bold text-gaufre">
{w.price.toFixed(2).replace(".", ",")}
</span>
</div>
</div>
<p className="text-sm leading-relaxed text-muted">
{w.ingredients}
</p>
<WaffleIngredientList items={w.ingredientItems} />
</div>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
</section>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from "next/link";
const textureStyle = {
backgroundImage: "url(/gallery/menu-button-texture.png)",
backgroundSize: "220px 220px",
backgroundRepeat: "repeat" as const,
backgroundPosition: "center",
};
type Props = {
href: string;
children: React.ReactNode;
className?: string;
};
export function MenuTextureButton({ href, children, className = "" }: Props) {
return (
<Link
href={href}
className={`inline-flex items-center justify-center rounded-[5px] border border-white/25 px-4 py-5 text-base font-semibold text-white shadow-lg shadow-chocolat/40 transition hover:brightness-110 ${className}`}
style={textureStyle}
>
<span className="[paint-order:stroke_fill] [-webkit-text-stroke:1.25px_#000]">
{children}
</span>
</Link>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type {
PublicTruckEvent,
PublicTruckRecurrence,
} from "@/lib/truck-schedule-shared";
import { WeekScheduleCalendar } from "./WeekScheduleCalendar";
type Props = {
events: PublicTruckEvent[];
recurrences: PublicTruckRecurrence[];
intro: string;
};
export function ScheduleSection({ events, recurrences, intro }: Props) {
return (
<section
id="ou-me-trouver"
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="text-center">
<h2 className="text-3xl font-bold text-chocolat sm:text-4xl">
me trouver
</h2>
{intro.trim() ? (
<p className="mx-auto mt-3 max-w-2xl text-muted">{intro}</p>
) : (
<p className="mx-auto mt-3 max-w-2xl text-muted">
Retrouvez le food truck Gaufrement Bon sur les marchés et événements
de la semaine.
</p>
)}
</div>
<WeekScheduleCalendar events={events} recurrences={recurrences} />
</div>
</section>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Image from "next/image";
import Link from "next/link";
import { SITE } from "@/lib/constants";
import { isUploadAssetPath } from "@/lib/image-url";
type Props = {
logoUrl?: string;
};
export function SiteHeader({ logoUrl = SITE.logoUrl }: Props) {
return (
<header className="sticky top-0 z-50 border-b border-chocolat/10 bg-cream/95 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<Link
href="/#accueil"
className="flex items-center gap-2.5 text-lg font-bold tracking-tight text-chocolat sm:gap-3 sm:text-xl"
>
<Image
src={logoUrl}
alt=""
width={44}
height={44}
unoptimized={isUploadAssetPath(logoUrl)}
className="h-9 w-9 shrink-0 sm:h-11 sm:w-11"
priority
/>
<span>{SITE.name}</span>
</Link>
<nav className="flex flex-wrap items-center justify-end gap-2 text-sm font-medium text-chocolat/90 sm:gap-6">
<Link href="/#menu" className="rounded-full px-3 py-1.5 hover:bg-gaufre/15">
Menu
</Link>
<Link
href="/#ou-me-trouver"
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
>
me trouver
</Link>
<Link
href="/#galerie"
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
>
Galerie
</Link>
<Link
href="/#infos"
className="rounded-full px-3 py-1.5 hover:bg-gaufre/15"
>
Infos
</Link>
</nav>
</div>
</header>
);
}
@@ -0,0 +1,25 @@
export type MenuIngredient = {
id: string;
name: string;
};
export function WaffleIngredientList({
items,
}: {
items: MenuIngredient[];
}) {
if (!items.length) return null;
return (
<ul className="mt-1.5 flex flex-wrap gap-1">
{items.map((ing) => (
<li
key={ing.id}
className="inline-flex items-center rounded-full border border-chocolat/10 bg-cream/60 px-2 py-0.5 text-xs font-medium leading-tight text-chocolat"
>
{ing.name}
</li>
))}
</ul>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Image from "next/image";
import { isUploadAssetPath } from "@/lib/image-url";
type Props = {
src: string;
alt: string;
};
export function WaffleMenuImage({ src, alt }: Props) {
if (src.startsWith("http://") || src.startsWith("https://")) {
return (
// eslint-disable-next-line @next/next/no-img-element -- URLs externes arbitraires
<img
src={src}
alt={alt}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
);
}
return (
<Image
src={src}
alt={alt}
fill
unoptimized={isUploadAssetPath(src)}
className="object-cover"
sizes="(max-width: 640px) 100vw, 50vw"
/>
);
}
@@ -0,0 +1,189 @@
"use client";
import { useMemo, useState } from "react";
import {
mergeScheduleForDays,
type PublicTruckEvent,
type PublicTruckRecurrence,
} from "@/lib/truck-schedule-shared";
import {
formatWeekRangeLabel,
getWeekDays,
type WeekDay,
} from "@/lib/week-calendar";
import { WaffleMenuImage } from "./WaffleMenuImage";
type Props = {
events: PublicTruckEvent[];
recurrences: PublicTruckRecurrence[];
};
/** Plus d’événements → colonne plus large ; jour vide → colonne compacte. */
function dayColumnFlex(eventCount: number): number {
if (eventCount === 0) return 0.42;
if (eventCount === 1) return 1;
return 1 + (eventCount - 1) * 0.38;
}
export function WeekScheduleCalendar({ events, recurrences }: Props) {
const [weekOffset, setWeekOffset] = useState(0);
const days = useMemo(
() => getWeekDays(new Date(), weekOffset),
[weekOffset]
);
const byDate = useMemo(() => {
const merged = mergeScheduleForDays(events, recurrences, days);
const map = new Map<string, PublicTruckEvent[]>();
for (const ev of merged) {
const list = map.get(ev.eventDate) ?? [];
list.push(ev);
map.set(ev.eventDate, list);
}
return map;
}, [events, recurrences, days]);
const rangeLabel = formatWeekRangeLabel(days);
return (
<div className="mt-8">
<div className="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setWeekOffset((w) => w - 1)}
className="rounded-full border border-chocolat/15 bg-white px-3 py-2 text-sm font-medium text-chocolat hover:bg-cream"
aria-label="Semaine précédente"
>
</button>
<button
type="button"
onClick={() => setWeekOffset(0)}
className="rounded-full border border-chocolat/15 bg-white px-4 py-2 text-sm font-medium text-chocolat hover:bg-cream"
>
Cette semaine
</button>
<button
type="button"
onClick={() => setWeekOffset((w) => w + 1)}
className="rounded-full border border-chocolat/15 bg-white px-3 py-2 text-sm font-medium text-chocolat hover:bg-cream"
aria-label="Semaine suivante"
>
</button>
</div>
<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">
{days.map((day) => {
const dayEvents = byDate.get(day.date) ?? [];
return (
<DayColumn
key={day.date}
day={day}
events={dayEvents}
flexGrow={dayColumnFlex(dayEvents.length)}
/>
);
})}
</div>
</div>
);
}
function DayColumn({
day,
events,
flexGrow,
}: {
day: WeekDay;
events: PublicTruckEvent[];
flexGrow: number;
}) {
const hasEvents = events.length > 0;
return (
<article
style={{ flex: `${flexGrow} 1 0%` }}
className={`flex min-w-0 flex-col overflow-hidden rounded-2xl border transition-all duration-300 ${
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]"}`}
>
<header
className={`shrink-0 border-b px-3 py-2.5 text-center ${
day.isToday
? "border-gaufre/20 bg-gaufre/5"
: "border-chocolat/8 bg-cream/30"
}`}
>
<div className="flex items-center justify-center gap-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted">
{day.label}
</p>
{hasEvents ? (
<span
className="rounded-full bg-gaufre/20 px-1.5 py-0.5 text-[10px] font-bold leading-none text-chocolat"
aria-label={`${events.length} événement${events.length > 1 ? "s" : ""}`}
>
{events.length}
</span>
) : null}
</div>
<p
className={`mt-0.5 text-xl font-bold leading-none ${
day.isToday ? "text-gaufre" : "text-chocolat"
}`}
>
{day.dayNum}
</p>
</header>
<ul
className={`flex min-h-0 flex-1 flex-col ${
hasEvents ? "gap-3 p-2.5" : "items-center justify-center p-3"
}`}
>
{hasEvents ? (
events.map((ev) => <EventCard key={ev.id} event={ev} />)
) : (
<li className="text-center text-xs text-muted"></li>
)}
</ul>
</article>
);
}
function EventCard({ event: ev }: { event: PublicTruckEvent }) {
const isRecurring = ev.id.startsWith("recur-");
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">
<WaffleMenuImage src={ev.imageUrl} alt={ev.location} />
</div>
) : null}
<div className="w-full px-3 py-2.5 text-left text-xs">
{isRecurring ? (
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gaufre">
Récurrent
</p>
) : null}
{ev.timeLabel ? (
<p className="font-semibold text-gaufre">{ev.timeLabel}</p>
) : null}
<p className="mt-0.5 font-medium leading-snug text-chocolat">
{ev.location}
</p>
{ev.note ? (
<p className="mt-1 leading-snug text-muted">{ev.note}</p>
) : null}
</div>
</li>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { SignJWT, jwtVerify } from "jose";
const encoder = new TextEncoder();
/** Secret par défaut uniquement pour le dev local — définissez AUTH_SECRET en prod. */
const DEV_FALLBACK =
"dev-secret-change-me-in-production-min-32-chars-required!!";
export function getJwtSecretKey() {
const secret = process.env.AUTH_SECRET ?? DEV_FALLBACK;
return encoder.encode(secret);
}
export async function createSessionToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(getJwtSecretKey());
}
export async function verifySessionToken(
token: string
): Promise<string | null> {
try {
const { payload } = await jwtVerify(token, getJwtSecretKey());
return typeof payload.sub === "string" ? payload.sub : null;
} catch {
return null;
}
}
export const SESSION_COOKIE = "gauffre_session";
+32
View File
@@ -0,0 +1,32 @@
export const SITE = {
name: "Gaufrement Bon",
logoUrl: "/logo.png",
tagline: "Gaufres artisanales, textures croustillantes, cœur moelleux.",
address: "Impasse du moulin de la tour, 30200 Bagnols-sur-Cèze",
phone: "06 44 08 08 97",
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",
hours: [
{ days: "Lun — Ven", hours: "11h — 22h" },
{ days: "Sam — Dim", hours: "10h — 23h" },
],
} as const;
export const WAFFLE_CATEGORIES = {
SUCREE: "SUCREE",
SALEE: "SALEE",
DU_MOIS: "DU_MOIS",
} as const;
export type WaffleCategory =
(typeof WAFFLE_CATEGORIES)[keyof typeof WAFFLE_CATEGORIES];
export const CATEGORY_LABELS: Record<string, string> = {
SUCREE: "Gaufres sucrées",
SALEE: "Gaufres salées",
DU_MOIS: "Gaufre du moment",
};
/** Préfixe des routes API réservées à ladmin (cookie de session limité à ce chemin). */
export const ADMIN_API = "/admin/api";
+70
View File
@@ -0,0 +1,70 @@
import {
DEFAULT_CUSTOM_WAFFLE,
type CustomWaffleConfig,
} from "./custom-waffle";
import { normalizeImageUrl } from "./image-url";
import { prisma } from "./prisma";
import { SITE_CONTENT_ID } from "./site-content-defaults";
export async function getCustomWaffleConfig(): Promise<CustomWaffleConfig> {
const row = await prisma.siteContent.findUnique({
where: { id: SITE_CONTENT_ID },
select: {
customWaffleTitle: true,
customWaffleDescription: true,
customWaffleBasePrice: true,
customWaffleImageUrl: true,
customWaffleVisible: true,
},
});
if (!row) {
return { ...DEFAULT_CUSTOM_WAFFLE };
}
const basePrice =
Number.isFinite(row.customWaffleBasePrice) && row.customWaffleBasePrice >= 0
? row.customWaffleBasePrice
: DEFAULT_CUSTOM_WAFFLE.basePrice;
return {
title: row.customWaffleTitle.trim() || DEFAULT_CUSTOM_WAFFLE.title,
description:
row.customWaffleDescription.trim() || DEFAULT_CUSTOM_WAFFLE.description,
basePrice,
imageUrl:
row.customWaffleImageUrl?.trim() || DEFAULT_CUSTOM_WAFFLE.imageUrl,
visible: row.customWaffleVisible,
};
}
export function parseCustomWafflePayload(
body: Record<string, unknown>
): { data: CustomWaffleConfig; error?: undefined } | { data?: undefined; error: string } {
const title = String(body.title ?? "").trim();
const description = String(body.description ?? "").trim();
if (!title) return { error: "Le titre est requis" };
if (!description) return { error: "La description est requise" };
const rawPrice =
typeof body.basePrice === "number"
? body.basePrice
: parseFloat(String(body.basePrice ?? "").replace(",", "."));
if (!Number.isFinite(rawPrice) || rawPrice < 0) {
return { error: "Prix minimum invalide" };
}
const imageUrl =
normalizeImageUrl(String(body.imageUrl ?? "")) ??
DEFAULT_CUSTOM_WAFFLE.imageUrl;
return {
data: {
title,
description,
basePrice: rawPrice,
imageUrl,
visible: body.visible !== false,
},
};
}
+28
View File
@@ -0,0 +1,28 @@
export type CustomWaffleConfig = {
title: string;
description: string;
basePrice: number;
imageUrl: string;
visible: boolean;
};
/** Valeurs par défaut de la gaufre « sur mesure ». */
export const DEFAULT_CUSTOM_WAFFLE: CustomWaffleConfig = {
title: "La gaufre sur mesure",
description:
"Composez votre gaufre en choisissant parmi nos ingrédients : chacun avec sa photo et son prix.",
basePrice: 3,
imageUrl: "/gallery/gaufres-sucrees.png",
visible: true,
};
export type IngredientPickerItem = {
id: string;
name: string;
price: number;
imageUrl: string | null;
};
export function formatEuro(amount: number): string {
return `${amount.toFixed(2).replace(".", ",")}`;
}
+30
View File
@@ -0,0 +1,30 @@
/** Détection du type réel à partir des premiers octets (ne pas se fier au Content-Type client). */
export function detectImageMime(buf: Buffer): string | null {
if (buf.length < 12) return null;
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
return "image/jpeg";
}
if (
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47
) {
return "image/png";
}
if (
buf[0] === 0x47 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x38
) {
return "image/gif";
}
if (
buf.toString("ascii", 0, 4) === "RIFF" &&
buf.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
return null;
}
+32
View File
@@ -0,0 +1,32 @@
/** Valide une URL dimage publique (chemin local ou https). Pas de http: sauf localhost en dev (évite le contenu mixte). */
export function normalizeImageUrl(raw: string | null | undefined): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (s === "") return null;
if (s.startsWith("/") && !s.includes("..") && !s.includes("//")) {
return s;
}
try {
const u = new URL(s);
if (u.protocol === "https:") {
return u.href;
}
if (u.protocol === "http:") {
const isLocal =
u.hostname === "localhost" ||
u.hostname === "127.0.0.1" ||
u.hostname === "[::1]";
if (process.env.NODE_ENV !== "production" && isLocal) {
return u.href;
}
}
} catch {
/* ignore */
}
return null;
}
/** Chemins générés par lupload admin (`/uploads/…`). */
export function isUploadAssetPath(src: string): boolean {
return src.startsWith("/uploads/");
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Parse un booléen JSON strict (rejette les chaînes truthy comme `"false"`).
* Si la clé est absente, retourne `defaultIfAbsent`.
*/
export function parseStrictBoolean(
value: unknown,
fieldName: string,
defaultIfAbsent: boolean
):
| { ok: true; value: boolean }
| { ok: false; error: string } {
if (value === undefined) {
return { ok: true, value: defaultIfAbsent };
}
if (typeof value === "boolean") {
return { ok: true, value };
}
return {
ok: false,
error: `${fieldName} doit être un booléen (true ou false)`,
};
}
+14
View File
@@ -0,0 +1,14 @@
import bcrypt from "bcryptjs";
const SALT_ROUNDS = 10;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object";
}
/** P2025 : enregistrement introuvable pour update/delete avec `where` unique. */
const PRISMA_NOT_FOUND = "P2025";
export function prismaMutationErrorResponse(error: unknown): NextResponse {
if (isRecord(error) && typeof error.code === "string") {
if (error.code === PRISMA_NOT_FOUND) {
return NextResponse.json({ error: "Introuvable" }, { status: 404 });
}
}
console.error("[api] Prisma", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
+14
View File
@@ -0,0 +1,14 @@
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "@/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createClient() {
const url = process.env.DATABASE_URL ?? "file:./dev.db";
const adapter = new PrismaBetterSqlite3({ url });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+91
View File
@@ -0,0 +1,91 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
type Entry = { count: number; resetAt: number };
const memoryStore = new Map<string, Entry>();
function pruneMemory() {
const now = Date.now();
for (const [k, v] of memoryStore) {
if (v.resetAt < now) memoryStore.delete(k);
}
}
/** Fallback mono-instance si Upstash nest pas configuré. */
function rateLimitMemory(
key: string,
max: number,
windowMs: number
): { ok: true } | { ok: false; retryAfterSec: number } {
pruneMemory();
const now = Date.now();
let e = memoryStore.get(key);
if (!e || e.resetAt < now) {
e = { count: 0, resetAt: now + windowMs };
memoryStore.set(key, e);
}
e.count += 1;
if (e.count > max) {
return {
ok: false,
retryAfterSec: Math.max(1, Math.ceil((e.resetAt - now) / 1000)),
};
}
return { ok: true };
}
const WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_PER_WINDOW = 8;
let upstashLimiter: Ratelimit | null | undefined;
function getUpstashLoginLimiter(): Ratelimit | null {
if (upstashLimiter !== undefined) return upstashLimiter;
if (
!process.env.UPSTASH_REDIS_REST_URL ||
!process.env.UPSTASH_REDIS_REST_TOKEN
) {
upstashLimiter = null;
return null;
}
upstashLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(MAX_FAILED_PER_WINDOW, "15 m"),
prefix: "gauffre:login",
});
return upstashLimiter;
}
/**
* Limite les échecs de connexion par identifiant (IP).
* Avec `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`, le compteur est partagé entre toutes les instances.
*/
export async function rateLimitFailedLogin(
ip: string
): Promise<{ ok: true } | { ok: false; retryAfterSec: number }> {
const limiter = getUpstashLoginLimiter();
if (limiter) {
const { success, reset } = await limiter.limit(ip);
if (!success) {
const retryAfterSec = Math.max(
1,
Math.ceil((reset - Date.now()) / 1000)
);
return { ok: false, retryAfterSec };
}
return { ok: true };
}
return rateLimitMemory(`login:${ip}`, MAX_FAILED_PER_WINDOW, WINDOW_MS);
}
export function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) {
const first = xff.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return "unknown";
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextResponse } from "next/server";
import { SESSION_COOKIE } from "./auth";
/** Cookie de session limité à lespace admin + API admin (pas envoyé sur la page publique). */
export const SESSION_COOKIE_PATH = "/admin";
export function sessionCookieBase() {
return {
httpOnly: true as const,
sameSite: "lax" as const,
path: SESSION_COOKIE_PATH,
secure: process.env.NODE_ENV === "production",
};
}
function expireOpts(path: string) {
return {
httpOnly: true as const,
sameSite: "lax" as const,
path,
secure: process.env.NODE_ENV === "production",
maxAge: 0,
expires: new Date(0),
};
}
/**
* Next.js `cookies.set` est indexé par **nom** : un second `set` avec le même nom
* écrase le premier. Pour plusieurs chemins (`/admin` vs `/`), il faut un second
* en-tête `Set-Cookie` via `headers.append`.
*/
function appendExpiredSessionCookie(res: NextResponse, path: string) {
const secure = process.env.NODE_ENV === "production";
const parts = [
`${SESSION_COOKIE}=`,
`Path=${path}`,
"HttpOnly",
"SameSite=Lax",
"Max-Age=0",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
];
if (secure) parts.push("Secure");
res.headers.append("Set-Cookie", parts.join("; "));
}
/**
* Supprime toutes les variantes connues du cookie de session (évite un ancien `path=/`
* qui resterait valide après déconnexion et réactiverait la session sur /admin/login).
*/
export function clearAllSessionCookieVariants(res: NextResponse) {
res.cookies.set(SESSION_COOKIE, "", expireOpts(SESSION_COOKIE_PATH));
appendExpiredSessionCookie(res, "/");
}
/** À lissue dun login réussi : retirer un éventuel cookie legacy `path=/` sans écraser le jeton. */
export function clearLegacyRootSessionCookie(res: NextResponse) {
appendExpiredSessionCookie(res, "/");
}
+9
View File
@@ -0,0 +1,9 @@
import { cookies } from "next/headers";
import { SESSION_COOKIE, verifySessionToken } from "./auth";
export async function getSessionUserId(): Promise<string | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
return verifySessionToken(token);
}
+35
View File
@@ -0,0 +1,35 @@
import { SITE } from "./constants";
export type SiteHoursRow = { days: string; hours: string };
export const SITE_CONTENT_ID = "default" as const;
export type PublicSiteCopy = {
logoUrl: string;
heroEyebrow: string;
heroTitle: string;
heroSubtitle: string;
heroBackgroundImageUrl: string;
/** true : texte sur limage ; false : texte sous le héros, au-dessus du menu */
heroTextOverImage: boolean;
menuIntro: string;
galleryIntro: string;
scheduleIntro: string;
hours: SiteHoursRow[];
};
export const DEFAULT_PUBLIC_SITE_COPY: PublicSiteCopy = {
logoUrl: SITE.logoUrl,
heroEyebrow: "Artisanal · Belge · Fait maison",
heroTitle: SITE.tagline,
heroSubtitle:
"Craquez pour nos gaufres caramélisées, nos salées gourmandes et la gaufre du mois — à déguster sur place ou à emporter.",
heroBackgroundImageUrl: "/gallery/hero-coco.png",
heroTextOverImage: true,
menuIntro:
"Une sélection qui évolue — demandez les suggestions du jour au comptoir.",
galleryIntro: "",
scheduleIntro:
"Retrouvez le food truck Gaufrement Bon sur les marchés et événements de la semaine.",
hours: SITE.hours.map((h) => ({ days: h.days, hours: h.hours })),
};

Some files were not shown because too many files have changed in this diff Show More