Compare commits

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

12 commits
main ... dev

Author SHA1 Message Date
2fe192452d feat(back-office): page Stock en tableau de bord (alertes + reappro en avant) (#105)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 51s
CI / static-tests (push) Successful in 1m35s
CI / js-tests (push) Successful in 41s
2026-06-24 14:44:25 +02:00
9bdd53120c feat(back-office): saisie commande comptoir/drive en POS tactile a tuiles (#104)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 50s
CI / static-tests (push) Successful in 1m44s
CI / js-tests (push) Successful in 52s
2026-06-24 14:32:11 +02:00
6f2aedc699 chore(borne): bascule allergenes sur /api/allergens + menage donnees/docs (#103)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 36s
CI / static-tests (push) Successful in 1m25s
CI / js-tests (push) Successful in 37s
2026-06-24 12:37:54 +02:00
3c53908952 fix(borne): confirmation avant Abandon de la commande (#102)
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 47s
CI / static-tests (push) Successful in 1m8s
CI / js-tests (push) Successful in 34s
2026-06-24 12:29:32 +02: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
75 changed files with 4654 additions and 1660 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

@ -51,10 +51,9 @@ Code de reference : routes dans `src/public/admin/index.php`, controleurs dans
| Famille | Prefixe | Rendu | Authentification | Exemple |
|---|---|---|---|---|
| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` |
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) |
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (livre) |
La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON
statique sous `src/public/borne/data/` (voir section 8.3).
La borne (kiosk) consomme l'API REST `/api/*` en lecture pour le catalogue (voir section 8.3).
---
@ -107,7 +106,7 @@ is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Re
si la session est absente, expiree ou le compte desactive. Les autorisations par operation
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)
### 5.2 API kiosk - lecture catalogue + commande (livre, public)
La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk.
@ -270,18 +269,16 @@ Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_S
`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format
d'enveloppe.
### 8.3 Divergence connue : repli JSON de la borne
### 8.3 Nommage borne vs canonique : le rapprochement dans data.js
Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient
des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`,
`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via
`data.js`.
Le front de la borne attend un nommage historique heterogene issu des sources de l'ecole
(`title`/`nom`, `prix`, `image`, `type`). L'API sert la forme canonique de 8.1
(`/api/categories`, `/api/products`, `/api/menus`, `/api/allergens`). Le rapprochement se fait
en un point unique : la couche `data.js`, qui deballe l'enveloppe `{ data }` et mappe la forme
canonique vers ce que la borne attend. Les anciens fichiers JSON statiques sous
`src/public/borne/data/` ont ete retires.
La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche
`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle
servira la forme canonique ; `data.js` mappera vers ce que la borne attend.
| Repli borne | Canonique API / dictionnaire |
| Forme borne | Canonique API / dictionnaire |
|---|---|
| `title` (categorie) | `name` |
| `nom` (produit) | `name` |

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

@ -23,9 +23,12 @@ Accueil
-> Remerciement
```
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne
correspond pas".
Le kiosk construit a desormais rejoint ce paradigme : l'ecran de commande
(`products.html`) porte un **panneau de commande persistant** a droite, les options
produit et le composeur de menu s'ouvrent **en modale** par-dessus la grille, et le
**chevalet** (saisie du numero de table) s'ouvre en modale au paiement sur place. Les
pages intermediaires `product.html` et `cart.html` du premier jet ont ete retirees.
Cette note garde la trace de la decomposition maquette -> code et des ecarts resorbes.
## 2. Decomposition ecran par ecran
@ -87,25 +90,29 @@ correspond pas".
| Maquette | Kiosk construit | Verdict |
|----------|-----------------|---------|
| 1. Accueil sur place / a emporter | `index.html` | conforme |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | eclate en `categories.html` -> `products.html` -> `cart.html` | divergence structurante : multi-pages, et **pas de panneau de commande persistant** |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | `products.html` : bandeau categories (`category-strip.js`) + grille + **panneau de commande persistant** a droite (`order-panel.js`) | conforme |
| (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` = composition **libre** | divergence (le refactor "consommer les slots /api/menus" est deja en file P4) |
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale |
| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` : composeur **modal pilote par les slots** de `/api/menus/{id}` (format Maxi puis 1 etape par slot) | conforme |
| 8. Modale d'option produit (taille + quantite) | `product-options.js` : **modale** d'options (taille R4 + stepper de quantite) au-dessus de la grille | conforme |
| 9. Ecran **chevalet** dedie (saisie numero) | **modale chevalet** au paiement sur place (`page-payment.js`), numero pose via l'API ; rappele en confirmation | conforme |
| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build |
| 10. Remerciement | `confirmation.html` | conforme |
## 4. Ecarts structurants (le fond du sujet)
## 4. Ecarts structurants (resorbes)
1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec
categories en bandeau et un panneau recapitulatif persistant a droite, modales
par-dessus). Build = **multi-pages** classiques (categories -> produits ->
produit -> panier). C'est l'ecart structurant principal.
2. **Panneau de commande lateral absent.** La piece centrale de la maquette
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon /
Payer, visible en permanence) n'est pas presente dans le build.
3. **Composition de menu.** Maquette = assistant modal en etapes ; build =
composition libre cote client (`page-product-menu.js`).
Les ecarts structurants du premier jet ont ete realignes sur la maquette :
1. **Paradigme.** L'ecran de commande (`products.html`) suit le plan mono-ecran de
la maquette : categories en bandeau (`category-strip.js`), grille produits, et
panneau recapitulatif persistant a droite ; les options et le composeur de menu
s'ouvrent en modale par-dessus. Les pages `product.html` et `cart.html` du
premier jet ont ete retirees.
2. **Panneau de commande lateral.** La piece centrale de la maquette (numero de
commande, lignes editables avec quantite et retrait, TOTAL ttc, Abandon / Payer)
est rendue par `order-panel.js`, visible en permanence sur l'ecran de commande.
3. **Composition de menu.** Le composeur (`page-product-menu.js`) est un assistant
modal en etapes pilote par les slots de `/api/menus/{id}` (format Maxi puis une
etape par slot), conforme a l'enchainement de la maquette.
## 5. Rebrand McDonald's -> Wakdo
@ -116,6 +123,8 @@ note n'est donc pas le rebrand mais la **structure** des ecrans.
## 6. Suite
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories +
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des
divergences = section 3 ci-dessus.
Le re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories
+ composeur en modale + chevalet en modale) est livre. La borne lit le catalogue
via l'API REST (`/api/categories|products|menus|allergens`). Reste a faire : la
generation dynamique de l'ecran categories depuis `GET /api/categories` (section 3,
ecran categories) et le polissage visuel du rebrand Wakdo.

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

@ -9,8 +9,9 @@ use App\Core\DatabaseInterface;
/**
* Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14
* categories), pas un calcul par produit (le mapping ingredient_allergen reste
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema ne porte que
* code + name ; les descriptions riches restent cote borne (data/allergens.json).
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema porte
* code + name + description ; la description (texte INCO seede) est exposee par
* l'API et consommee par la borne via /api/allergens.
*
* Non `final` : seam de test (sous-classe -> double sans base).
*/
@ -27,6 +28,6 @@ class AllergenRepository
*/
public function all(): array
{
return $this->db->fetchAll('SELECT id, code, name FROM allergen ORDER BY id');
return $this->db->fetchAll('SELECT id, code, name, description FROM allergen ORDER BY id');
}
}

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]);
}
@ -168,14 +187,15 @@ class CatalogueController extends Controller
/**
* @param array<string, mixed> $row
* @return array{id: int, code: string, name: string}
* @return array{id: int, code: string, name: string, description: ?string}
*/
private function presentAllergen(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'description' => $this->nullableString($row['description'] ?? null),
];
}
@ -200,9 +220,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 +249,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 +273,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

