Compare commits
2 commits
fix/borne-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee190a8c | |||
| 510404013c |
48 changed files with 990 additions and 1729 deletions
13
.env.example
13
.env.example
|
|
@ -131,16 +131,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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
3
.gitignore
vendored
|
|
@ -56,9 +56,6 @@ 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).
|
||||
|
|
|
|||
|
|
@ -8,15 +8,10 @@
|
|||
-- 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 (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.
|
||||
-- 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).
|
||||
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
||||
-- =============================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
-- =============================================================================
|
||||
-- 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;
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -89,12 +89,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -52,40 +52,25 @@ if ! command -v docker >/dev/null 2>&1; then
|
|||
fi
|
||||
|
||||
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
||||
# 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
|
||||
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
||||
read -r answer
|
||||
if [ "$answer" != "oui" ]; then
|
||||
echo "deploy: annule."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||
git fetch --prune "$REMOTE" "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
git merge --ff-only "$REMOTE/$BRANCH"
|
||||
|
||||
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)"
|
||||
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
||||
docker compose -f "$COMPOSE_FILE" build --pull
|
||||
|
||||
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
echo "[5/5] etat des services"
|
||||
echo "[4/4] etat des services"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo "Deploiement termine ($SHA)."
|
||||
echo "Deploiement termine."
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
<?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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,9 +63,8 @@ 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, 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).
|
||||
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
|
||||
* RG-T21, differe au seed des recettes.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -63,10 +63,8 @@ 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 (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.
|
||||
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
|
||||
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
|
||||
*
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -56,16 +56,8 @@ 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)] ?? [],
|
||||
!isset($unavailable[(int) ($row['id'] ?? 0)]),
|
||||
),
|
||||
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
|
||||
$repo->availableForCatalogue(),
|
||||
);
|
||||
|
||||
|
|
@ -92,10 +84,8 @@ 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 : [], $orderable)]);
|
||||
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -103,15 +93,8 @@ 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,
|
||||
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
|
||||
),
|
||||
fn (array $row): array => $this->presentMenu($row),
|
||||
$this->menusRepo()->availableForCatalogue(),
|
||||
);
|
||||
|
||||
|
|
@ -134,10 +117,8 @@ 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, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
||||
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
||||
|
||||
return $this->json(['data' => $menu]);
|
||||
}
|
||||
|
|
@ -219,9 +200,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}>, is_orderable: bool}
|
||||
* @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}>}
|
||||
*/
|
||||
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
|
||||
private function presentProduct(array $row, array $sizes = []): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
|
|
@ -248,19 +229,14 @@ 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, is_orderable: bool}
|
||||
* @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}
|
||||
*/
|
||||
private function presentMenu(array $row, bool $isOrderable = true): array
|
||||
private function presentMenu(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
|
|
@ -272,9 +248,6 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,9 @@ 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. 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).
|
||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
|
||||
*/
|
||||
class HealthController extends Controller
|
||||
final class HealthController extends Controller
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $params
|
||||
|
|
@ -39,8 +35,6 @@ class HealthController extends Controller
|
|||
$httpStatus = 503;
|
||||
}
|
||||
|
||||
$version = $this->readVersion();
|
||||
|
||||
return $this->json(
|
||||
[
|
||||
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||
|
|
@ -48,45 +42,8 @@ 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,9 @@ 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;
|
||||
|
||||
|
|
@ -128,33 +124,7 @@ class PasswordResetController extends Controller
|
|||
$this->database,
|
||||
$this->config,
|
||||
new PasswordHasher($this->config),
|
||||
$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',
|
||||
new LogMailer(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,14 +184,8 @@ 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, $unavailable), $items);
|
||||
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
|
||||
|
||||
$totalTtc = 0;
|
||||
$totalHt = 0;
|
||||
|
|
@ -603,10 +597,9 @@ 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 $unavailable = []): array
|
||||
private function resolveLine(array $item): array
|
||||
{
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||
|
|
@ -617,10 +610,6 @@ 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']);
|
||||
|
|
@ -634,14 +623,6 @@ 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'];
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ button {
|
|||
justify-self: center;
|
||||
}
|
||||
|
||||
.site-header__cart,
|
||||
.site-header__mode {
|
||||
justify-self: end;
|
||||
}
|
||||
|
|
@ -453,6 +454,49 @@ 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;
|
||||
|
|
@ -632,35 +676,6 @@ 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;
|
||||
|
|
@ -700,9 +715,215 @@ button {
|
|||
}
|
||||
|
||||
/* ============================================================
|
||||
10. COMPONENT — QUANTITY CONTROLS (modale options produit)
|
||||
9. COMPONENT — PRODUCT DETAIL (product.html)
|
||||
============================================================ */
|
||||
|
||||
.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;
|
||||
|
|
@ -734,6 +955,73 @@ 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)
|
||||
============================================================ */
|
||||
|
|
@ -951,6 +1239,15 @@ 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;
|
||||
|
|
@ -966,10 +1263,14 @@ button {
|
|||
.products-main {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cart-main {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
14. COMPONENT — MENU COMPOSER MODAL (modale ouverte depuis la grille produits)
|
||||
14. COMPONENT — MENU COMPOSER MODAL (product.html, type=menu)
|
||||
============================================================ */
|
||||
|
||||
/*
|
||||
|
|
@ -1185,6 +1486,10 @@ button {
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
.product-detail__info .allergen-info-btn {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.allergen-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -1454,6 +1759,31 @@ 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) {
|
||||
|
|
@ -1561,7 +1891,8 @@ button {
|
|||
}
|
||||
|
||||
.order-panel__line {
|
||||
padding: var(--space-3) 0;
|
||||
position: relative;
|
||||
padding: var(--space-3) var(--space-6) var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
|
|
@ -1592,49 +1923,14 @@ 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
* Traduction panier borne -> contrat API :
|
||||
* - produit simple -> { type:'product', product_id, quantity }
|
||||
* - menu -> { type:'menu', menu_id, quantity, format, selections }
|
||||
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
|
||||
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
|
||||
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
|
||||
* 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'.
|
||||
|
|
@ -65,10 +64,7 @@ export function buildOrderItem(cartItem, menuSlotsById) {
|
|||
type: 'menu',
|
||||
menu_id: cartItem.id,
|
||||
quantity: cartItem.quantite,
|
||||
// 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'),
|
||||
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
|
||||
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,16 +89,12 @@ 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', commandable: m.is_orderable !== false });
|
||||
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
|
||||
}
|
||||
return bySlug;
|
||||
}).catch(e => { _productsPromise = null; throw e; });
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
/*
|
||||
* order-panel.js — Panneau de commande persistant (maquette : recap a droite de
|
||||
* l'ecran de commande). Rendu sur l'ecran de commande (products) pour que le panier
|
||||
* reste visible en permanence, comme sur la maquette borne.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
getCart,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
computeMenuLineCents,
|
||||
clearCart,
|
||||
formatPrice,
|
||||
|
|
@ -35,8 +35,8 @@ export function lineCents(item) {
|
|||
|
||||
/**
|
||||
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
|
||||
* Sans le supplement (le panneau affiche le total de ligne, pas le detail TVA).
|
||||
* Tolerant aux composants absents.
|
||||
* 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.
|
||||
* @param {Object|undefined} c — objet composition de l'item menu
|
||||
* @returns {string[]}
|
||||
*/
|
||||
|
|
@ -93,7 +93,7 @@ function modeLabel() {
|
|||
|
||||
/**
|
||||
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
|
||||
* echappee (RG-T15 anti-XSS).
|
||||
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
|
||||
* @param {Object} line — element de buildPanelModel().lines
|
||||
* @returns {string}
|
||||
*/
|
||||
|
|
@ -106,37 +106,18 @@ function lineHtml(line) {
|
|||
return `
|
||||
<li class="order-panel__line">
|
||||
<div class="order-panel__line-main">
|
||||
<span class="order-panel__line-name">${escHtml(line.libelle)}</span>
|
||||
<span class="order-panel__line-name">${line.quantite}× ${escHtml(line.libelle)}</span>
|
||||
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
|
||||
</div>
|
||||
${options}
|
||||
<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)}"
|
||||
>−</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>
|
||||
<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>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
|
@ -184,19 +165,6 @@ 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));
|
||||
|
|
@ -213,7 +181,7 @@ export function renderOrderPanel(container) {
|
|||
}
|
||||
|
||||
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
|
||||
// via aria-disabled (parade a11y, cf. fix E2E #45).
|
||||
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
|
||||
const pay = container.querySelector('.order-panel__pay');
|
||||
if (pay) {
|
||||
pay.addEventListener('click', e => {
|
||||
|
|
|
|||
194
src/public/borne/assets/js/page-cart.js
Normal file
194
src/public/borne/assets/js/page-cart.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* 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);
|
||||
|
|
@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
const items = getCart();
|
||||
if (!items.length) {
|
||||
window.location.href = 'categories.html';
|
||||
window.location.href = 'cart.html';
|
||||
return;
|
||||
}
|
||||
if (recap) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* page-product-menu.js — Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
|
||||
*
|
||||
* Importe par page-products.js quand le produit clique est un menu (type === 'menu').
|
||||
* Importe par page-product.js quand le produit charge 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 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 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.
|
||||
*
|
||||
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
|
||||
*/
|
||||
|
|
@ -121,11 +121,6 @@ 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -155,7 +150,7 @@ export function composerIsViable(model) {
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Entree publique — appelee par page-products.js */
|
||||
/* Entree publique — appelee par page-product.js */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
|
|
|
|||
142
src/public/borne/assets/js/page-product.js
Normal file
142
src/public/borne/assets/js/page-product.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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);
|
||||
|
|
@ -3,8 +3,7 @@
|
|||
*
|
||||
* 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, opens an in-page modal (composer for a menu, options
|
||||
* for a simple product) above the grid ; the order panel reflects the addition.
|
||||
* On product card click, navigates to product.html?id=<id>&category=<slug>.
|
||||
*/
|
||||
|
||||
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
|
||||
|
|
@ -64,16 +63,10 @@ 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 = 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.className = 'product-card';
|
||||
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
|
||||
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="product-card__image-wrap">
|
||||
|
|
@ -84,7 +77,6 @@ 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>
|
||||
|
|
@ -97,13 +89,11 @@ 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) :
|
||||
// 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).
|
||||
// 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).
|
||||
card.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!orderable) return;
|
||||
if (product.type === 'menu') openMenuComposer(product, categorySlug);
|
||||
else openProductOptions(product, categorySlug);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/*
|
||||
* product-options.js — Modale d'options produit (P5 L3, taille R4).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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,
|
||||
|
|
|
|||
102
src/public/borne/cart.html
Normal file
102
src/public/borne/cart.html
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<!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"
|
||||
>
|
||||
← 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>
|
||||
|
|
@ -21,10 +21,10 @@
|
|||
<header class="site-header">
|
||||
<a
|
||||
class="site-header__back"
|
||||
href="categories.html"
|
||||
aria-label="Retour aux categories"
|
||||
href="cart.html"
|
||||
aria-label="Retour au panier"
|
||||
>
|
||||
← Retour
|
||||
← Panier
|
||||
</a>
|
||||
<img
|
||||
class="site-header__logo"
|
||||
|
|
|
|||
73
src/public/borne/product.html
Normal file
73
src/public/borne/product.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<!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"
|
||||
>
|
||||
← 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">🛒</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>
|
||||
|
|
@ -33,6 +33,15 @@
|
|||
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">🛒</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">
|
||||
|
|
|
|||
|
|
@ -79,14 +79,6 @@ final class FakeCatalogueDatabase implements DatabaseInterface
|
|||
*/
|
||||
public array $productSizes = [];
|
||||
|
||||
/**
|
||||
* Lignes {product_id} renvoyees par ProductRepository::autoUnavailableIds()
|
||||
* (RG-T21 : produits en rupture calculee par le stock). Vide = rien en rupture.
|
||||
*
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $autoUnavailableRows = [];
|
||||
|
||||
/**
|
||||
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
|
||||
*
|
||||
|
|
@ -117,12 +109,6 @@ 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')) {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ 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 */
|
||||
|
|
@ -95,11 +93,6 @@ 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']] ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<?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());
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ final class CatalogueControllerTest extends TestCase
|
|||
|
||||
$product = $payload['data'][0];
|
||||
self::assertSame(
|
||||
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes', 'is_orderable'],
|
||||
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'],
|
||||
array_keys($product),
|
||||
);
|
||||
self::assertSame(12, $product['id']);
|
||||
|
|
@ -125,41 +125,6 @@ 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
|
||||
|
|
@ -326,52 +291,18 @@ 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', 'is_orderable'],
|
||||
['id', 'category_id', 'burger_product_id', 'name', 'description', 'price_normal_cents', 'price_maxi_cents', 'image_path', 'display_order'],
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
<?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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -121,86 +121,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// 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).
|
||||
// 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.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('parcours borne : de l\'accueil a la confirmation de commande', async ({ page }) => {
|
||||
|
|
@ -18,46 +16,40 @@ 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 ouvrent
|
||||
// le composeur a slots, un autre parcours). Un produit simple ouvre la modale options.
|
||||
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui rendent
|
||||
// un autre gabarit a slots, sans bouton d'ajout direct).
|
||||
await page.locator('a[href="products.html?category=2"]').click();
|
||||
await expect(page).toHaveURL(/products\.html\?category=2/);
|
||||
});
|
||||
|
||||
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 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 expect(firstCard).toBeVisible();
|
||||
await firstCard.click();
|
||||
// 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 expect(page).toHaveURL(/product\.html\?id=/);
|
||||
});
|
||||
|
||||
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('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('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 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 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).
|
||||
|
|
|
|||
|
|
@ -60,17 +60,6 @@ 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', () => {
|
||||
|
|
|
|||
|
|
@ -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/products.html' });
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/product.html' });
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.localStorage = dom.window.localStorage;
|
||||
|
|
@ -71,7 +71,6 @@ 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' });
|
||||
|
|
@ -84,7 +83,6 @@ 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');
|
||||
});
|
||||
|
|
@ -93,21 +91,10 @@ 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 (cas bouteille).
|
||||
// Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas).
|
||||
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 } });
|
||||
|
|
|
|||
|
|
@ -69,8 +69,7 @@ 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.
|
||||
// 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 },
|
||||
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -101,21 +100,10 @@ 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', commandable: true },
|
||||
{ id: 1, nom: 'Menu Big Mac', prix: 800, image: 'assets/images/produits/burgers/bigmac.png', type: 'menu' },
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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('/payment.html', 'bidon'), true);
|
||||
assert.equal(needsModeRedirect('/cart.html', 'bidon'), true);
|
||||
assert.equal(needsModeRedirect('/categories.html', ''), true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -138,24 +138,6 @@ 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: 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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue