feat(borne): composeur menu pilote par les slots /api/menus (P5 L2) (#65)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 31s
CI / static-tests (push) Successful in 58s
CI / js-tests (push) Successful in 33s

This commit is contained in:
Corentin JOGUET 2026-06-19 18:30:38 +02:00
parent c73afdf471
commit 6e5a5f9334
5 changed files with 472 additions and 556 deletions

View file

@ -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<Object<number, Object>>}
*/
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<Object|null>}
*/
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.

View file

@ -151,23 +151,31 @@ 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}`);
}
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 `
<ul class="cart-line__composition" aria-label="Composition du menu">
<li class="cart-line__comp-item">+ ${escHtml(c.burger.libelle)}${burgerOpts}</li>
<li class="cart-line__comp-item">+ ${escHtml(c.accompagnement.libelle)}${accompTailleLabel}</li>
<li class="cart-line__comp-item">+ ${escHtml(c.boisson.libelle)}${boissonTailleLabel}</li>
<li class="cart-line__comp-item">+ ${escHtml(c.sauce.libelle)}</li>
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Supplement ${nbGrandes} grande(s) : +${formatPrice(supplTotal)}</li>` : ''}
${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>
`;
}

View file

@ -1,139 +1,228 @@
/*
* 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<number,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<number, number>}} 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<number,number>} 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.hidden = true;
overlay.innerHTML = `
<div class="composer-container" role="document">
<div class="composer-container" role="dialog" aria-modal="true" aria-labelledby="composer-title">
<div class="composer-header">
<h2 class="composer-title" id="composer-title">${escHtml(menu.nom)}</h2>
<div class="composer-progress" aria-label="Progression">
<span class="composer-progress__text" id="composer-step-indicator" aria-live="polite">Etape 1 / ${TOTAL_STEPS}</span>
<span class="composer-progress__text" id="composer-step-indicator" aria-live="polite"></span>
<div class="composer-progress__bar">
<div class="composer-progress__fill" id="composer-progress-fill" style="width: 20%"></div>
<div class="composer-progress__fill" id="composer-progress-fill"></div>
</div>
</div>
</div>
<div class="composer-body" id="composer-body">
<!-- step content injected here -->
</div>
<div class="composer-footer" id="composer-footer">
<!-- navigation buttons injected here -->
</div>
<div class="composer-body" id="composer-body"></div>
<div class="composer-footer" id="composer-footer"></div>
</div>
`;
return overlay;
}
/* ------------------------------------------------------------------ */
/* Step renderer — decides which step to paint */
/* Rendu d'etape */
/* ------------------------------------------------------------------ */
function renderStep(modal, state) {
@ -142,325 +231,134 @@ function renderStep(modal, state) {
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 = `
<p class="composer-step__subtitle">Choisissez votre burger</p>
<ul class="composer-grid" role="list" id="burger-grid">
${state.burgers.map(b => `
<li>
<button
class="composer-card ${state.burger && state.burger.id === b.id ? 'composer-card--selected' : ''}"
type="button"
data-id="${b.id}"
aria-pressed="${state.burger && state.burger.id === b.id ? 'true' : 'false'}"
aria-label="${escHtml(b.nom)}, ${formatPrice(b.prix)}"
>
<img
class="composer-card__image"
src="${escHtml(b.image)}"
alt="${escHtml(b.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(b.nom)}</span>
<span class="composer-card__price">${formatPrice(b.prix)}</span>
<p class="composer-step__subtitle">Votre menu : ${burgerName}</p>
<div class="composer-taille" role="group" aria-label="Format du menu">
<button class="composer-card ${state.size === 'N' ? 'composer-card--selected' : ''}"
type="button" data-size="N" aria-pressed="${state.size === 'N'}">
<span class="composer-card__name">Normal</span>
<span class="composer-card__price">${formatPrice(model.priceNormalCents)}</span>
</button>
</li>
`).join('')}
</ul>
<fieldset class="composer-options" id="burger-options">
<legend class="composer-options__legend">Personnalisation</legend>
<label class="composer-option-label">
<input type="checkbox" name="burger-opt" value="sans-oignon"
${state.burgerOptions.includes('sans-oignon') ? 'checked' : ''}>
Sans oignon
</label>
<label class="composer-option-label">
<input type="checkbox" name="burger-opt" value="avec-fromage"
${state.burgerOptions.includes('avec-fromage') ? 'checked' : ''}>
Avec fromage
</label>
</fieldset>
<button class="composer-card ${state.size === 'M' ? 'composer-card--selected' : ''}"
type="button" data-size="M" aria-pressed="${state.size === 'M'}">
<span class="composer-card__name">Maxi</span>
<span class="composer-card__price">${formatPrice(model.priceMaxiCents)}</span>
</button>
</div>
`;
/* 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 = `
<p class="composer-step__subtitle">Choisissez votre accompagnement</p>
<ul class="composer-grid" role="list" id="accomp-grid">
${state.accompagnements.map(a => `
<p class="composer-step__subtitle">${escHtml(slot.name)}${optional ? ' (optionnel)' : ''}</p>
<ul class="composer-grid" role="list" id="slot-grid">
${optional ? `
<li>
<button
class="composer-card ${state.accompagnement && state.accompagnement.id === a.id ? 'composer-card--selected' : ''}"
type="button"
data-id="${a.id}"
aria-pressed="${state.accompagnement && state.accompagnement.id === a.id ? 'true' : 'false'}"
aria-label="${escHtml(a.nom)}"
>
<img
class="composer-card__image"
src="${escHtml(a.image)}"
alt="${escHtml(a.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(a.nom)}</span>
<button class="composer-card ${state.selections[slot.id] == null ? 'composer-card--selected' : ''}"
type="button" data-pid="" aria-pressed="${state.selections[slot.id] == null}">
<span class="composer-card__name">Sans</span>
</button>
</li>
`).join('')}
</ul>
${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 = `
<p class="composer-step__subtitle">Choisissez votre boisson</p>
<ul class="composer-grid" role="list" id="boisson-grid">
${state.boissons.map(b => `
</li>` : ''}
${slot.options.map(o => `
<li>
<button
class="composer-card ${state.boisson && state.boisson.id === b.id ? 'composer-card--selected' : ''}"
type="button"
data-id="${b.id}"
aria-pressed="${state.boisson && state.boisson.id === b.id ? 'true' : 'false'}"
aria-label="${escHtml(b.nom)}"
>
<img
class="composer-card__image"
src="${escHtml(b.image)}"
alt="${escHtml(b.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(b.nom)}</span>
</button>
</li>
`).join('')}
</ul>
${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 = `
<p class="composer-step__subtitle">Choisissez votre sauce</p>
<ul class="composer-grid" role="list" id="sauce-grid">
${state.sauces.map(s => `
<li>
<button
class="composer-card ${state.sauce && state.sauce.id === s.id ? 'composer-card--selected' : ''}"
type="button"
data-id="${s.id}"
aria-pressed="${state.sauce && state.sauce.id === s.id ? 'true' : 'false'}"
aria-label="${escHtml(s.nom)}"
>
<img
class="composer-card__image"
src="${escHtml(s.image)}"
alt="${escHtml(s.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(s.nom)}</span>
<button class="composer-card ${state.selections[slot.id] === o.id ? 'composer-card--selected' : ''}"
type="button" data-pid="${o.id}"
aria-pressed="${state.selections[slot.id] === o.id}"
aria-label="${escHtml(o.nom)}">
<img class="composer-card__image" src="${escHtml(o.image)}" alt="${escHtml(o.nom)}"
onerror="this.src='assets/images/ui/logo.png';">
<span class="composer-card__name">${escHtml(o.nom)}</span>
</button>
</li>
`).join('')}
</ul>
`;
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 = `
<p class="composer-step__subtitle">Recapitulatif de votre menu</p>
<p class="composer-step__subtitle">Recapitulatif (${state.size === 'M' ? 'Maxi' : 'Normal'})</p>
<ul class="composer-recap" aria-label="Composition du menu">
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.burger.nom)}
${optionsText ? `<span class="composer-recap__opts">(${escHtml(optionsText)})</span>` : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.accompagnement.nom)}
<span class="composer-recap__taille">${state.accompTaille === 'G' ? 'grande' : 'normale'}</span>
${state.accompTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.boisson.nom)}
<span class="composer-recap__taille">${state.boissonTaille === 'G' ? 'grande' : 'normale'}</span>
${state.boissonTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">${escHtml(state.sauce.nom)}</span>
</li>
${lines.map(l => `<li class="composer-recap__line"><span class="composer-recap__icon" aria-hidden="true">&#9632;</span><span class="composer-recap__label">${l}</span></li>`).join('')}
</ul>
<div class="composer-recap__totals">
<span class="composer-recap__base">Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)}</span>
${supplement > 0 ? `<span class="composer-recap__suppl-total">Supplement grande(s) taille(s) : +${formatPrice(supplement)}</span>` : ''}
<span class="composer-recap__total-line">Total : <strong>${formatPrice(totalLine)}</strong></span>
<span class="composer-recap__total-line">Total : <strong>${formatPrice(total)}</strong></span>
</div>
`;
footer.innerHTML = `
<div class="composer-footer__row">
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">
Annuler
</button>
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
Precedent
</button>
<button class="btn btn--primary composer-footer__add" type="button" id="composer-add">
Ajouter au panier
</button>
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">Annuler</button>
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">Precedent</button>
<button class="btn btn--primary composer-footer__add" type="button" id="composer-add">Ajouter a ma commande</button>
</div>
`;
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 = `
<div class="composer-footer__row">
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">
Annuler
</button>
${!isFirst ? `
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
Precedent
</button>` : ''}
<button class="btn btn--primary composer-footer__next" type="button" id="composer-next">
Suivant
</button>
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">Annuler</button>
${!isFirst ? `<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">Precedent</button>` : ''}
<button class="btn btn--primary composer-footer__next" type="button" id="composer-next">Suivant</button>
</div>
`;
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 `
<div class="composer-taille" role="group" aria-label="Taille">
<button
class="composer-taille__btn ${currentTaille === 'N' ? 'composer-taille__btn--active' : ''}"
type="button"
data-taille="N"
id="${prefix}-taille-n"
aria-pressed="${currentTaille === 'N' ? 'true' : 'false'}"
>
Normale
</button>
<button
class="composer-taille__btn ${currentTaille === 'G' ? 'composer-taille__btn--active' : ''}"
type="button"
data-taille="G"
id="${prefix}-taille-g"
aria-pressed="${currentTaille === 'G' ? 'true' : 'false'}"
>
Grande <span class="composer-taille__price-hint">+0,50 EUR</span>
</button>
</div>
`;
}
/**
* 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();
}
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View file

@ -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('<!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;
({ 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);
});

View file

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