@ -47,10 +47,22 @@ class IngredientController extends AdminController
return $guard;
}
$ingredients = $this->ingredientRepository()->all();
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
// Calcules cote serveur a partir de stock_band deja resolu par le depot,
// pour que la vue reste declarative et la valeur testable directement.
$counts = ['critical' => 0, 'low' => 0, 'normal' => 0];
foreach ($ingredients as $row) {
$band = (string) ($row['stock_band'] ?? 'normal');
$counts[$band] = ($counts[$band] ?? 0) + 1;
}
return $this->adminView('admin/ingredients/index', [
'title' => 'Stock - Wakdo Admin',
'activeNav' => 'stock',
'ingredients' => $this->ingredientRepository()->all(),
'ingredients' => $ingredients,
'bandCounts' => $counts,
'canManage' => $this->may($guard, 'ingredient.manage'),
'canRestock' => $this->may($guard, 'stock.manage'),
'canCount' => $this->may($guard, 'stock.count'),

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,25 +3,32 @@
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans
* admin/layout.php. Produits commandables ET menus composes (slots
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients).
* POS tactile a tuiles (comptoir / drive), injecte dans admin/layout.php. Refonte de
* la saisie : a la place du formulaire-liste, un ecran de caisse facon borne client
* (onglets categories en haut, grille de tuiles produits/menus a gauche, panneau
* commande persistant a droite). Pensee pour la tablette : grandes cibles tactiles,
* un tap sur une tuile ajoute le produit a la commande (qty 1), un produit a
* modificateurs ou un menu ouvre la modale de composition.
*
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
* zero handler inline) : il lit produits et menus depuis les data-* de
* #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).
* zero handler inline) : il lit produits et menus depuis un script JSON inerte
* (type="application/json"), construit les onglets, rend la grille, gere le panneau
* commande, 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) : les prix affiches cote client (par ligne + total + libelle du
* bouton) sont INDICATIFS, le serveur reste seul juge. Le contrat de soumission est
* inchange (items_json + service_mode + service_tag + _csrf). Sans JS, la grille ne
* s'affiche pas : un message invite a activer JS (le POS est interactif par nature).
*
* 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
@ -30,19 +37,12 @@ declare(strict_types=1);
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
// Donnees pour counter-order.js, passees en attributs data-* (CSP 'self' : pas de
// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut.
$attr = static fn (mixed $data): string => htmlspecialchars(
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
ENT_QUOTES,
'UTF-8',
);
$csrf = $esc($csrfToken ?? '');
$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 */
@ -50,10 +50,11 @@ $productRows = isset($products) && is_array($products) ? $products : [];
/** @var list<array<string, mixed>> $menuRows */
$menuRows = isset($menus) && is_array($menus) ? $menus : [];
// Projection compacte pour le JS : seules les cles utiles a la composition. Les
// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16).
// modifiers : ingredients retirables / ajoutables proposables (le client les affiche
// en cases a cocher ; resolveModifiers revalide chacun cote serveur).
// Projection compacte pour le JS : seules les cles utiles a la composition, l'affichage
// (tuiles : nom, prix, image, categorie) et le calcul local. Les prix sont passes pour
// l'affichage local (le serveur reste seul juge, RG-T16). modifiers : ingredients
// retirables / ajoutables proposables (cases a cocher cote client ; resolveModifiers
// revalide chacun cote serveur).
$jsModifiers = static fn (mixed $rows): array => array_map(
static fn (array $r): array => [
'ingredient_id' => (int) ($r['ingredient_id'] ?? 0),
@ -64,17 +65,28 @@ $jsModifiers = static fn (mixed $rows): array => array_map(
],
is_array($rows) ? $rows : [],
);
// Nom de categorie d'une ligne : category_name si fourni, sinon repli "Autres" pour ne
// pas creer d'onglet a libelle vide.
$catNameOf = static fn (array $r): string => isset($r['category_name'])
&& is_string($r['category_name']) && $r['category_name'] !== ''
? $r['category_name']
: 'Autres';
$jsProducts = array_map(
static fn (array $p): array => [
'id' => (int) ($p['id'] ?? 0),
'name' => (string) ($p['name'] ?? ''),
'price' => (int) ($p['price_cents'] ?? 0),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
'id' => (int) ($p['id'] ?? 0),
'name' => (string) ($p['name'] ?? ''),
'price' => (int) ($p['price_cents'] ?? 0),
'image' => (string) ($p['image_path'] ?? ''),
'category_id' => (int) ($p['category_id'] ?? 0),
'category_name' => $catNameOf($p),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
],
$productRows,
);
$jsMenus = array_map(
static function (array $m) use ($jsModifiers): array {
static function (array $m) use ($jsModifiers, $catNameOf): array {
/** @var list<array<string, mixed>> $slots */
$slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : [];
@ -83,6 +95,9 @@ $jsMenus = array_map(
'name' => (string) ($m['name'] ?? ''),
'price_normal' => (int) ($m['price_normal_cents'] ?? 0),
'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0),
'image' => (string) ($m['image_path'] ?? ''),
'category_id' => (int) ($m['category_id'] ?? 0),
'category_name' => $catNameOf($m),
// Modificateurs du burger support : la selection d'un menu cible le burger
// (resolveModifiers cote serveur le resout sur burger_product_id).
'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null),
@ -102,109 +117,95 @@ $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'];
// JSON inerte (type="application/json") plutot que data-* : la charge (compo de chaque
// produit + slots de chaque menu) peut etre volumineuse ; un script JSON reste CSP-safe
// (non execute) et plus lisible qu'un long attribut data-*. JSON_HEX_* echappe < > & '
// pour que la sortie soit sure a l'interieur d'un <script> (anti-XSS, RG-T15).
$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
<?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form"
data-products="<?= $attr($jsProducts) ?>"
data-menus="<?= $attr($jsMenus) ?>">
<form method="post" action="<?= $esc($action) ?>" class="pos" id="counter-order-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="items_json" id="items_json" value="">
<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>
</div>
<?php /* Donnees du catalogue pour counter-order.js : script JSON inerte (CSP-safe). */ ?>
<script type="application/json" id="pos-products"><?= (string) json_encode($jsProducts, $jsonFlags) ?></script>
<script type="application/json" id="pos-menus"><?= (string) json_encode($jsMenus, $jsonFlags) ?></script>
<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'] !== [];
?>
<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>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</fieldset>
<div class="pos__main">
<div class="pos__catalogue">
<?php /* Barre d'onglets categories (construite par le JS depuis le catalogue). */ ?>
<div class="pos__tabs" id="pos-tabs" role="tablist" aria-label="Categories"></div>
<fieldset class="form-group">
<legend>Menus</legend>
<?php if ($menuRows === []): ?>
<p class="admin-empty">Aucun menu commandable pour le moment.</p>
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php $mid = (int) ($m['id'] ?? 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>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
</li>
<?php endforeach; ?>
<?php if ($productRows === [] && $menuRows === []): ?>
<p class="admin-empty">Aucun produit ni menu commandable pour le moment.</p>
<?php else: ?>
<?php /* Grille de tuiles (remplie par le JS) + repli sans JS. role=tabpanel
relie au tablist (aria-labelledby pose par le JS vers l'onglet
actif). Pas d'aria-live ici : la grille est rebatie a chaque
changement de categorie, une re-annonce complete serait verbeuse. */ ?>
<div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0">
<p class="pos__nojs">Activez JavaScript pour saisir une commande sur cet ecran de caisse.</p>
</div>
<?php endif; ?>
</div>
<?php /* Panneau commande persistant (recap a droite, facon caisse). */ ?>
<aside class="pos__panel" aria-label="Commande en cours">
<div class="pos__panel-head">
<span class="pos__panel-title">Commande</span>
<div class="pos__service">
<?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: ?>
<label class="pos__service-label" for="service_mode">Mode</label>
<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
hors dine_in (toggle JS sur le mode) ; le champ reste soumis tel quel,
persist() l'ignore hors dine_in. */ ?>
<div class="pos__service" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="pos__service-label" for="service_tag">Table</label>
<input class="form-input" type="text" id="service_tag" name="service_tag"
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
</div>
<?php endif; ?>
</div>
<?php /* Pas d'aria-live sur la liste : elle est rebatie a chaque +/- (une
re-annonce de tout le panier serait verbeuse). Une region live dediee
(#pos-announce) annonce un message concis a chaque mutation. */ ?>
<ul class="order-cart" id="order-cart">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Panier</legend>
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
</fieldset>
<div class="pos__panel-foot">
<?php /* 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>
<button class="btn btn-primary pos__pay" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
<?php /* Region live concise (C) : recoit "Total X EUR, N articles" a chaque
mutation du panier. Visuellement discrete (classe sr-only). */ ?>
<span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>
</aside>
</div>
</form>

View file

@ -3,11 +3,15 @@
declare(strict_types=1);
/**
* Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le
* pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes
* aux permissions (la garde reelle reste par-route). Texte echappe.
* Tableau de bord stock (READ_STOCK 9.3), injecte dans admin/layout.php. Oriente
* usage quotidien : on met en avant ce qui est bas a reapprovisionner, le CRUD de
* definition (config rare) est relegue. Le lien metier explique a quoi sert le stock :
* un ingredient requis sous le seuil critique rend les produits qui l'utilisent
* indisponibles sur la borne (RG-T21). Pourcentage/bande resolus cote depot ; les
* liens d'action restent conditionnes aux permissions (garde reelle par-route). Texte echappe.
*
* @var array<int, array<string, mixed>> $ingredients
* @var array<string, int> $bandCounts
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
@ -16,94 +20,172 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
/** @var array<string, int> $counts */
$counts = isset($bandCounts) && is_array($bandCounts) ? $bandCounts : [];
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$manage = (bool) ($canManage ?? false);
$restock = (bool) ($canRestock ?? false);
$count = (bool) ($canCount ?? false);
$bandLabel = static fn (string $band): string => match ($band) {
'critical' => 'pill pill-danger',
'low' => 'pill pill-warning',
default => 'pill pill-success',
$nCritical = (int) ($counts['critical'] ?? 0);
$nLow = (int) ($counts['low'] ?? 0);
$nNormal = (int) ($counts['normal'] ?? 0);
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
$critical = [];
$low = [];
foreach ($rows as $row) {
$band = (string) ($row['stock_band'] ?? 'normal');
if ($band === 'critical') {
$critical[] = $row;
} elseif ($band === 'low') {
$low[] = $row;
}
}
$toRestock = array_merge($critical, $low);
$barClass = static fn (string $band): string => match ($band) {
'critical' => 'stock-bar__fill stock-bar--critical',
'low' => 'stock-bar__fill stock-bar--low',
default => 'stock-bar__fill stock-bar--normal',
};
$bandText = static fn (string $band): string => match ($band) {
'critical' => 'Critique',
'low' => 'Alerte',
default => 'Normal',
/**
* Barre de niveau : conteneur + portion remplie (largeur = pct%, couleur = bande).
* La largeur est bornee a 100 pour rester dans le conteneur meme si le depot renvoie
* un pourcentage superieur. Style inline pour la largeur (deja la convention admin).
*
* @param array<string, mixed> $row
*/
$renderBar = static function (array $row) use ($esc, $barClass): string {
$pct = (int) ($row['stock_pct'] ?? 0);
$width = max(0, min(100, $pct));
$band = (string) ($row['stock_band'] ?? 'normal');
$qty = (int) ($row['stock_quantity'] ?? 0);
$cap = (int) ($row['stock_capacity'] ?? 0);
$state = match ($band) {
'critical' => 'critique',
'low' => 'en alerte',
default => 'au-dessus du seuil',
};
$html = '<div class="stock-bar" role="img" aria-label="Niveau de stock ' . $pct . ' pourcent, etat ' . $state . '">';
$html .= '<span class="' . $esc($barClass($band)) . '" style="width:' . $width . '%"></span>';
$html .= '</div>';
$html .= '<div class="stock-bar__meta"><span class="stock-bar__pct">' . $pct . '%</span>';
$html .= '<span class="stock-bar__qty">' . $esc((string) $qty) . ' / ' . $esc((string) $cap) . '</span></div>';
return $html;
};
?>
<div class="page-header">
<div>
<h1 class="page-title">Stock</h1>
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
<h1 class="page-title">Stock des ingredients</h1>
<p class="page-subtitle">Ce qui est bas a reapprovisionner, en un coup d oeil</p>
</div>
<?php if ($manage): ?>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/ingredients/new">Nouvel ingredient</a>
<a class="btn btn-secondary" href="/admin/ingredients/new">Nouvel ingredient</a>
</div>
<?php endif; ?>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Unite</th>
<th>Stock</th>
<th>Niveau</th>
<th>Statut</th>
<th style="width:280px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$band = (string) ($row['stock_band'] ?? 'normal');
$pct = (int) ($row['stock_pct'] ?? 0);
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
<td>
<?= $esc((string) ((int) ($row['stock_quantity'] ?? 0))) ?>
<span class="muted">/ <?= $esc((string) ((int) ($row['stock_capacity'] ?? 0))) ?> (<?= $pct ?>%)</span>
</td>
<td><span class="<?= $bandLabel($band) ?>"><?= $bandText($band) ?></span></td>
<td>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($restock): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/restock">Reappro</a>
<?php endif; ?>
<?php if ($count): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<?php if ($manage): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" style="display:inline;">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="stock-explainer">
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
recette qui passe sous son seuil critique rend les produits qui l utilisent
indisponibles a la commande. Tenez les niveaux a jour pour garder le menu ouvert.
</p>
<div class="stock-summary">
<div class="stock-summary__item stock-summary__item--danger">
<span class="stock-summary__count"><?= $nCritical ?></span>
<span class="stock-summary__label">critiques</span>
</div>
<div class="stock-summary__item stock-summary__item--warning">
<span class="stock-summary__count"><?= $nLow ?></span>
<span class="stock-summary__label">en alerte</span>
</div>
<div class="stock-summary__item stock-summary__item--success">
<span class="stock-summary__count"><?= $nNormal ?></span>
<span class="stock-summary__label">au-dessus du seuil</span>
</div>
</div>
<section class="stock-section stock-section--restock">
<h2 class="stock-section__title">A reapprovisionner</h2>
<?php if ($toRestock === []): ?>
<div class="stock-empty stock-empty--ok">
Tous les ingredients sont au-dessus de leurs seuils.
</div>
<?php else: ?>
<div class="stock-cards">
<?php foreach ($toRestock as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$band = (string) ($row['stock_band'] ?? 'normal');
$bandPill = $band === 'critical' ? 'pill pill-danger' : 'pill pill-warning';
$bandText = $band === 'critical' ? 'Critique' : 'Alerte';
?>
<div class="stock-card stock-card--<?= $esc($band) ?>">
<div class="stock-card__head">
<div>
<span class="stock-card__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-card__unit"><?= $esc($row['unit'] ?? '') ?></span>
</div>
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
</div>
<?= $renderBar($row) ?>
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="stock-section">
<h2 class="stock-section__title">Tous les ingredients</h2>
<?php if ($rows === []): ?>
<div class="stock-empty">Aucun ingredient.</div>
<?php else: ?>
<ul class="stock-list">
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
?>
<li class="stock-list__row">
<div class="stock-list__main">
<span class="stock-list__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-list__unit"><?= $esc($row['unit'] ?? '') ?></span>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</div>
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
<div class="stock-list__actions">
<?php if ($count): ?>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($manage): ?>
<span class="stock-list__crud">
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" class="stock-list__inline-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
</span>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>

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

@ -85,6 +85,36 @@ button {
cursor: pointer;
}
/* Utilitaire lecteur d'ecran : retire du flux visuel, lu par les technologies
d'assistance (regions live discretes type #pos-announce). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
/* Message d'erreur de formulaire (global admin : erreurs de validation / serveur). */
.form-error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border-radius: var(--radius-md);
padding: 8px 12px;
margin: 6px 0;
font-size: 14px;
}
/* Etat vide d'une liste / d'un catalogue (global admin). */
.admin-empty {
color: var(--color-text-muted);
padding: 16px 0;
}
/* --- Layout Shell --- */
.admin-layout {
display: grid;
@ -1431,3 +1461,581 @@ tbody td.mono {
padding: 0;
margin-bottom: 4px;
}
/* =============================================================================
POS tactile a tuiles comptoir/drive (counter-order.js + admin/counter/new.php)
Ecran de caisse facon borne : onglets categories, grille de tuiles, panneau
commande persistant (lignes + stepper + total), modale de composition.
Cibles tactiles superieures ou egales a 44px (usage tablette).
============================================================================= */
/* 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;
}
/* Disposition POS : catalogue a gauche (flex 1), panneau commande a droite (fixe). */
.pos { margin: 0; }
.pos__main {
display: flex;
gap: 20px;
align-items: flex-start;
}
.pos__catalogue {
flex: 1 1 auto;
min-width: 0;
}
/* Onglets categories : bandeau scrollable horizontal (un onglet par categorie). */
.pos__tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--color-border);
-webkit-overflow-scrolling: touch;
}
.pos__tab {
flex: 0 0 auto;
min-height: 44px;
padding: 10px 18px;
border: 2px solid var(--color-border-dark);
border-radius: var(--radius-pill, 9999px);
background: var(--color-white);
color: var(--color-text-sec);
font-size: 15px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.pos__tab:hover,
.pos__tab:focus-visible {
border-color: var(--color-yellow);
color: var(--color-text);
outline: none;
}
.pos__tab.is-active {
border-color: var(--color-yellow);
background: var(--color-yellow);
color: var(--color-text);
}
/* Grille de tuiles produits/menus : auto-fit, grandes cibles tactiles. */
.pos__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.pos__nojs {
grid-column: 1 / -1;
color: var(--color-text-muted);
padding: 16px 0;
}
/* Tuile : image/pastille + nom + prix. Bouton plein (tap = ajout ou modale). */
.pos-tile {
display: flex;
flex-direction: column;
min-height: 44px;
padding: 0;
background: var(--color-white);
border: 2px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
overflow: hidden;
cursor: pointer;
text-align: left;
position: relative;
transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
}
.pos-tile:hover,
.pos-tile:focus-visible {
border-color: var(--color-yellow);
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
outline: none;
}
.pos-tile:active { transform: translateY(0); box-shadow: var(--shadow-card); }
.pos-tile__media {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pos-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
padding: 10px;
background: var(--color-surface);
}
/* Pastille de repli (initiale) quand aucune image n'est disponible cote back-office. */
.pos-tile__pastille {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-yellow-soft, #FFF3D1);
color: var(--color-yellow-ink, #C8920A);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
}
.pos-tile__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px 12px;
flex: 1;
}
.pos-tile__name {
font-size: 14px;
font-weight: 700;
color: var(--color-text);
line-height: 1.25;
}
.pos-tile__price {
font-size: 13px;
font-weight: 700;
color: var(--color-text-sec);
font-variant-numeric: tabular-nums;
}
/* Badge "Menu" / "A composer" sur une tuile qui ouvre la modale au tap. */
.pos-tile__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--color-text);
color: var(--color-white);
font-size: 11px;
font-weight: 700;
}
/* Panneau commande persistant a droite (facon caisse). */
.pos__panel {
flex: 0 0 340px;
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.pos__panel-head {
padding: 16px;
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 10px;
}
.pos__panel-title {
font-size: 17px;
font-weight: 800;
color: var(--color-text);
}
.pos__service {
display: flex;
align-items: center;
gap: 8px;
}
.pos__service-label {
font-size: 14px;
font-weight: 700;
color: var(--color-text-sec);
min-width: 48px;
}
.pos__service .form-input { flex: 1; min-height: 44px; }
/* Lignes du panier (corps scrollable du panneau). */
.order-cart {
list-style: none;
padding: 0 16px;
margin: 0;
overflow-y: auto;
flex: 1 1 auto;
}
.order-cart__empty { color: var(--color-text-muted); padding: 16px 0; }
.order-cart__line {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.order-cart__main {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.order-cart__label { font-weight: 600; flex: 1; }
.order-cart__price { font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
.order-cart__controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.order-cart__qty {
display: inline-flex;
align-items: center;
gap: 6px;
}
.order-cart__qty-btn {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
border: 2px solid var(--color-border-dark);
background: var(--color-white);
font-size: 20px;
font-weight: 800;
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.12s ease, background 0.12s ease;
}
.order-cart__qty-btn:hover,
.order-cart__qty-btn:focus-visible {
border-color: var(--color-yellow);
background: var(--color-yellow-bg);
outline: none;
}
.order-cart__qty-value {
min-width: 32px;
text-align: center;
font-size: 16px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* Pied du panneau : total indicatif + bouton d'encaissement (pleine largeur). */
.pos__panel-foot {
padding: 16px;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.order-total {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 18px;
font-weight: 800;
margin: 0 0 12px;
}
.order-total span { font-variant-numeric: tabular-nums; }
.pos__pay {
width: 100%;
min-height: 52px;
font-size: 17px;
}
/* Tablette etroite / portrait : le panneau passe sous le catalogue. */
@media (max-width: 860px) {
.pos__main { flex-direction: column; }
.pos__panel {
flex-basis: auto;
width: 100%;
position: static;
max-height: none;
}
.order-cart { max-height: 320px; }
}
/* 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;
}
/* --- Stock dashboard (page d'accueil ingredients) --- */
.stock-explainer {
background: var(--color-yellow-bg);
border: 1px solid var(--color-yellow-soft);
border-radius: var(--radius-lg);
padding: 12px 16px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-sec);
margin-bottom: 20px;
}
.stock-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stock-summary__item {
display: flex;
align-items: baseline;
gap: 10px;
background: var(--color-white);
border: 1px solid var(--color-border);
border-left-width: 4px;
border-radius: var(--radius-card);
padding: 18px 22px;
box-shadow: var(--shadow-card);
}
.stock-summary__item--danger { border-left-color: var(--color-danger); }
.stock-summary__item--warning { border-left-color: var(--color-warning); }
.stock-summary__item--success { border-left-color: var(--color-success); }
.stock-summary__count {
font-size: 28px;
font-weight: 700;
line-height: 1;
color: var(--color-text);
}
.stock-summary__item--danger .stock-summary__count { color: var(--color-danger-text); }
.stock-summary__item--warning .stock-summary__count { color: var(--color-warning-text); }
.stock-summary__item--success .stock-summary__count { color: var(--color-success-text); }
.stock-summary__label {
font-size: 13px;
color: var(--color-text-muted);
}
.stock-section {
margin-bottom: 32px;
}
.stock-section__title {
font-size: 16px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 14px;
}
.stock-empty {
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
font-size: 14px;
color: var(--color-text-muted);
}
.stock-empty--ok {
background: var(--color-success-bg);
border-color: var(--color-success-bg);
color: var(--color-success-text);
}
/* Barre de niveau : conteneur gris + portion remplie coloree selon la bande. */
.stock-bar {
height: 8px;
background: var(--color-neutral-bg);
border-radius: var(--radius-sm);
overflow: hidden;
}
.stock-bar__fill {
display: block;
height: 100%;
border-radius: var(--radius-sm);
}
.stock-bar--critical { background: var(--color-danger); }
.stock-bar--low { background: var(--color-warning); }
.stock-bar--normal { background: var(--color-success); }
.stock-bar__meta {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 12px;
}
.stock-bar__pct { font-weight: 600; color: var(--color-text); }
.stock-bar__qty { color: var(--color-text-muted); }
/* Section "A reapprovisionner" : cartes mises en avant. */
.stock-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 16px;
}
.stock-card {
background: var(--color-white);
border: 1px solid var(--color-border);
border-top-width: 3px;
border-radius: var(--radius-card);
padding: 16px 18px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-card--critical { border-top-color: var(--color-danger); }
.stock-card--low { border-top-color: var(--color-warning); }
.stock-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.stock-card__name {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
.stock-card__unit {
font-size: 12px;
color: var(--color-text-muted);
margin-left: 6px;
}
.stock-card__action {
height: 44px;
justify-content: center;
width: 100%;
}
/* Section "Tous les ingredients" : liste calme, actions secondaires discretes. */
.stock-list {
list-style: none;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.stock-list__row {
display: grid;
grid-template-columns: 1fr 200px auto;
align-items: center;
gap: 20px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-border);
}
.stock-list__row:last-child {
border-bottom: none;
}
.stock-list__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.stock-list__name {
font-weight: 600;
color: var(--color-text);
}
.stock-list__unit {
font-size: 12px;
color: var(--color-text-muted);
}
.stock-list__actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.stock-list__crud {
display: inline-flex;
align-items: center;
gap: 6px;
padding-left: 6px;
margin-left: 2px;
border-left: 1px solid var(--color-border);
}
.stock-list__inline-form {
display: inline;
}
@media (max-width: 900px) {
.stock-summary {
grid-template-columns: 1fr;
}
.stock-list__row {
grid-template-columns: 1fr;
gap: 10px;
}
.stock-list__actions {
justify-content: flex-start;
}
}

View file

@ -1,26 +1,28 @@
/*
* counter-order.js Composeur de commande comptoir/drive (back-office, sous-lot 3c).
* counter-order.js POS tactile a tuiles (comptoir / drive, back-office).
*
* CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees
* (produits commandables + leurs modificateurs, menus + slots + format + modificateurs
* du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier
* ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/
* ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le
* burger). A la soumission, le panier est serialise en JSON dans le champ cache
* #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le
* passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque
* modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/
* prix affiches ici sont indicatifs, jamais source de verite.
* du burger) sont lues depuis deux scripts JSON inertes (#pos-products, #pos-menus,
* type="application/json") du formulaire #counter-order-form. L'ecran imite la borne
* client : des onglets de categories en haut, une grille de tuiles a gauche, un panneau
* commande persistant a droite. Un tap sur une tuile de produit simple ajoute le produit
* (qty 1) ; un tap sur un menu ou un produit a modificateurs ouvre la modale de
* composition (slots + format + retrait / ajout d'ingredients). Le panneau commande
* affiche les lignes (qty x nom + prix de ligne) avec +/- et retrait, le total, et un
* bouton "Encaisser X,XX EUR".
*
* La logique de slots (un pas par slot, requis/optionnel, format) calque
* A la soumission, le panier est serialise en JSON dans le champ cache #items_json
* (Request::formBody cote serveur ne garde que les scalaires, d'ou le passage par une
* chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque modificateur
* metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles / prix
* affiches ici restent indicatifs, pas une source de verite.
*
* La logique de slots (un pas par slot, requis / optionnel, format) calque
* page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les
* ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX
* borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes
* configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans
* le panier ; les produits sans modificateur sont derives a la soumission depuis les
* champs qty_<id> (repli sans JS conserve : le serveur accepte aussi qty_<id> si
* #items_json est vide). Un produit personnalisable est routé par la modale (sa
* quantite directe est ignoree quand JS s'execute) pour ne pas le compter deux fois.
* ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX borne ;
* le panneau commande calque order-panel.js (lignes, stepper +/-, total). Seul le rendu
* differe (idiome back-office, palette admin).
*
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est
* exporte pour les tests et auto-appele au DOMContentLoaded en production.
@ -32,19 +34,66 @@
// aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse).
var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' };
function parseData(form, key, fallback) {
try {
var v = JSON.parse(form.dataset[key] || fallback);
return Array.isArray(v) ? v : JSON.parse(fallback);
} catch (e) {
return JSON.parse(fallback);
// Lit un script JSON inerte (type="application/json") par id et retourne le tableau
// decode. Tolerant : un script absent / mal forme retombe sur un tableau vide.
function parseJsonScript(doc, id) {
var node = doc.getElementById(id);
if (!node) {
return [];
}
try {
var v = JSON.parse(node.textContent || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return [];
}
}
// 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 superieurs a 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
@ -72,6 +121,26 @@
});
}
// Onglets de categories construits depuis les produits ET menus : une entree par
// categorie distincte, dans l'ordre d'apparition du catalogue (deja trie par
// categorie / display_order cote serveur). Pur ; chaque entree porte le libelle et
// le nombre de tuiles. La cle est l'id de categorie (0 = "Autres" par defaut).
function buildCategoryTabs(products, menus) {
var order = [];
var byKey = {};
function add(row) {
var key = Number(row.category_id) || 0;
if (!byKey[key]) {
byKey[key] = { id: key, name: row.category_name || 'Autres', count: 0 };
order.push(key);
}
byKey[key].count += 1;
}
(products || []).forEach(add);
(menus || []).forEach(add);
return order.map(function (key) { return byKey[key]; });
}
function init(doc) {
var form = doc.getElementById('counter-order-form');
var hidden = doc.getElementById('items_json');
@ -82,8 +151,28 @@
return;
}
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}]
// Conteneurs du POS : onglets categories + grille de tuiles. Optionnels (rendu
// degrade sans eux) -> gardes au moment d'ecrire.
var tabsHost = doc.getElementById('pos-tabs');
var grid = doc.getElementById('pos-grid');
// Elements de prix (1) : valeur du total + libelle du bouton d'encaissement.
var totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit');
// Region live concise (C) : un message court (total + nombre d'articles) annonce
// a chaque mutation du panier, sans deballer toute la liste au lecteur d'ecran.
var announce = doc.getElementById('pos-announce');
// 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');
// [{id, name, price, image, category_id, category_name, modifiers:[...]}]
var products = parseJsonScript(doc, 'pos-products');
// [{id, name, price_normal, price_maxi, image, category_id, category_name,
// burger_modifiers:[...], slots:[...]}]
var menus = parseJsonScript(doc, 'pos-menus');
// Index produit par id : resolution des libelles d'options de slot + acces aux
// modificateurs proposables d'un produit a la carte.
@ -92,19 +181,19 @@
productById[Number(p.id)] = p;
});
// Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap.
// menuLines : menus configures ; productLines : produits personnalises (modifiers).
var menuLines = [];
var productLines = [];
// Panier unifie : une liste de lignes. Chaque ligne porte un kind :
// - 'product' simple : { kind, localId, productId, productName, quantity }
// - 'product' modifie : ... + proposable, modifiers (config par la modale)
// - 'menu' : { kind, localId, menuId, menuName, format,
// selections, proposable, modifiers } (quantity ajustable)
// Le tap d'une tuile simple FUSIONNE avec une ligne simple existante (meme
// produit) en incrementant la quantite, comme une caisse ; les lignes
// configurees (modifiers / menu) restent distinctes (compositions differentes).
var cartLines = [];
var lineSeq = 0;
// 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).
var configurableIds = {};
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
configurableIds[Number(btn.dataset.productId)] = true;
});
// Categorie active (filtre la grille) : 1er onglet par defaut.
var activeCategory = null;
function el(tag, className) {
var e = doc.createElement(tag);
@ -203,28 +292,37 @@
return parts.join(', ');
}
// Vrai si la ligne porte au moins un modificateur (produit personnalise).
function hasMods(line) {
return line.modifiers && line.modifiers.length;
}
/* ----------------------------------------------------------------- */
/* Serialisation du panier -> #items_json */
/* ----------------------------------------------------------------- */
// Produits sans modificateur : derives des champs qty_<id> (>= 1) NON routes par
// la modale. Produits personnalises : productLines. Menus : menuLines. La forme
// calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur).
// Forme calquee sur ce qu'attend OrderRepository::resolveLine (revalide cote
// serveur). Produits (simples ou personnalises) -> {type:'product', ...} ;
// menus -> {type:'menu', ...}. La quantite d'un menu vaut sa quantite de ligne
// (N menus identiques = un menu x N, facture par quantite cote serveur).
function serialize() {
var items = [];
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
var productId = Number(input.dataset.productId);
if (configurableIds[productId]) {
return; // route par la modale -> pas de double comptage.
cartLines.forEach(function (line) {
if (line.kind === 'menu') {
items.push({
type: 'menu',
menu_id: line.menuId,
quantity: line.quantity,
format: line.format,
selections: line.selections.map(function (s) {
return { menu_slot_id: s.slotId, product_id: s.productId };
}),
modifiers: line.modifiers.map(function (m) {
return { ingredient_id: m.ingredient_id, action: m.action };
}),
});
return;
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
items.push({ type: 'product', product_id: productId, quantity: quantity });
}
});
productLines.forEach(function (line) {
items.push({
type: 'product',
product_id: line.productId,
@ -234,62 +332,62 @@
}),
});
});
menuLines.forEach(function (line) {
items.push({
type: 'menu',
menu_id: line.menuId,
quantity: 1,
format: line.format,
selections: line.selections.map(function (s) {
return { menu_slot_id: s.slotId, product_id: s.productId };
}),
modifiers: line.modifiers.map(function (m) {
return { ingredient_id: m.ingredient_id, action: m.action };
}),
});
});
hidden.value = JSON.stringify(items);
}
/* ----------------------------------------------------------------- */
/* Rendu du panier (recap des lignes configurees) */
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */
function renderCart() {
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) {
node.parentNode.removeChild(node);
});
// Prix d'une ligne PRODUIT : 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);
}
productLines.forEach(function (line) {
var li = el('li', 'order-cart__line');
// Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le
// surcout des ajouts sur le burger, multiplie par la quantite. 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) * Number(line.quantity || 1);
}
var label = el('span', 'order-cart__label');
var text = line.productName + ' x' + line.quantity;
var modLabel = modifierLabel(line.proposable, line.modifiers);
if (modLabel) {
text += ' (' + modLabel + ')';
}
label.textContent = text;
li.appendChild(label);
function lineTotal(line) {
return line.kind === 'menu' ? menuLineTotal(line) : productLineTotal(line);
}
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
productLines = productLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
// Total indicatif du panier : somme des lignes. Met a jour le pied de panier, le
// libelle du bouton ("Encaisser X,XX EUR") ET la region live concise (C) : un
// message court "Total X EUR, N articles" tient le lecteur d'ecran informe de
// l'essentiel a chaque mutation, sans re-annoncer toute la liste du panier.
function updateTotal() {
var total = cartLines.reduce(function (sum, line) { return sum + lineTotal(line); }, 0);
var count = cartLines.reduce(function (sum, line) { return sum + Number(line.quantity || 0); }, 0);
if (totalValue) {
totalValue.textContent = formatEuros(total);
}
if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total);
}
if (announce) {
announce.textContent = count === 0
? 'Panier vide'
: 'Total ' + formatEuros(total) + ', ' + count + (count > 1 ? ' articles' : ' article');
}
}
cart.appendChild(li);
});
/* ----------------------------------------------------------------- */
/* Panier (panneau commande : lignes + stepper +/- + retrait) */
/* ----------------------------------------------------------------- */
menuLines.forEach(function (line) {
var li = el('li', 'order-cart__line');
var label = el('span', 'order-cart__label');
// Libelle d'une ligne du panneau (nom + composition recap).
function lineLabel(line) {
if (line.kind === 'menu') {
var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')'];
line.selections.forEach(function (s) {
var p = productById[Number(s.productId)];
@ -299,44 +397,232 @@
});
var text = parts.join(' - ');
var modLabel = modifierLabel(line.proposable, line.modifiers);
if (modLabel) {
text += ' (' + modLabel + ')';
}
label.textContent = text;
li.appendChild(label);
return modLabel ? (text + ' (' + modLabel + ')') : text;
}
var label = line.productName;
var pm = modifierLabel(line.proposable, line.modifiers);
return pm ? (label + ' (' + pm + ')') : label;
}
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
// Ajuste la quantite d'une ligne (delta +1 / -1). Tomber a 0 retire la ligne
// (comme order-panel.js borne : decrementer a zero = retrait).
function adjustQuantity(line, delta) {
var next = Number(line.quantity || 1) + delta;
if (next <= 0) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
} else {
line.quantity = next;
}
renderCart();
}
cart.appendChild(li);
function removeLine(line) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
}
// Construit une ligne du panneau : libelle + prix + stepper (-/qty/+) + retrait.
function cartLineNode(line) {
var li = el('li', 'order-cart__line');
var main = el('div', 'order-cart__main');
var label = el('span', 'order-cart__label');
label.textContent = lineLabel(line);
main.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(lineTotal(line));
main.appendChild(price);
li.appendChild(main);
var controls = el('div', 'order-cart__controls');
var stepper = el('div', 'order-cart__qty');
stepper.setAttribute('role', 'group');
stepper.setAttribute('aria-label', 'Quantite de ' + lineLabel(line));
var dec = el('button', 'order-cart__qty-btn');
dec.type = 'button';
dec.textContent = ''; // signe moins
dec.setAttribute('aria-label', 'Diminuer la quantite de ' + lineLabel(line));
dec.addEventListener('click', function () { adjustQuantity(line, -1); });
stepper.appendChild(dec);
var qty = el('span', 'order-cart__qty-value');
qty.textContent = String(line.quantity);
stepper.appendChild(qty);
var inc = el('button', 'order-cart__qty-btn');
inc.type = 'button';
inc.textContent = '+';
inc.setAttribute('aria-label', 'Augmenter la quantite de ' + lineLabel(line));
inc.addEventListener('click', function () { adjustQuantity(line, 1); });
stepper.appendChild(inc);
controls.appendChild(stepper);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.setAttribute('aria-label', 'Retirer ' + lineLabel(line) + ' de la commande');
removeBtn.addEventListener('click', function () { removeLine(line); });
controls.appendChild(removeBtn);
li.appendChild(controls);
return li;
}
function renderCart() {
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) {
node.parentNode.removeChild(node);
});
cartLines.forEach(function (line) {
cart.appendChild(cartLineNode(line));
});
if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : '';
cartEmpty.style.display = cartLines.length ? 'none' : '';
}
updateTotal();
}
/* ----------------------------------------------------------------- */
/* Ajout au panier */
/* ----------------------------------------------------------------- */
// Tap d'une tuile produit simple (sans modificateur) : fusionne avec une ligne
// simple existante du meme produit (increment), sinon cree une ligne qty 1.
function addSimpleProduct(product) {
var existing = cartLines.filter(function (l) {
return l.kind === 'product' && l.productId === Number(product.id) && !hasMods(l);
})[0];
if (existing) {
existing.quantity += 1;
} else {
cartLines.push({
kind: 'product',
localId: ++lineSeq,
productId: Number(product.id),
productName: product.name,
quantity: 1,
proposable: product.modifiers || [],
modifiers: [],
});
}
renderCart();
}
/* ----------------------------------------------------------------- */
/* Modales de configuration */
/* ----------------------------------------------------------------- */
function closeComposer() {
modalHost.textContent = '';
modalHost.setAttribute('hidden', '');
// 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));
}
// Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout).
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 (tuile produit / menu).
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).
function openProductComposer(product) {
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');
@ -357,6 +643,9 @@
qtyInput.addEventListener('change', function () {
var v = parseInt(qtyInput.value, 10);
state.quantity = v >= 1 ? v : 1;
// E : une saisie invalide (0 / vide / non numerique) est ramenee a 1 ; on
// reaffiche la valeur corrigee pour que l'equipier voie ce qui sera ajoute.
qtyInput.value = String(state.quantity);
});
qtyBlock.appendChild(qtyInput);
panel.appendChild(qtyBlock);
@ -369,7 +658,8 @@
addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier';
addBtn.addEventListener('click', function () {
productLines.push({
cartLines.push({
kind: 'product',
localId: ++lineSeq,
productId: Number(product.id),
productName: product.name,
@ -389,8 +679,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 +694,6 @@
}
});
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -475,18 +763,40 @@
panel.appendChild(block);
});
// Modificateurs du burger support (retrait/ajout d'ingredients).
// 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);
// Impasse : un slot requis sans aucune option resoluble rend le menu non
// composable. On desactive l'ajout et on affiche un message clair plutot que
// de laisser l'equipier buter sur "options obligatoires" sans pouvoir corriger.
var deadEnd = steps.some(function (s) { return s.isRequired && !s.options.length; });
// 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');
addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier';
if (deadEnd) {
addBtn.disabled = true;
inlineError.textContent = 'Ce menu n\'est pas composable : une option obligatoire est indisponible.';
}
addBtn.addEventListener('click', function () {
if (deadEnd) {
return;
}
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 = [];
@ -496,10 +806,12 @@
selections.push({ slotId: step.id, productId: chosen });
}
});
menuLines.push({
cartLines.push({
kind: 'menu',
localId: ++lineSeq,
menuId: Number(menu.id),
menuName: menu.name,
quantity: 1,
format: state.format,
selections: selections,
proposable: proposable,
@ -517,43 +829,255 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
/* ----------------------------------------------------------------- */
/* Grille de tuiles + onglets categories */
/* ----------------------------------------------------------------- */
// Pastille de repli : initiale du nom sur fond colore, quand aucune image
// exploitable n'est disponible cote back-office (image_path vide ou injoignable).
function buildPastille(name) {
var pastille = el('span', 'pos-tile__pastille');
pastille.setAttribute('aria-hidden', 'true');
var initial = (String(name || '').trim().charAt(0) || '?').toUpperCase();
pastille.textContent = initial;
return pastille;
}
// Construit une tuile. kind : 'product' | 'menu'. Le tap declenche onTap. Une
// image n'est tentee que si image_path est non vide ; sur erreur de chargement,
// un listener (CSP-safe, pas d'onerror inline) masque l'image et revele la
// pastille de repli (le back-office n'a pas garantie d'image exploitable).
function buildTile(entry, kind, priceLabel, onTap) {
var tile = el('button', 'pos-tile');
tile.type = 'button';
// Une tuile qui ouvre la modale (menu ou produit a modificateurs) annonce
// l'intention dans son nom accessible (D) et porte aria-haspopup=dialog : le
// lecteur d'ecran sait qu'un tap ouvre une boite de dialogue de composition,
// pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif.
var opensModal = kind === 'menu' || (entry.modifiers && entry.modifiers.length);
var intent = opensModal ? (kind === 'menu' ? ', menu a composer' : ', a composer') : '';
tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent);
if (opensModal) {
tile.setAttribute('aria-haspopup', 'dialog');
}
var media = el('span', 'pos-tile__media');
var pastille = buildPastille(entry.name);
media.appendChild(pastille);
var src = String(entry.image || '');
if (src !== '') {
var img = el('img', 'pos-tile__image');
img.src = src;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
// CSP-safe : pas d'onerror inline. Sur echec, masque l'image (la
// pastille dessous redevient visible).
img.addEventListener('error', function () {
img.style.display = 'none';
});
media.appendChild(img);
}
tile.appendChild(media);
var body = el('span', 'pos-tile__body');
var nameEl = el('span', 'pos-tile__name');
nameEl.textContent = entry.name;
body.appendChild(nameEl);
var priceEl = el('span', 'pos-tile__price');
priceEl.textContent = priceLabel;
body.appendChild(priceEl);
tile.appendChild(body);
// Badge visuel "Menu"/"A composer" (decoratif : l'intention est deja dans
// l'aria-label ci-dessus ; aria-hidden evite la double annonce).
if (opensModal) {
var badge = el('span', 'pos-tile__badge');
badge.setAttribute('aria-hidden', 'true');
badge.textContent = kind === 'menu' ? 'Menu' : 'A composer';
tile.appendChild(badge);
}
tile.addEventListener('click', onTap);
return tile;
}
// Rend la grille pour la categorie active : produits puis menus de cette
// categorie. Un produit simple -> ajout direct ; un produit a modificateurs ou
// un menu -> modale.
function renderGrid() {
if (!grid) {
return;
}
grid.textContent = '';
var catProducts = products.filter(function (p) { return (Number(p.category_id) || 0) === activeCategory; });
var catMenus = menus.filter(function (m) { return (Number(m.category_id) || 0) === activeCategory; });
if (!catProducts.length && !catMenus.length) {
var empty = el('p', 'pos__nojs');
empty.textContent = 'Aucun produit dans cette categorie.';
grid.appendChild(empty);
return;
}
catProducts.forEach(function (product) {
var tile = buildTile(product, 'product', formatEuros(product.price), function () {
if (product.modifiers && product.modifiers.length) {
openProductComposer(product);
} else {
addSimpleProduct(product);
}
});
grid.appendChild(tile);
});
catMenus.forEach(function (menu) {
var label = 'Normal ' + formatEuros(menu.price_normal) + ' / Maxi ' + formatEuros(menu.price_maxi);
var tile = buildTile(menu, 'menu', label, function () {
openComposer(menu);
});
grid.appendChild(tile);
});
}
// Boutons d'onglet (references stables), construits UNE fois au demarrage. On les
// garde pour MUTER l'etat actif (A) plutot que de reconstruire la barre au clic :
// reconstruire detruisait le bouton focalise et faisait retomber le focus sur body.
var tabButtons = [];
// Bascule la categorie active : mute les boutons existants (classe is-active +
// aria-selected + roving tabindex), met a jour activeCategory et le tabpanel
// (aria-labelledby vers l'onglet actif), rerend la grille. Si moveFocus, pose le
// focus sur l'onglet actif (navigation clavier : le focus suit la selection).
function setActiveCategory(catId, moveFocus) {
activeCategory = catId;
tabButtons.forEach(function (btn) {
var selected = Number(btn.dataset.categoryId) === Number(catId);
btn.classList.toggle('is-active', selected);
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// Roving tabindex (B) : seul l'onglet actif est dans l'ordre de tabulation ;
// les autres sont atteints par les fleches une fois la barre focalisee.
btn.tabIndex = selected ? 0 : -1;
if (selected) {
// Le tabpanel (grille) est libelle par l'onglet actif (B).
if (grid) {
grid.setAttribute('aria-labelledby', btn.id);
}
if (moveFocus && typeof btn.focus === 'function') {
btn.focus();
}
}
});
renderGrid();
}
// Navigation clavier WAI-ARIA tablist (B) : Fleche gauche/droite (cycliques) +
// Home/Fin deplacent le focus ET activent l'onglet (le focus suit la selection).
function onTabsKeydown(event) {
var idx = tabButtons.indexOf(event.target);
if (idx < 0 || !tabButtons.length) {
return;
}
var next = null;
var key = event.key;
if (key === 'ArrowRight' || key === 'ArrowDown') {
next = (idx + 1) % tabButtons.length;
} else if (key === 'ArrowLeft' || key === 'ArrowUp') {
next = (idx - 1 + tabButtons.length) % tabButtons.length;
} else if (key === 'Home') {
next = 0;
} else if (key === 'End') {
next = tabButtons.length - 1;
}
if (next === null) {
return;
}
event.preventDefault();
setActiveCategory(Number(tabButtons[next].dataset.categoryId), true);
}
// Construit la barre d'onglets UNE fois (A) et cable clic + clavier.
function buildTabs() {
if (!tabsHost) {
return;
}
tabsHost.textContent = '';
tabButtons = [];
var tabs = buildCategoryTabs(products, menus);
if (!tabs.length) {
return;
}
if (activeCategory === null) {
activeCategory = tabs[0].id;
}
tabs.forEach(function (tab, i) {
var selected = tab.id === activeCategory;
var btn = el('button', 'pos__tab' + (selected ? ' is-active' : ''));
btn.type = 'button';
btn.id = 'pos-tab-' + tab.id;
btn.dataset.categoryId = String(tab.id);
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// aria-controls relie l'onglet au tabpanel unique (la grille filtree, B).
if (grid && grid.id) {
btn.setAttribute('aria-controls', grid.id);
}
btn.tabIndex = selected ? 0 : -1;
btn.textContent = tab.name;
btn.addEventListener('click', function () {
setActiveCategory(tab.id, false);
});
tabButtons.push(btn);
tabsHost.appendChild(btn);
});
tabsHost.addEventListener('keydown', onTabsKeydown);
// Pose le libelle initial du tabpanel sur l'onglet actif.
if (grid) {
grid.setAttribute('aria-labelledby', 'pos-tab-' + activeCategory);
}
}
/* ----------------------------------------------------------------- */
/* Cablage */
/* ----------------------------------------------------------------- */
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
btn.addEventListener('click', function () {
var productId = Number(btn.dataset.productId);
var product = productById[productId];
if (product) {
openProductComposer(product);
}
});
});
Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) {
btn.addEventListener('click', function () {
var menuId = Number(btn.dataset.menuId);
var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0];
if (menu) {
openComposer(menu);
}
});
});
// 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();
});
buildTabs();
renderGrid();
renderCart();
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { init: init, composerSteps: composerSteps };
module.exports = { init: init, composerSteps: composerSteps, buildCategoryTabs: buildCategoryTabs };
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () {

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

@ -7,9 +7,9 @@
*
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
* l'API (createElement/textContent) ; textContent neutralise toute injection.
* Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens
* au swap P4. openAllergenModal prend la liste en parametre pour rester independant
* de la couche de chargement (et testable sans fetch).
* Les donnees viennent de data.js (loadAllergens), qui lit /api/allergens.
* openAllergenModal prend la liste en parametre pour rester independant de la
* couche de chargement (et testable sans fetch).
*/
const OVERLAY_CLASS = 'allergen-modal-overlay';

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

@ -10,18 +10,17 @@
* indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures
* publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge.
*
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule
* sur /api/allergens est un chunk ulterieur.
* Les allergenes sont desormais lus depuis /api/allergens (id/code/name/description),
* comme les autres collections catalogue : le repli statique a ete retire.
*/
const CATEGORIES_URL = '/api/categories';
const PRODUCTS_URL = '/api/products';
const MENUS_URL = '/api/menus';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint
* /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON
* statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule
* possible si les descriptions sont ajoutees cote API. */
const ALLERGENS_URL = 'data/allergens.json';
/* Les 14 allergenes INCO (info generale, modale borne). L'endpoint /api/allergens
* porte id/code/name/description (la description INCO est seede en base) -> la borne
* la consomme via l'API, comme les autres collections catalogue. */
const ALLERGENS_URL = '/api/allergens';
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
* chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants
@ -89,12 +88,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; });
@ -144,17 +147,16 @@ export async function loadMenu(id) {
}
/**
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique :
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.
* @returns {Promise<Array>}
* Fetches and caches the 14 INCO allergens (general info modal). Consomme
* /api/allergens (enveloppe { data }, forme canonique id/code/name/description) et
* ramene chaque entree a la forme borne { id, name, description } attendue par la
* modale (allergens.js) ; le champ `code` n'est pas utilise cote borne.
* @returns {Promise<Array<{id:number, name:string, description:?string}>>}
*/
export function loadAllergens() {
if (!_allergensPromise) {
_allergensPromise = fetch(ALLERGENS_URL)
.then(res => {
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
return res.json();
})
_allergensPromise = fetchCollection(ALLERGENS_URL)
.then(rows => rows.map(a => ({ id: a.id, name: a.name, description: a.description ?? null })))
.catch(e => { _allergensPromise = null; throw e; });
}
return _allergensPromise;

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

@ -13,11 +13,11 @@
<body class="categories-page">
<!--
Categories screen.
Data source: docs/merise/_sources/categories.json (9 categories).
Categories screen — static scaffold (9 categories) listed in catalogue order.
Image paths: assets/images/categories/{title}.png — verified against filesystem.
In P4 this page will be generated dynamically from GET /api/categories.
For now it is a static scaffold that matches the data contract exactly.
The cards link to products.html?category=<id>; the product/menu/allergen data
is fetched from the REST API by data.js. Generating this list from
GET /api/categories is a later UI alignment step.
-->
<header class="site-header">
@ -41,10 +41,9 @@
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
<!--
9 categories from categories.json, in the same order as the source.
Each card links to a product page (products.html?category=<id>) — stub URL
for future P5 implementation. The link is functional HTML; no JS needed.
title field from JSON used as alt text and visible label.
9 categories in catalogue order. Each card links to a product page
(products.html?category=<id>). The link is functional HTML; no JS needed.
The category title is used as alt text and visible label.
-->
<nav class="category-grid" aria-label="Navigation par categorie">

View file

@ -1,22 +1,10 @@
# Donnees statiques de la borne (repli P5)
# Donnees de la borne
`categories.json` et `produits.json` sont un **repli statique fige** consomme par
le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont
copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas**
generes depuis la base.
La borne consomme l'API REST en lecture : `/api/categories`, `/api/products`,
`/api/menus` et `/api/allergens` (cf. `docs/api/conventions.md` section 5.2). La
couche `assets/js/data.js` deballe l'enveloppe `{ data }` et traduit la forme
canonique vers la forme attendue par les pages.
## Ces fichiers ne refletent pas la base
Le catalogue servi ici est le jeu source complet (66 produits) ; le seed de la
base (`db/seeds/0002_catalogue.sql`) en est un sous-ensemble curate (53 produits).
Les categories, elles, coincident (9 de chaque cote). La borne est une demo front
sur donnees statiques : un ecart de comptage produits avec la table `product` est
**attendu**, ce n'est pas une incoherence a corriger.
## Point de bascule (P4)
`assets/js/data.js` lit ces fichiers via les constantes `CATEGORIES_URL` /
`PRODUCTS_URL`. En P4, ces constantes pointeront vers `/api/categories` et
`/api/products` (memes formes de retour, le reste du code est agnostique). La
borne refletera alors la base via l'API, et ces fichiers deviendront obsoletes
(a retirer a ce moment-la).
Les anciens fichiers JSON statiques (`categories.json`, `produits.json`,
`allergens.json`) qui servaient de repli avant l'API ont ete retires : la borne
reflete la base via l'API.

View file

@ -1,16 +0,0 @@
[
{ "id": 1, "name": "Cereales contenant du gluten", "description": "Ble, seigle, orge, avoine, epeautre, kamut et produits derives." },
{ "id": 2, "name": "Crustaces", "description": "Et produits a base de crustaces." },
{ "id": 3, "name": "Oeufs", "description": "Et produits a base d'oeufs." },
{ "id": 4, "name": "Poissons", "description": "Et produits a base de poissons." },
{ "id": 5, "name": "Arachides", "description": "Et produits a base d'arachides." },
{ "id": 6, "name": "Soja", "description": "Et produits a base de soja." },
{ "id": 7, "name": "Lait", "description": "Et produits a base de lait (y compris le lactose)." },
{ "id": 8, "name": "Fruits a coque", "description": "Amandes, noisettes, noix, noix de cajou, pistaches et autres." },
{ "id": 9, "name": "Celeri", "description": "Et produits a base de celeri." },
{ "id": 10, "name": "Moutarde", "description": "Et produits a base de moutarde." },
{ "id": 11, "name": "Graines de sesame", "description": "Et produits a base de graines de sesame." },
{ "id": 12, "name": "Anhydride sulfureux et sulfites", "description": "En concentration de plus de 10 mg/kg ou 10 mg/l." },
{ "id": 13, "name": "Lupin", "description": "Et produits a base de lupin." },
{ "id": 14, "name": "Mollusques", "description": "Et produits a base de mollusques." }
]

View file

@ -1,11 +0,0 @@
[
{ "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" },
{ "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" },
{ "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" },
{ "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" },
{ "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" },
{ "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" },
{ "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" },
{ "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" },
{ "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" }
]

View file

@ -1,86 +0,0 @@
{
"menus": [
{ "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" },
{ "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" },
{ "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" },
{ "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" },
{ "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" },
{ "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" },
{ "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" },
{ "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" },
{ "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" },
{ "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" },
{ "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" },
{ "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" },
{ "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" }
],
"burgers": [
{ "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" },
{ "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" },
{ "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" },
{ "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" },
{ "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" },
{ "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" },
{ "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" },
{ "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" },
{ "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" },
{ "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" },
{ "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" },
{ "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" },
{ "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" }
],
"boissons": [
{ "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" },
{ "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" },
{ "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" },
{ "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" },
{ "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" },
{ "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" },
{ "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" },
{ "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" }
],
"frites": [
{ "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" },
{ "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" },
{ "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" },
{ "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" },
{ "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" }
],
"encas": [
{ "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" },
{ "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" },
{ "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" },
{ "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" }
],
"desserts": [
{ "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" },
{ "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" },
{ "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" },
{ "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" },
{ "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" },
{ "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" },
{ "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" },
{ "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" },
{ "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" }
],
"sauces": [
{ "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" },
{ "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" },
{ "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" },
{ "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" },
{ "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" },
{ "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" },
{ "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" }
],
"salades": [
{ "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" },
{ "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" },
{ "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" }
],
"wraps": [
{ "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" },
{ "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" },
{ "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" },
{ "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" }
]
}

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

@ -15,8 +15,8 @@
<!--
products.html — List of products in a category.
Category is determined at runtime from ?category=<id>.
JS (page-products.js) fetches data/produits.json and renders cards.
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
JS (page-products.js) reads the catalogue via data.js, which fetches
GET /api/products (and /api/categories, /api/menus), then renders cards.
-->
<header class="site-header">
@ -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

@ -13,7 +13,8 @@ use App\Core\Database;
/**
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
* Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec code+name.
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec
* code + name + description.
*/
final class AllergenReadDbTest extends TestCase
{
@ -34,7 +35,7 @@ final class AllergenReadDbTest extends TestCase
}
}
public function testListsIncoReferenceWithCodeAndName(): void
public function testListsIncoReferenceWithCodeNameAndDescription(): void
{
$rows = (new AllergenRepository($this->db))->all();
@ -42,7 +43,11 @@ final class AllergenReadDbTest extends TestCase
foreach ($rows as $a) {
self::assertArrayHasKey('code', $a);
self::assertArrayHasKey('name', $a);
self::assertArrayHasKey('description', $a);
self::assertNotSame('', (string) ($a['name'] ?? ''));
}
// La description INCO est seede (migration 0001 + seed 0001) : au moins une non vide.
$descriptions = array_filter($rows, static fn (array $a): bool => (string) ($a['description'] ?? '') !== '');
self::assertNotEmpty($descriptions, 'la description INCO doit etre exposee');
}
}

