Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
Imugiii
ba660e7d5a fix(borne): confirmation avant Abandon de la commande (geste destructeur)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 39s
CI / static-tests (push) Successful in 1m11s
CI / js-tests (push) Successful in 37s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 31s
CI / static-tests (pull_request) Successful in 1m7s
CI / js-tests (pull_request) Successful in 41s
Le bouton Abandon effacait toute la commande au moindre tap. Ajout d'une modale
de confirmation reutilisable (confirm-modal.js, CSP-safe : role=dialog,
aria-modal, focus piege, Echap et clic-fond = annuler, focus rendu au
declencheur, focus initial sur Annuler). Abandon passe par cette confirmation
avant clearCart + retour accueil.

La suppression d'une ligne reste immediate (re-ajoutable, et le stepper offre
le decrement doux) ; les cibles tactiles 44px ont ete posees au lot panier-unique.

Tests : confirm-modal.test.js (6 cas : affichage, confirmer, annuler, Echap,
clic-fond, echappement) + order-panel (Abandon demande confirmation). JS 118 verts.
2026-06-24 10:23:10 +00:00
6bf3597b5e fix(borne): panier unique = panneau persistant (retrait cart.html + product.html) (#101)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m12s
CI / js-tests (push) Successful in 53s
2026-06-24 12:18:30 +02:00
352355f5a5 feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file) (#100)
All checks were successful
CI / secret-scan (push) Successful in 15s
CI / php-lint (push) Successful in 35s
CI / static-tests (push) Successful in 1m0s
CI / js-tests (push) Successful in 32s
2026-06-24 12:05:25 +02:00
0968a98668 feat(borne): produit/menu en rupture stock non commandable (RG-T21) (#99)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 1m6s
CI / js-tests (push) Successful in 31s
2026-06-24 11:25:14 +02:00
411b04d548 feat(borne): menu Maxi agrandit la boisson en 50cl + transport du format (#98)
All checks were successful
CI / secret-scan (push) Successful in 13s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m5s
CI / js-tests (push) Successful in 35s
2026-06-24 11:04:20 +02:00
8e2e0382ba fix(devops): passer les variables SMTP/MAIL au conteneur wakdo-app (#97)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 28s
2026-06-23 16:11:31 +02:00
ef71101453 feat(auth): envoi reel de l'email de reset via relais SMTP (Brevo) (#96)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 1m3s
CI / js-tests (push) Successful in 1m29s
2026-06-23 15:34:27 +02:00
80b8272291 chore(devops): modeles versionnes docker-compose.prod.yml + .env de prod (#95)
All checks were successful
CI / secret-scan (push) Successful in 12s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 36s
2026-06-23 15:01:02 +02:00
8c5d942de8 feat(devops): CD push-based vers Vision (prod) + preuve de version (#94)
All checks were successful
CI / secret-scan (push) Successful in 14s
CI / php-lint (push) Successful in 24s
CI / static-tests (push) Successful in 1m4s
CI / js-tests (push) Successful in 35s
2026-06-23 11:32:57 +02:00
61 changed files with 2959 additions and 1083 deletions

View file

@ -131,3 +131,16 @@ CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
REVERSE_PROXY_NETWORK=admin_proxy
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
# ===================================================================
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
# SMTP_HOST=smtp-relay.brevo.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# MAIL_FROM_EMAIL=noreply@example.com
# MAIL_FROM_NAME=Wakdo

75
.env.prod.example Normal file
View file

@ -0,0 +1,75 @@
# Modele de configuration de PRODUCTION (derriere Traefik).
#
# cp .env.prod.example .env
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
#
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
APP_ENV=prod
APP_DEBUG=false
APP_TIMEZONE=Europe/Paris
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
DB_HOST=wakdo-db
DB_PORT=3306
DB_NAME=wakdo
DB_USER=wakdo
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
SESSION_LIFETIME_IDLE=14400
SESSION_LIFETIME_ABSOLUTE=36000
SESSION_NAME=WAKDO_SID
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
ARGON2_MEMORY_COST=65536
ARGON2_TIME_COST=4
ARGON2_THREADS=1
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_BASE_SECONDS=60
ACCOUNT_LOCKOUT_MAX_SECONDS=900
IP_THROTTLE_WINDOW_SECONDS=900
IP_THROTTLE_MAX_ATTEMPTS=20
STAFF_PIN_MIN_LENGTH=4
STAFF_PIN_MAX_LENGTH=12
PIN_THROTTLE_THRESHOLD=5
PIN_THROTTLE_BASE_SECONDS=30
PIN_THROTTLE_MAX_SECONDS=300
PIN_THROTTLE_WINDOW_SECONDS=900
PASSWORD_RESET_TTL=3600
AUDIT_LOG_RETENTION_DAYS=365
THROTTLE_PURGE_AFTER_HOURS=24
ORDER_RETENTION_DAYS=1095
UPLOAD_MAX_SIZE_MB=5
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
CRON_TIMEZONE=Europe/Paris
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
# ===================================================================
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
# ===================================================================
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_USER=<REMPLIR-login-smtp>
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
MAIL_FROM_EMAIL=noreply@a3n.fr
MAIL_FROM_NAME=Wakdo

View file

@ -0,0 +1,45 @@
name: Deploy
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
#
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
# peut ainsi declencher QUE le deploiement, rien d'autre.
#
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Install SSH client
run: |
apt-get update -qq
apt-get install -y -qq openssh-client >/dev/null
- name: Deploy to Vision over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
run: |
set -eu
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
# presente pas la cle attendue.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes -o ConnectTimeout=15 \
"$DEPLOY_USER@$DEPLOY_HOST"

3
.gitignore vendored
View file

@ -56,6 +56,9 @@ Thumbs.db
*.log
/logs/
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
/src/VERSION
# === Data / Uploads / Backups ===
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
# et tout futur artefact run-time (caches persistes, logs).

View file

@ -8,10 +8,15 @@
-- domaine commande facture deja par product_id : le flux de commande
-- reste inchange, la borne resout juste la taille choisie en product_id.
--
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
-- (migration 0006) : base_product_id pilote la selection de taille A LA
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
-- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
-- =============================================================================

View file

@ -0,0 +1,50 @@
-- =============================================================================
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
-- =============================================================================
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
--
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
-- deja exploite par OrderRepository::resolveSelections (substitution de
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
-- de libelle reflechira "<soda> 50cl".
--
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
-- boisson : aucune incidence de prix sur ces bouteilles.
--
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
-- lexicographique du runner db/seed.sh).
--
-- Conventions:
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
-- =============================================================================
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- -----------------------------------------------------------------------------
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
-- NULL.
-- -----------------------------------------------------------------------------
UPDATE product AS base
JOIN product AS variant
ON variant.base_product_id = base.id
AND variant.size_cl = 50
SET base.maxi_variant_product_id = variant.id
WHERE base.base_product_id IS NULL;

View file

@ -0,0 +1,184 @@
# Modele de compose de production (derriere un reverse proxy Traefik).
#
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
# seules les valeurs du .env changent. Sur l'hote de prod :
# cp docker-compose.prod.yml.example docker-compose.prod.yml
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
# docker compose -f docker-compose.prod.yml up -d --build
#
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
# doivent correspondre a la config Traefik de l'hote.
name: wakdo
networks:
wakdo_internal:
driver: bridge
reverse_proxy:
name: ${REVERSE_PROXY_NETWORK}
external: true
volumes:
wakdo_db_data:
wakdo_uploads:
services:
wakdo-db:
image: mariadb:11.4
container_name: wakdo-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
TZ: ${APP_TIMEZONE:-Europe/Paris}
volumes:
- wakdo_db_data:/var/lib/mysql
- ./db/init:/docker-entrypoint-initdb.d:ro
networks:
- wakdo_internal
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
wakdo-migrate:
image: mariadb:11.4
container_name: wakdo-migrate
restart: "no"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- ./db:/db:ro
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy
entrypoint: ["bash", "/db/migrate-container.sh"]
wakdo-app:
build:
context: ./docker/php-fpm
dockerfile: Dockerfile
container_name: wakdo-app
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV}
APP_DEBUG: ${APP_DEBUG}
APP_TIMEZONE: ${APP_TIMEZONE}
APP_URL_KIOSK: ${APP_URL_KIOSK}
APP_URL_ADMIN: ${APP_URL_ADMIN}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
SESSION_NAME: ${SESSION_NAME}
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
ARGON2_THREADS: ${ARGON2_THREADS}
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-db:
condition: service_healthy
wakdo-web:
build:
context: ./docker/apache
dockerfile: Dockerfile
container_name: wakdo-web
restart: unless-stopped
environment:
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads
networks:
- wakdo_internal
- reverse_proxy
depends_on:
wakdo-migrate:
condition: service_completed_successfully
wakdo-app:
condition: service_started
wakdo-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
- "traefik.http.routers.wakdo-kiosk.tls=true"
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
- "traefik.http.routers.wakdo-admin.tls=true"
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
wakdo-cron:
build:
context: ./docker/cron
dockerfile: Dockerfile
container_name: wakdo-cron
restart: unless-stopped
init: true
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
TZ: ${CRON_TIMEZONE:-Europe/Paris}
volumes:
- ./var/backups:/backups
networks:
- wakdo_internal
depends_on:
wakdo-db:
condition: service_healthy

View file

@ -89,6 +89,12 @@ services:
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
volumes:
- ./src:/var/www/html
- wakdo_uploads:/var/www/html/public/uploads

View file

@ -0,0 +1,115 @@
# Deploiement continu (CD) — Wakdo
Ce document decrit le deploiement automatique vers la production et la mise en place
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
`.forgejo/workflows/deploy.yml`.
## Topologie
| Hote | Role |
|---|---|
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
## Flux
```
merge dev -> main (release, deja passee par la CI sur la PR)
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
│ ssh deploy@vision (sans commande : forced command cote Vision)
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
```
## Ce qui est automatise (dans le depot)
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
Mode non-interactif via `DEPLOY_YES=1`.
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
`src/VERSION`.
## Mise en place cote Vision (une fois)
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
derivent des modeles fournis dans le depot :
```bash
cp docker-compose.prod.yml.example docker-compose.prod.yml
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
docker compose -f docker-compose.prod.yml up -d --build
```
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
```bash
sudo useradd -m -G docker deploy
```
2. Lui donner le depot (ou ajuster les droits du clone existant) :
```bash
sudo chown -R deploy:deploy /srv/wakdo
```
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
```
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
```
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
passer une autre commande, elle serait ignoree.
## Generer la cle et la connaitre cote forge
Sur un poste de confiance :
```bash
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
```
## Secrets et variables a creer sur la forge
Depot -> Settings -> Actions -> Secrets / Variables :
| Type | Nom | Valeur |
|---|---|---|
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
| Variable | `DEPLOY_USER` | `deploy` |
## Verification
1. Faire une release (`dev -> main`).
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
sur `main`).
3. Interroger la sonde et lire la version deployee :
```bash
curl -s https://<fqdn-admin-prod>/api/health
# { ... "version": "<sha>", "deployed_at": "<date>" }
```
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
mise a jour sans intervention manuelle.
## Notes de securite
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
shell, tunnels et forwarding.
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
confiance a la premiere connexion.
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
restent gitignores.
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.

View file

@ -52,25 +52,40 @@ if ! command -v docker >/dev/null 2>&1; then
fi
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
# influencer ni la branche ni le compose, seulement declencher CE script.
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
echo "deploy: confirmation automatique (DEPLOY_YES)."
else
printf 'Confirmer le deploiement en production ? [oui/NON] '
read -r answer
if [ "$answer" != "oui" ]; then
echo "deploy: annule."
exit 1
fi
fi
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
git fetch --prune "$REMOTE" "$BRANCH"
git checkout "$BRANCH"
git merge --ff-only "$REMOTE/$BRANCH"
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
echo "[2/5] marqueur de version (preuve CD cote app)"
SHA="$(git rev-parse --short HEAD)"
NOW="$(date --iso-8601=seconds)"
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
docker compose -f "$COMPOSE_FILE" build --pull
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
docker compose -f "$COMPOSE_FILE" up -d
echo "[4/4] etat des services"
echo "[5/5] etat des services"
docker compose -f "$COMPOSE_FILE" ps
echo "Deploiement termine."
echo "Deploiement termine ($SHA)."

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
* pour un relais authentifie type Brevo. Conduit la conversation contre un
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
*/
final class SmtpClient
{
public function __construct(
private readonly SmtpTransport $transport,
private readonly string $heloName = 'wakdo',
) {
}
/**
* Ouvre la session, s'authentifie, transmet un message deja assemble
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
*/
public function send(
string $host,
int $port,
string $user,
string $password,
string $from,
string $to,
string $message,
): void {
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
$this->assertNoInjection($from, 'expediteur');
$this->assertNoInjection($to, 'destinataire');
$t = $this->transport;
try {
$t->open($host, $port, 15);
$this->expect($t->readReply(), 220, 'greeting');
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
$this->command('STARTTLS', 220, 'STARTTLS');
$t->enableCrypto();
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
$this->command(base64_encode($user), 334, 'AUTH user');
$this->command(base64_encode($password), 235, 'AUTH password');
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
$this->command('DATA', 354, 'DATA');
// Corps + terminateur "<CRLF>.<CRLF>".
$t->write($message . "\r\n.\r\n");
$this->expect($t->readReply(), 250, 'corps du message');
$t->write("QUIT\r\n");
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
$t->readReply();
} finally {
$t->close();
}
}
private function command(string $line, int $expected, string $stage): void
{
$this->transport->write($line . "\r\n");
$this->expect($this->transport->readReply(), $expected, $stage);
}
private function assertNoInjection(string $address, string $label): void
{
if (preg_match('/[\r\n]/', $address) === 1) {
throw new RuntimeException(
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
);
}
}
private function expect(string $reply, int $code, string $stage): void
{
$got = (int) substr(ltrim($reply), 0, 3);
if ($got !== $code) {
// On ne journalise pas le corps : il peut contenir le lien de reset.
throw new RuntimeException(
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
);
}
}
}

101
src/app/Auth/SmtpMailer.php Normal file
View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
*/
final class SmtpMailer implements Mailer
{
public function __construct(
private readonly SmtpClient $client,
private readonly string $host,
private readonly int $port,
private readonly string $user,
private readonly string $password,
private readonly string $fromEmail,
private readonly string $fromName,
) {
}
public function sendPasswordReset(string $email, string $resetUrl): void
{
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
// d'injection (verrou en plus de la garde transport de SmtpClient).
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
}
$subject = 'Reinitialisation de votre mot de passe Wakdo';
$body = "Bonjour,\r\n\r\n"
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
. $resetUrl . "\r\n\r\n"
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
. "ignorez cet email.\r\n";
$message = $this->buildMessage($email, $subject, $body);
$this->client->send(
$this->host,
$this->port,
$this->user,
$this->password,
$this->fromEmail,
$email,
$message,
);
}
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
private function buildMessage(string $to, string $subject, string $body): string
{
$headers = [
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
'To: <' . $to . '>',
'Subject: ' . $this->encodeHeader($subject),
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
return $this->dotStuff($raw);
}
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
private function encodeHeader(string $value): string
{
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
private function normalizeEol(string $text): string
{
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
}
/** Double un point en debut de ligne (RFC 5321 transparency). */
private function dotStuff(string $message): string
{
$lines = explode("\r\n", $message);
foreach ($lines as $i => $line) {
if (isset($line[0]) && $line[0] === '.') {
$lines[$i] = '.' . $line;
}
}
return implode("\r\n", $lines);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Auth;
/**
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
*/
interface SmtpTransport
{
public function open(string $host, int $port, int $timeoutSeconds): void;
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
public function write(string $raw): void;
/**
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
* de continuation ont un '-' en 4e position, la derniere un espace).
*/
public function readReply(): string;
/** Bascule la connexion en TLS (apres STARTTLS). */
public function enableCrypto(): void;
public function close(): void;
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use RuntimeException;
/**
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
* du protocole est couverte via SmtpClient + un transport double.
*/
final class StreamSmtpTransport implements SmtpTransport
{
/** @var resource|null */
private $stream = null;
public function open(string $host, int $port, int $timeoutSeconds): void
{
$errno = 0;
$errstr = '';
$stream = @stream_socket_client(
sprintf('tcp://%s:%d', $host, $port),
$errno,
$errstr,
$timeoutSeconds,
);
if ($stream === false) {
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
}
stream_set_timeout($stream, $timeoutSeconds);
$this->stream = $stream;
}
public function write(string $raw): void
{
fwrite($this->requireStream(), $raw);
}
public function readReply(): string
{
$stream = $this->requireStream();
$data = '';
$lines = 0;
while (($line = fgets($stream, 515)) !== false) {
$data .= $line;
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
if (++$lines > 100 || strlen($data) > 65536) {
break;
}
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
// (y compris trop courte) termine la reponse.
if (!(strlen($line) >= 4 && $line[3] === '-')) {
break;
}
}
if ($data === '') {
throw new RuntimeException('SMTP : aucune reponse du serveur');
}
return $data;
}
public function enableCrypto(): void
{
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
}
}
public function close(): void
{
if (is_resource($this->stream)) {
fclose($this->stream);
}
$this->stream = null;
}
/** @return resource */
private function requireStream()
{
if (!is_resource($this->stream)) {
throw new RuntimeException('SMTP : transport non ouvert');
}
return $this->stream;
}
}

View file

@ -18,12 +18,16 @@ final class UserDirectory
}
/**
* @return array{name: string, role_label: string, email: string}
* order_source : canal de saisie du role ('counter' | 'drive' | '' pour les
* roles globaux admin/manager/kitchen). Sert au layout a router le lien
* "Saisie commande" vers la landing du bon canal sans une requete dediee.
*
* @return array{name: string, role_label: string, email: string, order_source: string}
*/
public function displayInfo(int $userId): array
{
$row = $this->db->fetch(
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label, r.order_source '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
@ -33,9 +37,10 @@ final class UserDirectory
$name = trim($first . ' ' . $last);
return [
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'order_source' => is_string($row['order_source'] ?? null) ? $row['order_source'] : '',
];
}
}

View file

@ -63,8 +63,9 @@ final class MenuRepository
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
* Projection enrichie (description, image_path) absente de all() back-office.
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
* RG-T21, differe au seed des recettes.
* disponibilite du burger impose (B1, RG-T21) est calculee par CatalogueController
* (croisement avec ProductRepository::autoUnavailableIds) et exposee en is_orderable :
* un menu dont le burger est en rupture est grise par la borne (granularite burger seul).
*
* @return array<int, array<string, mixed>>
*/

View file

@ -63,8 +63,10 @@ final class ProductRepository
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
* categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
* disponibilite = flag is_available (retrait manuel) ; la dispo CALCULEE RG-T21
* (rupture par stock) n'exclut PAS la ligne ici : CatalogueController la croise
* avec autoUnavailableIds() pour exposer is_orderable, et la borne grise la tuile
* (visible mais non commandable) au lieu de la masquer.
*
* base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl")
* ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le
@ -82,11 +84,11 @@ final class ProductRepository
// un libelle d'affichage seulement.
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, '
. 'p.image_path, p.display_order, mv.name AS maxi_variant_name '
. 'p.image_path, p.display_order, c.name AS category_name, mv.name AS maxi_variant_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id '
. 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL '
. 'ORDER BY p.display_order, p.name',
. 'ORDER BY c.display_order, c.name, p.display_order, p.name',
);
}

View file

@ -68,6 +68,11 @@ abstract class AdminController extends AuthenticatedController
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '',
// Canal de saisie du role courant ('counter' | 'drive') pour que le lien
// "Saisie commande" du layout envoie un equipier drive vers /drive/orders
// et un equipier comptoir vers /counter/orders. Derive de role.order_source
// (remonte par displayInfo, qui joint deja la table role).
'orderChannel' => $info['order_source'] === 'drive' ? 'drive' : 'counter',
'flash' => $this->takeFlash(),
];

View file

@ -56,8 +56,16 @@ class CatalogueController extends Controller
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
// aller-retour cache-friendly cote borne (data.js memoise la liste).
$sizesByBase = $repo->sizesByBase();
// RG-T21 : rupture calculee par le stock, en UNE requete (set d'ids). Un
// produit liste (is_available=1) mais en rupture devient non commandable ->
// la borne le grise au lieu de laisser composer une commande vouee a echouer.
$unavailable = array_fill_keys($repo->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
fn (array $row): array => $this->presentProduct(
$row,
$sizesByBase[(int) ($row['id'] ?? 0)] ?? [],
!isset($unavailable[(int) ($row['id'] ?? 0)]),
),
$repo->availableForCatalogue(),
);
@ -84,8 +92,10 @@ class CatalogueController extends Controller
// au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la
// base seule n'est pas une dimension de taille -> sizes vide cote presentation).
$sizes = $repo->sizesForProduct($id);
// RG-T21 : meme dispo calculee qu'en liste, pour ce produit (membership du set).
$orderable = !in_array($id, $repo->autoUnavailableIds(), true);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [], $orderable)]);
}
/**
@ -93,8 +103,15 @@ class CatalogueController extends Controller
*/
public function menus(array $params = []): Response
{
// RG-T21 (granularite : burger impose seul) : un menu dont le burger principal
// est en rupture calculee n'est plus commandable. Set d'ids produits en rupture
// reutilise pour tous les menus (pas de N+1).
$unavailable = array_fill_keys($this->productsRepo()->autoUnavailableIds(), true);
$rows = array_map(
fn (array $row): array => $this->presentMenu($row),
fn (array $row): array => $this->presentMenu(
$row,
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
),
$this->menusRepo()->availableForCatalogue(),
);
@ -117,8 +134,10 @@ class CatalogueController extends Controller
);
}
// RG-T21 (burger impose seul) : dispo calculee du menu = burger non en rupture.
$orderable = !in_array((int) ($row['burger_product_id'] ?? 0), $this->productsRepo()->autoUnavailableIds(), true);
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
$menu = $this->presentMenu($row, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
return $this->json(['data' => $menu]);
}
@ -200,9 +219,9 @@ class CatalogueController extends Controller
* variantes ; vide si le produit n'a pas de dimension taille. Chaque entree
* devient {product_id, size_cl, price_cents, label} ; le label humain est
* derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran.
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>, is_orderable: bool}
*/
private function presentProduct(array $row, array $sizes = []): array
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -229,14 +248,19 @@ class CatalogueController extends Controller
},
array_values($sizes),
),
// is_orderable : false si rupture calculee par le stock (RG-T21). La borne
// grise la tuile (echo UX) ; l'enforcement qui fait foi est cote serveur a la
// creation de commande (OrderRepository::resolveLine refuse un item en
// rupture). Le retrait manuel (is_available=0) est deja exclu en amont.
'is_orderable' => $isOrderable,
];
}
/**
* @param array<string, mixed> $row
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int, is_orderable: bool}
*/
private function presentMenu(array $row): array
private function presentMenu(array $row, bool $isOrderable = true): array
{
return [
'id' => (int) ($row['id'] ?? 0),
@ -248,6 +272,9 @@ class CatalogueController extends Controller
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
'image_path' => $this->nullableString($row['image_path'] ?? null),
'display_order' => (int) ($row['display_order'] ?? 0),
// is_orderable : false si le burger impose est en rupture calculee (RG-T21,
// granularite burger seul). La borne grise le menu.
'is_orderable' => $isOrderable,
];
}

