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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# sqlite local
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# photos uploadées (fichiers générés)
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
@@ -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
|
```bash
|
||||||
|
npm install
|
||||||
|
npx prisma migrate deploy # crée dev.db si besoin
|
||||||
|
npm run db:seed # optionnel : données de démo + compte admin
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Ouvrir [http://localhost:3000](http://localhost:3000). 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.
|
## Scripts npm
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
| Commande | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `npm run dev` | Serveur de dev (port 3000) |
|
||||||
|
| `npm run build` | Build production (migrations + generate Prisma) |
|
||||||
|
| `npm run start` | Serveur Next en production |
|
||||||
|
| `npm run db:seed` | Seed manuel (menu, galerie, contenus, admin) |
|
||||||
|
| `npm run lint` | ESLint |
|
||||||
|
|
||||||
## Deploy on Vercel
|
## Docker
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
Image publiée : `foufure/gauffre:latest`.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
### Compose local (build + run)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AUTH_SECRET="$(openssl rand -base64 32)"
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Application sur [http://localhost:3007](http://localhost:3007) (port hôte `3007` → conteneur `3000`).
|
||||||
|
|
||||||
|
Volumes : base SQLite (`gauffre-data`) et fichiers uploadés (`gauffre-uploads`).
|
||||||
|
|
||||||
|
### Déploiement serveur
|
||||||
|
|
||||||
|
Fichier dédié : `docker-compose.server.yml` (HTTPS assuré par Nginx + Certbot sur 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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -4,24 +4,43 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
"prebuild": "prisma migrate deploy && prisma generate",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.5.0",
|
||||||
|
"@prisma/client": "^7.5.0",
|
||||||
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
"@upstash/redis": "^1.37.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"next": "15.5.14",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"next": "15.5.14"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.14",
|
"eslint-config-next": "15.5.14",
|
||||||
"@eslint/eslintrc": "^3"
|
"prisma": "^7.5.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #fff8f0;
|
||||||
--foreground: #171717;
|
--foreground: #3d2b1f;
|
||||||
|
--muted: #6b5344;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-muted: var(--muted);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-gaufre: #f39c12;
|
||||||
}
|
--color-chocolat: #3d2b1f;
|
||||||
|
--color-cream: #fff8f0;
|
||||||
@media (prefers-color-scheme: dark) {
|
--font-sans: var(--font-outfit);
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -1,20 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const outfit = Outfit({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-outfit",
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Gaufrement Bon",
|
||||||
description: "Generated by create next app",
|
description:
|
||||||
|
"Gaufres sucrées et salées artisanales. Horaires, adresse et menu à Lille.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,10 +20,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<body
|
<body className={`${outfit.variable} font-sans antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,103 +1,76 @@
|
|||||||
import Image from "next/image";
|
import { Footer } from "@/components/site/Footer";
|
||||||
|
import { GallerySection } from "@/components/site/GallerySection";
|
||||||
|
import { ScheduleSection } from "@/components/site/ScheduleSection";
|
||||||
|
import { Hero } from "@/components/site/Hero";
|
||||||
|
import { HeroIntro } from "@/components/site/HeroIntro";
|
||||||
|
import { MenuSection } from "@/components/site/MenuSection";
|
||||||
|
import { SiteHeader } from "@/components/site/SiteHeader";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getCustomWaffleConfig } from "@/lib/custom-waffle-content";
|
||||||
|
import { getPublicSiteContent } from "@/lib/site-content";
|
||||||
|
import { getPublicTruckEvents } from "@/lib/truck-event";
|
||||||
|
import { getPublicTruckRecurrences } from "@/lib/truck-recurrence";
|
||||||
|
import { waffleIngredientInclude } from "@/lib/waffle-ingredients";
|
||||||
|
|
||||||
|
/** Page mise en cache (ISR) ; invalidation via `revalidatePath("/")` après modifs admin. */
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const [
|
||||||
|
waffles,
|
||||||
|
ingredientCatalog,
|
||||||
|
galleryImages,
|
||||||
|
site,
|
||||||
|
customWaffle,
|
||||||
|
truckEvents,
|
||||||
|
truckRecurrences,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.waffle.findMany({ include: waffleIngredientInclude }),
|
||||||
|
prisma.ingredient.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, price: true, imageUrl: true },
|
||||||
|
}),
|
||||||
|
prisma.galleryImage.findMany({
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
}),
|
||||||
|
getPublicSiteContent(),
|
||||||
|
getCustomWaffleConfig(),
|
||||||
|
getPublicTruckEvents(),
|
||||||
|
getPublicTruckRecurrences(),
|
||||||
|
]);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
<>
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<SiteHeader logoUrl={site.logoUrl} />
|
||||||
<Image
|
<main>
|
||||||
className="dark:invert"
|
<Hero
|
||||||
src="/next.svg"
|
eyebrow={site.heroEyebrow}
|
||||||
alt="Next.js logo"
|
title={site.heroTitle}
|
||||||
width={180}
|
subtitle={site.heroSubtitle}
|
||||||
height={38}
|
backgroundImageUrl={site.heroBackgroundImageUrl}
|
||||||
priority
|
textOverImage={site.heroTextOverImage}
|
||||||
/>
|
/>
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
{!site.heroTextOverImage ? (
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
<HeroIntro
|
||||||
Get started by editing{" "}
|
eyebrow={site.heroEyebrow}
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
title={site.heroTitle}
|
||||||
src/app/page.tsx
|
subtitle={site.heroSubtitle}
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
/>
|
||||||
Deploy now
|
) : null}
|
||||||
</a>
|
<MenuSection
|
||||||
<a
|
waffles={waffles}
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
intro={site.menuIntro}
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
ingredientCatalog={ingredientCatalog}
|
||||||
target="_blank"
|
customWaffle={customWaffle}
|
||||||
rel="noopener noreferrer"
|
/>
|
||||||
>
|
<ScheduleSection
|
||||||
Read our docs
|
events={truckEvents}
|
||||||
</a>
|
recurrences={truckRecurrences}
|
||||||
</div>
|
intro={site.scheduleIntro}
|
||||||
|
/>
|
||||||
|
<GallerySection images={galleryImages} intro={site.galleryIntro} />
|
||||||
</main>
|
</main>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
<Footer hours={site.hours} />
|
||||||
<a
|
</>
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
};
|
||||||