From 0e51d6151ded49392a52f1ac393a2faf0015411c Mon Sep 17 00:00:00 2001 From: Imugiii Date: Fri, 19 Jun 2026 16:38:21 +0000 Subject: [PATCH] feat(borne): modale options produit + grille en modales (P5 L3) Cliquer un produit ouvre desormais une modale au-dessus de la grille (paradigme maquette : ecran de commande unique + modales) au lieu de naviguer vers product.html. Menu -> composeur (L2) ; produit simple -> modale quantite+ajout. A l'ajout, le panneau de commande persistant (L1) est re-rendu : la commande se met a jour sans navigation. product.html reste un repli (lien direct). - product-options.js : modale (image, prix unitaire, stepper quantite, total) ; productCartItem pur ; a11y (role=dialog, focus-trap, ESC, fond aria-hidden, overflow restaure, aria-live sur quantite ET total). - page-products.js : clic carte -> modale (preventDefault sur le ) ; bouton 'i' allergenes garde son stopPropagation. - style.css : .product-options + .qty-control (reutilise .qty-btn/.qty-value). - tests : product-options.test.js (productCartItem + rendu/stepper/ajout). 56/56 verts. Taille 30/50Cl de la maquette differee : absente du modele produit (un seul price_cents) -> necessitera des variantes produit cote API. Revue adversariale : 1 finding MEDIUM (aria-live du total) corrige. --- src/public/borne/assets/css/style.css | 29 ++++ src/public/borne/assets/js/page-products.js | 11 ++ src/public/borne/assets/js/product-options.js | 137 ++++++++++++++++++ tests/js/product-options.test.js | 90 ++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 src/public/borne/assets/js/product-options.js create mode 100644 tests/js/product-options.test.js diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index 05c2ad3..b4ce655 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -2081,3 +2081,32 @@ button { outline: 3px solid var(--color-brand-yellow-dk); outline-offset: 2px; } + +/* === Modale d'options produit (L3 - quantite + ajout, au-dessus de la grille) === */ +.product-options { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + text-align: center; +} + +.product-options__image { + width: 160px; + height: 160px; + object-fit: contain; +} + +.product-options__unit { + color: var(--color-text-secondary); +} + +.product-options__total { + font-size: var(--font-size-md); +} + +.qty-control { + display: flex; + align-items: center; + gap: var(--space-4); +} diff --git a/src/public/borne/assets/js/page-products.js b/src/public/borne/assets/js/page-products.js index 9d81e56..8c2f5a3 100644 --- a/src/public/borne/assets/js/page-products.js +++ b/src/public/borne/assets/js/page-products.js @@ -9,6 +9,8 @@ import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js'; import { formatPrice, escHtml } from './state.js'; import { buildAllergenInfoButton, openAllergenModal } from './allergens.js'; +import { openMenuComposer } from './page-product-menu.js'; +import { openProductOptions } from './product-options.js'; const params = new URLSearchParams(window.location.search); const categoryId = parseInt(params.get('category'), 10) || 1; @@ -87,6 +89,15 @@ async function renderProducts() { const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens)); card.querySelector('.product-card__image-wrap').appendChild(infoBtn); + // Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu + // de naviguer vers product.html : menu -> composeur (L2), produit -> options + // (L3). Le reste un repli (lien direct / sans JS). + card.addEventListener('click', (e) => { + e.preventDefault(); + if (product.type === 'menu') openMenuComposer(product, categorySlug); + else openProductOptions(product, categorySlug); + }); + grid.appendChild(card); }); diff --git a/src/public/borne/assets/js/product-options.js b/src/public/borne/assets/js/product-options.js new file mode 100644 index 0000000..09cfb5c --- /dev/null +++ b/src/public/borne/assets/js/product-options.js @@ -0,0 +1,137 @@ +/* + * product-options.js — Modale d'options produit (P5 L3). + * + * Remplace la navigation vers product.html : cliquer un produit simple ouvre une + * modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille, + * facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant + * (L1) est re-rendu pour refleter immediatement la commande -> pas de navigation. + * + * Note : la taille (30/50 Cl de la maquette) n'est PAS dans le modele produit actuel + * (un seul price_cents par produit) -> differee (necessite des variantes produit cote + * API). Ce lot couvre quantite + ajout. + * + * A11y : role=dialog, aria-modal, focus-trap, ESC, fond aria-hidden. + */ + +import { addToCart, formatPrice, escHtml } from './state.js'; +import { refreshCartBadge } from './nav.js'; +import { renderOrderPanel } from './order-panel.js'; + +const QTY_MAX = 99; + +/** + * Construit l'item panier d'un produit simple pour une quantite donnee. Pur. + * Quantite bornee a [1, QTY_MAX]. categorie = celle du produit, sinon le slug courant. + * @param {Object} product — forme borne {id, nom, prix, image, categorie?} + * @param {string} categorySlug + * @param {number} qty + * @returns {Object} item panier + */ +export function productCartItem(product, categorySlug, qty) { + const quantite = Math.min(QTY_MAX, Math.max(1, Math.floor(qty) || 1)); + return { + id: product.id, + type: 'produit', + categorie: product.categorie ?? categorySlug, + libelle: product.nom, + prix_cents: product.prix, + quantite, + image: product.image, + }; +} + +/** Re-rend le panneau de commande persistant (s'il est present sur la page). */ +function refreshOrderPanel() { + document.querySelectorAll('[data-order-panel]').forEach(renderOrderPanel); +} + +/** + * Ouvre la modale d'options pour un produit simple. + * @param {Object} product — forme borne {id, nom, prix, image, categorie?} + * @param {string} categorySlug — slug de la categorie courante (categorie de repli) + */ +export function openProductOptions(product, categorySlug) { + let qty = 1; + + const overlay = document.createElement('div'); + overlay.className = 'composer-overlay'; + overlay.hidden = true; + overlay.innerHTML = ` + + `; + + const prevOverflow = document.body.style.overflow; + document.body.appendChild(overlay); + overlay.removeAttribute('hidden'); + document.body.style.overflow = 'hidden'; + const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay); + bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true')); + + const qtyEl = overlay.querySelector('#po-qty'); + const totalEl = overlay.querySelector('#po-total'); + const sync = () => { + qtyEl.textContent = String(qty); + totalEl.textContent = formatPrice(product.prix * qty); + }; + overlay.querySelector('.qty-btn--minus').addEventListener('click', () => { qty = Math.max(1, qty - 1); sync(); }); + overlay.querySelector('.qty-btn--plus').addEventListener('click', () => { qty = Math.min(QTY_MAX, qty + 1); sync(); }); + + const close = () => { + document.removeEventListener('keydown', escHandler); + bgSiblings.forEach(el => el.removeAttribute('aria-hidden')); + overlay.remove(); + document.body.style.overflow = prevOverflow; + }; + const escHandler = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', escHandler); + + overlay.querySelector('#po-cancel').addEventListener('click', close); + overlay.querySelector('#po-add').addEventListener('click', () => { + addToCart(productCartItem(product, categorySlug, qty)); + refreshCartBadge(); + refreshOrderPanel(); + close(); + }); + + trapFocus(overlay); + requestAnimationFrame(() => { + const first = overlay.querySelector('button:not([disabled])'); + if (first) first.focus(); + }); +} + +/** Piege Tab/Shift+Tab a l'interieur de la modale. */ +function trapFocus(overlay) { + overlay.addEventListener('keydown', (e) => { + if (e.key !== 'Tab') return; + const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])')); + if (!focusable.length) return; + const first = focusable[0]; + 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(); } + }); +} diff --git a/tests/js/product-options.test.js b/tests/js/product-options.test.js new file mode 100644 index 0000000..5f8d7e2 --- /dev/null +++ b/tests/js/product-options.test.js @@ -0,0 +1,90 @@ +/* + * Tests de la modale d'options produit (P5 L3), node:test + jsdom. + * + * product-options.js importe order-panel.js + nav.js (DOM au chargement) -> import + * dynamique apres globals jsdom. Cible : productCartItem (PUR) + openProductOptions + * (rendu jsdom : stepper quantite, ajout au panier, fermeture). + */ +import { test, before, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +let productCartItem, openProductOptions; + +before(async () => { + const dom = new JSDOM('', { url: 'https://kiosk.test/products.html' }); + global.window = dom.window; + global.document = dom.window.document; + global.localStorage = dom.window.localStorage; + global.requestAnimationFrame = (cb) => cb(); + ({ productCartItem, openProductOptions } = + await import('../../src/public/borne/assets/js/product-options.js')); +}); + +beforeEach(() => { + global.localStorage.clear(); + document.body.innerHTML = ''; +}); + +const product = { id: 14, nom: 'Coca', prix: 190, image: 'c.png', categorie: 'boissons' }; + +/* --- productCartItem (pur) ----------------------------------------------- */ + +test('productCartItem: forme item, quantite et categorie du produit', () => { + const it = productCartItem(product, 'boissons', 3); + assert.deepEqual(it, { + id: 14, type: 'produit', categorie: 'boissons', libelle: 'Coca', + prix_cents: 190, quantite: 3, image: 'c.png', + }); +}); + +test('productCartItem: quantite bornee a [1,99], categorie de repli = slug', () => { + assert.equal(productCartItem({ id: 1, nom: 'X', prix: 100, image: 'x.png' }, 'frites', 0).quantite, 1); + assert.equal(productCartItem(product, 'boissons', 9999).quantite, 99); + assert.equal(productCartItem({ id: 1, nom: 'X', prix: 100, image: 'x.png' }, 'frites', 2).categorie, 'frites'); +}); + +/* --- openProductOptions (jsdom) ------------------------------------------ */ + +test('openProductOptions: rend la modale (dialog) avec total = prix unitaire', () => { + openProductOptions(product, 'boissons'); + const dialog = document.querySelector('.composer-overlay [role="dialog"]'); + assert.ok(dialog); + assert.match(document.querySelector('#po-total').textContent, /1,90/); + assert.equal(document.querySelector('#po-qty').textContent, '1'); +}); + +test('openProductOptions: le stepper met a jour quantite et total', () => { + openProductOptions(product, 'boissons'); + document.querySelector('.qty-btn--plus').click(); + document.querySelector('.qty-btn--plus').click(); + assert.equal(document.querySelector('#po-qty').textContent, '3'); + assert.match(document.querySelector('#po-total').textContent, /5,70/); // 1,90 x 3 + document.querySelector('.qty-btn--minus').click(); + assert.equal(document.querySelector('#po-qty').textContent, '2'); +}); + +test('openProductOptions: quantite plancher a 1', () => { + openProductOptions(product, 'boissons'); + const minus = document.querySelector('.qty-btn--minus'); + minus.click(); minus.click(); minus.click(); + assert.equal(document.querySelector('#po-qty').textContent, '1'); +}); + +test('openProductOptions: Ajouter met l item (avec quantite) au panier et ferme la modale', () => { + openProductOptions(product, 'boissons'); + document.querySelector('.qty-btn--plus').click(); // qty 2 + document.querySelector('#po-add').click(); + const cart = JSON.parse(localStorage.getItem('wakdo_cart')); + assert.equal(cart.length, 1); + assert.equal(cart[0].id, 14); + assert.equal(cart[0].quantite, 2); + assert.equal(document.querySelector('.composer-overlay'), null); // modale fermee +}); + +test('openProductOptions: Annuler ferme sans rien ajouter', () => { + openProductOptions(product, 'boissons'); + document.querySelector('#po-cancel').click(); + assert.equal(document.querySelector('.composer-overlay'), null); + assert.equal(localStorage.getItem('wakdo_cart'), null); +}); -- 2.45.3