View file

@ -56,18 +56,25 @@ class CounterOrderController extends AdminController
}
$source = $this->source();
$orderQuery = $this->orderQuery();
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
// ramene les plus recentes tous canaux ; on filtre sur la source derivee du
// chemin pour que le comptoir ne voie pas le drive et inversement.
$orders = array_values(array_filter(
$this->orderQuery()->recent(50),
$orderQuery->recent(50),
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
));
// File "En cours" (RG-T12) : commandes du canal au statut paid non livrees,
// la plus ancienne d'abord (tri paid_at croissant fait par paidQueue). Filtree
// a la SEULE source du canal pour que l'equipier ne voie que ce qu'il sert.
$inProgress = $orderQuery->paidQueue([$source]);
return $this->channelView('admin/counter/index', $source, [
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'inProgress' => $inProgress,
], $guard);
}
@ -115,6 +122,11 @@ class CounterOrderController extends AdminController
$source = $this->source();
$serviceMode = (string) ($form['service_mode'] ?? '');
// Numero de table (confort comptoir) : ne porte de sens qu'en sur place. On ne
// le transmet qu'en dine_in ; persist() le rejette de toute facon hors dine_in,
// mais ne pas le passer evite un INVALID_SERVICE_TAG sur une saisie residuelle.
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($form['service_tag'] ?? '')) : '';
// Chemin unifie : le panier construit par counter-order.js arrive serialise
// dans items_json. Quand il est present, il fait foi ; les quantites legacy
// qty_<id> ne servent qu'au repli sans JS (degradation gracieuse).
@ -127,9 +139,14 @@ class CounterOrderController extends AdminController
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422);
}
$req = ['service_mode' => $serviceMode, 'items' => $items];
if ($serviceTag !== '') {
$req['service_tag'] = $serviceTag;
}
try {
$order = $this->orders()->createStaffOrder(
['service_mode' => $serviceMode, 'items' => $items],
$req,
$guard->userId ?? 0,
$source,
);
@ -360,6 +377,7 @@ class CounterOrderController extends AdminController
'products' => $products,
'menus' => $this->menusWithSlots($productRepository),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'serviceTag' => (string) ($values['service_tag'] ?? ''),
'error' => $error,
], $guard, $status);
}

View file

@ -12,9 +12,13 @@ use App\Core\Response;
* Sonde de sante. GET /api/health.
*
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
*
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
*/
final class HealthController extends Controller
class HealthController extends Controller
{
/**
* @param array<string, string> $params
@ -35,6 +39,8 @@ final class HealthController extends Controller
$httpStatus = 503;
}
$version = $this->readVersion();
return $this->json(
[
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
@ -42,8 +48,45 @@ final class HealthController extends Controller
'php_version' => PHP_VERSION,
'db' => $dbStatus,
'categories' => $categories,
'version' => $version['version'],
'deployed_at' => $version['deployed_at'],
],
$httpStatus,
);
}
/**
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
* donc lisible a chaud par l'app sans rebuild.
*/
protected function versionFilePath(): string
{
return dirname(__DIR__, 2) . '/VERSION';
}
/**
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
* deploiement) : les deux champs retombent a null.
*
* @return array{version: ?string, deployed_at: ?string}
*/
private function readVersion(): array
{
$path = $this->versionFilePath();
if (!is_file($path) || !is_readable($path)) {
return ['version' => null, 'deployed_at' => null];
}
$line = trim((string) @file_get_contents($path));
if ($line === '') {
return ['version' => null, 'deployed_at' => null];
}
$parts = explode(' ', $line, 2);
return [
'version' => $parts[0] !== '' ? $parts[0] : null,
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
];
}
}

View file

@ -7,9 +7,13 @@ namespace App\Controllers;
use Throwable;
use App\Auth\Csrf;
use App\Auth\LogMailer;
use App\Auth\Mailer;
use App\Auth\PasswordHasher;
use App\Auth\PasswordResetService;
use App\Auth\SessionManager;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Auth\StreamSmtpTransport;
use App\Core\Controller;
use App\Core\Response;
@ -124,7 +128,33 @@ class PasswordResetController extends Controller
$this->database,
$this->config,
new PasswordHasher($this->config),
new LogMailer(),
$this->mailer(),
);
}
/**
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
* reste sans infra mail, la prod envoie via le relais.
*/
protected function mailer(): Mailer
{
$host = $this->config->get('SMTP_HOST');
$user = $this->config->get('SMTP_USER');
$password = $this->config->get('SMTP_PASSWORD');
if ($host === null || $user === null || $password === null) {
return new LogMailer();
}
return new SmtpMailer(
new SmtpClient(new StreamSmtpTransport()),
$host,
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
$user,
$password,
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
);
}

View file

@ -184,8 +184,14 @@ class OrderRepository
throw new OrderValidationException('EMPTY_ORDER');
}
// RG-T21 : garde a la creation de commande. Un produit (ou le burger d'un menu)
// en rupture calculee par le stock est REFUSE, quel que soit le canal et meme par
// acces direct (la borne ne fait que griser l'affichage, F2). C'est la couche qui
// fait foi. Set calcule UNE seule fois (pas de N+1 sur les lignes).
$unavailable = array_fill_keys($this->products->autoUnavailableIds(), true);
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
$lines = array_map(fn (array $item): array => $this->resolveLine($item, $unavailable), $items);
$totalTtc = 0;
$totalHt = 0;
@ -597,9 +603,10 @@ class OrderRepository
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
*
* @param array<string, mixed> $item
* @param array<int, bool> $unavailable set des product_id en rupture calculee (RG-T21)
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
*/
private function resolveLine(array $item): array
private function resolveLine(array $item, array $unavailable = []): array
{
$type = (string) ($item['type'] ?? '');
$quantity = max(1, (int) ($item['quantity'] ?? 1));
@ -610,6 +617,10 @@ class OrderRepository
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
// RG-T21 : rupture calculee (ingredient requis sous la bande critique).
if (isset($unavailable[(int) $product['id']])) {
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
}
$unitBase = (int) $product['price_cents'];
$vat = (int) $product['vat_rate'];
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
@ -623,6 +634,14 @@ class OrderRepository
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
// RG-T21, granularite "burger impose seul" (coherente avec l'affichage borne
// F2) : si le burger principal est en rupture calculee, le menu n'est pas
// commandable. La rupture d'un accompagnement/boisson requis n'est PAS
// verifiee ici (decision produit : granularite burger seul, a elargir au
// besoin via les produits des slots requis).
if (isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)])) {
throw new OrderValidationException('MENU_UNAVAILABLE');
}
$burger = $this->products->find((int) $menu['burger_product_id']);
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];

View file

