diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js
new file mode 100644
index 0000000..7ceb13e
--- /dev/null
+++ b/src/public/borne/assets/js/page-product-menu.js
@@ -0,0 +1,702 @@
+/*
+ * page-product-menu.js — Multi-step menu composer for the Wakdo kiosk.
+ *
+ * Imported by page-product.js only when the loaded product has type === 'menu'.
+ * Keeping the composer in its own module avoids bloating page-product.js and
+ * makes future unit-testing of the composition logic straightforward.
+ *
+ * Steps:
+ * 1 — Burger selection + personalisation options (sans oignon / avec fromage)
+ * 2 — Accompagnement (frites or salades) + taille toggle
+ * 3 — Boisson + taille toggle
+ * 4 — Sauce
+ * 5 — Recap + "Ajouter au panier"
+ *
+ * Price rule: grande taille = +50 centimes per sized item (accompagnement + boisson).
+ *
+ * A11y: role=dialog, aria-modal=true, focus-trap (Tab cycles inside the modal),
+ * ESC closes/cancels, focus is moved to the first interactive element on each step.
+ */
+
+import { getProductsByCategory } from './data.js';
+import { addToCart, computeMenuLineCents, formatPrice } from './state.js';
+import { refreshCartBadge } from './nav.js';
+
+const SUPPLEMENT_GRANDE_CENTS = 50;
+const TOTAL_STEPS = 5;
+
+/* ------------------------------------------------------------------ */
+/* Public entry-point — called from page-product.js */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Initialises and opens the menu composer modal.
+ * Fetches required category products, builds the initial state, then renders.
+ *
+ * @param {Object} menu — product object with type === 'menu'
+ * @param {string} returnCategory — category slug to redirect to after add/cancel
+ */
+export async function openMenuComposer(menu, returnCategory) {
+ let burgers, frites, salades, boissons, sauces;
+ try {
+ [burgers, frites, salades, boissons, sauces] = await Promise.all([
+ getProductsByCategory('burgers'),
+ getProductsByCategory('frites'),
+ getProductsByCategory('salades'),
+ getProductsByCategory('boissons'),
+ getProductsByCategory('sauces')
+ ]);
+ } catch (err) {
+ console.error('Menu composer: failed to load category products', err);
+ return;
+ }
+
+ const accompagnements = [...frites, ...salades];
+
+ /* Heuristic pre-selection: if the menu name contains a burger name, pre-select it.
+ * "Menu CBO" -> first burger whose nom equals "CBO".
+ * Fallback: first burger in the list. */
+ const menuNameUpper = menu.nom.toUpperCase();
+ const preselectedBurger =
+ burgers.find(b => menuNameUpper.includes(b.nom.toUpperCase())) ?? burgers[0] ?? null;
+
+ /* Composer internal state — single mutable object, re-read on each render. */
+ const state = {
+ currentStep: 1,
+ menu,
+ returnCategory,
+ burgers,
+ accompagnements,
+ boissons,
+ sauces,
+ /* Selections */
+ burger: preselectedBurger,
+ burgerOptions: [], // subset of ['sans-oignon', 'avec-fromage']
+ accompagnement: accompagnements[0] ?? null,
+ accompTaille: 'N', // 'N' or 'G'
+ boisson: boissons[0] ?? null,
+ boissonTaille: 'N',
+ sauce: sauces[0] ?? null
+ };
+
+ const modal = buildModalShell(menu);
+ document.body.appendChild(modal);
+ modal.removeAttribute('hidden');
+
+ /* Prevent background scroll while composer is open. */
+ document.body.style.overflow = 'hidden';
+
+ renderStep(modal, state);
+ trapFocus(modal);
+
+ /* ESC closes the modal and returns to product list. */
+ const escHandler = (e) => {
+ if (e.key === 'Escape') {
+ cancelComposer(modal, returnCategory, escHandler);
+ }
+ };
+ document.addEventListener('keydown', escHandler);
+}
+
+/* ------------------------------------------------------------------ */
+/* Modal shell builder */
+/* ------------------------------------------------------------------ */
+
+function buildModalShell(menu) {
+ const overlay = document.createElement('div');
+ overlay.className = 'composer-overlay';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-labelledby', 'composer-title');
+ overlay.hidden = true;
+
+ overlay.innerHTML = `
+
+ `;
+ return overlay;
+}
+
+/* ------------------------------------------------------------------ */
+/* Step renderer — decides which step to paint */
+/* ------------------------------------------------------------------ */
+
+function renderStep(modal, state) {
+ const body = modal.querySelector('#composer-body');
+ const footer = modal.querySelector('#composer-footer');
+ const stepEl = modal.querySelector('#composer-step-indicator');
+ const fillEl = modal.querySelector('#composer-progress-fill');
+
+ stepEl.textContent = `Etape ${state.currentStep} / ${TOTAL_STEPS}`;
+ fillEl.style.width = `${(state.currentStep / TOTAL_STEPS) * 100}%`;
+
+ /* Each step renderer returns {bodyHTML, canAdvance()} and may attach
+ * its own event listeners after DOM insertion. */
+ switch (state.currentStep) {
+ case 1: renderStep1(body, footer, modal, state); break;
+ case 2: renderStep2(body, footer, modal, state); break;
+ case 3: renderStep3(body, footer, modal, state); break;
+ case 4: renderStep4(body, footer, modal, state); break;
+ case 5: renderStep5(body, footer, modal, state); break;
+ }
+
+ /* Move focus to the first interactive element so keyboard users and
+ * screen readers start at the right place after each step transition. */
+ requestAnimationFrame(() => {
+ const first = modal.querySelector(
+ 'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
+ );
+ if (first) first.focus();
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Step 1 — Burger + personalisation options */
+/* ------------------------------------------------------------------ */
+
+function renderStep1(body, footer, modal, state) {
+ body.innerHTML = `
+ Choisissez votre burger
+
+ ${state.burgers.map(b => `
+ -
+
+
+ `).join('')}
+
+
+
+ `;
+
+ /* Burger card selection */
+ body.querySelectorAll('#burger-grid .composer-card').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.id, 10);
+ state.burger = state.burgers.find(b => b.id === id) ?? state.burger;
+ /* Update pressed states without full re-render to preserve scroll position */
+ body.querySelectorAll('#burger-grid .composer-card').forEach(b => {
+ const active = parseInt(b.dataset.id, 10) === state.burger.id;
+ b.classList.toggle('composer-card--selected', active);
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ });
+ });
+
+ /* Personalisation checkboxes */
+ body.querySelectorAll('input[name="burger-opt"]').forEach(cb => {
+ cb.addEventListener('change', () => {
+ state.burgerOptions = Array.from(
+ body.querySelectorAll('input[name="burger-opt"]:checked')
+ ).map(el => el.value);
+ });
+ });
+
+ renderFooter(footer, modal, state, {
+ canAdvance: () => state.burger !== null
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Step 2 — Accompagnement + taille toggle */
+/* ------------------------------------------------------------------ */
+
+function renderStep2(body, footer, modal, state) {
+ body.innerHTML = `
+ Choisissez votre accompagnement
+
+ ${state.accompagnements.map(a => `
+ -
+
+
+ `).join('')}
+
+ ${renderTailleToggle('accomp', state.accompTaille)}
+ `;
+
+ body.querySelectorAll('#accomp-grid .composer-card').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.id, 10);
+ state.accompagnement = state.accompagnements.find(a => a.id === id) ?? state.accompagnement;
+ body.querySelectorAll('#accomp-grid .composer-card').forEach(b => {
+ const active = parseInt(b.dataset.id, 10) === state.accompagnement.id;
+ b.classList.toggle('composer-card--selected', active);
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ });
+ });
+
+ attachTailleToggle(body, 'accomp', state, 'accompTaille');
+
+ renderFooter(footer, modal, state, {
+ canAdvance: () => state.accompagnement !== null
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Step 3 — Boisson + taille toggle */
+/* ------------------------------------------------------------------ */
+
+function renderStep3(body, footer, modal, state) {
+ body.innerHTML = `
+ Choisissez votre boisson
+
+ ${state.boissons.map(b => `
+ -
+
+
+ `).join('')}
+
+ ${renderTailleToggle('boisson', state.boissonTaille)}
+ `;
+
+ body.querySelectorAll('#boisson-grid .composer-card').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.id, 10);
+ state.boisson = state.boissons.find(b => b.id === id) ?? state.boisson;
+ body.querySelectorAll('#boisson-grid .composer-card').forEach(b => {
+ const active = parseInt(b.dataset.id, 10) === state.boisson.id;
+ b.classList.toggle('composer-card--selected', active);
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ });
+ });
+
+ attachTailleToggle(body, 'boisson', state, 'boissonTaille');
+
+ renderFooter(footer, modal, state, {
+ canAdvance: () => state.boisson !== null
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Step 4 — Sauce */
+/* ------------------------------------------------------------------ */
+
+function renderStep4(body, footer, modal, state) {
+ body.innerHTML = `
+ Choisissez votre sauce
+
+ ${state.sauces.map(s => `
+ -
+
+
+ `).join('')}
+
+ `;
+
+ body.querySelectorAll('#sauce-grid .composer-card').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.dataset.id, 10);
+ state.sauce = state.sauces.find(s => s.id === id) ?? state.sauce;
+ body.querySelectorAll('#sauce-grid .composer-card').forEach(b => {
+ const active = parseInt(b.dataset.id, 10) === state.sauce.id;
+ b.classList.toggle('composer-card--selected', active);
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ });
+ });
+
+ renderFooter(footer, modal, state, {
+ canAdvance: () => state.sauce !== null
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Step 5 — Recap + add to cart */
+/* ------------------------------------------------------------------ */
+
+function renderStep5(body, footer, modal, state) {
+ const supplement = computeSupplement(state);
+ const baseItem = buildCartItem(state, supplement);
+ const totalLine = computeMenuLineCents(baseItem);
+
+ const optionsText = state.burgerOptions.length
+ ? state.burgerOptions.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')
+ : null;
+
+ body.innerHTML = `
+ Recapitulatif de votre menu
+
+ -
+ ■
+
+ ${escHtml(state.burger.nom)}
+ ${optionsText ? `(${escHtml(optionsText)})` : ''}
+
+
+ -
+ ■
+
+ ${escHtml(state.accompagnement.nom)}
+ ${state.accompTaille === 'G' ? 'grande' : 'normale'}
+ ${state.accompTaille === 'G' ? '+0,50 EUR' : ''}
+
+
+ -
+ ■
+
+ ${escHtml(state.boisson.nom)}
+ ${state.boissonTaille === 'G' ? 'grande' : 'normale'}
+ ${state.boissonTaille === 'G' ? '+0,50 EUR' : ''}
+
+
+ -
+ ■
+ ${escHtml(state.sauce.nom)}
+
+
+
+ Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)}
+ ${supplement > 0 ? `Supplement grande(s) taille(s) : +${formatPrice(supplement)}` : ''}
+ Total : ${formatPrice(totalLine)}
+
+ `;
+
+ footer.innerHTML = `
+
+ `;
+
+ footer.querySelector('#composer-cancel').addEventListener('click', () => {
+ cancelComposer(modal, state.returnCategory, null);
+ });
+
+ footer.querySelector('#composer-prev').addEventListener('click', () => {
+ state.currentStep--;
+ renderStep(modal, state);
+ });
+
+ footer.querySelector('#composer-add').addEventListener('click', () => {
+ addToCart(baseItem);
+ refreshCartBadge();
+ closeComposer(modal);
+ window.location.href = `products.html?category=${state.returnCategory}`;
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Footer renderer (steps 1-4) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Renders the navigation footer for steps 1 through 4.
+ * @param {HTMLElement} footer
+ * @param {HTMLElement} modal
+ * @param {Object} state
+ * @param {{ canAdvance: () => boolean }} opts
+ */
+function renderFooter(footer, modal, state, opts) {
+ const isFirst = state.currentStep === 1;
+
+ footer.innerHTML = `
+
+ `;
+
+ footer.querySelector('#composer-cancel').addEventListener('click', () => {
+ cancelComposer(modal, state.returnCategory, null);
+ });
+
+ if (!isFirst) {
+ footer.querySelector('#composer-prev').addEventListener('click', () => {
+ state.currentStep--;
+ renderStep(modal, state);
+ });
+ }
+
+ footer.querySelector('#composer-next').addEventListener('click', () => {
+ if (!opts.canAdvance()) return;
+ state.currentStep++;
+ renderStep(modal, state);
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Taille toggle — shared between accompagnement and boisson steps */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Generates the HTML for the Normale/Grande toggle.
+ * @param {string} prefix — 'accomp' or 'boisson', used for IDs
+ * @param {'N'|'G'} currentTaille
+ * @returns {string}
+ */
+function renderTailleToggle(prefix, currentTaille) {
+ return `
+
+
+
+
+ `;
+}
+
+/**
+ * Attaches click handlers to the taille toggle buttons and keeps state in sync.
+ * @param {HTMLElement} body
+ * @param {string} prefix
+ * @param {Object} state
+ * @param {'accompTaille'|'boissonTaille'} stateKey
+ */
+function attachTailleToggle(body, prefix, state, stateKey) {
+ body.querySelectorAll('.composer-taille__btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ state[stateKey] = btn.dataset.taille;
+ body.querySelectorAll('.composer-taille__btn').forEach(b => {
+ const active = b.dataset.taille === state[stateKey];
+ b.classList.toggle('composer-taille__btn--active', active);
+ b.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ });
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Cart item assembly + supplement calculation */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Counts how many grande-taille choices were made (0, 1, or 2).
+ * @param {Object} state
+ * @returns {number} centimes
+ */
+function computeSupplement(state) {
+ let suppl = 0;
+ if (state.accompTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
+ if (state.boissonTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
+ return suppl;
+}
+
+/**
+ * Builds the cart item object from the current composer state.
+ * prix_cents is the base menu price; supplement_cents accumulates size upgrades.
+ *
+ * @param {Object} state
+ * @param {number} supplement
+ * @returns {Object}
+ */
+function buildCartItem(state, supplement) {
+ /* Support both raw produits.json field (prix) and normalised (prix_cents) */
+ const prixCents = state.menu.prix_cents ?? state.menu.prix;
+
+ return {
+ id: state.menu.id,
+ type: 'menu',
+ categorie: 'menus',
+ libelle: state.menu.nom,
+ prix_cents: prixCents,
+ quantite: 1,
+ image: state.menu.image,
+ supplement_cents: supplement,
+ composition: {
+ burger: {
+ id: state.burger.id,
+ libelle: state.burger.nom,
+ options: [...state.burgerOptions]
+ },
+ accompagnement: {
+ id: state.accompagnement.id,
+ libelle: state.accompagnement.nom,
+ categorie: state.accompagnement.categorie ?? 'frites',
+ taille: state.accompTaille
+ },
+ boisson: {
+ id: state.boisson.id,
+ libelle: state.boisson.nom,
+ taille: state.boissonTaille
+ },
+ sauce: {
+ id: state.sauce.id,
+ libelle: state.sauce.nom
+ }
+ }
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* Focus trap */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Traps Tab / Shift+Tab inside the modal container.
+ * The handler is attached to the modal element itself; it is removed
+ * automatically when the modal is removed from the DOM.
+ */
+function trapFocus(modal) {
+ modal.addEventListener('keydown', (e) => {
+ if (e.key !== 'Tab') return;
+
+ const focusable = Array.from(modal.querySelectorAll(
+ 'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
+ )).filter(el => !el.closest('[hidden]'));
+
+ if (!focusable.length) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Close helpers */
+/* ------------------------------------------------------------------ */
+
+function closeComposer(modal) {
+ modal.remove();
+ document.body.style.overflow = '';
+}
+
+function cancelComposer(modal, returnCategory, escHandler) {
+ if (escHandler) {
+ document.removeEventListener('keydown', escHandler);
+ }
+ closeComposer(modal);
+ window.location.href = `products.html?category=${returnCategory}`;
+}
+
+/* ------------------------------------------------------------------ */
+/* Utilities */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Minimal HTML escaping to prevent XSS when injecting product names/paths
+ * into innerHTML. Applied to all data-derived strings.
+ */
+function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/src/public/borne/assets/js/page-product.js b/src/public/borne/assets/js/page-product.js
index 4f78a06..d345e1f 100644
--- a/src/public/borne/assets/js/page-product.js
+++ b/src/public/borne/assets/js/page-product.js
@@ -2,11 +2,14 @@
* page-product.js — Product detail screen.
*
* Reads ?id=&category= from the query string.
- * For menus (type === 'menu'): shows a fixed composition note rather than
- * a detailed breakdown — the school JSON does not include composition data.
- * Decision: composition_libre_pour_MVP=non (fixed menu composition message).
*
- * After "Ajouter au panier":
+ * 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=
@@ -15,6 +18,7 @@
import { findProduct } from './data.js';
import { addToCart, formatPrice } from './state.js';
import { refreshCartBadge } from './nav.js';
+import { openMenuComposer } from './page-product-menu.js';
const params = new URLSearchParams(window.location.search);
const productId = parseInt(params.get('id'), 10);
@@ -43,7 +47,13 @@ async function renderProduct() {
document.title = `Wakdo - ${product.nom}`;
- const isMenu = product.type === 'menu';
+ 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;
+ }
container.innerHTML = `
@@ -57,7 +67,6 @@ async function renderProduct() {
${product.nom}
${formatPrice(product.prix)}
- ${isMenu ? renderMenuComposition() : ''}
- `;
-}
-
function showError(msg) {
if (errorBlock) {
errorBlock.hidden = false;