From 25f47a50745345cda9f9418adfd277dfb9a23cbf Mon Sep 17 00:00:00 2001 From: Imugiii Date: Fri, 19 Jun 2026 16:28:22 +0000 Subject: [PATCH] feat(borne): composeur menu pilote par les slots /api/menus (P5 L2) Le composeur de menu consomme desormais GET /api/menus/{id} au lieu de composer librement a partir des categories (dette P4 #3). Burger impose (burger_product_id), un pas par slot reel (drink/side/sauce, options resolues), format Normal/Maxi au prix du menu. - data.js : loadMenu(id) (detail + slots) ; loadProductsById (index produit par id, type 'produit' uniquement -> pas de collision avec les ids de menu). - page-product-menu.js : reecrit slot-driven. Fonctions pures buildComposerSteps / buildMenuCartItem / selectionsComplete / composerIsViable. composition produite compatible avec page-cart.js et le panneau persistant L1 (slot_type -> champ). - page-cart.js : rendu de composition tolerant aux champs absents ; libelle Maxi ("Format Maxi : +X") au lieu de "N grande(s)" devenu trompeur sous le forfait menu. - tests : composer-slots.test.js + loadProductsById (data.test.js). 49/49 verts. Revue adversariale (workflow, 14 findings / 12 confirmes) : must-fix integres -- SLOT_FIELD fait foi (slot_type hors drink/side/sauce ignore, l'enum DB autorise dessert/extra -> plus de choix perdu) ; composerIsViable refuse d'ouvrir si burger absent ou slot requis sans option (plus d'etape impassable) ; aria-hidden du fond sous la modale ; role=dialog sur le conteneur ; overflow body sauvegarde/restaure. Differes notes : multi-slots de meme type (menu famille), display_order vs maquette. --- src/public/borne/assets/js/data.js | 39 + src/public/borne/assets/js/page-cart.js | 32 +- .../borne/assets/js/page-product-menu.js | 806 ++++++------------ tests/js/composer-slots.test.js | 127 +++ tests/js/data.test.js | 24 + 5 files changed, 472 insertions(+), 556 deletions(-) create mode 100644 tests/js/composer-slots.test.js diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index b02b5d1..dcfa974 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -109,6 +109,45 @@ export async function loadProducts() { return _productsCache; } +/** @type {Object|null} — cache id->produit (type 'produit' uniquement) */ +let _productsByIdCache = null; + +/** + * Index des PRODUITS par id (type 'produit' seulement : exclut les menus, dont + * l'espace d'id est distinct -> pas de collision id produit/menu). Sert au composeur + * de menu (L2) pour resoudre les option_product_ids des slots /api/menus en produits + * affichables. Derive de loadProducts() : aucune requete reseau supplementaire. + * @returns {Promise>} + */ +export async function loadProductsById() { + if (_productsByIdCache) return _productsByIdCache; + const bySlug = await loadProducts(); + const byId = {}; + for (const slug of Object.keys(bySlug)) { + for (const item of bySlug[slug]) { + if (item.type === 'produit') byId[item.id] = item; + } + } + _productsByIdCache = byId; + return _productsByIdCache; +} + +/** + * Charge le detail d'un menu avec ses slots depuis GET /api/menus/{id}. Renvoie la + * forme canonique de l'API (snake_case) telle quelle : { id, burger_product_id, + * price_normal_cents, price_maxi_cents, name, image_path, slots: [{ id, name, + * slot_type, is_required, display_order, option_product_ids }] }. Le composeur (L2) + * la traduit en etapes. Pas de cache : un menu est compose ponctuellement. + * @param {number} id + * @returns {Promise} + */ +export async function loadMenu(id) { + const res = await fetch(`${MENUS_URL}/${id}`); + if (!res.ok) throw new Error(`Failed to load menu ${id}: HTTP ${res.status}`); + const body = await res.json(); + return body && typeof body === 'object' ? (body.data ?? null) : null; +} + /** * Fetches and caches the 14 INCO allergens (general info modal). Repli statique : * la reponse est un tableau nu (pas d'enveloppe), conserve tel quel. diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js index 6d7d1b1..1f010d1 100644 --- a/src/public/borne/assets/js/page-cart.js +++ b/src/public/borne/assets/js/page-cart.js @@ -151,23 +151,31 @@ function renderCompositionBlock(item) { const c = item.composition; if (!c) return ''; - const burgerOpts = c.burger.options && c.burger.options.length - ? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})` - : ''; + // 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}`); + } + if (c.accompagnement) { + parts.push(`${escHtml(c.accompagnement.libelle)}${c.accompagnement.taille === 'G' ? ' grande' : ' normale'}`); + } + if (c.boisson) { + parts.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' grande' : ' normale'}`); + } + if (c.sauce) { + parts.push(escHtml(c.sauce.libelle)); + } - const accompTailleLabel = c.accompagnement.taille === 'G' ? ' grande' : ' normale'; - const boissonTailleLabel = c.boisson.taille === 'G' ? ' grande' : ' normale'; - - const nbGrandes = (c.accompagnement.taille === 'G' ? 1 : 0) + (c.boisson.taille === 'G' ? 1 : 0); const supplTotal = item.supplement_cents ?? 0; return `
    -
  • + ${escHtml(c.burger.libelle)}${burgerOpts}
  • -
  • + ${escHtml(c.accompagnement.libelle)}${accompTailleLabel}
  • -
  • + ${escHtml(c.boisson.libelle)}${boissonTailleLabel}
  • -
  • + ${escHtml(c.sauce.libelle)}
  • - ${supplTotal > 0 ? `
  • Supplement ${nbGrandes} grande(s) : +${formatPrice(supplTotal)}
  • ` : ''} + ${parts.map(t => `
  • + ${t}
  • `).join('')} + ${supplTotal > 0 ? `
  • Format Maxi : +${formatPrice(supplTotal)}
  • ` : ''}
`; } diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js index 7ceb13e..624188f 100644 --- a/src/public/borne/assets/js/page-product-menu.js +++ b/src/public/borne/assets/js/page-product-menu.js @@ -1,466 +1,364 @@ /* - * page-product-menu.js — Multi-step menu composer for the Wakdo kiosk. + * page-product-menu.js — Composeur de menu PILOTE PAR LES SLOTS (P5 L2). * - * 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. + * Importe par page-product.js quand le produit charge est un menu (type === 'menu'). * - * 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" + * Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers, + * frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme + * GET /api/menus/{id} : le burger est IMPOSE (burger_product_id), et chaque slot + * (slot_type drink/side/sauce, option_product_ids) devient une etape. Le prix vient + * du menu (Normal vs Maxi), pas d'un supplement arbitraire. * - * Price rule: grande taille = +50 centimes per sized item (accompagnement + boisson). + * Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans + * l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap. * - * 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. + * 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. */ -import { getProductsByCategory } from './data.js'; -import { addToCart, computeMenuLineCents, formatPrice } from './state.js'; +import { loadMenu, loadProductsById } from './data.js'; +import { addToCart, computeMenuLineCents, formatPrice, escHtml } from './state.js'; import { refreshCartBadge } from './nav.js'; -const SUPPLEMENT_GRANDE_CENTS = 50; -const TOTAL_STEPS = 5; +/* slot_type de l'API -> champ de composition attendu par le rendu panier existant. */ +const SLOT_FIELD = { side: 'accompagnement', drink: 'boisson', sauce: 'sauce' }; /* ------------------------------------------------------------------ */ -/* Public entry-point — called from page-product.js */ +/* Fonctions PURES (cible des tests, sans DOM ni fetch) */ /* ------------------------------------------------------------------ */ /** - * 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 + * Construit le modele d'etapes a partir du detail menu (slots) et de l'index + * produit par id. Resout les option_product_ids en produits affichables, trie les + * slots par display_order. Pur. + * @param {Object} detail — sortie de loadMenu() + * @param {Object} byId — sortie de loadProductsById() + * @returns {{burger: Object|null, slots: Array, priceNormalCents: number, priceMaxiCents: number}} + */ +export function buildComposerSteps(detail, byId) { + const burger = byId[detail.burger_product_id] ?? null; + const slots = [...(detail.slots ?? [])] + // SLOT_FIELD fait foi : un slot_type non mappe (l'enum DB autorise aussi + // dessert/extra) ne devient PAS une etape -> pas de choix perdu silencieusement. + .filter(slot => { + if (SLOT_FIELD[slot.slot_type]) return true; + console.warn(`Menu composer: slot_type non gere, slot ignore: ${slot.slot_type}`); + return false; + }) + .sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)) + .map(slot => ({ + id: slot.id, + name: slot.name, + slotType: slot.slot_type, + isRequired: !!slot.is_required, + options: (slot.option_product_ids ?? []).map(pid => byId[pid]).filter(Boolean), + })); + return { + burger, + slots, + priceNormalCents: detail.price_normal_cents, + priceMaxiCents: detail.price_maxi_cents, + }; +} + +/** + * Construit l'item panier du menu compose. `composition` reste compatible avec le + * rendu existant (burger/accompagnement/boisson/sauce). Maxi -> taille 'G' sur les + * items dimensionnables + supplement = prix_maxi - prix_normal (prix_cents = normal). + * @param {Object} menu — produit borne {id, nom, image, prix?} + * @param {Object} model — sortie de buildComposerSteps + * @param {{size: 'N'|'M', selections: Object}} choice + * @returns {Object} item panier + */ +export function buildMenuCartItem(menu, model, { size, selections }) { + const isMaxi = size === 'M'; + const taille = isMaxi ? 'G' : 'N'; + const supplement = isMaxi + ? Math.max(0, (model.priceMaxiCents ?? 0) - (model.priceNormalCents ?? 0)) + : 0; + + const composition = { + burger: { id: model.burger?.id, libelle: model.burger?.nom ?? menu.nom, options: [] }, + }; + + for (const slot of model.slots) { + const chosen = slot.options.find(o => o.id === selections[slot.id]); + if (!chosen) continue; // slot optionnel laisse "sans" + const field = SLOT_FIELD[slot.slotType]; + if (!field) continue; + composition[field] = field === 'sauce' + ? { id: chosen.id, libelle: chosen.nom } + : { id: chosen.id, libelle: chosen.nom, taille }; + } + + return { + id: menu.id, + type: 'menu', + categorie: 'menus', + libelle: menu.nom, + prix_cents: model.priceNormalCents, + quantite: 1, + image: menu.image, + supplement_cents: supplement, + composition, + }; +} + +/** + * Indique si toutes les etapes obligatoires ont une selection. Pur. + * @param {Object} model + * @param {Object} selections + * @returns {boolean} + */ +export function selectionsComplete(model, selections) { + return model.slots + .filter(s => s.isRequired) + .every(s => s.options.some(o => o.id === selections[s.id])); +} + +/** + * Le menu est-il composable ? Faux si le burger impose est introuvable, ou si un + * slot REQUIS n'a aucune option resolue (catalogue desync). Pur. Garde-fou : eviter + * d'ouvrir une modale ou une etape requise serait impassable. + * @param {Object} model + * @returns {boolean} + */ +export function composerIsViable(model) { + if (!model.burger) return false; + return model.slots.filter(s => s.isRequired).every(s => s.options.length > 0); +} + +/* ------------------------------------------------------------------ */ +/* Entree publique — appelee par page-product.js */ +/* ------------------------------------------------------------------ */ + +/** + * Initialise et ouvre la modale du composeur pour un menu. + * @param {Object} menu — produit borne avec type === 'menu' + * @param {string} returnCategory — slug de categorie de retour apres ajout/annulation */ export async function openMenuComposer(menu, returnCategory) { - let burgers, frites, salades, boissons, sauces; + let detail, byId; try { - [burgers, frites, salades, boissons, sauces] = await Promise.all([ - getProductsByCategory('burgers'), - getProductsByCategory('frites'), - getProductsByCategory('salades'), - getProductsByCategory('boissons'), - getProductsByCategory('sauces') - ]); + [detail, byId] = await Promise.all([loadMenu(menu.id), loadProductsById()]); } catch (err) { - console.error('Menu composer: failed to load category products', err); + console.error('Menu composer: chargement /api/menus echoue', err); + return; + } + if (!detail) { + console.error('Menu composer: detail menu introuvable', menu.id); return; } - const accompagnements = [...frites, ...salades]; + const model = buildComposerSteps(detail, byId); + if (!composerIsViable(model)) { + console.error('Menu composer: menu non composable (burger absent ou slot requis sans option)', menu.id); + return; + } - /* 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 + model, + size: 'N', // 'N' (Normal) | 'M' (Maxi) + selections: {}, // slotId -> productId ; pre-selection du 1er requis + currentStep: 0, // 0 = format ; 1..N = slots ; N+1 = recap }; + for (const slot of model.slots) { + if (slot.isRequired && slot.options[0]) state.selections[slot.id] = slot.options[0].id; + } const modal = buildModalShell(menu); + modal._prevOverflow = document.body.style.overflow; document.body.appendChild(modal); modal.removeAttribute('hidden'); - - /* Prevent background scroll while composer is open. */ document.body.style.overflow = 'hidden'; + // Focus-trap : neutralise le fond pour les lecteurs d'ecran tant que la modale + // est ouverte (freres de l'overlay : header, .order-layout). + modal._bgSiblings = Array.from(document.body.children).filter(el => el !== modal); + modal._bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true')); 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); - } + if (e.key === 'Escape') cancelComposer(modal, returnCategory, escHandler); }; document.addEventListener('keydown', escHandler); + modal._escHandler = escHandler; } /* ------------------------------------------------------------------ */ -/* Modal shell builder */ +/* Coque modale */ /* ------------------------------------------------------------------ */ +function totalSteps(state) { + return state.model.slots.length + 2; // format + slots + recap +} + 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.className = 'composer-overlay'; overlay.hidden = true; - overlay.innerHTML = ` -
+ `; return overlay; } /* ------------------------------------------------------------------ */ -/* Step renderer — decides which step to paint */ +/* Rendu d'etape */ /* ------------------------------------------------------------------ */ function renderStep(modal, state) { - const body = modal.querySelector('#composer-body'); + 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}%`; + const total = totalSteps(state); + stepEl.textContent = `Etape ${state.currentStep + 1} / ${total}`; + fillEl.style.width = `${((state.currentStep + 1) / total) * 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; + if (state.currentStep === 0) { + renderFormatStep(body, footer, modal, state); + } else if (state.currentStep <= state.model.slots.length) { + renderSlotStep(body, footer, modal, state, state.model.slots[state.currentStep - 1]); + } else { + renderRecapStep(body, footer, modal, state); } - /* 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"]' - ); + const first = modal.querySelector('button:not([disabled]), [tabindex="0"]'); if (first) first.focus(); }); } -/* ------------------------------------------------------------------ */ -/* Step 1 — Burger + personalisation options */ -/* ------------------------------------------------------------------ */ - -function renderStep1(body, footer, modal, state) { +/* Etape 0 — Format Normal / Maxi (burger impose affiche) */ +function renderFormatStep(body, footer, modal, state) { + const { model } = state; + const burgerName = model.burger ? escHtml(model.burger.nom) : escHtml(state.menu.nom); body.innerHTML = ` -

Choisissez votre burger

-
    - ${state.burgers.map(b => ` -
  • - -
  • - `).join('')} -
- -
- Personnalisation - - -
+

Votre menu : ${burgerName}

+
+ + +
`; - - /* Burger card selection */ - body.querySelectorAll('#burger-grid .composer-card').forEach(btn => { + body.querySelectorAll('[data-size]').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; + state.size = btn.dataset.size; + body.querySelectorAll('[data-size]').forEach(b => { + const active = b.dataset.size === state.size; 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 - }); + renderFooter(footer, modal, state, { canAdvance: () => true }); } -/* ------------------------------------------------------------------ */ -/* Step 2 — Accompagnement + taille toggle */ -/* ------------------------------------------------------------------ */ - -function renderStep2(body, footer, modal, state) { +/* Etapes 1..N — un slot (drink/side/sauce) */ +function renderSlotStep(body, footer, modal, state, slot) { + const optional = !slot.isRequired; body.innerHTML = ` -

Choisissez votre accompagnement

-
    - ${state.accompagnements.map(a => ` +

    ${escHtml(slot.name)}${optional ? ' (optionnel)' : ''}

    +
      + ${optional ? `
    • - -
    • - `).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 => ` + ` : ''} + ${slot.options.map(o => `
    • - -
    • - `).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 => { + body.querySelectorAll('#slot-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; + const raw = btn.dataset.pid; + if (raw === '') delete state.selections[slot.id]; + else state.selections[slot.id] = parseInt(raw, 10); + body.querySelectorAll('#slot-grid .composer-card').forEach(b => { + const active = (b.dataset.pid === '' && state.selections[slot.id] == null) + || parseInt(b.dataset.pid, 10) === state.selections[slot.id]; b.classList.toggle('composer-card--selected', active); b.setAttribute('aria-pressed', active ? 'true' : 'false'); }); }); }); - renderFooter(footer, modal, state, { - canAdvance: () => state.sauce !== null + canAdvance: () => optional || state.selections[slot.id] != 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; +/* Etape finale — recap + ajout */ +function renderRecapStep(body, footer, modal, state) { + const item = buildMenuCartItem(state.menu, state.model, { + size: state.size, selections: state.selections, + }); + const total = computeMenuLineCents(item); + const c = item.composition; + const lines = []; + lines.push(`${escHtml(c.burger.libelle)}`); + if (c.accompagnement) lines.push(`${escHtml(c.accompagnement.libelle)}${c.accompagnement.taille === 'G' ? ' (Maxi)' : ''}`); + if (c.boisson) lines.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' (Maxi)' : ''}`); + if (c.sauce) lines.push(escHtml(c.sauce.libelle)); body.innerHTML = ` -

    Recapitulatif de votre menu

    +

    Recapitulatif (${state.size === 'M' ? 'Maxi' : 'Normal'})

      -
    • - - - ${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)} -
    • + ${lines.map(l => `
    • ${l}
    • `).join('')}
    - Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)} - ${supplement > 0 ? `Supplement grande(s) taille(s) : +${formatPrice(supplement)}` : ''} - Total : ${formatPrice(totalLine)} + Total : ${formatPrice(total)}
    `; - 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-cancel').addEventListener('click', () => cancelComposer(modal, state.returnCategory, modal._escHandler)); + footer.querySelector('#composer-prev').addEventListener('click', () => { state.currentStep--; renderStep(modal, state); }); footer.querySelector('#composer-add').addEventListener('click', () => { - addToCart(baseItem); + addToCart(item); refreshCartBadge(); closeComposer(modal); window.location.href = `products.html?category=${state.returnCategory}`; @@ -468,45 +366,22 @@ function renderStep5(body, footer, modal, state) { } /* ------------------------------------------------------------------ */ -/* Footer renderer (steps 1-4) */ +/* Footer de navigation (etapes non-recap) */ /* ------------------------------------------------------------------ */ -/** - * 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; - + const isFirst = state.currentStep === 0; footer.innerHTML = ` `; - - footer.querySelector('#composer-cancel').addEventListener('click', () => { - cancelComposer(modal, state.returnCategory, null); - }); - + footer.querySelector('#composer-cancel').addEventListener('click', () => cancelComposer(modal, state.returnCategory, modal._escHandler)); if (!isFirst) { - footer.querySelector('#composer-prev').addEventListener('click', () => { - state.currentStep--; - renderStep(modal, state); - }); + footer.querySelector('#composer-prev').addEventListener('click', () => { state.currentStep--; renderStep(modal, state); }); } - footer.querySelector('#composer-next').addEventListener('click', () => { if (!opts.canAdvance()) return; state.currentStep++; @@ -515,188 +390,31 @@ function renderFooter(footer, modal, state, opts) { } /* ------------------------------------------------------------------ */ -/* Taille toggle — shared between accompagnement and boisson steps */ +/* Focus trap + fermeture */ /* ------------------------------------------------------------------ */ -/** - * 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]')); - + const focusable = Array.from(modal.querySelectorAll('button: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(); - } - } + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); } -/* ------------------------------------------------------------------ */ -/* Close helpers */ -/* ------------------------------------------------------------------ */ - function closeComposer(modal) { + if (modal._escHandler) document.removeEventListener('keydown', modal._escHandler); + if (modal._bgSiblings) modal._bgSiblings.forEach(el => el.removeAttribute('aria-hidden')); modal.remove(); - document.body.style.overflow = ''; + document.body.style.overflow = modal._prevOverflow ?? ''; } function cancelComposer(modal, returnCategory, escHandler) { - if (escHandler) { - document.removeEventListener('keydown', 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/tests/js/composer-slots.test.js b/tests/js/composer-slots.test.js new file mode 100644 index 0000000..5f9df64 --- /dev/null +++ b/tests/js/composer-slots.test.js @@ -0,0 +1,127 @@ +/* + * Tests du composeur de menu slot-driven (P5 L2), node:test + jsdom. + * + * page-product-menu.js importe nav.js (qui touche le DOM au chargement) -> import + * dynamique apres pose des globals jsdom. Cible : fonctions PURES buildComposerSteps, + * buildMenuCartItem, selectionsComplete (logique slots -> etapes -> item panier). + */ +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable; + +before(async () => { + const dom = new JSDOM('', { url: 'https://kiosk.test/product.html' }); + global.window = dom.window; + global.document = dom.window.document; + global.localStorage = dom.window.localStorage; + ({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable } = + await import('../../src/public/borne/assets/js/page-product-menu.js')); +}); + +const detail = () => ({ + id: 1, + burger_product_id: 100, + price_normal_cents: 880, + price_maxi_cents: 1030, + slots: [ + { id: 16, name: 'Accompagnement', slot_type: 'side', is_required: true, display_order: 2, option_product_ids: [22, 23] }, + { id: 1, name: 'Boisson', slot_type: 'drink', is_required: true, display_order: 1, option_product_ids: [14, 15, 999] }, + { id: 31, name: 'Sauce', slot_type: 'sauce', is_required: false, display_order: 3, option_product_ids: [47] }, + ], +}); + +const byId = () => ({ + 100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit' }, + 22: { id: 22, nom: 'Frites', prix: 0, image: 'f.png', type: 'produit' }, + 23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit' }, + 14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit' }, + 15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit' }, + 47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit' }, +}); + +const menu = { id: 1, nom: 'Menu Le 280', image: 'b.png', type: 'menu' }; + +/* --- buildComposerSteps -------------------------------------------------- */ + +test('buildComposerSteps: burger impose resolu, slots tries par display_order', () => { + const m = buildComposerSteps(detail(), byId()); + assert.equal(m.burger.nom, 'Le 280'); + assert.equal(m.priceNormalCents, 880); + assert.equal(m.priceMaxiCents, 1030); + assert.deepEqual(m.slots.map(s => s.slotType), ['drink', 'side', 'sauce']); // par display_order 1,2,3 +}); + +test('buildComposerSteps: option_product_ids resolus en produits, ids inconnus filtres', () => { + const m = buildComposerSteps(detail(), byId()); + const drink = m.slots.find(s => s.slotType === 'drink'); + assert.deepEqual(drink.options.map(o => o.nom), ['Coca', 'Eau']); // 999 inconnu -> filtre + assert.equal(drink.isRequired, true); + assert.equal(m.slots.find(s => s.slotType === 'sauce').isRequired, false); +}); + +/* --- buildMenuCartItem --------------------------------------------------- */ + +test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, composition mappee', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } }); + assert.equal(item.type, 'menu'); + assert.equal(item.prix_cents, 880); + assert.equal(item.supplement_cents, 0); + assert.equal(item.composition.burger.libelle, 'Le 280'); + assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Frites', taille: 'N' }); + assert.deepEqual(item.composition.boisson, { id: 14, libelle: 'Coca', taille: 'N' }); + assert.deepEqual(item.composition.sauce, { id: 47, libelle: 'Ketchup' }); +}); + +test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drink', () => { + const m = buildComposerSteps(detail(), byId()); + 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.composition.accompagnement.taille, 'G'); + assert.equal(item.composition.boisson.taille, 'G'); +}); + +test('buildMenuCartItem: slot optionnel non choisi -> champ absent de composition', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22 } }); // pas de sauce + assert.equal(item.composition.sauce, undefined); + assert.ok(item.composition.accompagnement); + assert.ok(item.composition.boisson); +}); + +/* --- selectionsComplete -------------------------------------------------- */ + +test('selectionsComplete: vrai si tous les slots REQUIS sont choisis (sauce optionnelle ignoree)', () => { + const m = buildComposerSteps(detail(), byId()); + assert.equal(selectionsComplete(m, { 1: 14, 16: 22 }), true); // requis ok, sauce absente + assert.equal(selectionsComplete(m, { 1: 14 }), false); // accompagnement requis manquant + assert.equal(selectionsComplete(m, { 1: 14, 16: 999 }), false); // id hors options du slot +}); + +/* --- garde-fous (findings revue L2) -------------------------------------- */ + +test('buildComposerSteps: ignore les slot_type hors {drink,side,sauce} (anti-perte silencieuse)', () => { + const d = detail(); + d.slots.push({ id: 99, name: 'Dessert', slot_type: 'dessert', is_required: true, display_order: 4, option_product_ids: [22] }); + const m = buildComposerSteps(d, byId()); + assert.deepEqual(m.slots.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu +}); + +test('composerIsViable: vrai pour un modele complet', () => { + assert.equal(composerIsViable(buildComposerSteps(detail(), byId())), true); +}); + +test('composerIsViable: faux si un slot requis n a aucune option resolue', () => { + const d = detail(); + d.slots = [{ id: 1, name: 'Boisson', slot_type: 'drink', is_required: true, display_order: 1, option_product_ids: [999, 888] }]; + assert.equal(composerIsViable(buildComposerSteps(d, byId())), false); +}); + +test('composerIsViable: faux si le burger impose est introuvable', () => { + const d = detail(); + d.burger_product_id = 12345; + assert.equal(composerIsViable(buildComposerSteps(d, byId())), false); +}); diff --git a/tests/js/data.test.js b/tests/js/data.test.js index 1eb06f8..5b93094 100644 --- a/tests/js/data.test.js +++ b/tests/js/data.test.js @@ -165,3 +165,27 @@ test('findProduct renvoie null si l id est absent de la categorie ciblee', async const { findProduct } = await freshData(fixtures()); assert.equal(await findProduct(999, 'burgers'), null); }); + +test('loadProductsById indexe les PRODUITS par id et exclut les menus (anti-collision)', async () => { + // colliding : un produit id 4 ET un menu id 4. L'index ne doit contenir QUE le + // produit a la cle 4 (les option_product_ids des slots referencent des produits). + const colliding = { + '/api/categories': { data: [ + { id: 1, name: 'Menus', slug: 'menus', image_path: 'm.png', display_order: 1 }, + { id: 3, name: 'Burgers', slug: 'burgers', image_path: 'b.png', display_order: 3 }, + ] }, + '/api/products': { data: [ + { id: 4, category_id: 3, name: 'Big Mac', description: null, price_cents: 600, image_path: 'bigmac.png', display_order: 4 }, + { id: 14, category_id: 3, name: 'Coca', description: null, price_cents: 190, image_path: 'coca.png', display_order: 1 }, + ] }, + '/api/menus': { data: [ + { id: 4, category_id: 1, burger_product_id: 4, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'bigmac.png', display_order: 1 }, + ] }, + }; + const { loadProductsById } = await freshData(colliding); + const byId = await loadProductsById(); + assert.equal(byId[4].type, 'produit', 'id 4 doit etre le PRODUIT, pas le menu'); + assert.equal(byId[4].nom, 'Big Mac'); + assert.equal(byId[14].nom, 'Coca'); + assert.equal(Object.keys(byId).length, 2, 'que les 2 produits, le menu est exclu'); +});