@ -4,11 +4,15 @@ declare(strict_types=1);
/**
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
* Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle commande".
* Partagee par les deux canaux ; le titre, le lien de creation et la source viennent
* du controleur (CounterOrderController::channelView). Toute valeur est echappee (RG-T15).
* Deux sections : "En cours" (commandes payees non livrees du canal, la plus ancienne
* d'abord, RG-T12) EN HAUT pour le service, puis l'historique recent (tous statuts)
* en dessous. Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle
* commande". Partagee par les deux canaux ; le titre, le lien de creation et la source
* viennent du controleur (CounterOrderController::channelView). Echappement RG-T15.
* Aucun rafraichissement auto (polling hors scope) : la page se relit a la navigation.
*
* @var list<array<string, mixed>> $orders
* @var list<array<string, mixed>> $orders historique recent (tous statuts)
* @var list<array<string, mixed>> $inProgress file "En cours" (paid non livre, canal)
* @var string $channelTitle
* @var string $newPath
*/
@ -39,6 +43,8 @@ $statusPill = static fn (string $s): string => match ($s) {
/** @var list<array<string, mixed>> $rows */
$rows = isset($orders) && is_array($orders) ? $orders : [];
/** @var list<array<string, mixed>> $queue */
$queue = isset($inProgress) && is_array($inProgress) ? $inProgress : [];
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
?>
@ -48,8 +54,45 @@ $createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orde
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
</div>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<h2 class="admin-section__subtitle">En cours</h2>
<p class="admin-section__sub"><?= count($queue) ?> commande(s) a servir</p>
<?php if ($queue === []): ?>
<p class="admin-empty">Aucune commande en cours.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Numero</th>
<th>Mode</th>
<th>Table</th>
<th>Total</th>
<th>Payee a</th>
</tr>
</thead>
<tbody>
<?php foreach ($queue as $o): ?>
<?php
// Numero de table : pertinent seulement en sur place (service_tag est
// NULL hors dine_in cote serveur). On affiche un tiret sinon, pour que
// l'equipier distingue "pas de table" d'une donnee manquante.
$queueMode = (string) ($o['service_mode'] ?? '');
$queueTag = $queueMode === 'dine_in' ? (string) ($o['service_tag'] ?? '') : '';
?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><?= $esc($modeLabel($queueMode)) ?></td>
<td><?= $queueTag !== '' ? $esc($queueTag) : '-' ?></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['paid_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2 class="admin-section__subtitle">Historique recent</h2>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucune commande pour ce canal.</p>
<?php else: ?>

View file

@ -3,8 +3,8 @@
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans
* admin/layout.php. Produits commandables ET menus composes (slots
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c + refonte saisie),
* injecte dans admin/layout.php. Produits commandables ET menus composes (slots
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients).
*
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
@ -12,16 +12,24 @@ declare(strict_types=1);
* #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger
* de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les
* items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de
* quantites produit `qty_<id>` reste present comme repli sans JS (3a).
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Les prix affiches
* cote client (par ligne + total + libelle du bouton) sont INDICATIFS : le serveur
* reste seul juge. Le tableau de quantites produit `qty_<id>` reste present comme
* repli sans JS (3a) : le champ quantite est rendu EDITABLE pour TOUS les produits.
* Pour un produit personnalisable, c'est counter-order.js qui neutralise le champ au
* cablage (desactivation + indice "via Personnaliser") et route la saisie vers la
* modale ; sans JS, le champ qty de base fonctionne (commande sans modificateurs, ce
* que legacyQuantities sait traiter). La gestion des modificateurs depend donc de JS.
*
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
* drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15.
* drive, service_mode est FIGE a 'drive' (affichage non editable + input cache,
* RG-T09 : un select readonly reste editable, on ne s'y fie pas). Echappement RG-T15.
*
* @var list<array<string, mixed>> $products
* @var list<array<string, mixed>> $menus menus + slots (option_product_ids)
* @var string $source 'counter' | 'drive'
* @var string $serviceMode valeur preselectionnee / reaffichee
* @var string $serviceTag numero de table reaffiche (re-rendu d'erreur)
* @var string $landing retour a la liste du canal
* @var string|null $error
* @var string $csrfToken
@ -43,6 +51,7 @@ $chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
$tag = isset($serviceTag) && is_string($serviceTag) ? $serviceTag : '';
$errorMessage = isset($error) && is_string($error) ? $error : null;
/** @var list<array<string, mixed>> $productRows */
@ -102,10 +111,20 @@ $jsMenus = array_map(
$menuRows,
);
// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement.
$modeOptions = $chan === 'drive'
? ['drive' => 'Drive']
: ['dine_in' => 'Sur place', 'takeaway' => 'A emporter'];
// Regroupement des produits par categorie (7b) : sous-titre par categorie pour que
// l'equipier scanne plus vite. availableForCatalogue trie deja par categorie puis
// display_order ; on conserve cet ordre et on agrege les lignes consecutives de meme
// categorie. category_name absent -> groupe "Autres" (evite une cle vide a l'ecran).
$productGroups = [];
foreach ($productRows as $p) {
$catName = isset($p['category_name']) && is_string($p['category_name']) && $p['category_name'] !== ''
? $p['category_name']
: 'Autres';
if (!isset($productGroups[$catName])) {
$productGroups[$catName] = [];
}
$productGroups[$catName][] = $p;
}
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
@ -123,55 +142,86 @@ $modeOptions = $chan === 'drive'
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
<?php foreach ($modeOptions as $value => $label): ?>
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
<?php endforeach; ?>
</select>
<?php if ($chan === 'drive'): ?>
<?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
transmet la valeur par un champ cache (un select readonly resterait
editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
<p class="form-static" id="service_mode_display">Drive</p>
<input type="hidden" name="service_mode" id="service_mode" value="drive">
<?php else: ?>
<select class="form-input" id="service_mode" name="service_mode">
<option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
<option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
</select>
<?php endif; ?>
</div>
<?php if ($chan !== 'drive'): ?>
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
(toggle JS sur le mode) ; le champ reste soumis tel quel, persist()
l'ignore hors dine_in. */ ?>
<div class="form-group" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="form-label" for="service_tag">Numero de table</label>
<input class="form-input" type="text" id="service_tag" name="service_tag"
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
</tr>
</thead>
<tbody>
<?php foreach ($productRows as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
?>
<?php foreach ($productGroups as $catName => $catProducts): ?>
<h3 class="order-group__title"><?= $esc($catName) ?></h3>
<table class="admin-table">
<thead>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</thead>
<tbody>
<?php foreach ($catProducts as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<?php /* 4 (progressive enhancement) : le champ quantite est
rendu EDITABLE pour TOUS les produits, pour que la saisie
marche SANS JS (qty de base, sans modificateurs). C'est
counter-order.js qui neutralise ce champ au cablage pour un
produit personnalisable et route la saisie vers la modale.
L'indice "via Personnaliser" est cache par defaut et revele
par le JS, pour ne pas perturber le rendu sans JS. */ ?>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
<?php if ($hasModifiers): ?>
<span class="order-qty-hint" data-qty-hint="<?= $pid ?>" hidden>via Personnaliser</span>
<?php endif; ?>
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
<?php endif; ?>
</fieldset>
@ -182,10 +232,18 @@ $modeOptions = $chan === 'drive'
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php $mid = (int) ($m['id'] ?? 0); ?>
<?php
$mid = (int) ($m['id'] ?? 0);
$priceNormal = (int) ($m['price_normal_cents'] ?? 0);
$priceMaxi = (int) ($m['price_maxi_cents'] ?? 0);
?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<span class="menu-list__price"><?= $esc($euros($m['price_normal_cents'] ?? 0)) ?></span>
<?php /* 6 : afficher les deux prix qualifies (Normal / Maxi) pour que
le choix de format soit lisible avant d'ouvrir la modale. */ ?>
<span class="menu-list__price">
Normal <?= $esc($euros($priceNormal)) ?> / Maxi <?= $esc($euros($priceMaxi)) ?>
</span>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
@ -200,10 +258,12 @@ $modeOptions = $chan === 'drive'
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php /* 1 : total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<p class="order-total" id="order-total">Total : <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<button class="btn btn-primary" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
</form>

View file

@ -18,6 +18,7 @@ declare(strict_types=1);
* @var list<string> $permissions
* @var string $csrfToken
* @var string $activeNav
* @var string $orderChannel 'counter' | 'drive' : canal de saisie du role courant
* @var string|null $flash
*/
@ -27,6 +28,13 @@ $userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$active = is_string($activeNav ?? null) ? $activeNav : '';
// Canal de saisie du role courant : un equipier drive est route vers /drive/orders,
// les autres roles vers /counter/orders. CounterOrderController pose activeNav a
// 'counter' ou 'drive' selon le chemin ; on marque le lien actif sur l'un OU l'autre.
$channel = ($orderChannel ?? '') === 'drive' ? 'drive' : 'counter';
$orderHref = $channel === 'drive' ? '/drive/orders' : '/counter/orders';
$orderActive = ($active === 'counter' || $active === 'drive') ? 'sidebar-item active' : 'sidebar-item';
/** @var list<string> $perms */
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
$can = static fn (string $code): bool => in_array($code, $perms, true);
@ -119,9 +127,9 @@ $navClass = static function (string $code, string $current): string {
<div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div>
<?php if ($can('order.create')): ?>
<?php /* Lien generique vers le comptoir ; le canal effectif (counter/drive)
est derive du chemin par CounterOrderController (mlt 4.1). */ ?>
<a href="/counter/orders" class="<?= $navClass('counter', $active) ?>">Saisie commande</a>
<?php /* Le canal (counter/drive) vient de role.order_source : un equipier
drive est route vers /drive/orders, les autres vers /counter/orders. */ ?>
<a href="<?= htmlspecialchars($orderHref, ENT_QUOTES, 'UTF-8') ?>" class="<?= $orderActive ?>">Saisie commande</a>
<?php endif; ?>
<?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>

View file

@ -1431,3 +1431,123 @@ tbody td.mono {
padding: 0;
margin-bottom: 4px;
}
/* =============================================================================
Saisie de commande comptoir/drive (counter-order.js + admin/counter/new.php)
Composeur : groupes produits, liste menus, panier chiffre, total, modale.
============================================================================= */
/* Mode de service fige (drive) : affichage non editable a la place du select. */
.form-static {
font-weight: 700;
color: var(--color-text);
padding: 8px 0;
margin: 0;
}
/* Sous-titre de groupe de produits (regroupement par categorie). */
.order-group__title {
font-size: 14px;
font-weight: 700;
color: var(--color-yellow-ink);
margin: 14px 0 6px;
}
/* Champ quantite desactive pour un produit personnalisable (saisie en modale). */
.order-qty--disabled {
background: var(--color-surface);
color: var(--color-text-muted);
cursor: not-allowed;
}
/* Indice "via Personnaliser" revele par JS a cote du champ qty neutralise. */
.order-qty-hint {
display: inline-block;
margin-left: 8px;
font-size: 13px;
color: var(--color-text-muted);
font-style: italic;
}
/* Deux prix d'un menu (Normal / Maxi) dans la liste. */
.menu-list { list-style: none; padding: 0; margin: 0; }
.menu-list__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.menu-list__name { font-weight: 600; flex: 1; }
.menu-list__price { color: var(--color-text-sec); font-variant-numeric: tabular-nums; }
/* Panier : une ligne = libelle + prix + bouton retirer. */
.order-cart { list-style: none; padding: 0; margin: 0; }
.order-cart__empty { color: var(--color-text-muted); padding: 8px 0; }
.order-cart__line {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.order-cart__label { flex: 1; }
.order-cart__price { font-weight: 700; font-variant-numeric: tabular-nums; }
/* Total indicatif du panier. */
.order-total {
text-align: right;
font-size: 16px;
font-weight: 800;
margin: 12px 0 0;
}
/* Modale de composition : overlay + panneau centre. */
.menu-composer__overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 26, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.menu-composer {
background: var(--color-white);
border-radius: var(--radius-card);
border-top: 3px solid var(--color-yellow);
box-shadow: var(--shadow-card-hover);
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.menu-composer__title { font-size: 18px; font-weight: 800; margin-bottom: 14px; }
.menu-composer__legend { font-weight: 600; margin: 12px 0 4px; }
.menu-composer__slot, .menu-composer__format, .menu-composer__modifiers { margin-bottom: 10px; }
.menu-composer__radio, .menu-composer__modifier {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 3px 14px 3px 0;
}
.menu-composer__error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border-radius: var(--radius-md);
padding: 8px 10px;
margin: 10px 0;
font-size: 14px;
}
/* Le <p role=alert> reste dans le DOM en permanence (annonce lecteur d'ecran) ;
tant qu'il est vide, on l'efface visuellement pour ne pas afficher un bloc vide. */
.menu-composer__error:empty {
display: none;
}
.menu-composer__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}

View file

@ -41,10 +41,51 @@
}
}
// Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des
// vues : virgule decimale ET espace separateur de milliers. Aligne l'affichage
// client sur le rendu serveur (ex. 1 234,50 EUR) pour eviter une divergence visible
// sur les montants >= 1000. Indicatif : le serveur recalcule tout (RG-T16).
function moneyParts(cents) {
var fixed = (Number(cents) / 100).toFixed(2);
var dot = fixed.indexOf('.');
var intPart = fixed.slice(0, dot);
var decPart = fixed.slice(dot + 1);
var sign = '';
if (intPart.charAt(0) === '-') {
sign = '-';
intPart = intPart.slice(1);
}
// Insere un espace tous les 3 chiffres depuis la droite (separateur de milliers).
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return sign + intPart + ',' + decPart;
}
// Surcout d'un ajout, formate en euros (affichage local indicatif ; le serveur
// refige extra_price_cents, RG-T16).
function formatExtra(cents) {
return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR';
return '+' + moneyParts(cents) + ' EUR';
}
// Montant en euros (sans signe), pour les prix de ligne et le total. Meme format
// que les vues PHP (cf. moneyParts).
function formatEuros(cents) {
return moneyParts(cents) + ' EUR';
}
// Somme des surcouts d'ajout (action 'add') d'une liste de modificateurs choisis,
// resolus via la liste proposable (extra_price_cents). Les retraits ne changent pas
// le prix indicatif. Pur ; le serveur reste seul juge du surcout reel.
function modifiersExtra(proposable, chosen) {
if (!chosen || !chosen.length) {
return 0;
}
var extraById = {};
(proposable || []).forEach(function (m) {
extraById[Number(m.ingredient_id)] = Number(m.extra_price_cents) || 0;
});
return chosen.reduce(function (sum, c) {
return c.action === 'add' ? sum + (extraById[Number(c.ingredient_id)] || 0) : sum;
}, 0);
}
// Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par
@ -82,6 +123,15 @@
return;
}
// Elements de prix (1) : valeur du total + libelle du bouton d'encaissement.
// Optionnels (le rendu degrade sans eux) -> garde-fous au moment d'ecrire.
var totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit');
// 7a : champ numero de table, visible seulement en sur place (toggle au mode).
var serviceMode = doc.getElementById('service_mode');
var serviceTagGroup = doc.getElementById('service_tag_group');
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}]
@ -100,10 +150,24 @@
// Produits routes par la modale (ils portent un bouton "Personnaliser") : leur
// quantite directe qty_<id> est ignoree a la serialisation pour eviter le double
// comptage (le champ reste present pour le repli sans JS).
// comptage. Progressive enhancement (4) : le champ qty est EDITABLE dans le HTML
// (repli sans JS) ; ici, en presence de JS, on le neutralise et on revele
// l'indice "via Personnaliser" pour que l'equipier sache ou saisir la quantite.
var configurableIds = {};
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
configurableIds[Number(btn.dataset.productId)] = true;
var pid = Number(btn.dataset.productId);
configurableIds[pid] = true;
var qtyInput = doc.getElementById('qty_' + pid);
if (qtyInput) {
qtyInput.disabled = true;
qtyInput.classList.add('order-qty--disabled');
qtyInput.setAttribute('aria-label', (qtyInput.getAttribute('aria-label') || 'Quantite') + ' (via Personnaliser)');
}
var hint = doc.querySelector('[data-qty-hint="' + pid + '"]');
if (hint) {
hint.hidden = false;
}
});
function el(tag, className) {
@ -253,6 +317,56 @@
hidden.value = JSON.stringify(items);
}
/* ----------------------------------------------------------------- */
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */
// Prix d'une ligne PRODUIT (configuree par la modale) : prix de base + surcout
// des ajouts, le tout multiplie par la quantite. Indicatif (RG-T16 serveur).
function productLineTotal(line) {
var base = (productById[Number(line.productId)] || {}).price || 0;
var extra = modifiersExtra(line.proposable, line.modifiers);
return (Number(base) + extra) * Number(line.quantity || 1);
}
// Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le
// surcout des ajouts sur le burger. Les selections de slot n'ajoutent rien (le
// prix du menu est forfaitaire cote serveur). Indicatif.
function menuLineTotal(line) {
var menu = menus.filter(function (m) { return Number(m.id) === Number(line.menuId); })[0] || {};
var base = line.format === 'maxi' ? (menu.price_maxi || 0) : (menu.price_normal || 0);
var extra = modifiersExtra(line.proposable, line.modifiers);
return Number(base) + extra;
}
// Total indicatif du panier : derive des champs qty_<id> (produits simples) +
// des lignes configurees (produits personnalises + menus). Met a jour le pied
// de panier ET le libelle du bouton ("Encaisser X,XX EUR").
function updateTotal() {
var total = 0;
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
var productId = Number(input.dataset.productId);
if (configurableIds[productId]) {
return; // route par la modale -> compte plus bas (pas de double comptage).
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
total += ((productById[productId] || {}).price || 0) * quantity;
}
});
productLines.forEach(function (line) { total += productLineTotal(line); });
menuLines.forEach(function (line) { total += menuLineTotal(line); });
if (totalValue) {
totalValue.textContent = formatEuros(total);
}
if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total);
}
}
/* ----------------------------------------------------------------- */
/* Rendu du panier (recap des lignes configurees) */
/* ----------------------------------------------------------------- */
@ -274,6 +388,10 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(productLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -305,6 +423,10 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(menuLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -320,15 +442,113 @@
if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : '';
}
updateTotal();
}
/* ----------------------------------------------------------------- */
/* Modales de configuration */
/* ----------------------------------------------------------------- */
// Handlers de modale courants (un jeu a la fois) : retires a la fermeture pour
// ne pas accumuler de listeners a chaque ouverture. lastFocused memorise
// l'element qui avait le focus AVANT l'ouverture, pour le restaurer a la
// fermeture (a11y : le focus ne doit pas retomber en haut de page).
var escHandler = null;
var trapHandler = null;
var lastFocused = null;
// Selecteur des controles focusables d'une modale (boutons, champs, selects ;
// les champs desactives/caches sont exclus). Le trap cycle sur cet ensemble.
var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
function focusableIn(root) {
return Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE));
}
function closeComposer() {
if (escHandler) {
doc.removeEventListener('keydown', escHandler);
escHandler = null;
}
if (trapHandler) {
doc.removeEventListener('keydown', trapHandler);
trapHandler = null;
}
modalHost.textContent = '';
modalHost.setAttribute('hidden', '');
// Restaure le focus sur l'element declencheur (bouton Personnaliser/Configurer).
if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
lastFocused = null;
}
// 7c + a11y : monte un panneau dans la modale avec un overlay (clic = fermeture),
// pose role=dialog / aria-modal / aria-labelledby (titre h2), gere Echap, piege
// Tab/Shift+Tab dans le panneau, memorise et restaure le focus. Le panel est
// deja construit par l'appelant ; on ne fait qu'habiller l'ouverture.
function openModal(panel) {
lastFocused = doc.activeElement;
modalHost.textContent = '';
// role=dialog modal + libelle = titre h2 de la modale (id stable, partage
// par les deux composeurs car une seule modale est ouverte a la fois).
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
var titleEl = panel.querySelector('.menu-composer__title');
if (titleEl) {
titleEl.id = 'menu-composer-title';
panel.setAttribute('aria-labelledby', 'menu-composer-title');
}
var overlay = el('div', 'menu-composer__overlay');
// Clic sur le fond (overlay lui-meme, pas un enfant) -> fermeture.
overlay.addEventListener('click', function (event) {
if (event.target === overlay) {
closeComposer();
}
});
overlay.appendChild(panel);
modalHost.appendChild(overlay);
modalHost.removeAttribute('hidden');
escHandler = function (event) {
if (event.key === 'Escape' || event.keyCode === 27) {
closeComposer();
}
};
doc.addEventListener('keydown', escHandler);
// Focus-trap : Tab/Shift+Tab cyclent dans les controles focusables du panel.
trapHandler = function (event) {
if (event.key !== 'Tab' && event.keyCode !== 9) {
return;
}
var focusable = focusableIn(panel);
if (!focusable.length) {
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
var active = doc.activeElement;
if (event.shiftKey && (active === first || !panel.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
doc.addEventListener('keydown', trapHandler);
// Focus sur le premier controle pour la saisie clavier.
var firstControl = focusableIn(panel)[0];
if (firstControl && typeof firstControl.focus === 'function') {
firstControl.focus();
}
}
// Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout).
@ -336,7 +556,6 @@
var proposable = product.modifiers || [];
var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} };
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -389,8 +608,7 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
// Ouvre la modale d'un menu : choix du format, une selection par slot, puis les
@ -405,7 +623,6 @@
}
});
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -478,6 +695,15 @@
// Modificateurs du burger support (retrait/ajout d'ingredients).
panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd));
// 7c : message inline au lieu d'un return muet quand un slot requis n'est pas
// choisi. Le <p role=alert> reste present en permanence (non hidden), vide au
// depart : on ne change que textContent a l'erreur, pour fiabiliser l'annonce
// lecteur d'ecran (un element revele apres coup peut ne pas etre annonce).
var inlineError = el('p', 'menu-composer__error');
inlineError.setAttribute('role', 'alert');
inlineError.textContent = '';
panel.appendChild(inlineError);
// Actions : ajouter (si tous les requis choisis) / annuler.
var actions = el('div', 'menu-composer__actions');
var addBtn = el('button', 'btn btn-primary menu-composer__add');
@ -487,6 +713,7 @@
var allRequired = steps.filter(function (s) { return s.isRequired; })
.every(function (s) { return state.selections[s.id] != null; });
if (!allRequired) {
inlineError.textContent = 'Choisissez toutes les options obligatoires avant d\'ajouter.';
return;
}
var selections = [];
@ -517,8 +744,7 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
/* ----------------------------------------------------------------- */
@ -545,6 +771,34 @@
});
});
// 1 : le total et le libelle du bouton suivent la saisie des quantites des
// produits simples (les lignes configurees rafraichissent via renderCart).
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
if (configurableIds[Number(input.dataset.productId)]) {
return; // champ desactive (route par la modale).
}
input.addEventListener('input', updateTotal);
input.addEventListener('change', updateTotal);
});
// 7a : le numero de table n'a de sens qu'en sur place -> visible seulement quand
// service_mode = dine_in (au comptoir ; au drive le champ n'existe pas).
function syncServiceTag() {
if (!serviceTagGroup) {
return;
}
var dineIn = serviceMode && serviceMode.value === 'dine_in';
if (dineIn) {
serviceTagGroup.removeAttribute('hidden');
} else {
serviceTagGroup.setAttribute('hidden', '');
}
}
if (serviceMode) {
serviceMode.addEventListener('change', syncServiceTag);
}
syncServiceTag();
form.addEventListener('submit', function () {
serialize();
});

