feat(borne): modale options produit + grille en modales (P5 L3) (#66)
All checks were successful
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 54s
CI / js-tests (push) Successful in 29s

This commit is contained in:
Corentin JOGUET 2026-06-19 18:41:57 +02:00
parent 6e5a5f9334
commit 22a4bacc22
4 changed files with 267 additions and 0 deletions

View file

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

View file

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

View file

@ -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 = `
<div class="composer-container" role="dialog" aria-modal="true" aria-labelledby="po-title">
<div class="composer-header">
<h2 class="composer-title" id="po-title">${escHtml(product.nom)}</h2>
</div>
<div class="composer-body">
<div class="product-options">
<img class="product-options__image" src="${escHtml(product.image)}"
alt="${escHtml(product.nom)}" onerror="this.src='assets/images/ui/logo.png';">
<p class="product-options__unit">${formatPrice(product.prix)} / unite</p>
<div class="qty-control" role="group" aria-label="Quantite">
<button class="qty-btn qty-btn--minus" type="button" aria-label="Diminuer la quantite">-</button>
<span class="qty-value" id="po-qty" aria-live="polite">1</span>
<button class="qty-btn qty-btn--plus" type="button" aria-label="Augmenter la quantite">+</button>
</div>
<p class="product-options__total" aria-live="polite" aria-atomic="true">Total : <strong id="po-total">${formatPrice(product.prix)}</strong></p>
</div>
</div>
<div class="composer-footer">
<div class="composer-footer__row">
<button class="btn btn--secondary" type="button" id="po-cancel">Annuler</button>
<button class="btn btn--primary" type="button" id="po-add">Ajouter a ma commande</button>
</div>
</div>
</div>
`;
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(); }
});
}

View file

@ -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('<!DOCTYPE html><html><body></body></html>', { 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);
});