View file

@ -79,6 +79,22 @@ 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 = [];
/**
* Lignes renvoyees par AllergenRepository::all() (14 allergenes INCO :
* id, code, name, description).
*
* @var list<array<string, mixed>>
*/
public array $allergensRows = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -109,6 +125,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')) {
@ -130,6 +152,10 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->menuSlotRows;
}
if (str_contains($sql, 'FROM allergen')) {
return $this->allergensRows;
}
return [];
}

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
@ -167,7 +184,11 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Cheeseburger', $body);
self::assertStringContainsString('qty_12', $body); // champ quantite par produit
// POS tactile : le catalogue est embarque dans un script JSON inerte (id/prix),
// pas en champs qty_<id>. La grille de tuiles est rendue cote client.
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"id":12', $body);
self::assertStringContainsString('id="pos-grid"', $body);
self::assertStringContainsString('service_mode', $body);
}
@ -232,7 +253,10 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Menu Cheeseburger', $body);
self::assertStringContainsString('data-menu-id="5"', $body); // bouton configurer
// POS tactile : les menus + slots sont embarques dans un script JSON inerte
// (la tuile menu et la modale sont rendues cote client par counter-order.js).
self::assertStringContainsString('id="pos-menus"', $body);
self::assertStringContainsString('"id":5', $body);
self::assertStringContainsString('items_json', $body); // champ cache du panier
self::assertStringContainsString('counter-order.js', $body); // script du composeur
}
@ -273,6 +297,39 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(22, $selInsert['pid']);
}
public function testStoreCreatesMenuOrderWithQuantityTwo(): void
{
// G : un item menu via items_json avec quantity:2 persiste qty=2 sur order_item ;
// les selections de slot ne sont pas dupliquees par la quantite (un seul INSERT).
$db = $this->permittedDb();
$db->menuRow = ['id' => 5, 'name' => 'Menu Cheeseburger', 'burger_product_id' => 12, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'is_available' => 1];
$db->productRow = ['id' => 22, 'name' => 'Frites', 'price_cents' => 250, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->menuSlotRows = [
['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22],
];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 1980, 'status' => 'pending_payment'];
$items = json_encode([
['type' => 'menu', 'menu_id' => 5, 'quantity' => 2, 'format' => 'normal', 'selections' => [['menu_slot_id' => 16, 'product_id' => 22]]],
]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$itemInsert = $this->writeParams($db, 'INSERT INTO order_item ');
self::assertSame('menu', $itemInsert['type']);
self::assertSame(5, $itemInsert['mid']);
self::assertSame(2, $itemInsert['qty']);
// La selection de slot est persistee UNE fois (independante de la quantite).
$selectionWrites = array_values(array_filter(
$db->writes,
static fn (array $w): bool => str_contains($w['sql'], 'INSERT INTO order_item_selection'),
));
self::assertCount(1, $selectionWrites);
}
public function testStoreCreatesProductOrderViaItemsJson(): void
{
// Chemin unifie : items_json est prefere a qty_<id>, et un produit y passe aussi.
@ -340,15 +397,16 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status());
$body = $response->body();
// data-products encode le JSON avec htmlspecialchars : les guillemets sont
// echappes en &quot;. On cherche les fragments echappes (forme reellement rendue).
self::assertStringContainsString('ingredient_id&quot;:3', $body);
self::assertStringContainsString('ingredient_id&quot;:8', $body);
// POS tactile : la composition PROPOSABLE est embarquee dans le script JSON inerte
// #pos-products (type="application/json"). json_encode avec JSON_HEX_TAG protege
// l'insertion dans un <script> (un '<' deviendrait < ; pas de </script>
// injectable). On cherche les fragments JSON reellement rendus (guillemets bruts,
// surs dans un script). La tuile "A composer" et la modale sont rendues client-side.
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"ingredient_id":3', $body);
self::assertStringContainsString('"ingredient_id":8', $body);
self::assertStringContainsString('Oignon', $body);
self::assertStringContainsString('Bacon', $body);
// Bouton de personnalisation expose pour le produit a modificateurs.
self::assertStringContainsString('product-configure', $body);
self::assertStringContainsString('Personnaliser', $body);
}
public function testStoreCreatesProductOrderWithModifiers(): void
@ -456,6 +514,181 @@ 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 testCreateExposesConfigurableProductModifiersInJson(): void
{
// POS tactile : un produit a modificateurs est expose dans #pos-products avec sa
// composition proposable. Le client en rend une tuile "A composer" qui ouvre la
// modale au tap (la saisie de la quantite et des modificateurs se fait en modale).
$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 produit et sa composition sont dans le JSON inerte (pas de champ qty_<id>).
self::assertStringContainsString('id="pos-products"', $body);
self::assertStringContainsString('"id":12', $body);
self::assertStringContainsString('"ingredient_id":3', $body);
self::assertStringContainsString('Oignon', $body);
// Plus de champ quantite par produit (la saisie passe par les tuiles + modale).
self::assertStringNotContainsString('name="qty_12"', $body);
}
public function testCreateExposesCategoryNamesForTabs(): void
{
// POS tactile : les onglets de categories sont construits cote client a partir
// de category_name embarque dans le JSON de chaque produit/menu.
$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('id="pos-tabs"', $body);
self::assertStringContainsString('"category_name":"Burgers"', $body);
self::assertStringContainsString('"category_name":"Accompagnements"', $body);
}
public function testCreateExposesBothMenuPrices(): void
{
// 6 : les deux prix d'un menu (Normal / Maxi, en centimes) sont exposes dans le
// JSON inerte ; le client affiche "Normal X / Maxi Y" sur la tuile et la modale.
$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('id="pos-menus"', $body);
self::assertStringContainsString('"price_normal":990', $body);
self::assertStringContainsString('"price_maxi":1190', $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

@ -198,6 +198,64 @@ final class IngredientControllerTest extends TestCase
self::assertStringContainsString('Alerte', $response->body());
}
public function testIndexShowsBusinessExplainerBanner(): void
{
// Le bandeau explique le lien metier stock -> disponibilite borne (RG-T21),
// l'info qui manquait dans l'ancien tableau brut.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient()];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('stock-explainer', $body);
self::assertStringContainsString('borne', $body);
}
public function testIndexPromotesLowAndCriticalIntoRestockSection(): void
{
// Un ingredient critique (3% < seuil 5) doit apparaitre dans la section
// "A reapprovisionner" mise en avant, pas seulement dans la liste calme.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3])];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('A reapprovisionner', $body);
self::assertStringContainsString('stock-section--restock', $body);
self::assertStringContainsString('Buns', $body);
self::assertStringContainsString('Critique', $body);
}
public function testIndexShowsPositiveEmptyStateWhenNothingLow(): void
{
// Tous au-dessus des seuils -> etat vide positif dans la section restock.
$db = $this->permittedDb();
$db->ingredientsRows = [$this->ingredient(['stock_quantity' => 100])]; // 100% -> normal
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
self::assertStringContainsString('au-dessus de leurs seuils', $body);
}
public function testIndexCountsIngredientsPerBand(): void
{
// Resume en haut : 1 critique (3%), 1 alerte (8%), 1 au-dessus (100%).
$db = $this->permittedDb();
$db->ingredientsRows = [
$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3]),
$this->ingredient(['name' => 'Cheddar', 'stock_quantity' => 8]),
$this->ingredient(['name' => 'Salade', 'stock_quantity' => 100]),
];
$body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body();
// Chaque compteur est verrouille a SON libelle (sinon une regex generique
// passerait meme avec les trois compteurs inverses).
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">critiques/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">en alerte/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
}
public function testIndexForbiddenWithoutStockRead(): void
{
$db = $this->permittedDb();

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

@ -94,6 +94,32 @@ final class CatalogueControllerTest extends TestCase
self::assertSame(0, $payload['total']);
}
public function testAllergensReturnsIncoCollectionWithDescription(): void
{
$db = new FakeCatalogueDatabase();
// Entiers en CHAINE (comme PDO peut les rendre) + une description null pour
// verifier la preservation du NULL.
$db->allergensRows = [
['id' => '1', 'code' => 'gluten', 'name' => 'Cereales contenant du gluten', 'description' => 'Ble, seigle, orge.'],
['id' => '7', 'code' => 'lait', 'name' => 'Lait', 'description' => null],
];
$response = $this->controller($db, '/api/allergens')->allergens();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame(2, $payload['total']);
self::assertIsArray($payload['data']);
$first = $payload['data'][0];
self::assertSame(['id', 'code', 'name', 'description'], array_keys($first));
self::assertSame(1, $first['id']); // chaine '1' -> int 1
self::assertSame('gluten', $first['code']);
self::assertSame('Cereales contenant du gluten', $first['name']);
self::assertSame('Ble, seigle, orge.', $first['description']);
self::assertNull($payload['data'][1]['description']); // null preserve
}
public function testProductsReturnsAvailableCollectionWithoutVatRate(): void
{
$db = new FakeCatalogueDatabase();
@ -115,7 +141,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 +151,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 +352,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

@ -1,16 +1,14 @@
/*
* Tests du module allergens du front borne (node:test + jsdom).
*
* Couvre le contrat de PR-C : la liste fixe des 14 allergenes INCO (data borne,
* se branchera sur /api/allergens au swap P4), la construction du bouton "i", et
* la modale GENERALE (ouverture, listing des 14, fermeture par bouton/overlay/
* Escape, idempotence). DOM simule par jsdom : aucun navigateur requis.
* Couvre : la construction du bouton "i", la modale GENERALE (ouverture, listing,
* fermeture par bouton/overlay/Escape, idempotence) et le chargement via l'API
* (loadAllergens consomme /api/allergens et ramene la forme borne). Les cas de
* rendu utilisent une fixture INLINE pour rester independants de la source de
* donnees. DOM simule par jsdom : aucun navigateur requis.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { JSDOM } from 'jsdom';
import {
@ -19,8 +17,20 @@ import {
closeAllergenModal,
} from '../../src/public/borne/assets/js/allergens.js';
const here = dirname(fileURLToPath(import.meta.url));
const allergensJsonPath = join(here, '../../src/public/borne/data/allergens.json');
let _seq = 0;
/* Fixture INLINE : un echantillon des 14 allergenes INCO a la forme borne
* { id, name, description }. Suffisant pour couvrir le rendu de la modale sans
* dependre d'un fichier de donnees. */
function allergensFixture() {
return [
{ id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge, avoine.' },
{ id: 5, name: 'Arachides', description: "Et produits a base d'arachides." },
{ id: 6, name: 'Soja', description: 'Et produits a base de soja.' },
{ id: 7, name: 'Lait', description: 'Et produits a base de lait.' },
{ id: 14, name: 'Mollusques', description: 'Et produits a base de mollusques.' },
];
}
function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
@ -29,28 +39,6 @@ function setupDom() {
return dom;
}
function loadAllergensFixture() {
return JSON.parse(readFileSync(allergensJsonPath, 'utf8'));
}
test('data/allergens.json liste exactement les 14 allergenes INCO', () => {
const list = loadAllergensFixture();
assert.ok(Array.isArray(list));
assert.equal(list.length, 14);
for (const a of list) {
assert.equal(typeof a.id, 'number');
assert.equal(typeof a.name, 'string');
assert.ok(a.name.trim().length > 0);
}
const names = list.map((a) => a.name);
assert.equal(new Set(names).size, 14, 'noms uniques');
// Quelques jalons de la liste reglementaire (UE INCO 1169/2011 annexe II).
const joined = names.join(' | ').toLowerCase();
for (const expected of ['gluten', 'lait', 'arachide', 'soja', 'mollusque']) {
assert.ok(joined.includes(expected), `attendu: ${expected}`);
}
});
test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
setupDom();
let opened = 0;
@ -65,46 +53,78 @@ test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
assert.equal(opened, 1, 'le clic ouvre la modale');
});
test('openAllergenModal affiche une modale listant les 14 allergenes', () => {
test('openAllergenModal affiche une modale listant les allergenes fournis', () => {
setupDom();
const list = loadAllergensFixture();
const list = allergensFixture();
const overlay = openAllergenModal(list);
assert.ok(document.body.contains(overlay));
assert.equal(overlay.getAttribute('role'), 'dialog');
assert.equal(overlay.getAttribute('aria-modal'), 'true');
const items = overlay.querySelectorAll('.allergen-modal-list li');
assert.equal(items.length, 14);
assert.equal(items.length, list.length);
assert.ok(overlay.textContent.toLowerCase().includes('lait'));
});
test('openAllergenModal affiche la description quand elle est fournie', () => {
setupDom();
const overlay = openAllergenModal([{ id: 7, name: 'Lait', description: 'Et produits a base de lait.' }]);
const desc = overlay.querySelector('.allergen-desc');
assert.ok(desc, 'la description doit etre rendue');
assert.ok(desc.textContent.toLowerCase().includes('lait'));
});
test('la modale se ferme via le bouton de fermeture', () => {
setupDom();
openAllergenModal(loadAllergensFixture());
openAllergenModal(allergensFixture());
document.querySelector('.allergen-modal-close').click();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme par clic sur l overlay (hors contenu)', () => {
const dom = setupDom();
const overlay = openAllergenModal(loadAllergensFixture());
const overlay = openAllergenModal(allergensFixture());
overlay.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme avec la touche Echap', () => {
const dom = setupDom();
openAllergenModal(loadAllergensFixture());
openAllergenModal(allergensFixture());
document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('ouvrir deux fois ne duplique pas la modale (idempotent)', () => {
setupDom();
const list = loadAllergensFixture();
const list = allergensFixture();
openAllergenModal(list);
openAllergenModal(list);
assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1);
closeAllergenModal();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('loadAllergens consomme /api/allergens, deballe {data} et ramene la forme borne', async () => {
const calls = [];
// Reponse canonique de l'API : enveloppe { data, total }, entrees id/code/name/description.
const apiRows = [
{ id: 1, code: 'gluten', name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' },
{ id: 7, code: 'lait', name: 'Lait', description: 'Et produits a base de lait.' },
];
global.fetch = async (url) => {
calls.push(url);
if (url !== '/api/allergens') throw new Error(`fetch inattendu: ${url}`);
return { ok: true, status: 200, json: async () => ({ data: apiRows, total: apiRows.length }) };
};
const { loadAllergens } = await import(`../../src/public/borne/assets/js/data.js?case=allergens${_seq++}`);
const list = await loadAllergens();
assert.ok(calls.includes('/api/allergens'), 'doit fetch /api/allergens');
assert.equal(list.length, 2);
// Forme borne : name + description presents, code ignore.
assert.deepEqual(list[0], { id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' });
assert.equal(list[1].name, 'Lait');
assert.equal(list[1].description, 'Et produits a base de lait.');
});

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

@ -1,13 +1,18 @@
/*
* Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c).
* node:test + jsdom. Couvre la serialisation du panier dans #items_json :
* - ajout produit (champ quantite) -> item {type:'product', ...}
* - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...]
* - configuration menu (slots + format Maxi + modificateurs burger) -> item menu
* Tests du POS tactile de commande comptoir/drive (counter-order.js). node:test + jsdom.
* Couvre la logique pure (serialisation du panier dans #items_json, calcul prix/total,
* onglets categories) et l'UI a tuiles :
* - tap d'une tuile produit simple -> item {type:'product', quantity}, fusion sur re-tap
* - tap d'une tuile produit a modificateurs -> modale -> modifiers:[...]
* - tap d'une tuile menu -> modale (slots + format Maxi + modificateurs burger)
* - stepper +/- du panneau commande (ajuste qty, retire a 0)
* - slot requis non choisi -> message inline (pas d'ajout muet)
* - 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';
@ -18,15 +23,15 @@ import counterOrder from '../../src/public/admin/assets/js/counter-order.js';
const PRODUCTS = [
{
id: 12, name: 'Cheeseburger', price: 890,
id: 12, name: 'Cheeseburger', price: 890, image: '', category_id: 1, category_name: 'Burgers',
modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
],
},
{ id: 22, name: 'Frites', price: 250, modifiers: [] },
{ id: 14, name: 'Coca', price: 200, modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, modifiers: [] },
{ id: 22, name: 'Frites', price: 250, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
{ id: 14, name: 'Coca', price: 200, image: '', category_id: 3, category_name: 'Boissons', modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
];
const MENUS = [
@ -35,6 +40,9 @@ const MENUS = [
name: 'Menu Cheeseburger',
price_normal: 990,
price_maxi: 1190,
image: '',
category_id: 4,
category_name: 'Menus',
burger_modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
@ -47,29 +55,28 @@ const MENUS = [
},
];
function setup(menus = MENUS) {
const menuItems = 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).
const productRows = PRODUCTS
.map(p => {
const configure = (p.modifiers && p.modifiers.length)
? `<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}`;
})
.join('');
function setup(products = PRODUCTS, menus = MENUS) {
// Le catalogue est embarque dans deux scripts JSON inertes (CSP-safe), lus par le JS.
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
'<form id="counter-order-form" method="post" action="/counter/orders">' +
' <input type="hidden" name="items_json" id="items_json" value="">' +
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>' +
` <script type="application/json" id="pos-products">${JSON.stringify(products)}</script>` +
` <script type="application/json" id="pos-menus">${JSON.stringify(menus)}</script>` +
' <div class="pos__main">' +
' <div class="pos__catalogue">' +
' <div class="pos__tabs" id="pos-tabs" role="tablist"></div>' +
' <div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0"></div>' +
' </div>' +
' <aside class="pos__panel">' +
' <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>' +
' <ul class="order-cart" id="order-cart"><li class="order-cart__empty" id="order-cart-empty">Panier vide.</li></ul>' +
' <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>' +
' <span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>' +
' </aside>' +
' </div>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -86,30 +93,71 @@ function itemsJson(dom) {
return JSON.parse(dom.window.document.getElementById('items_json').value || '[]');
}
test('ajout produit sans modificateur (quantite) -> items_json contient {type:product}', () => {
function click(dom, node) {
node.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
}
// Active l'onglet d'une categorie par son libelle (les tuiles d'une seule categorie sont
// rendues a la fois). Renvoie la liste des tuiles affichees apres activation.
function activateCategory(dom, label) {
const doc = dom.window.document;
const tab = Array.prototype.find.call(
doc.querySelectorAll('.pos__tab'),
t => t.textContent === label,
);
assert.ok(tab, 'onglet "' + label + '" present');
click(dom, tab);
return Array.prototype.slice.call(doc.querySelectorAll('.pos-tile'));
}
// Tuile par nom de produit/menu (dans la grille de la categorie active).
function tileByName(dom, name) {
const doc = dom.window.document;
return Array.prototype.find.call(
doc.querySelectorAll('.pos-tile'),
t => t.querySelector('.pos-tile__name') && t.querySelector('.pos-tile__name').textContent === name,
);
}
test('onglets categories : un onglet par categorie distincte (produits + menus)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
// Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_<id>.
const qty = dom.window.document.getElementById('qty_22');
qty.value = '2';
fireSubmit(dom);
const items = itemsJson(dom);
assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]);
const labels = Array.prototype.map.call(
dom.window.document.querySelectorAll('.pos__tab'),
t => t.textContent,
);
assert.deepEqual(labels, ['Burgers', 'Accompagnements', 'Boissons', 'Menus']);
});
test('produit personnalisable : qty directe ignoree (route par la modale, anti-double-comptage)', () => {
test('tuile produit simple : tap ajoute {type:product, quantity:1} ; re-tap fusionne (qty 2)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Cheeseburger (12) porte un bouton Personnaliser : son qty_<id> est ignore par le
// JS pour eviter le double comptage avec la ligne configuree.
doc.getElementById('qty_12').value = '3';
fireSubmit(dom);
activateCategory(dom, 'Accompagnements'); // Frites (22), Ketchup (47)
const frites = tileByName(dom, 'Frites');
click(dom, frites);
assert.ok(doc.querySelector('.order-cart__line'));
assert.deepEqual(itemsJson(dom), []);
click(dom, frites); // re-tap -> fusion (qty 2), pas une 2e ligne.
assert.equal(doc.querySelectorAll('.order-cart__line').length, 1);
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), [{ type: 'product', product_id: 22, quantity: 2, modifiers: [] }]);
});
test('tuile produit a modificateurs : tap ouvre la modale (pas d ajout direct)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Burgers'); // Cheeseburger (12) a modificateurs
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false); // modale ouverte
assert.equal(doc.querySelector('.order-cart__line'), null); // rien ajoute sans validation
});
test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[remove, add]', () => {
@ -117,8 +165,8 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du produit 12 (Cheeseburger).
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
@ -131,7 +179,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
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 }));
click(dom, modal.querySelector('.menu-composer__add'));
assert.equal(modal.hasAttribute('hidden'), true);
assert.ok(doc.querySelector('.order-cart__line'));
@ -148,7 +196,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
]);
});
test('quantite 0 ignoree -> panier vide serialise []', () => {
test('panier vide -> items_json serialise []', () => {
const dom = setup();
counterOrder.init(dom.window.document);
@ -156,13 +204,48 @@ test('quantite 0 ignoree -> panier vide serialise []', () => {
assert.deepEqual(itemsJson(dom), []);
});
test('stepper +/- : + incremente, - decremente, 0 retire la ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
const inc = doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]');
click(dom, inc); // qty 1 -> 2
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2');
const dec = doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]');
click(dom, dec); // 2 -> 1
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '1');
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]')); // 1 -> 0 = retrait
assert.equal(doc.querySelector('.order-cart__line'), null);
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), []);
});
test('retirer une ligne via le bouton Retirer', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
assert.ok(doc.querySelector('.order-cart__line'));
click(dom, doc.querySelector('.order-cart__remove'));
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du menu 5.
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
@ -183,7 +266,7 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu
sauceSelect.value = '47';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
// Modale fermee, panier recap mis a jour.
assert.equal(modal.hasAttribute('hidden'), true);
@ -204,12 +287,63 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu
]);
});
test('quantite MENU : stepper + sur une ligne menu -> items_json porte quantity:2, un seul jeu de selections', () => {
// G : la quantite d'une ligne menu est ajustable au panneau (stepper) et serialisee
// dans quantity ; les selections de slot ne sont PAS dupliquees par la quantite.
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
click(dom, modal.querySelector('.menu-composer__add')); // ajoute le menu (requis pre-selectionnes)
// Stepper + sur la ligne menu : qty 1 -> 2.
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]'));
assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2');
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items.length, 1);
assert.equal(items[0].type, 'menu');
assert.equal(items[0].quantity, 2);
// Un SEUL jeu de selections (requis : drink + side), pas duplique par la quantite.
assert.deepEqual(items[0].selections, [
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 16, product_id: 22 },
]);
});
test('total : menu Maxi (11,90) x2 -> 23,80 EUR (quantite multipliee)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
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 }));
click(dom, modal.querySelector('.menu-composer__add'));
click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]')); // x2
assert.equal(doc.querySelector('.order-cart__price').textContent, '23,80 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '23,80 EUR');
});
test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => {
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 }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Laisse la sauce a "Sans" (valeur vide) ; ajoute directement.
@ -220,7 +354,7 @@ test('menu Normal sans la sauce optionnelle -> selections ne contient que les re
sauceSelect.value = '';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -236,12 +370,14 @@ test('produit + menu combines -> items_json contient les deux lignes', () => {
const doc = dom.window.document;
counterOrder.init(doc);
// Frites (22) sans modificateur -> chemin qty_<id>.
doc.getElementById('qty_22').value = '1';
// Frites (22) sans modificateur -> ajout direct par tap.
activateCategory(dom, 'Accompagnements');
click(dom, tileByName(dom, 'Frites'));
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -255,7 +391,8 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Retire l'oignon du burger (ingredient 3, is_removable).
@ -263,7 +400,7 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
removeBox.checked = true;
removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
click(dom, modal.querySelector('.menu-composer__add'));
fireSubmit(dom);
const items = itemsJson(dom);
@ -271,6 +408,208 @@ 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);
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // 250c
click(dom, frites);
click(dom, frites); // qty 2
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);
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
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 }));
click(dom, modal.querySelector('.menu-composer__add'));
// 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);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
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 }));
click(dom, modal.querySelector('.menu-composer__add'));
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);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) : 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)
click(dom, modal.querySelector('.menu-composer__add'));
// 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('tuile : pastille de repli quand aucune image (image vide)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const tile = tileByName(dom, 'Cheeseburger');
// image vide -> aucune <img>, une pastille (initiale C) a la place.
assert.equal(tile.querySelector('.pos-tile__image'), null);
assert.equal(tile.querySelector('.pos-tile__pastille').textContent, 'C');
});
test('tuile : image rendue quand image fournie', () => {
const withImg = [{ id: 99, name: 'Special', price: 500, image: '/img/special.png', category_id: 1, category_name: 'Burgers', modifiers: [] }];
const dom = setup(withImg, []);
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const img = tileByName(dom, 'Special').querySelector('.pos-tile__image');
assert.ok(img);
assert.equal(img.getAttribute('src'), '/img/special.png');
});
test('modale : focus restaure sur la tuile declencheuse a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Menus');
const trigger = tileByName(dom, 'Menu Cheeseburger');
trigger.focus();
assert.equal(doc.activeElement, trigger);
click(dom, trigger);
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur la tuile declencheuse).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur la tuile.
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);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
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, image: '', category_id: 1, category_name: 'Plateaux', modifiers: [] }];
const dom = setup(PRICEY, []);
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Plateaux');
const tile = tileByName(dom, 'Plateau');
click(dom, tile);
click(dom, tile); // qty 2
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);
activateCategory(dom, 'Menus');
click(dom, tileByName(dom, 'Menu Cheeseburger'));
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('buildCategoryTabs: une entree par categorie, comptage cumule produits+menus', () => {
const tabs = counterOrder.buildCategoryTabs(PRODUCTS, MENUS);
assert.deepEqual(tabs.map(t => t.name), ['Burgers', 'Accompagnements', 'Boissons', 'Menus']);
// Accompagnements regroupe Frites + Ketchup.
assert.equal(tabs.find(t => t.name === 'Accompagnements').count, 2);
assert.equal(tabs.find(t => t.name === 'Menus').count, 1);
});
test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });
@ -284,3 +623,160 @@ test('composerSteps: slot_type non gere (dessert) ignore, slots tries par displa
const steps = counterOrder.composerSteps(menu, productById);
assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order
});
test('A : changer d onglet conserve le focus clavier sur l onglet actif (pas de retour vers body)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = doc.querySelectorAll('.pos__tab');
const second = tabs[1]; // Accompagnements
second.focus();
assert.equal(doc.activeElement, second);
click(dom, second);
// Le bouton n'est PAS detruit (pas de reconstruction de la barre) : focus preserve.
assert.equal(doc.activeElement, second);
assert.equal(second.classList.contains('is-active'), true);
// Les autres onglets existent encore (memes references, simplement mutees).
assert.equal(doc.querySelectorAll('.pos__tab').length, tabs.length);
});
test('B : roving tabindex (actif=0, autres=-1) et aria-selected coherents', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
// Au depart : 1er onglet actif (tabindex 0), les autres -1.
assert.equal(tabs[0].tabIndex, 0);
assert.equal(tabs[0].getAttribute('aria-selected'), 'true');
tabs.slice(1).forEach(t => {
assert.equal(t.tabIndex, -1);
assert.equal(t.getAttribute('aria-selected'), 'false');
});
// Apres activation du 3e : le roving tabindex suit.
click(dom, tabs[2]);
assert.equal(tabs[2].tabIndex, 0);
assert.equal(tabs[2].getAttribute('aria-selected'), 'true');
assert.equal(tabs[0].tabIndex, -1);
});
test('B : Fleche droite/gauche deplace le focus ET active l onglet (cyclique)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
tabs[0].focus();
// L'event remonte (bubbles) jusqu'au conteneur tablist ; event.target est l'onglet
// focalise. On dispatche depuis l'element actif pour refleter le focus clavier reel.
function arrowFromActive(key) {
doc.activeElement.dispatchEvent(
new dom.window.KeyboardEvent('keydown', { key, bubbles: true }),
);
}
arrowFromActive('ArrowRight'); // 0 -> 1
assert.equal(doc.activeElement, tabs[1]);
assert.equal(tabs[1].getAttribute('aria-selected'), 'true');
arrowFromActive('ArrowLeft'); // 1 -> 0
assert.equal(doc.activeElement, tabs[0]);
arrowFromActive('ArrowLeft'); // 0 -> dernier (cyclique)
assert.equal(doc.activeElement, tabs[tabs.length - 1]);
arrowFromActive('Home'); // -> premier
assert.equal(doc.activeElement, tabs[0]);
arrowFromActive('End'); // -> dernier
assert.equal(doc.activeElement, tabs[tabs.length - 1]);
});
test('B : onglets relies au tabpanel (aria-controls vers la grille, grille labellisee par l onglet actif)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const grid = doc.getElementById('pos-grid');
const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab'));
tabs.forEach(t => assert.equal(t.getAttribute('aria-controls'), 'pos-grid'));
// La grille (tabpanel) est libellee par l'onglet actif.
assert.equal(grid.getAttribute('aria-labelledby'), tabs[0].id);
click(dom, tabs[1]);
assert.equal(grid.getAttribute('aria-labelledby'), tabs[1].id);
});
test('C : region live concise mise a jour a chaque mutation (total + nombre d articles)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const announce = doc.getElementById('pos-announce');
// Init : panier vide.
assert.equal(announce.textContent, 'Panier vide');
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // 250c
click(dom, frites);
assert.equal(announce.textContent, 'Total 2,50 EUR, 1 article');
click(dom, frites); // qty 2
assert.equal(announce.textContent, 'Total 5,00 EUR, 2 articles');
});
test('C : ni #order-cart ni #pos-grid ne portent aria-live (eviter la verbosite)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
assert.equal(doc.getElementById('order-cart').hasAttribute('aria-live'), false);
assert.equal(doc.getElementById('pos-grid').hasAttribute('aria-live'), false);
});
test('D : tuile qui ouvre la modale porte aria-haspopup=dialog et l intention dans l aria-label', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Burgers');
const burger = tileByName(dom, 'Cheeseburger'); // a modificateurs -> modale
assert.equal(burger.getAttribute('aria-haspopup'), 'dialog');
assert.match(burger.getAttribute('aria-label'), /a composer/);
activateCategory(dom, 'Menus');
const menu = tileByName(dom, 'Menu Cheeseburger');
assert.equal(menu.getAttribute('aria-haspopup'), 'dialog');
assert.match(menu.getAttribute('aria-label'), /menu a composer/);
});
test('D : tuile produit simple n a PAS aria-haspopup (ajout direct au tap)', () => {
const dom = setup();
counterOrder.init(dom.window.document);
activateCategory(dom, 'Accompagnements');
const frites = tileByName(dom, 'Frites'); // sans modificateur
assert.equal(frites.hasAttribute('aria-haspopup'), false);
});
test('E : quantite invalide dans la modale produit -> ramenee a 1 et reaffichee dans l input', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
activateCategory(dom, 'Burgers');
click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal');
const qtyInput = modal.querySelector('#composer-product-qty');
qtyInput.value = '0';
qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(qtyInput.value, '1'); // valeur corrigee reaffichee
qtyInput.value = '';
qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(qtyInput.value, '1');
});

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');