feat(borne): modale options produit + grille en modales (P5 L3) (#66)
This commit is contained in:
parent
6e5a5f9334
commit
22a4bacc22
4 changed files with 267 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
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