View file

@ -292,7 +292,6 @@ button {
justify-self: center;
}
.site-header__cart,
.site-header__mode {
justify-self: end;
}
@ -454,49 +453,6 @@ button {
7. SHARED COMPONENTS header extensions + badges + buttons
============================================================ */
/* Cart link in header (products / product / cart pages) */
.site-header__cart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
font-size: 1.6rem;
color: var(--color-text-primary);
text-decoration: none;
}
.site-header__cart:hover,
.site-header__cart:focus-visible {
background: rgba(255, 199, 44, 0.18);
outline: none;
}
.cart-icon {
line-height: 1;
}
.cart-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: var(--radius-pill);
background: var(--color-brand-red);
color: #fff;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* Mode badge — shown in header for context */
.mode-badge {
display: inline-block;
@ -676,6 +632,35 @@ button {
box-shadow: var(--shadow-card);
}
/* Rupture de stock (RG-T21) : tuile visible mais non commandable. Grisee, non
cliquable, sans effet de survol (l'image est attenuee, pas l'etat ne ment pas). */
.product-card--unavailable {
opacity: 0.55;
filter: grayscale(0.6);
cursor: not-allowed;
}
.product-card--unavailable:hover,
.product-card--unavailable:focus-visible {
border-color: var(--color-border-default);
box-shadow: var(--shadow-card);
transform: none;
}
/* Badge "Indisponible" superpose a l'image d'une tuile en rupture. */
.product-card__badge {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 2;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--color-brand-dark);
color: #fff;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.product-card__image-wrap {
width: 100%;
aspect-ratio: 1 / 1;
@ -715,215 +700,9 @@ button {
}
/* ============================================================
9. COMPONENT PRODUCT DETAIL (product.html)
10. COMPONENT QUANTITY CONTROLS (modale options produit)
============================================================ */
.product-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.product-main {
flex: 1;
padding: var(--space-6);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.product-error {
color: var(--color-brand-red);
font-size: var(--font-size-md);
padding: var(--space-5);
}
.product-detail {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.product-detail__skeleton {
/* Placeholder shown while JS loads data */
width: 100%;
height: 300px;
background: var(--color-bg-card);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.product-detail__image-wrap {
width: 100%;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
overflow: hidden;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
}
.product-detail__image {
width: 100%;
height: 100%;
object-fit: contain;
padding: var(--space-5);
}
.product-detail__info {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.product-detail__name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
line-height: 1.2;
}
.product-detail__price {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-brand-dark);
}
.product-detail__composition {
background: var(--color-bg-card);
border-left: 4px solid var(--color-brand-yellow);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: var(--space-4) var(--space-5);
}
.product-detail__composition-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-2);
color: var(--color-text-primary);
}
.product-detail__composition-text {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
}
.product-detail__add {
width: 100%;
margin-top: var(--space-2);
}
/* ============================================================
10. COMPONENT CART (cart.html)
============================================================ */
.cart-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.cart-main {
flex: 1;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.cart-main__heading {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.cart-empty {
text-align: center;
padding: var(--space-10) var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
}
.cart-empty__message {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
}
.cart-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
list-style: none;
padding: 0;
margin: 0;
}
/* One cart line */
.cart-line {
display: flex;
align-items: center;
gap: var(--space-4);
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-3) var(--space-4);
}
.cart-line__image {
width: 72px;
height: 72px;
object-fit: contain;
flex-shrink: 0;
border-radius: var(--radius-sm);
background: var(--color-bg-page);
}
.cart-line__info {
flex: 1;
min-width: 0;
}
.cart-line__name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-line__unit-price {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Quantity controls */
.cart-line__qty {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.qty-btn {
width: 44px;
height: 44px;
@ -955,73 +734,6 @@ button {
font-weight: var(--font-weight-bold);
}
.cart-line__total {
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
flex-shrink: 0;
min-width: 80px;
text-align: right;
}
.cart-line__remove {
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background var(--transition-fast);
}
.cart-line__remove:hover,
.cart-line__remove:focus-visible {
background: rgba(218, 2, 14, 0.10);
outline: none;
}
/* Order summary block */
.cart-summary {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.cart-summary__line {
display: flex;
justify-content: space-between;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.cart-summary__line--total {
padding-top: var(--space-3);
border-top: 2px solid var(--color-border-default);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
/* Cart action row */
.cart-actions {
display: flex;
gap: var(--space-4);
justify-content: space-between;
flex-wrap: wrap;
padding-bottom: var(--space-8);
}
.cart-actions .btn {
flex: 1 1 200px;
}
/* ============================================================
11. COMPONENT PAYMENT (payment.html)
============================================================ */
@ -1239,15 +951,6 @@ button {
grid-template-columns: repeat(2, 1fr);
}
.cart-line {
flex-wrap: wrap;
gap: var(--space-3);
}
.cart-line__info {
flex: 1 1 calc(100% - 80px);
}
.payment-methods {
flex-direction: column;
align-items: stretch;
@ -1263,14 +966,10 @@ button {
.products-main {
padding: var(--space-4);
}
.cart-main {
padding: var(--space-4);
}
}
/* ============================================================
14. COMPONENT MENU COMPOSER MODAL (product.html, type=menu)
14. COMPONENT MENU COMPOSER MODAL (modale ouverte depuis la grille produits)
============================================================ */
/*
@ -1290,6 +989,42 @@ button {
animation: composer-fade-in var(--transition-base) both;
}
/* Modale de confirmation d'un geste destructeur (Abandon). */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 210;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
animation: composer-fade-in var(--transition-base) both;
}
.confirm-modal {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-overlay);
padding: var(--space-6);
max-width: 28rem;
width: 100%;
text-align: center;
}
.confirm-modal__message {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-5);
}
.confirm-modal__actions {
display: flex;
gap: var(--space-3);
justify-content: center;
flex-wrap: wrap;
}
@keyframes composer-fade-in {
from { opacity: 0; }
to { opacity: 1; }
@ -1486,10 +1221,6 @@ button {
z-index: 2;
}
.product-detail__info .allergen-info-btn {
margin-top: var(--space-3);
}
.allergen-modal-overlay {
position: fixed;
inset: 0;
@ -1759,31 +1490,6 @@ button {
margin-right: auto;
}
/* ---------- Cart line composition display -------------------- */
.cart-line__composition {
list-style: none;
padding: var(--space-1) 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cart-line__comp-item {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
line-height: 1.4;
}
.cart-line__comp-suppl {
font-size: var(--font-size-xs);
color: var(--color-brand-yellow-dk);
font-weight: var(--font-weight-bold);
line-height: 1.4;
padding-top: var(--space-1);
}
/* ---------- Responsive — narrow screens --------------------- */
@media (max-width: 600px) {
@ -1891,8 +1597,7 @@ button {
}
.order-panel__line {
position: relative;
padding: var(--space-3) var(--space-6) var(--space-3) 0;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-default);
}
@ -1923,14 +1628,49 @@ button {
content: "+ ";
}
/* Ligne de controles : stepper de quantite a gauche, retrait a droite. */
.order-panel__line-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-2);
}
.order-panel__qty {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Boutons +/- : cible tactile confortable (borne). */
.order-panel__qty-btn {
min-width: 44px;
min-height: 44px;
border: 2px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
line-height: 1;
cursor: pointer;
}
.order-panel__qty-value {
min-width: 2ch;
text-align: center;
font-weight: var(--font-weight-bold);
}
.order-panel__remove {
position: absolute;
top: var(--space-3);
right: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: none;
background: none;
cursor: pointer;
padding: var(--space-1);
line-height: 0;
}

View file

@ -8,7 +8,8 @@
* Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
@ -64,7 +65,10 @@ export function buildOrderItem(cartItem, menuSlotsById) {
type: 'menu',
menu_id: cartItem.id,
quantity: cartItem.quantite,
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
// Format choisi par l'utilisateur, transporte explicitement. Repli sur
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
// sessionStorage avant l'ajout du champ format.
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
};
}

View file

@ -0,0 +1,73 @@
/*
* confirm-modal.js Modale de confirmation reutilisable pour un geste destructeur
* (ex. Abandon de toute la commande). CSP-safe (createElement + addEventListener,
* aucun handler inline). Accessible : role="dialog" aria-modal, fond mis aria-hidden,
* Echap et clic sur le fond = annuler, focus piege dans la modale et rendu au
* declencheur a la fermeture. Defaut sur Annuler : un appui accidentel sur Entree
* n'execute pas l'action destructrice. Public non-technique : message + 2 boutons clairs.
*/
import { escHtml } from './state.js';
/**
* Affiche une demande de confirmation. onConfirm n'est appele que si l'utilisateur
* confirme explicitement ; Annuler / Echap / clic-fond ferment sans rien faire.
* @param {{message:string, confirmLabel?:string, cancelLabel?:string, onConfirm:Function}} opts
* @returns {{close:Function}}
*/
export function confirmAction({ message, confirmLabel = 'Confirmer', cancelLabel = 'Annuler', onConfirm }) {
const previouslyFocused = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-msg">
<p class="confirm-modal__message" id="confirm-modal-msg">${escHtml(message)}</p>
<div class="confirm-modal__actions">
<button type="button" class="confirm-modal__cancel btn btn--secondary">${escHtml(cancelLabel)}</button>
<button type="button" class="confirm-modal__confirm btn btn--primary">${escHtml(confirmLabel)}</button>
</div>
</div>
`;
const prevOverflow = document.body.style.overflow;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay);
bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
const close = () => {
document.removeEventListener('keydown', onKey);
bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
overlay.remove();
document.body.style.overflow = prevOverflow;
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
};
// Echap = annuler ; Tab/Shift+Tab pieges sur les boutons de la modale.
const onKey = (e) => {
if (e.key === 'Escape') { close(); return; }
if (e.key !== 'Tab') return;
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('.confirm-modal__cancel').addEventListener('click', close);
overlay.querySelector('.confirm-modal__confirm').addEventListener('click', () => {
close();
onConfirm();
});
// Focus initial sur Annuler (defaut sur), pour ne pas confirmer par inadvertance.
requestAnimationFrame(() => overlay.querySelector('.confirm-modal__cancel').focus());
return { close };
}

View file

@ -89,12 +89,16 @@ export function loadProducts() {
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
maxiNom: p.maxi_variant_name ?? null,
sizes: Array.isArray(p.sizes) ? p.sizes : [],
// commandable : false si rupture de stock calculee (RG-T21, is_orderable
// serveur) -> la borne grise la tuile et bloque le clic. Defaut true si
// l'API ne porte pas le flag (compat).
commandable: p.is_orderable !== false,
});
}
for (const m of menus) {
const slug = slugByCategoryId[m.category_id];
if (slug === undefined) continue;
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu', commandable: m.is_orderable !== false });
}
return bySlug;
}).catch(e => { _productsPromise = null; throw e; });

View file

@ -1,18 +1,18 @@
/*
* order-panel.js Panneau de commande persistant (maquette : recap a droite de
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
* que le panier reste visible en permanence, comme sur la maquette borne.
* l'ecran de commande). Rendu sur l'ecran de commande (products) pour que le panier
* reste visible en permanence, comme sur la maquette borne.
*
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
* compositionLabels) pour etre testable sans DOM.
* C'est l'UNIQUE vue panier : il montre lignes + total + Abandon/Payer, permet
* d'ajuster la quantite de chaque ligne (+/-) et de la retirer. La logique de mise
* en forme est extraite en fonctions PURES (buildPanelModel, compositionLabels)
* pour etre testable sans DOM.
*/
import {
getCart,
removeFromCart,
updateQuantity,
computeMenuLineCents,
clearCart,
formatPrice,
@ -20,6 +20,7 @@ import {
getMode,
} from './state.js';
import { refreshCartBadge } from './nav.js';
import { confirmAction } from './confirm-modal.js';
/**
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
@ -35,8 +36,8 @@ export function lineCents(item) {
/**
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
* Sans le supplement (le panneau affiche le total de ligne, pas le detail TVA).
* Tolerant aux composants absents.
* @param {Object|undefined} c objet composition de l'item menu
* @returns {string[]}
*/
@ -93,7 +94,7 @@ function modeLabel() {
/**
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
* echappee (RG-T15 anti-XSS).
* @param {Object} line element de buildPanelModel().lines
* @returns {string}
*/
@ -106,18 +107,37 @@ function lineHtml(line) {
return `
<li class="order-panel__line">
<div class="order-panel__line-main">
<span class="order-panel__line-name">${line.quantite}&times; ${escHtml(line.libelle)}</span>
<span class="order-panel__line-name">${escHtml(line.libelle)}</span>
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
</div>
${options}
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
<div class="order-panel__line-controls">
<div class="order-panel__qty" role="group" aria-label="Quantite de ${escHtml(line.libelle)}">
<button
class="order-panel__qty-btn"
data-action="dec"
data-index="${line.index}"
type="button"
aria-label="Diminuer la quantite de ${escHtml(line.libelle)}"
>&minus;</button>
<span class="order-panel__qty-value">${line.quantite}</span>
<button
class="order-panel__qty-btn"
data-action="inc"
data-index="${line.index}"
type="button"
aria-label="Augmenter la quantite de ${escHtml(line.libelle)}"
>+</button>
</div>
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
</div>
</li>
`;
}
@ -165,6 +185,19 @@ export function renderOrderPanel(container) {
</div>
`;
// Stepper +/- : ajuste la quantite de la ligne. Decrementer a 0 retire la ligne
// (updateQuantity supprime quand qty <= 0). Couvre produits ET menus (un menu a
// quantite > 1 = N menus identiques, facture par quantite cote serveur).
container.querySelectorAll('.order-panel__qty-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.index, 10);
const cart = getCart();
const current = cart[index] ? cart[index].quantite : 0;
updateQuantity(index, btn.dataset.action === 'inc' ? current + 1 : current - 1);
renderOrderPanel(container);
});
});
container.querySelectorAll('.order-panel__remove').forEach(btn => {
btn.addEventListener('click', () => {
removeFromCart(parseInt(btn.dataset.index, 10));
@ -174,14 +207,23 @@ export function renderOrderPanel(container) {
const abandon = container.querySelector('.order-panel__abandon');
if (abandon) {
// Geste destructeur (efface toute la commande) -> confirmation explicite
// avant d'agir, plutot qu'un effacement immediat au moindre tap.
abandon.addEventListener('click', () => {
clearCart();
window.location.href = 'index.html';
confirmAction({
message: 'Abandonner toute la commande ? Votre selection sera perdue.',
confirmLabel: 'Oui, abandonner',
cancelLabel: 'Continuer ma commande',
onConfirm: () => {
clearCart();
window.location.href = 'index.html';
},
});
});
}
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
// via aria-disabled (parade a11y, cf. fix E2E #45).
const pay = container.querySelector('.order-panel__pay');
if (pay) {
pay.addEventListener('click', e => {

View file

@ -1,194 +0,0 @@
/*
* page-cart.js Shopping cart screen.
*
* Displays all cart lines with quantity controls and totals.
* Handles two item shapes:
* - Simple product: { id, type, libelle, prix_cents, quantite, image }
* - Composed menu: { ...above, composition: {...}, supplement_cents: number }
*
* Menu lines render a composition breakdown beneath the product name.
* Simple product lines render as before (no composition block).
*
* TVA: 10% (taux normal restauration, France 2024 simplification MVP).
* TODO: verify exact applicable TVA rate with an accountant in P3.
* The real rate depends on sur-place vs a-emporter, alcohol content, etc.
*
* The total displayed is TTC (tax inclusive) because French consumer law
* requires prices shown to end-consumers to include all taxes.
*/
import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
/* TVA rate used for display breakdown only — stored prices are already TTC */
const TVA_RATE = 0.10;
const cartList = document.getElementById('cart-list');
const emptyBlock = document.getElementById('cart-empty');
const summaryBlock= document.getElementById('cart-summary');
const totalTTC = document.getElementById('total-ttc');
const totalHT = document.getElementById('total-ht');
const totalTVA = document.getElementById('total-tva');
const payBtn = document.getElementById('pay-btn');
const abandonBtn = document.getElementById('abandon-btn');
function renderCart() {
const items = getCart();
refreshCartBadge();
if (!items.length) {
cartList.innerHTML = '';
emptyBlock.hidden = false;
summaryBlock.hidden = true;
// pay-btn est un <a> : `.disabled` n'existe pas dessus, il faut piloter
// aria-disabled (sinon le bouton reste annonce desactive panier rempli).
if (payBtn) payBtn.setAttribute('aria-disabled', 'true');
return;
}
emptyBlock.hidden = true;
summaryBlock.hidden = false;
if (payBtn) payBtn.setAttribute('aria-disabled', 'false');
cartList.innerHTML = '';
items.forEach((item, index) => {
const isMenu = item.type === 'menu';
const lineTotalCents = isMenu
? computeMenuLineCents(item)
: item.prix_cents * item.quantite;
const row = document.createElement('li');
row.className = 'cart-line';
row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`);
row.innerHTML = `
<img
class="cart-line__image"
src="${escHtml(item.image)}"
alt="${escHtml(item.libelle)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
<div class="cart-line__info">
<span class="cart-line__name">${escHtml(item.libelle)}</span>
<span class="cart-line__unit-price">${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''}</span>
${isMenu && item.composition ? renderCompositionBlock(item) : ''}
</div>
<div class="cart-line__qty" role="group" aria-label="Quantite de ${escHtml(item.libelle)}">
<button
class="qty-btn qty-btn--minus"
data-index="${index}"
aria-label="Diminuer la quantite de ${escHtml(item.libelle)}"
type="button"
>-</button>
<span class="qty-value" aria-live="polite">${item.quantite}</span>
<button
class="qty-btn qty-btn--plus"
data-index="${index}"
aria-label="Augmenter la quantite de ${escHtml(item.libelle)}"
type="button"
>+</button>
</div>
<span class="cart-line__total">${formatPrice(lineTotalCents)}</span>
<button
class="cart-line__remove"
data-index="${index}"
aria-label="Supprimer ${escHtml(item.libelle)} du panier"
type="button"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="24" height="24">
</button>
`;
cartList.appendChild(row);
});
/* Attach event listeners after render */
cartList.querySelectorAll('.qty-btn--minus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite - 1);
renderCart();
});
});
cartList.querySelectorAll('.qty-btn--plus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite + 1);
renderCart();
});
});
cartList.querySelectorAll('.cart-line__remove').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
removeFromCart(idx);
renderCart();
});
});
/* Update totals */
const ttcCents = getTotalCents();
/* Back-calculate HT from TTC (prices assumed to be TTC already) */
const htCents = Math.round(ttcCents / (1 + TVA_RATE));
const tvaCents = ttcCents - htCents;
if (totalTTC) totalTTC.textContent = formatPrice(ttcCents);
if (totalHT) totalHT.textContent = formatPrice(htCents);
if (totalTVA) totalTVA.textContent = formatPrice(tvaCents);
}
/**
* Builds the composition breakdown HTML for a menu cart line.
* Renders burger (with personalisation options), accompagnement, boisson, sauce,
* and the supplement summary if applicable. Le format Maxi se lit dans le libelle de
* l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe.
*
* @param {Object} item cart item with type === 'menu' and composition object
* @returns {string} HTML string
*/
function renderCompositionBlock(item) {
const c = item.composition;
if (!c) return '';
// Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut
// ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence.
const parts = [];
if (c.burger) {
const burgerOpts = c.burger.options && c.burger.options.length
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
: '';
parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`);
}
// libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom
// ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite
// grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas).
if (c.accompagnement) {
parts.push(escHtml(c.accompagnement.libelle));
}
if (c.boisson) {
parts.push(escHtml(c.boisson.libelle));
}
if (c.sauce) {
parts.push(escHtml(c.sauce.libelle));
}
const supplTotal = item.supplement_cents ?? 0;
return `
<ul class="cart-line__composition" aria-label="Composition du menu">
${parts.map(t => `<li class="cart-line__comp-item">+ ${t}</li>`).join('')}
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Format Maxi : +${formatPrice(supplTotal)}</li>` : ''}
</ul>
`;
}
if (abandonBtn) {
abandonBtn.addEventListener('click', () => {
clearCart();
window.location.href = 'categories.html';
});
}
document.addEventListener('DOMContentLoaded', renderCart);

View file

@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => {
const items = getCart();
if (!items.length) {
window.location.href = 'cart.html';
window.location.href = 'categories.html';
return;
}
if (recap) {

View file

@ -1,7 +1,7 @@
/*
* page-product-menu.js Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
*
* Importe par page-product.js quand le produit charge est un menu (type === 'menu').
* Importe par page-products.js quand le produit clique est un menu (type === 'menu').
*
* Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
* frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
@ -12,9 +12,9 @@
* Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
*
* La forme de `composition` produite reste compatible avec page-cart.js et
* order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type
* mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
* La forme de `composition` produite reste compatible avec order-panel.js (burger /
* accompagnement / boisson / sauce + taille), le slot_type mappant vers le bon champ ;
* Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
*
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
*/
@ -121,6 +121,11 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
quantite: 1,
image: menu.image,
supplement_cents: supplement,
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
format: isMaxi ? 'maxi' : 'normal',
composition,
};
}
@ -150,7 +155,7 @@ export function composerIsViable(model) {
}
/* ------------------------------------------------------------------ */
/* Entree publique — appelee par page-product.js */
/* Entree publique — appelee par page-products.js */
/* ------------------------------------------------------------------ */
/**

View file

@ -1,142 +0,0 @@
/*
* page-product.js Product detail screen.
*
* Reads ?id=<int>&category=<slug> from the query string.
*
* Branch on product type:
* - type === 'menu' open the multi-step composer modal (page-product-menu.js).
* The standard detail layout is bypassed because a menu
* cannot be added to the cart without composition choices.
* - type === 'produit' render the standard detail card with "Ajouter au panier".
*
* After "Ajouter au panier" (simple product):
* 1. Item added to cart via state.addToCart()
* 2. Button changes to "Ajoute !" for 1 second (visual feedback)
* 3. Redirect to products.html?category=<slug>
*/
import { findProduct, loadAllergens } from './data.js';
import { addToCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
import { openMenuComposer } from './page-product-menu.js';
import { openProductOptions, productSizes } from './product-options.js';
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
const params = new URLSearchParams(window.location.search);
const productId = parseInt(params.get('id'), 10);
const categorySlug = params.get('category') ?? 'menus';
const container = document.getElementById('product-detail');
const errorBlock = document.getElementById('product-error');
const backBtn = document.getElementById('back-to-products');
if (backBtn) {
backBtn.href = `products.html?category=${categorySlug}`;
}
async function renderProduct() {
if (!productId) {
showError('Produit introuvable.');
return;
}
try {
const product = await findProduct(productId, categorySlug);
if (!product) {
showError('Ce produit n\'existe pas.');
return;
}
document.title = `Wakdo - ${product.nom}`;
if (product.type === 'menu') {
/* Hide the standard product detail area; the composer will overlay the page.
* The container stays in the DOM so the skeleton does not flash. */
container.hidden = true;
await openMenuComposer(product, categorySlug);
return;
}
/* Produit a tailles multiples (R4, ex. boisson 30/50 cl) : on delegue a la
* modale d'options (meme picker que la grille) plutot que de dupliquer la
* selection de taille dans la fiche -> un seul chemin pour choisir la taille. */
if (productSizes(product).length) {
container.hidden = true;
openProductOptions(product, categorySlug);
return;
}
container.innerHTML = `
<div class="product-detail__image-wrap">
<img
class="product-detail__image"
src="${escHtml(product.image)}"
alt="${escHtml(product.nom)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
</div>
<div class="product-detail__info">
<h1 class="product-detail__name">${escHtml(product.nom)}</h1>
<p class="product-detail__price">${formatPrice(product.prix)}</p>
<button
class="btn btn--primary btn--large product-detail__add"
id="add-to-cart-btn"
aria-label="Ajouter ${escHtml(product.nom)} au panier"
type="button"
>
Ajouter au panier
</button>
</div>
`;
// Bouton "i" allergenes (modale generale) dans le bloc info de la fiche.
// Echec de chargement non bloquant : la fiche reste fonctionnelle.
try {
const allergens = await loadAllergens();
const info = container.querySelector('.product-detail__info');
if (info) {
info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens)));
}
} catch (e) {
console.error('loadAllergens error:', e);
}
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
addToCart({
id: product.id,
type: product.type,
categorie: product.categorie ?? categorySlug,
libelle: product.nom,
prix_cents: product.prix,
quantite: 1,
image: product.image
});
refreshCartBadge();
const btn = document.getElementById('add-to-cart-btn');
btn.textContent = 'Ajoute !';
btn.disabled = true;
/* Redirect after brief confirmation pause */
setTimeout(() => {
window.location.href = `products.html?category=${categorySlug}`;
}, 1000);
});
} catch (err) {
showError('Erreur lors du chargement du produit.');
console.error('renderProduct error:', err);
}
}
function showError(msg) {
if (errorBlock) {
errorBlock.hidden = false;
errorBlock.textContent = msg;
}
if (container) {
container.hidden = true;
}
}
document.addEventListener('DOMContentLoaded', renderProduct);

View file

@ -3,7 +3,8 @@
*
* Reads ?category=<id> from the query string, maps to a slug via
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
* On product card click, navigates to product.html?id=<id>&category=<slug>.
* On product card click, opens an in-page modal (composer for a menu, options
* for a simple product) above the grid ; the order panel reflects the addition.
*/
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
@ -63,10 +64,16 @@ async function renderProducts() {
grid.innerHTML = '';
products.forEach(product => {
// commandable : false = rupture de stock (RG-T21). La tuile reste visible
// (le client voit le produit de la carte) mais grisee et non cliquable.
const orderable = product.commandable !== false;
const card = document.createElement('a');
card.className = 'product-card';
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
// Le <a> reste pour le focus/clavier (a11y) ; href='#' inerte, le handler
// click ci-dessous fait foi (preventDefault + ouverture de la modale).
card.href = '#';
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
if (!orderable) card.setAttribute('aria-disabled', 'true');
card.innerHTML = `
<div class="product-card__image-wrap">
@ -77,6 +84,7 @@ async function renderProducts() {
loading="lazy"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
${orderable ? '' : '<span class="product-card__badge">Indisponible</span>'}
</div>
<div class="product-card__body">
<span class="product-card__name">${escHtml(product.nom)}</span>
@ -89,11 +97,13 @@ async function renderProducts() {
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
// (L3). Le <a href> reste un repli (lien direct / sans JS).
// Clic produit -> modale au-dessus de la grille (paradigme maquette) :
// menu -> composeur (L2), produit -> options (L3). Le panneau de droite est
// l'unique vue panier ; pas de navigation au clic. Une tuile en rupture ne
// fait rien (ni navigation ni modale).
card.addEventListener('click', (e) => {
e.preventDefault();
if (!orderable) return;
if (product.type === 'menu') openMenuComposer(product, categorySlug);
else openProductOptions(product, categorySlug);
});

View file

@ -1,10 +1,10 @@
/*
* product-options.js Modale d'options produit (P5 L3, taille R4).
*
* Remplace la navigation vers product.html : cliquer un produit simple ouvre une
* modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille,
* facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant
* (L1) est re-rendu pour refleter immediatement la commande -> pas de navigation.
* Ouvre une modale d'options au clic produit, au lieu d'une navigation : cliquer un
* produit simple ouvre une modale (image, prix unitaire, stepper de quantite, total)
* au-dessus de la grille, facon maquette ("Une petite soif ?"). A l'ajout, le panneau
* de commande persistant (L1) est re-rendu pour refleter immediatement la commande.
*
* Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous
* forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl,

View file

@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Votre panier">
<title>Wakdo - Panier</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/cart.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="cart-page">
<!--
cart.html — Shopping cart.
page-cart.js renders all cart lines, handles qty controls and removal.
TVA: 10% (restauration France 2024 — simplified rate).
TODO: verify exact rate with accountant in P3 — actual rate depends
on sur-place vs a-emporter and product type (alcohol, etc.).
The stored prices are TTC. HT is back-calculated at display time only.
-->
<header class="site-header">
<a
class="site-header__back"
href="categories.html"
aria-label="Continuer mes achats"
>
&#8592; Continuer
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
</header>
<main class="cart-main" aria-label="Votre panier">
<h1 class="cart-main__heading">Votre panier</h1>
<!-- Empty cart state -->
<div id="cart-empty" class="cart-empty" hidden>
<p class="cart-empty__message">Votre panier est vide.</p>
<a class="btn btn--secondary" href="categories.html">Decouvrir nos produits</a>
</div>
<!-- Cart lines -->
<ul id="cart-list" class="cart-list" aria-label="Lignes du panier">
<!-- Filled by page-cart.js -->
</ul>
<!-- Order summary -->
<aside id="cart-summary" class="cart-summary" hidden aria-label="Recapitulatif de commande">
<div class="cart-summary__line">
<span>Total HT</span>
<span id="total-ht"></span>
</div>
<div class="cart-summary__line">
<!-- TVA 10% — taux restauration FR 2024 (simplifie, voir commentaire ci-dessus) -->
<span>TVA (10%)</span>
<span id="total-tva"></span>
</div>
<div class="cart-summary__line cart-summary__line--total">
<span>Total TTC</span>
<strong id="total-ttc"></strong>
</div>
</aside>
<!-- Actions -->
<div class="cart-actions">
<button
id="abandon-btn"
class="btn btn--secondary"
type="button"
aria-label="Abandonner la commande et retourner aux categories"
>
Abandonner
</button>
<a
id="pay-btn"
class="btn btn--primary"
href="payment.html"
role="button"
aria-label="Passer au paiement"
aria-disabled="true"
>
Valider ma commande
</a>
</div>
</main>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-cart.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -21,10 +21,10 @@
<header class="site-header">
<a
class="site-header__back"
href="cart.html"
aria-label="Retour au panier"
href="categories.html"
aria-label="Retour aux categories"
>
&#8592; Panier
&#8592; Retour
</a>
<img
class="site-header__logo"

View file

@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Detail du produit">
<title>Wakdo - Produit</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/product.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="product-page">
<!--
product.html — Product detail screen.
Reads ?id=<int>&category=<slug>.
JS (page-product.js) fetches the product, renders detail and handles
the "Ajouter au panier" action.
Menu composition is shown as a fixed note (MVP: no composition selection).
-->
<header class="site-header">
<a
id="back-to-products"
class="site-header__back"
href="products.html"
aria-label="Retour a la liste des produits"
>
&#8592; Retour
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">
<main class="product-main" aria-label="Detail du produit">
<!-- Error block: hidden unless fetch fails or id invalid -->
<p id="product-error" class="product-error" hidden role="alert"></p>
<!--
Container filled by page-product.js.
The JS replaces innerHTML once data is ready.
-->
<div id="product-detail" class="product-detail" aria-live="polite">
<!-- Skeleton placeholder visible during fetch -->
<div class="product-detail__skeleton" aria-hidden="true"></div>
</div>
</main>
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
</div>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-product.js"></script>
<script type="module" src="assets/js/order-panel.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -33,15 +33,6 @@
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">

View file

@ -79,6 +79,14 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $productSizes = [];
/**
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
* (RG-T21 : produits en rupture calculee par le stock). Vide = rien en rupture.
*
* @var list<array<string, mixed>>
*/
public array $autoUnavailableRows = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -109,6 +117,12 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// RG-T21 : ids des produits en rupture calculee (autoUnavailableIds). Desambigue
// de composition() (meme table) par SELECT DISTINCT, propre a cette requete.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
// R4 : tailles groupees (sizesByBase) et tailles d'un produit (sizesForProduct).
// Testees avant la branche catalogue : toutes deux lisent FROM product.
if (str_contains($sql, 'AS base_id')) {

View file

@ -28,6 +28,8 @@ final class FakeOrderDatabase implements DatabaseInterface
public array $slotRows = [];
/** @var array<int, list<array<string,mixed>>> recettes (composition) par produit id. */
public array $compositions = [];
/** @var list<array<string,mixed>> ids produits en rupture calculee (autoUnavailableIds, RG-T21). */
public array $autoUnavailableRows = [];
/** Commande existante renvoyee par la recherche idempotency_key ; null = aucune. */
/** @var array<string,mixed>|null */
@ -93,6 +95,11 @@ final class FakeOrderDatabase implements DatabaseInterface
if (str_contains($sql, 'FROM menu_slot s')) {
return $this->slotRows[(int) $params['id']] ?? [];
}
// RG-T21 : autoUnavailableIds() (sans param) AVANT composition() (avec :id) :
// les deux lisent product_ingredient ; on desambiguise sur SELECT DISTINCT.
if (str_contains($sql, 'SELECT DISTINCT pi.product_id')) {
return $this->autoUnavailableRows;
}
if (str_contains($sql, 'FROM product_ingredient pi')) {
return $this->compositions[(int) $params['id']] ?? [];
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\Auth\SmtpTransport;
use RuntimeException;
/**
* Transport SMTP double : rejoue des reponses serveur scriptees et enregistre les
* ecritures du client, pour tester la logique du protocole sans reseau.
*/
final class FakeSmtpTransport implements SmtpTransport
{
/** @var list<string> ce que le client a ecrit, dans l'ordre */
public array $writes = [];
public bool $cryptoEnabled = false;
public bool $closed = false;
public bool $opened = false;
/** @var list<string> reponses a rendre, dans l'ordre des readReply() */
private array $replies;
/** @param list<string> $replies */
public function __construct(array $replies)
{
$this->replies = $replies;
}
public function open(string $host, int $port, int $timeoutSeconds): void
{
$this->opened = true;
}
public function write(string $raw): void
{
$this->writes[] = $raw;
}
public function readReply(): string
{
if ($this->replies === []) {
throw new RuntimeException('FakeSmtpTransport : plus de reponse scriptee');
}
return array_shift($this->replies);
}
public function enableCrypto(): void
{
$this->cryptoEnabled = true;
}
public function close(): void
{
$this->closed = true;
}
/** Concatene toutes les ecritures (pratique pour assertions sur le message). */
public function written(): string
{
return implode('', $this->writes);
}
}

View file

@ -18,6 +18,7 @@ use App\Tests\Support\FakeDatabase;
/**
* Stub OrderQueryRepository : liste canned multi-source (rendu de la liste teste sans
* base). recent() ramene tous canaux ; le controleur filtre par source derivee du chemin.
* paidQueue() ramene la file "En cours" canned, deja filtree par source par l'appelant.
*/
final class StubChannelOrders extends OrderQueryRepository
{
@ -29,6 +30,22 @@ final class StubChannelOrders extends OrderQueryRepository
['order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 500, 'created_at' => '2026-06-22 10:06:00', 'paid_at' => '2026-06-22 10:06:01'],
];
}
public function paidQueue(array $sources): array
{
// File "En cours" du canal : ne ramene que des commandes dont la source est
// dans $sources (le controleur passe la SEULE source du canal courant). C100 est
// sur place avec un numero de table (12) ; D200 est un drive sans table.
$all = [
['order_number' => 'C100', 'source' => 'counter', 'service_mode' => 'dine_in', 'service_tag' => '12', 'total_ttc_cents' => 890, 'paid_at' => '2026-06-22 10:00:01'],
['order_number' => 'D200', 'source' => 'drive', 'service_mode' => 'drive', 'service_tag' => null, 'total_ttc_cents' => 990, 'paid_at' => '2026-06-22 10:05:01'],
];
return array_values(array_filter(
$all,
static fn (array $o): bool => in_array($o['source'], $sources, true),
));
}
}
final class TestCounterOrderController extends CounterOrderController
@ -456,6 +473,178 @@ final class CounterOrderControllerTest extends TestCase
self::assertFalse($db->wrote('INSERT INTO customer_order'));
}
public function testCounterIndexShowsInProgressQueueSection(): void
{
// 5 : la file "En cours" du canal (paid non livre) apparait en haut, filtree
// a la source counter (C100 present, D200 du drive absent).
$response = $this->controller($this->get('/counter/orders'), $this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('En cours', $body);
self::assertStringContainsString('Historique recent', $body);
self::assertStringContainsString('C100', $body);
self::assertStringNotContainsString('D200', $body);
// 4 : la file porte une colonne "Table" et affiche le numero de la commande
// sur place (C100 -> table 12).
self::assertStringContainsString('<th>Table</th>', $body);
self::assertStringContainsString('>12</td>', $body);
}
public function testDriveCreateFreezesServiceModeToDrive(): void
{
// 2 : au drive, service_mode n'est PAS un select editable. Il est fige a 'Drive'
// (affichage) + transmis par un champ cache (un select readonly resterait
// editable, donc on ne s'y fie pas).
$response = $this->controller($this->get('/drive/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Champ cache porteur de la valeur drive (soumis).
self::assertStringContainsString('type="hidden" name="service_mode" id="service_mode" value="drive"', $body);
// Aucun select de mode au drive (l'affichage est fige).
self::assertStringNotContainsString('<select class="form-input" id="service_mode"', $body);
}
public function testCounterCreateKeepsEditableServiceModeSelect(): void
{
// 2 (contre-exemple) : au comptoir, le select dine_in/takeaway reste editable.
$response = $this->controller($this->get('/counter/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('<select class="form-input" id="service_mode"', $body);
self::assertStringContainsString('Sur place', $body);
self::assertStringContainsString('A emporter', $body);
}
public function testCreateRendersEditableQuantityWithHintForConfigurableProduct(): void
{
// 4 (progressive enhancement) : le champ quantite d'un produit personnalisable
// est rendu EDITABLE en HTML (repli sans JS marche : commande de base sans
// modificateurs). Le PHP ne pose PAS readonly (c'est le JS qui neutralise au
// cablage). Un indice "via Personnaliser" (cache en HTML) accompagne le champ.
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
];
$db->compositionRows = [
['product_id' => 12, 'ingredient_id' => 3, 'ingredient_name' => 'Oignon', 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Le champ qty est present et EDITABLE (aucun readonly pose par le PHP).
self::assertStringContainsString('name="qty_12"', $body);
self::assertDoesNotMatchRegularExpression('/id="qty_12"[^>]*readonly/', $body);
// Indice de redirection vers la modale (revele par le JS).
self::assertStringContainsString('data-qty-hint="12"', $body);
self::assertStringContainsString('via Personnaliser', $body);
}
public function testCreateGroupsProductsByCategory(): void
{
// 7b : les produits sont regroupes par categorie (sous-titre = category_name).
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
['id' => 22, 'category_id' => 2, 'category_name' => 'Accompagnements', 'name' => 'Frites', 'description' => null, 'price_cents' => 250, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Burgers', $body);
self::assertStringContainsString('Accompagnements', $body);
}
public function testCreateShowsBothMenuPrices(): void
{
// 6 : la liste des menus affiche les deux prix (Normal / Maxi).
$db = $this->permittedDb();
$db->menusRows = [
['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('9,90 EUR', $body);
self::assertStringContainsString('11,90 EUR', $body);
self::assertStringContainsString('Maxi', $body);
}
public function testStorePassesServiceTagInDineIn(): void
{
// 7a : un numero de table saisi en sur place est transmis a createStaffOrder et
// persiste (service_tag) sur la commande.
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertSame('12', $insert['tag']);
}
public function testStoreDropsServiceTagWhenNotDineIn(): void
{
// 7a : un numero de table soumis hors sur place (takeaway) n'est pas transmis ;
// service_tag persiste NULL (la table n'a de sens qu'en sur place).
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'takeaway', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertNull($insert['tag']);
}
public function testNavRoutesDriveRoleToDriveLanding(): void
{
// 3 : le lien "Saisie commande" du layout pointe vers le canal du role courant.
// Un equipier drive (role.order_source = drive, remonte par displayInfo) est
// route vers /drive/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Dana', 'last_name' => 'D', 'role_label' => 'Drive', 'order_source' => 'drive'];
$response = $this->controller($this->get('/drive/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/drive/orders" class="sidebar-item active">Saisie commande', $body);
}
public function testNavRoutesCounterRoleToCounterLanding(): void
{
// 3 (contre-exemple) : un role comptoir (order_source counter / NULL) est route
// vers /counter/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Sam', 'last_name' => 'C', 'role_label' => 'Comptoir', 'order_source' => 'counter'];
$response = $this->controller($this->get('/counter/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/counter/orders" class="sidebar-item active">Saisie commande', $body);
}
/**
* Parametres lies de la premiere ecriture dont le SQL contient $needle.
*

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Tests\Support\FakeSmtpTransport;
use RuntimeException;
final class SmtpClientTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 smtp.brevo ready\r\n", // greeting
"250-smtp\r\n250 AUTH LOGIN\r\n", // EHLO (multiligne)
"220 go ahead\r\n", // STARTTLS
"250 ok\r\n", // EHLO post-TLS
"334 VXNlcm5hbWU6\r\n", // AUTH LOGIN
"334 UGFzc3dvcmQ6\r\n", // user
"235 authenticated\r\n", // password
"250 ok\r\n", // MAIL FROM
"250 ok\r\n", // RCPT TO
"354 end data with <CRLF>.<CRLF>\r\n", // DATA
"250 queued\r\n", // body
"221 bye\r\n", // QUIT
];
}
public function testNominalConversationAuthenticatesAndSends(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
$client->send('smtp-relay.brevo.com', 587, 'user@x', 'secret', 'from@a.fr', 'to@b.fr', "Subject: hi\r\n\r\ncorps");
self::assertTrue($t->opened);
self::assertTrue($t->cryptoEnabled, 'STARTTLS doit basculer le transport en TLS');
self::assertTrue($t->closed, 'le transport doit etre ferme');
$sent = $t->written();
self::assertStringContainsString("STARTTLS\r\n", $sent);
self::assertStringContainsString("AUTH LOGIN\r\n", $sent);
self::assertStringContainsString(base64_encode('user@x') . "\r\n", $sent);
self::assertStringContainsString(base64_encode('secret') . "\r\n", $sent);
self::assertStringContainsString("MAIL FROM:<from@a.fr>\r\n", $sent);
self::assertStringContainsString("RCPT TO:<to@b.fr>\r\n", $sent);
self::assertStringContainsString("DATA\r\n", $sent);
self::assertStringContainsString("\r\n.\r\n", $sent, 'le corps doit finir par le terminateur DATA');
self::assertStringContainsString("QUIT\r\n", $sent);
}
public function testReEhloHappensAfterStarttls(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', "x");
// Deux EHLO : un avant STARTTLS, un apres (session repart a zero apres TLS).
$ehloCount = substr_count($t->written(), 'EHLO ');
self::assertSame(2, $ehloCount);
}
public function testRejectedAuthThrowsAndCloses(): void
{
$replies = $this->happyReplies();
$replies[6] = "535 authentication failed\r\n"; // reponse au mot de passe
$t = new FakeSmtpTransport($replies);
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'bad', 'f@a.fr', 't@b.fr', 'x');
self::fail('une auth refusee doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('AUTH password', $e->getMessage());
}
self::assertTrue($t->closed, 'le transport doit etre ferme meme en cas d echec (finally)');
}
public function testUnexpectedGreetingThrows(): void
{
$t = new FakeSmtpTransport(["554 service unavailable\r\n"]);
$this->expectException(RuntimeException::class);
(new SmtpClient($t))->send('h', 587, 'u', 'p', 'f@a.fr', 't@b.fr', 'x');
}
public function testRejectsCrlfInRecipientBeforeConnecting(): void
{
// Tentative d'injection d'une commande RCPT via le destinataire.
$t = new FakeSmtpTransport($this->happyReplies());
$client = new SmtpClient($t);
try {
$client->send('h', 587, 'u', 'p', 'f@a.fr', "t@b.fr>\r\nRCPT TO:<evil@x.com", 'x');
self::fail('un CRLF dans l adresse doit lever');
} catch (RuntimeException $e) {
self::assertStringContainsString('destinataire', $e->getMessage());
}
self::assertFalse($t->opened, 'aucune connexion ne doit s ouvrir si l adresse est invalide');
self::assertSame([], $t->writes, 'rien ne doit etre emis');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Auth;
use PHPUnit\Framework\TestCase;
use App\Auth\SmtpClient;
use App\Auth\SmtpMailer;
use App\Tests\Support\FakeSmtpTransport;
final class SmtpMailerTest extends TestCase
{
/** @return list<string> sequence nominale de reponses serveur */
private function happyReplies(): array
{
return [
"220 ready\r\n", "250 ok\r\n", "220 go\r\n", "250 ok\r\n",
"334 u\r\n", "334 p\r\n", "235 ok\r\n", "250 ok\r\n", "250 ok\r\n",
"354 data\r\n", "250 queued\r\n", "221 bye\r\n",
];
}
private function mailer(FakeSmtpTransport $t): SmtpMailer
{
return new SmtpMailer(
new SmtpClient($t),
'smtp-relay.brevo.com',
587,
'login@smtp-brevo.com',
'secret',
'noreply@a3n.fr',
'Wakdo',
);
}
public function testBuildsAndSendsResetMessage(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('client@example.fr', 'https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc');
$sent = $t->written();
self::assertStringContainsString('From: Wakdo <noreply@a3n.fr>', $sent);
self::assertStringContainsString('To: <client@example.fr>', $sent);
self::assertStringContainsString('Subject: Reinitialisation de votre mot de passe Wakdo', $sent);
self::assertStringContainsString('Content-Type: text/plain; charset=UTF-8', $sent);
self::assertStringContainsString('https://corentin-wakdo-admin.stark.a3n.fr/reset_password?token=abc', $sent);
// L'enveloppe SMTP doit porter l'expediteur et le destinataire reels.
self::assertStringContainsString('MAIL FROM:<noreply@a3n.fr>', $sent);
self::assertStringContainsString('RCPT TO:<client@example.fr>', $sent);
}
public function testRejectsInvalidRecipient(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->expectException(\RuntimeException::class);
$this->mailer($t)->sendPasswordReset("victim@x.fr\r\nBcc: evil@x.com", 'https://x/reset?token=t');
}
public function testHeaderAndBodySeparatedByBlankLine(): void
{
$t = new FakeSmtpTransport($this->happyReplies());
$this->mailer($t)->sendPasswordReset('c@e.fr', 'https://x/reset?token=t');
// En-tetes et corps separes par une ligne vide (CRLF CRLF).
self::assertStringContainsString("Content-Transfer-Encoding: 8bit\r\n\r\nBonjour,", $t->written());
}
}

View file

@ -23,24 +23,42 @@ final class UserDirectoryTest extends TestCase
public function testDisplayInfoReturnsNameAndRoleLabel(): void
{
$this->db->userDisplayRow = [
'first_name' => 'Corentin',
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
'first_name' => 'Corentin',
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
'order_source' => null,
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(7),
);
}
public function testDisplayInfoExposesOrderSourceForChannelRoles(): void
{
// order_source remonte du role : sert au layout a router "Saisie commande".
$this->db->userDisplayRow = [
'first_name' => 'Dana',
'last_name' => 'D',
'email' => 'dana@wakdo.local',
'role_label' => 'Drive Staff',
'order_source' => 'drive',
];
self::assertSame(
['name' => 'Dana D', 'role_label' => 'Drive Staff', 'email' => 'dana@wakdo.local', 'order_source' => 'drive'],
(new UserDirectory($this->db))->displayInfo(8),
);
}
public function testDisplayInfoDefaultsWhenAbsent(): void
{
$this->db->userDisplayRow = null;
self::assertSame(
['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
['name' => 'Utilisateur', 'role_label' => '', 'email' => '', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}

View file

@ -115,7 +115,7 @@ final class CatalogueControllerTest extends TestCase
$product = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes', 'is_orderable'],
array_keys($product),
);
self::assertSame(12, $product['id']);
@ -125,6 +125,41 @@ final class CatalogueControllerTest extends TestCase
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
self::assertNull($product['maxi_variant_name']); // pas de variante -> null
self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide
self::assertTrue($product['is_orderable']); // aucune rupture -> commandable
}
public function testProductsMarksAutoUnavailableProductAsNotOrderable(): void
{
// RG-T21 : deux produits listes (is_available=1), mais l'un (id 12) est en
// rupture calculee par le stock -> is_orderable false ; l'autre (id 13) true.
$db = new FakeCatalogueDatabase();
$db->productsRows = [
['id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1', 'maxi_variant_name' => null],
['id' => '13', 'category_id' => '3', 'name' => 'Hamburger', 'description' => null, 'price_cents' => '790', 'image_path' => null, 'display_order' => '2', 'maxi_variant_name' => null],
];
$db->autoUnavailableRows = [['product_id' => '12']]; // 12 en rupture
$response = $this->controller($db, '/api/products')->products();
self::assertSame(200, $response->status());
$data = $this->decode($response->body())['data'];
self::assertFalse($data[0]['is_orderable']); // 12 en rupture
self::assertTrue($data[1]['is_orderable']); // 13 commandable
}
public function testProductDetailAutoUnavailableIsNotOrderable(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger',
'description' => null, 'price_cents' => '890', 'image_path' => null, 'display_order' => '1',
];
$db->autoUnavailableRows = [['product_id' => '12']];
$response = $this->controller($db, '/api/products/12')->product(['id' => '12']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testProductsListExposesMaxiVariantName(): void
@ -291,18 +326,52 @@ final class CatalogueControllerTest extends TestCase
$menu = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order', 'is_orderable'],
array_keys($menu),
);
self::assertSame(1, $menu['id']);
self::assertSame(5, $menu['burger_product_id']);
self::assertSame(990, $menu['price_normal_cents']);
self::assertSame(1190, $menu['price_maxi_cents']);
self::assertTrue($menu['is_orderable']); // burger non en rupture
self::assertArrayNotHasKey('slots', $menu); // liste legere : pas de slots
self::assertArrayNotHasKey('is_available', $menu); // toujours dispo ici
self::assertArrayNotHasKey('vat_rate', $menu);
}
public function testMenuNotOrderableWhenBurgerAutoUnavailable(): void
{
// RG-T21 (granularite burger seul) : le burger impose (id 5) est en rupture
// calculee -> le menu n'est plus commandable, meme s'il est is_available=1.
$db = new FakeCatalogueDatabase();
$db->menusRows = [
['id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big', 'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190', 'image_path' => null, 'display_order' => '1'],
];
$db->autoUnavailableRows = [['product_id' => '5']]; // burger en rupture
$response = $this->controller($db, '/api/menus')->menus();
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data'][0]['is_orderable']);
}
public function testMenuDetailNotOrderableWhenBurgerAutoUnavailable(): void
{
$db = new FakeCatalogueDatabase();
$db->menuRow = [
'id' => '1', 'category_id' => '1', 'burger_product_id' => '5', 'name' => 'Menu Big',
'description' => null, 'price_normal_cents' => '990', 'price_maxi_cents' => '1190',
'image_path' => null, 'display_order' => '1',
];
$db->menuSlotRows = [];
$db->autoUnavailableRows = [['product_id' => '5']];
$response = $this->controller($db, '/api/menus/1')->menu(['id' => '1']);
self::assertSame(200, $response->status());
self::assertFalse($this->decode($response->body())['data']['is_orderable']);
}
public function testMenuDetailReturnsDataWithSlots(): void
{
$db = new FakeCatalogueDatabase();

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Controllers;
use PHPUnit\Framework\TestCase;
use App\Controllers\HealthController;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
/**
* Sous-classe de test : pointe le fichier VERSION sur une fixture temporaire,
* pour couvrir l'exposition de la version deployee sans dependre d'un deploiement
* reel (le fichier est ecrit par scripts/deploy.sh sur l'hote, jamais en test).
*/
final class TestHealthController extends HealthController
{
public string $versionPath = '';
protected function versionFilePath(): string
{
return $this->versionPath;
}
}
/**
* La sonde expose la version deployee (SHA + date) pour prouver le CD : apres un
* deploiement, GET /api/health doit refleter le nouveau commit. Le test n'a pas de
* base : l'appel DB echoue et degrade le statut, mais les champs version restent
* presents (ils sont independants de la BDD).
*/
final class HealthControllerTest extends TestCase
{
private function controller(string $versionPath): TestHealthController
{
$request = new Request('GET', '/api/health', [], [], '', '203.0.113.5');
$c = new TestHealthController($request, new Config(), new Database(new Config()));
$c->versionPath = $versionPath;
return $c;
}
public function testExposesDeployedVersionWhenFilePresent(): void
{
$fixture = tempnam(sys_get_temp_dir(), 'wakdo_version_');
file_put_contents($fixture, "3dee190 2026-06-23T14:02:11+02:00\n");
try {
$body = $this->controller($fixture)->index()->body();
$payload = json_decode($body, true);
self::assertSame('3dee190', $payload['version']);
self::assertSame('2026-06-23T14:02:11+02:00', $payload['deployed_at']);
} finally {
@unlink($fixture);
}
}
public function testVersionNullWhenFileAbsent(): void
{
$missing = sys_get_temp_dir() . '/wakdo_version_does_not_exist_' . getmypid();
@unlink($missing);
$body = $this->controller($missing)->index()->body();
$payload = json_decode($body, true);
self::assertNull($payload['version']);
self::assertNull($payload['deployed_at']);
}
}

View file

@ -121,6 +121,86 @@ final class OrderRepositoryTest extends TestCase
self::assertSame(8, $sel['slot']);
}
public function testMenuMaxiSwapsDrinkSelectionToLargeVariant(): void
{
// Au format maxi, la boisson fontaine Coca Cola (variante = Coca Cola 50cl,
// id 15) doit etre persistee comme la 50 cl : meme mecanique que l'accompagnement
// Grande Frite (maxi_variant_product_id), pour que le stock decremente la 50 cl
// et que le snapshot reflete "Coca Cola 50cl". Aucune garde sur le slot_type.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => 15];
$db->products[15] = ['id' => 15, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 14]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 14]]]], // borne envoie la 30 cl
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(15, $sel['pid']); // swap -> Coca Cola 50cl
self::assertSame('Coca Cola 50cl', $sel['label']);
self::assertSame(9, $sel['slot']);
}
public function testMenuMaxiKeepsBottledDrinkWithoutVariant(): void
{
// Une boisson en bouteille (Eau) n'a pas de variante 50 cl : meme en Maxi la
// selection reste l'Eau de base (degradation gracieuse, modele fast-food).
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->products[16] = ['id' => 16, 'name' => 'Eau', 'price_cents' => 150, 'vat_rate' => 100, 'is_available' => 1, 'maxi_variant_product_id' => null];
$db->slotRows[5] = [['id' => 9, 'name' => 'Boisson', 'slot_type' => 'drink', 'is_required' => 1, 'display_order' => 1, 'product_id' => 16]];
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'maxi',
'selections' => [['menu_slot_id' => 9, 'product_id' => 16]]]],
]);
$sel = $db->firstWrite('INSERT INTO order_item_selection');
self::assertSame(16, $sel['pid']); // pas de variante -> reste l'Eau
self::assertSame('Eau', $sel['label']);
}
public function testProductInStockRuptureRejectedAtOrderCreation(): void
{
// RG-T21 : un produit liste (is_available=1) mais en rupture calculee par le
// stock est REFUSE a la creation de commande (garde serveur load-bearing, pas
// seulement grise sur la borne). Couvre le bypass URL directe / repli sans-JS.
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('PRODUCT_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
]);
}
public function testMenuRejectedAtOrderWhenBurgerInStockRupture(): void
{
// RG-T21 (granularite burger seul) : le burger impose en rupture calculee rend
// le menu non commandable cote serveur, meme is_available=1.
$db = new FakeOrderDatabase();
$db->menus[5] = ['id' => 5, 'burger_product_id' => 12, 'name' => 'Menu', 'price_normal_cents' => 990, 'price_maxi_cents' => 1200, 'is_available' => 1];
$db->products[12] = ['id' => 12, 'name' => 'Burger', 'price_cents' => 600, 'vat_rate' => 100, 'is_available' => 1];
$db->autoUnavailableRows = [['product_id' => 12]];
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('MENU_UNAVAILABLE');
$this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => []]],
]);
}
public function testMenuNormalKeepsBaseSideSelection(): void
{
// Format normal : aucune substitution, l'accompagnement reste la Moyenne

View file

@ -1,6 +1,8 @@
// Parcours E2E borne : welcome -> categories -> produit -> ajout panier -> panier
// -> paiement -> confirmation. La stack est montee a part (run.sh) ; le panier vit
// dans localStorage (meme origine), donc on peut naviguer par goto sans perdre l'etat.
// Parcours E2E borne : welcome -> categories -> produits (grille) -> modale d'options
// au clic produit -> ajout -> le panneau de commande persistant (unique vue panier)
// reflete la ligne -> paiement -> confirmation. La stack est montee a part (run.sh) ;
// le panier vit dans localStorage (meme origine), on navigue sans perdre l'etat.
// Il n'existe plus de page panier ni de page produit separees (lot F3 panier unique).
const { test, expect } = require('@playwright/test');
test('parcours borne : de l\'accueil a la confirmation de commande', async ({ page }) => {
@ -16,40 +18,46 @@ test('parcours borne : de l\'accueil a la confirmation de commande', async ({ pa
await test.step('categories -> produits', async () => {
await expect(page.locator('h1.categories-main__heading')).toBeVisible();
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui rendent
// un autre gabarit a slots, sans bouton d'ajout direct).
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui ouvrent
// le composeur a slots, un autre parcours). Un produit simple ouvre la modale options.
await page.locator('a[href="products.html?category=2"]').click();
await expect(page).toHaveURL(/products\.html\?category=2/);
});
await test.step('produits -> fiche produit', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte.
const firstCard = page.locator('#products-grid a.product-card').first();
await test.step('clic produit -> modale options', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte commandable.
const firstCard = page.locator('#products-grid a.product-card:not(.product-card--unavailable)').first();
await expect(firstCard).toBeVisible();
await firstCard.click();
await expect(page).toHaveURL(/product\.html\?id=/);
// product-options.js monte une modale (.composer-overlay) avec le bouton d'ajout #po-add.
await expect(page.locator('.composer-overlay [role="dialog"]')).toBeVisible();
await expect(page.locator('#po-add')).toBeVisible();
});
await test.step('ajout au panier', async () => {
const addBtn = page.locator('#add-to-cart-btn');
await expect(addBtn).toBeVisible();
await addBtn.click();
// Feedback visuel "Ajoute !" (page-product.js) ; l'ecriture localStorage est synchrone.
await expect(addBtn).toHaveText(/Ajoute/);
await test.step('ajout -> le panneau de commande reflete la ligne', async () => {
await page.locator('#po-add').click();
// La modale se ferme et le panneau persistant (unique vue panier) montre la ligne.
await expect(page.locator('.composer-overlay')).toHaveCount(0);
const panel = page.locator('[data-order-panel]');
await expect(panel.locator('.order-panel__line')).toHaveCount(1);
// Total calcule (le panneau affiche un montant, plus le placeholder vide).
await expect(panel.locator('.order-panel__total-value')).not.toHaveText('');
});
await test.step('panier : recapitulatif', async () => {
await page.goto('/cart.html');
await expect(page.locator('#cart-summary')).toBeVisible();
await expect(page.locator('#cart-list li')).toHaveCount(1);
// Total calcule, plus le placeholder "—".
await expect(page.locator('#total-ttc')).not.toHaveText('—');
await page.locator('#pay-btn').click();
await test.step('panneau Payer -> paiement', async () => {
const payLink = page.locator('[data-order-panel] .order-panel__pay');
await expect(payLink).toHaveAttribute('aria-disabled', 'false');
await payLink.click();
await expect(page).toHaveURL(/payment\.html/);
});
await test.step('paiement -> confirmation', async () => {
await page.locator('#pay-card').click();
// Sur-place : page-payment.js ouvre la modale chevalet (numero de table) avant de
// soumettre. On saisit un numero puis on enregistre pour declencher le checkout.
await expect(page.locator('#chevalet-input')).toBeVisible();
await page.locator('#chevalet-input').fill('12');
await page.locator('#chevalet-ok').click();
await expect(page).toHaveURL(/confirmation\.html/);
await expect(page.locator('.confirmation-banner__title')).toHaveText(/Commande confirmee/);
// Numero de commande genere (plus le placeholder).

View file

@ -60,6 +60,17 @@ test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
assert.equal(maxi.format, 'maxi');
});
test('buildOrderItem: format explicite prime sur l inference (maxi meme si supplement 0)', () => {
// Le choix utilisateur est transporte dans cartItem.format ; il ne doit PAS etre
// re-devine du prix (un menu maxi == normal serait sinon envoye en normal).
const explicit = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0, format: 'maxi',
composition: { boisson: { id: 14 } } };
assert.equal(buildOrderItem(explicit, { 1: slots() }).format, 'maxi');
// Repli historique : un panier serialise sans champ format infere depuis supplement.
const legacy = { id: 1, type: 'menu', quantite: 1, supplement_cents: 150, composition: {} };
assert.equal(buildOrderItem(legacy, { 1: slots() }).format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {

View file

@ -12,7 +12,7 @@ import { JSDOM } from 'jsdom';
let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/product.html' });
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
@ -71,6 +71,7 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos
assert.equal(item.type, 'menu');
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 0);
assert.equal(item.format, 'normal'); // format explicite transporte
assert.equal(item.composition.burger.libelle, 'Le 280');
// Normal : l'accompagnement garde son nom de base (pas la variante Maxi).
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' });
@ -83,6 +84,7 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 150); // 1030 - 880
assert.equal(item.format, 'maxi'); // format explicite transporte
assert.equal(item.composition.accompagnement.taille, 'G');
assert.equal(item.composition.boisson.taille, 'G');
});
@ -91,10 +93,21 @@ test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite),
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite"
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
// Boisson sans maxiNom : garde son nom de base meme en Maxi (cas bouteille).
assert.equal(item.composition.boisson.libelle, 'Coca');
});
test('buildMenuCartItem Maxi: la boisson AVEC variante (50cl) prend son nom agrandi', () => {
// Apres le seed 0006, une boisson fontaine porte maxiNom (ex. "Coca Cola 50cl") :
// en Maxi, le libelle et la taille refletent la grande boisson (meme regle que
// l'accompagnement). Aucune logique borne specifique : maxiNom suffit.
const byIdDrinkVariant = { ...byId(), 14: { id: 14, nom: 'Coca Cola', prix: 0, image: 'c.png', type: 'produit', maxiNom: 'Coca Cola 50cl' } };
const m = buildComposerSteps(detail(), byIdDrinkVariant);
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.composition.boisson.libelle, 'Coca Cola 50cl');
assert.equal(item.composition.boisson.taille, 'G');
});
test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });

View file

@ -0,0 +1,67 @@
/*
* Tests de confirm-modal.js (node:test + jsdom). Modale de confirmation d'un geste
* destructeur : onConfirm n'est appele QUE sur confirmation explicite ; Annuler /
* Echap / clic-fond ferment sans agir. Import dynamique apres pose des globals jsdom.
*/
import { test, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let confirmAction;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.requestAnimationFrame = (cb) => cb();
({ confirmAction } = await import('../../src/public/borne/assets/js/confirm-modal.js'));
});
beforeEach(() => { document.body.innerHTML = ''; });
test('confirmAction: affiche une modale role=dialog avec le message', () => {
confirmAction({ message: 'Abandonner ?', onConfirm: () => {} });
const modal = document.querySelector('.confirm-overlay .confirm-modal[role="dialog"]');
assert.ok(modal);
assert.equal(modal.getAttribute('aria-modal'), 'true');
assert.match(modal.querySelector('.confirm-modal__message').textContent, /Abandonner/);
});
test('confirmAction: Confirmer appelle onConfirm puis ferme', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.querySelector('.confirm-modal__confirm').click();
assert.equal(called, 1);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: Annuler ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.querySelector('.confirm-modal__cancel').click();
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: Echap ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: clic sur le fond ferme sans appeler onConfirm', () => {
let called = 0;
confirmAction({ message: 'x', onConfirm: () => { called++; } });
const overlay = document.querySelector('.confirm-overlay');
overlay.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(called, 0);
assert.equal(document.querySelector('.confirm-overlay'), null);
});
test('confirmAction: le message est echappe (anti-XSS)', () => {
confirmAction({ message: '<img src=x onerror=alert(1)>', onConfirm: () => {} });
assert.equal(document.querySelectorAll('img[onerror]').length, 0);
});

View file

@ -7,7 +7,9 @@
* - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse)
*
* Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers)
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise, pas un prix.
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise. Le prix affiche
* cote client (total + libelle du bouton) est INDICATIF : on verrouille seulement
* l'affichage local (somme price + surcouts), pas une verite metier serveur.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
@ -52,13 +54,20 @@ function setup(menus = MENUS) {
.map(m => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
// qty_<id> pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour
// ceux dont la recette offre des modificateurs (calque la vue new.php).
// ceux dont la recette offre des modificateurs (calque la vue new.php). Progressive
// enhancement (etape 4) : le champ qty est rendu EDITABLE en HTML pour TOUS les
// produits ; c'est le JS qui neutralise le champ d'un produit configurable au cablage
// et revele l'indice "via Personnaliser" (span data-qty-hint, hidden en HTML).
const productRows = PRODUCTS
.map(p => {
const configure = (p.modifiers && p.modifiers.length)
const hasMods = p.modifiers && p.modifiers.length;
const configure = hasMods
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${configure}`;
const hint = hasMods
? `<span class="order-qty-hint" data-qty-hint="${p.id}" hidden>via Personnaliser</span>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${hint}${configure}`;
})
.join('');
const dom = new JSDOM(
@ -66,10 +75,13 @@ function setup(menus = MENUS) {
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <button type="submit">Encaisser</button>' +
' <p id="order-total">Total : <span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -271,6 +283,210 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
assert.deepEqual(items[0].modifiers, [{ ingredient_id: 3, action: 'remove' }]);
});
test('total + bouton : produit simple (Frites 2,50 x2) -> 5,00 EUR affiche', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_22'); // Frites, 250c
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 5,00 EUR');
});
test('total : produit personnalise avec ajout (Cheeseburger 8,90 + Bacon 0,50) -> 9,40 EUR', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Prix de ligne affiche dans le panier.
assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '9,40 EUR');
});
test('total : menu Maxi (11,90) inclus dans le total de ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
assert.equal(doc.querySelector('.order-cart__price').textContent, '11,90 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '11,90 EUR');
});
test('numero de table : masque hors sur place, visible en sur place (toggle service_mode)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const group = doc.getElementById('service_tag_group');
const select = doc.getElementById('service_mode');
// Init : dine_in pre-selectionne -> visible.
assert.equal(group.hasAttribute('hidden'), false);
select.value = 'takeaway';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), true);
select.value = 'dine_in';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), false);
});
test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force
// l'etat en deselectionnant via un select dont l'option vide n'existe pas. On
// simule en retirant la selection requise par un slot side (16) mis a vide n'est pas
// possible (requis) ; on retire plutot la pre-selection en posant une valeur hors options.
// Plus simple : on supprime l'option pre-cochee du slot requis 'drink' (1) en le
// forcant a une chaine vide via le select (un slot requis n'a pas d'option Sans, mais
// jsdom autorise l'affectation d'une value vide -> change supprime la selection).
const drinkSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '1',
);
drinkSelect.value = '';
drinkSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
// Le <p role=alert> est present des l'ouverture (vide), avant toute erreur.
const errAtOpen = modal.querySelector('.menu-composer__error');
assert.ok(errAtOpen);
assert.equal(errAtOpen.getAttribute('role'), 'alert');
assert.equal(errAtOpen.textContent, '');
assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y)
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Modale encore ouverte, message inline renseigne (textContent), aucune ligne.
assert.equal(modal.hasAttribute('hidden'), false);
assert.notEqual(errAtOpen.textContent, '');
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('produit personnalisable : champ qty editable en HTML, desactive par JS + indice revele', () => {
const dom = setup();
const doc = dom.window.document;
// Avant init : le champ qty d'un produit a modificateurs est editable (repli sans JS)
// et l'indice "via Personnaliser" est cache.
const qty = doc.getElementById('qty_12');
const hint = doc.querySelector('[data-qty-hint="12"]');
assert.equal(qty.disabled, false);
assert.equal(hint.hidden, true);
counterOrder.init(doc);
// Apres init (JS present) : champ neutralise et indice revele.
assert.equal(qty.disabled, true);
assert.equal(hint.hidden, false);
// Un produit SANS modificateur reste editable (pas d'indice).
assert.equal(doc.getElementById('qty_22').disabled, false);
assert.equal(doc.querySelector('[data-qty-hint="22"]'), null);
});
test('modale : focus restaure sur le bouton declencheur a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]');
trigger.focus();
assert.equal(doc.activeElement, trigger);
trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur le bouton declencheur).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur le declencheur.
assert.equal(doc.activeElement, trigger);
});
test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const panel = doc.querySelector('.menu-composer');
assert.equal(panel.getAttribute('role'), 'dialog');
assert.equal(panel.getAttribute('aria-modal'), 'true');
const labelledby = panel.getAttribute('aria-labelledby');
assert.ok(labelledby);
const title = doc.getElementById(labelledby);
assert.ok(title);
assert.equal(title.classList.contains('menu-composer__title'), true);
});
test('total : separateur de milliers aligne sur PHP (1 234,50 EUR)', () => {
// Produit a 617,25 EUR (61725c) x2 = 1 234,50 EUR -> espace separateur de milliers.
const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, modifiers: [] }];
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRICEY)}' data-menus='[]'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <input class="order-qty" type="number" id="qty_99" name="qty_99" data-product-id="99" value="0">' +
' <ul id="menu-list"></ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p><span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_99');
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '1 234,50 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 1 234,50 EUR');
});
test('modale : touche Echap ferme la modale', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(modal.hasAttribute('hidden'), true);
});
test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });

View file

@ -69,7 +69,8 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)'
assert.deepEqual(data.burgers, [
// sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas.
// maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
// commandable : true par defaut quand l'API n'envoie pas is_orderable.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [], commandable: true },
]);
});
@ -100,10 +101,21 @@ test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_n
const data = await loadProducts();
assert.deepEqual(data.menus, [
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu', commandable: true },
]);
});
test('loadProducts: is_orderable=false -> commandable=false (rupture RG-T21)', async () => {
const fx = fixtures();
fx['/api/products'].data[0].is_orderable = false;
fx['/api/menus'].data[0].is_orderable = false;
const { loadProducts } = await freshData(fx);
const data = await loadProducts();
assert.equal(data.burgers[0].commandable, false);
assert.equal(data.menus[0].commandable, false);
});
test('loadProducts consomme bien les trois endpoints /api/*', async () => {
const calls = [];
const { loadProducts } = await freshData(fixtures(), calls);

View file

@ -27,7 +27,7 @@ test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)',
test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => {
assert.equal(needsModeRedirect('/payment.html', null), true);
assert.equal(needsModeRedirect('/products.html', undefined), true);
assert.equal(needsModeRedirect('/cart.html', 'bidon'), true);
assert.equal(needsModeRedirect('/payment.html', 'bidon'), true);
assert.equal(needsModeRedirect('/categories.html', ''), true);
});

View file

@ -22,6 +22,7 @@ before(async () => {
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.requestAnimationFrame = (cb) => cb();
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
await import('../../src/public/borne/assets/js/order-panel.js'));
});
@ -138,6 +139,38 @@ test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => {
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: stepper + augmente la quantite et re-rend', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 })]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="inc"]').click();
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart'))[0].quantite, 2);
assert.equal(el.querySelector('.order-panel__qty-value').textContent, '2');
});
test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 }), menu()]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="dec"]').click(); // 1re ligne 1 -> 0 -> retiree
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: Abandon demande confirmation avant d effacer', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple()]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__abandon').click();
// Une modale de confirmation apparait ; le panier n'est PAS encore efface.
assert.ok(document.querySelector('.confirm-overlay'));
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
// Annuler conserve le panier et ferme la modale.
document.querySelector('.confirm-modal__cancel').click();
assert.equal(document.querySelector('.confirm-overlay'), null);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
const el = document.createElement('aside');