feat(borne): modale options produit + grille en modales (P5 L3)
All checks were successful
CI / static-tests (pull_request) Successful in 51s
CI / secret-scan (push) Successful in 12s
CI / js-tests (push) Successful in 29s
CI / secret-scan (pull_request) Successful in 12s
CI / php-lint (pull_request) Successful in 24s
CI / js-tests (pull_request) Successful in 31s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 56s
All checks were successful
CI / static-tests (pull_request) Successful in 51s
CI / secret-scan (push) Successful in 12s
CI / js-tests (push) Successful in 29s
CI / secret-scan (pull_request) Successful in 12s
CI / php-lint (pull_request) Successful in 24s
CI / js-tests (pull_request) Successful in 31s
CI / php-lint (push) Successful in 28s
CI / static-tests (push) Successful in 56s
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 <a>) ; 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.
This commit is contained in:
parent
6e5a5f9334
commit
0e51d6151d
4 changed files with 267 additions and 0 deletions
|
|
@ -2081,3 +2081,32 @@ button {
|
||||||
outline: 3px solid var(--color-brand-yellow-dk);
|
outline: 3px solid var(--color-brand-yellow-dk);
|
||||||
outline-offset: 2px;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
|
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
|
||||||
import { formatPrice, escHtml } from './state.js';
|
import { formatPrice, escHtml } from './state.js';
|
||||||
import { buildAllergenInfoButton, openAllergenModal } from './allergens.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 params = new URLSearchParams(window.location.search);
|
||||||
const categoryId = parseInt(params.get('category'), 10) || 1;
|
const categoryId = parseInt(params.get('category'), 10) || 1;
|
||||||
|
|
@ -87,6 +89,15 @@ async function renderProducts() {
|
||||||
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
|
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
|
||||||
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
|
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);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
137
src/public/borne/assets/js/product-options.js
Normal file
137
src/public/borne/assets/js/product-options.js
Normal 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(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
90
tests/js/product-options.test.js
Normal file
90
tests/js/product-options.test.js
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue