update
@@ -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 l’image
|
||||
public/uploads/*.png
|
||||
public/uploads/*.jpg
|
||||
public/uploads/*.jpeg
|
||||
public/uploads/**/*.png
|
||||
public/uploads/**/*.jpg
|
||||
public/uploads/**/*.jpeg
|
||||
@@ -33,9 +33,19 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# sqlite local
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# photos uploadées (fichiers générés)
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 d’accueil, carte des gaufres, galerie photo, contenus éditables, et interface d’administration.
|
||||
|
||||
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 d’images** : 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
|
||||
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
|
||||
# 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). L’admin 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 s’appliquent :
|
||||
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Scripts npm
|
||||
|
||||
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 l’hô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 l’image 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 l’admin puis modifier le mot de passe.
|
||||
- Servir le site derrière HTTPS (Nginx ou équivalent).
|
||||
- Configurer Upstash si plusieurs instances partagent le rate limiting.
|
||||
|
||||
## Licence
|
||||
|
||||
Projet privé.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Déploiement serveur : app seule ; HTTPS géré par Nginx + Certbot sur l’hô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:
|
||||
@@ -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:
|
||||
@@ -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,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -4,24 +4,43 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"prebuild": "prisma migrate deploy && prisma generate",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"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-dom": "19.1.0",
|
||||
"next": "15.5.14"
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.14",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"prisma": "^7.5.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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 d’accueil et horaires. */
|
||||
model SiteContent {
|
||||
id String @id @default("default")
|
||||
heroEyebrow String
|
||||
heroTitle String
|
||||
heroSubtitle String
|
||||
heroBackgroundImageUrl String?
|
||||
/** Logo affiché dans l’en-tête du site public */
|
||||
logoUrl String?
|
||||
/** true : texte sur l’image (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])
|
||||
}
|
||||
@@ -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 d’exemple 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 d’exemple 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 d’exemple 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 d’exemple insérée.");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 294 KiB |
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publie l’image 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminSiteContent } from "@/components/admin/AdminSiteContent";
|
||||
|
||||
export default function AdminSitePage() {
|
||||
return <AdminSiteContent />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image 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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.create({
|
||||
data: { name, price, imageUrl },
|
||||
});
|
||||
revalidatePath("/");
|
||||
return NextResponse.json(ingredient);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
}
|
||||
@@ -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 d’image 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 279 KiB |
@@ -1,26 +1,27 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #fff8f0;
|
||||
--foreground: #3d2b1f;
|
||||
--muted: #6b5344;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--color-muted: var(--muted);
|
||||
--color-gaufre: #f39c12;
|
||||
--color-chocolat: #3d2b1f;
|
||||
--color-cream: #fff8f0;
|
||||
--font-sans: var(--font-outfit);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,20 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Outfit } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const outfit = Outfit({
|
||||
variable: "--font-outfit",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Gaufrement Bon",
|
||||
description:
|
||||
"Gaufres sucrées et salées artisanales. Horaires, adresse et menu à Lille.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,10 +20,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="fr">
|
||||
<body className={`${outfit.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
<>
|
||||
<SiteHeader logoUrl={site.logoUrl} />
|
||||
<main>
|
||||
<Hero
|
||||
eyebrow={site.heroEyebrow}
|
||||
title={site.heroTitle}
|
||||
subtitle={site.heroSubtitle}
|
||||
backgroundImageUrl={site.heroBackgroundImageUrl}
|
||||
textOverImage={site.heroTextOverImage}
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</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
|
||||
</a>
|
||||
<a
|
||||
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]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
{!site.heroTextOverImage ? (
|
||||
<HeroIntro
|
||||
eyebrow={site.heroEyebrow}
|
||||
title={site.heroTitle}
|
||||
subtitle={site.heroSubtitle}
|
||||
/>
|
||||
) : null}
|
||||
<MenuSection
|
||||
waffles={waffles}
|
||||
intro={site.menuIntro}
|
||||
ingredientCatalog={ingredientCatalog}
|
||||
customWaffle={customWaffle}
|
||||
/>
|
||||
<ScheduleSection
|
||||
events={truckEvents}
|
||||
recurrences={truckRecurrences}
|
||||
intro={site.scheduleIntro}
|
||||
/>
|
||||
<GallerySection images={galleryImages} intro={site.galleryIntro} />
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<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>
|
||||
<Footer hours={site.hours} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 l’envoi");
|
||||
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 l’enregistrement");
|
||||
}
|
||||
} 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 à l’affiche</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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’envoi");
|
||||
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 d’affichage (nombre le plus petit en premier). Sur la page
|
||||
d’accueil, 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 l’image
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’envoi");
|
||||
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 l’ingré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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’envoi");
|
||||
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 l’en-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 d’accueil 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'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 l’image (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 l’admin (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 l’image
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’envoi");
|
||||
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 l’enregistrement 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">Où 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 « Où 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 (aujourd’hui 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",
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 l’administration (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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Où me trouver
|
||||
</MenuTextureButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MenuTextureButton } from "./MenuTextureButton";
|
||||
|
||||
type Props = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/** Texte d’accueil 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">
|
||||
Où me trouver
|
||||
</MenuTextureButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Où 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
Où 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 à l’admin (cookie de session limité à ce chemin). */
|
||||
export const ADMIN_API = "/admin/api";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(".", ",")} €`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Valide une URL d’image 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 l’upload admin (`/uploads/…`). */
|
||||
export function isUploadAssetPath(src: string): boolean {
|
||||
return src.startsWith("/uploads/");
|
||||
}
|
||||
@@ -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)`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 n’est 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";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "./auth";
|
||||
|
||||
/** Cookie de session limité à l’espace 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, "/");
|
||||
}
|
||||
|
||||
/** À l’issue d’un login réussi : retirer un éventuel cookie legacy `path=/` sans écraser le jeton. */
|
||||
export function clearLegacyRootSessionCookie(res: NextResponse) {
|
||||
appendExpiredSessionCookie(res, "/");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 l’image ; 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 })),
|
||||
};
|
||||