feat(borne): composeur menu pilote par les slots /api/menus (P5 L2) #65
5 changed files with 472 additions and 556 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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.className = 'composer-overlay';
|
||||
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) {
|
||||
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 = `
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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">■</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">■</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">■</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">■</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">■</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();
|
||||
}
|
||||
}
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
|
|
|||
127
tests/js/composer-slots.test.js
Normal file
127
tests/js/composer-slots.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue