feat(borne): panneau commande persistant + bandeau categories (P5 L1)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 50s
CI / secret-scan (push) Successful in 9s
CI / js-tests (pull_request) Successful in 29s
CI / php-lint (push) Successful in 20s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 28s
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 50s
CI / secret-scan (push) Successful in 9s
CI / js-tests (pull_request) Successful in 29s
CI / php-lint (push) Successful in 20s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 28s
Realigne les ecrans de commande sur la maquette (docs/design/maquette-vs-build.md) : mono-ecran hybride avec recap persistant a droite + bandeau categories en haut. - order-panel.js : panneau commande persistant (vue-modele pure buildPanelModel + rendu) sur products.html/product.html ; lignes du panier localStorage avec options de menu, total ttc, Abandon (retour accueil) / Payer ; retrait par ligne. aria-live. - category-strip.js : bandeau categories horizontal (fleches), categorie active resolue par id (donnees API live), surlignee charte ; navigation par categorie. - charte : layout 2 colonnes, carte panneau + cartes bandeau (tokens existants), indicateur actif renforce (bordure foncee + fond teinte, a11y 1.4.11), focus-visible. - tests : tests/js/order-panel.test.js (11) + category-strip.test.js (7), node:test + jsdom (fonctions pures + rendu + anti-XSS RG-T15). 38/38 verts. Revue adversariale (workflow) : 8 findings, must-fix integres (actif par id, contraste, focus, aria-live, id echappe, logo). Differes hors lot : memoisation des loaders data.js, strip sur product.html (-> L3 modale).
This commit is contained in:
parent
f2fdaea89a
commit
0bb0048c64
7 changed files with 804 additions and 0 deletions
|
|
@ -1809,3 +1809,275 @@ button {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Panneau de commande persistant (L1 - maquette : recap a droite) =======
|
||||||
|
Layout deux colonnes sur les ecrans de commande : contenu a gauche, panneau
|
||||||
|
a droite, sticky pleine hauteur. Le panneau = entete (titre + mode), corps
|
||||||
|
scrollable (lignes du panier), pied (total + Abandon/Payer). Reutilise les
|
||||||
|
tokens de :root pour rester aligne sur la charte. */
|
||||||
|
.order-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: 0 var(--space-5) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-layout > main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; /* laisse la grille produits se retrecir au lieu de deborder */
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel {
|
||||||
|
flex: 0 0 360px;
|
||||||
|
width: 360px;
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-4);
|
||||||
|
max-height: calc(100vh - var(--space-6));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__logo {
|
||||||
|
height: 22px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__title {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__mode {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6) var(--space-3);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__lines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__line {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-3) var(--space-6) var(--space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__line:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__line-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__line-price {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__options {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__options li::before {
|
||||||
|
content: "+ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__remove {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-3);
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__foot {
|
||||||
|
border-top: 1px solid var(--color-border-default);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__total {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__total-value {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__abandon,
|
||||||
|
.order-panel__pay {
|
||||||
|
flex: 1 1 0;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__abandon {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__pay {
|
||||||
|
background: var(--color-brand-yellow);
|
||||||
|
border: 1px solid var(--color-brand-yellow);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel__pay[aria-disabled="true"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ecran etroit : le panneau passe sous le contenu (le kiosk reste large en
|
||||||
|
pratique ; repli de surete pour les petits viewports). */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.order-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-panel {
|
||||||
|
width: auto;
|
||||||
|
flex-basis: auto;
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Bandeau categories (L1 - maquette : strip horizontal en haut) =========
|
||||||
|
Cartes categorie defilantes avec fleches rouges ; la categorie courante porte
|
||||||
|
une bordure jaune (charte maquette). Sticky en haut du contenu de commande. */
|
||||||
|
.category-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__scroller {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
scrollbar-width: none; /* Firefox : masque la scrollbar, on navigue aux fleches */
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__scroller::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 110px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__item.is-active {
|
||||||
|
border-color: var(--color-brand-yellow-dk);
|
||||||
|
border-width: 3px;
|
||||||
|
background: #FFF8E6; /* 2e cue (pas que la couleur de bordure) -- a11y 1.4.11 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__img {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__label {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-strip__arrow {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-brand-red);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
line-height: 1;
|
||||||
|
padding: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus clavier visible sur les controles L1 (panneau + bandeau). Outline jaune
|
||||||
|
fonce decale pour rester percevable par-dessus la charte. */
|
||||||
|
.order-panel__pay:focus-visible,
|
||||||
|
.order-panel__abandon:focus-visible,
|
||||||
|
.order-panel__remove:focus-visible,
|
||||||
|
.category-strip__item:focus-visible,
|
||||||
|
.category-strip__arrow:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand-yellow-dk);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
|
||||||
104
src/public/borne/assets/js/category-strip.js
Normal file
104
src/public/borne/assets/js/category-strip.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/*
|
||||||
|
* category-strip.js — Bandeau categories horizontal (maquette : strip en haut de
|
||||||
|
* l'ecran de commande, avec fleches ◀ ▶ et la categorie courante surlignee).
|
||||||
|
*
|
||||||
|
* Permet de changer de categorie sans repasser par categories.html. Les categories
|
||||||
|
* viennent de /api/categories (via loadCategories de data.js : seules les categories
|
||||||
|
* actives/commandables). La logique est separee en : buildStripModel (PUR, testable)
|
||||||
|
* + renderStripInto (DOM sans fetch, testable jsdom) + renderCategoryStrip (lit l'URL,
|
||||||
|
* fetch, monte) pour l'auto-montage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadCategories } from './data.js';
|
||||||
|
import { escHtml } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue-modele PUR : marque la categorie active. Aucune dependance DOM/fetch.
|
||||||
|
* L'actif est resolu par ID (donnees live de l'API), pas via une table id->slug
|
||||||
|
* codee en dur, pour rester aligne sur le catalogue reel.
|
||||||
|
* @param {Array<{id:number,title:string,slug:string,image:string}>} categories
|
||||||
|
* @param {number} activeId
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function buildStripModel(categories, activeId) {
|
||||||
|
return categories.map(c => ({
|
||||||
|
id: Number(c.id),
|
||||||
|
slug: c.slug,
|
||||||
|
title: c.title,
|
||||||
|
image: c.image,
|
||||||
|
active: Number(c.id) === activeId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalise la 1re lettre (titre de categorie affiche).
|
||||||
|
* @param {string} s
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function cap(s) {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend le bandeau dans le conteneur a partir d'un modele deja construit. Pas de
|
||||||
|
* fetch : c'est la cible des tests jsdom. Chaque carte navigue vers la categorie
|
||||||
|
* (en preservant le mode). Les fleches font defiler le scroller horizontalement.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {Array} model — sortie de buildStripModel
|
||||||
|
* @param {string|null} modeParam — mode de consommation a propager dans l'URL
|
||||||
|
*/
|
||||||
|
export function renderStripInto(container, model, modeParam) {
|
||||||
|
if (!container) return;
|
||||||
|
const modeQS = modeParam ? `&mode=${encodeURIComponent(modeParam)}` : '';
|
||||||
|
|
||||||
|
const cards = model.map(c => `
|
||||||
|
<a
|
||||||
|
class="category-strip__item${c.active ? ' is-active' : ''}"
|
||||||
|
href="products.html?category=${c.id}${modeQS}"
|
||||||
|
aria-label="${escHtml(cap(c.title))}"
|
||||||
|
${c.active ? 'aria-current="true"' : ''}
|
||||||
|
>
|
||||||
|
<img class="category-strip__img" src="${escHtml(c.image)}" alt="" aria-hidden="true"
|
||||||
|
onerror="this.style.visibility='hidden';">
|
||||||
|
<span class="category-strip__label">${escHtml(cap(c.title))}</span>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<button class="category-strip__arrow category-strip__arrow--prev" type="button" aria-label="Categories precedentes">◀</button>
|
||||||
|
<div class="category-strip__scroller">${cards}</div>
|
||||||
|
<button class="category-strip__arrow category-strip__arrow--next" type="button" aria-label="Categories suivantes">▶</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scroller = container.querySelector('.category-strip__scroller');
|
||||||
|
const step = 320;
|
||||||
|
const prev = container.querySelector('.category-strip__arrow--prev');
|
||||||
|
const next = container.querySelector('.category-strip__arrow--next');
|
||||||
|
// scrollBy/scrollIntoView absents de jsdom -> gardes pour ne pas jeter en test.
|
||||||
|
if (prev) prev.addEventListener('click', () => scroller.scrollBy?.({ left: -step, behavior: 'smooth' }));
|
||||||
|
if (next) next.addEventListener('click', () => scroller.scrollBy?.({ left: step, behavior: 'smooth' }));
|
||||||
|
const active = scroller.querySelector('.is-active');
|
||||||
|
active?.scrollIntoView?.({ inline: 'center', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monte le bandeau : lit ?category=&mode= dans l'URL, charge les categories,
|
||||||
|
* construit le modele et rend. Tolerant a un echec de chargement (ne casse pas la page).
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export async function renderCategoryStrip(container) {
|
||||||
|
if (!container) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const activeId = parseInt(params.get('category'), 10) || 1;
|
||||||
|
const modeParam = params.get('mode');
|
||||||
|
try {
|
||||||
|
const categories = await loadCategories();
|
||||||
|
renderStripInto(container, buildStripModel(categories, activeId), modeParam);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('renderCategoryStrip error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('[data-category-strip]').forEach(renderCategoryStrip);
|
||||||
|
});
|
||||||
193
src/public/borne/assets/js/order-panel.js
Normal file
193
src/public/borne/assets/js/order-panel.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/*
|
||||||
|
* order-panel.js — Panneau de commande persistant (maquette : recap a droite de
|
||||||
|
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
|
||||||
|
* que le panier reste visible en permanence, comme sur la maquette borne.
|
||||||
|
*
|
||||||
|
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
|
||||||
|
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
|
||||||
|
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
|
||||||
|
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
|
||||||
|
* compositionLabels) pour etre testable sans DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCart,
|
||||||
|
removeFromCart,
|
||||||
|
computeMenuLineCents,
|
||||||
|
clearCart,
|
||||||
|
formatPrice,
|
||||||
|
escHtml,
|
||||||
|
getMode,
|
||||||
|
} from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
||||||
|
* produit simple : prix * quantite). Pur.
|
||||||
|
* @param {Object} item
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function lineCents(item) {
|
||||||
|
return item.type === 'menu'
|
||||||
|
? computeMenuLineCents(item)
|
||||||
|
: item.prix_cents * item.quantite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
|
||||||
|
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
|
||||||
|
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
|
||||||
|
* @param {Object|undefined} c — objet composition de l'item menu
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function compositionLabels(c) {
|
||||||
|
if (!c) return [];
|
||||||
|
const out = [];
|
||||||
|
if (c.burger) {
|
||||||
|
const opts = c.burger.options && c.burger.options.length
|
||||||
|
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
|
||||||
|
: '';
|
||||||
|
out.push(`${c.burger.libelle}${opts}`);
|
||||||
|
}
|
||||||
|
if (c.accompagnement) {
|
||||||
|
out.push(`${c.accompagnement.libelle}${c.accompagnement.taille === 'G' ? ' grande' : ''}`);
|
||||||
|
}
|
||||||
|
if (c.boisson) {
|
||||||
|
out.push(`${c.boisson.libelle}${c.boisson.taille === 'G' ? ' grande' : ''}`);
|
||||||
|
}
|
||||||
|
if (c.sauce) {
|
||||||
|
out.push(c.sauce.libelle);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue-modele PUR du panneau a partir d'un panier. Aucune dependance DOM/localStorage :
|
||||||
|
* c'est la cible des tests unitaires.
|
||||||
|
* @param {Array} cart
|
||||||
|
* @returns {{lines: Array, totalCents: number, count: number, empty: boolean}}
|
||||||
|
*/
|
||||||
|
export function buildPanelModel(cart) {
|
||||||
|
const lines = cart.map((item, index) => ({
|
||||||
|
index,
|
||||||
|
libelle: item.libelle,
|
||||||
|
quantite: item.quantite,
|
||||||
|
lineCents: lineCents(item),
|
||||||
|
options: item.type === 'menu' ? compositionLabels(item.composition) : [],
|
||||||
|
}));
|
||||||
|
const totalCents = cart.reduce((sum, item) => sum + lineCents(item), 0);
|
||||||
|
const count = cart.reduce((sum, item) => sum + item.quantite, 0);
|
||||||
|
return { lines, totalCents, count, empty: cart.length === 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelle lisible du mode de consommation pour l'en-tete du panneau.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function modeLabel() {
|
||||||
|
return getMode() === 'a-emporter' ? 'A emporter' : 'Sur place';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
|
||||||
|
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
|
||||||
|
* @param {Object} line — element de buildPanelModel().lines
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function lineHtml(line) {
|
||||||
|
const options = line.options.length
|
||||||
|
? `<ul class="order-panel__options">${line.options
|
||||||
|
.map(o => `<li>${escHtml(o)}</li>`)
|
||||||
|
.join('')}</ul>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<li class="order-panel__line">
|
||||||
|
<div class="order-panel__line-main">
|
||||||
|
<span class="order-panel__line-name">${line.quantite}× ${escHtml(line.libelle)}</span>
|
||||||
|
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
|
||||||
|
</div>
|
||||||
|
${options}
|
||||||
|
<button
|
||||||
|
class="order-panel__remove"
|
||||||
|
data-index="${line.index}"
|
||||||
|
type="button"
|
||||||
|
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
|
||||||
|
>
|
||||||
|
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend le panneau dans le conteneur fourni et cable les interactions (retrait de
|
||||||
|
* ligne, Abandon, Payer). Lit le panier courant via getCart(). Re-rend apres chaque
|
||||||
|
* mutation pour rester synchrone avec localStorage.
|
||||||
|
*
|
||||||
|
* Abandon = annuler toute la commande -> retour accueil (index.html), semantique borne.
|
||||||
|
* Payer = aller a la page de paiement ; desactive (aria-disabled) panier vide.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container — l'element [data-order-panel]
|
||||||
|
*/
|
||||||
|
export function renderOrderPanel(container) {
|
||||||
|
if (!container) return;
|
||||||
|
const model = buildPanelModel(getCart());
|
||||||
|
refreshCartBadge();
|
||||||
|
|
||||||
|
const body = model.empty
|
||||||
|
? '<p class="order-panel__empty">Votre commande est vide.<br>Ajoutez un produit pour commencer.</p>'
|
||||||
|
: `<ul class="order-panel__lines">${model.lines.map(lineHtml).join('')}</ul>`;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="order-panel__head">
|
||||||
|
<img class="order-panel__logo" src="assets/images/ui/logo.png" alt="Wakdo">
|
||||||
|
<span class="order-panel__title">Ma commande</span>
|
||||||
|
<span class="order-panel__mode">${escHtml(modeLabel())}</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-panel__body">${body}</div>
|
||||||
|
<div class="order-panel__foot">
|
||||||
|
<div class="order-panel__total">
|
||||||
|
<span>TOTAL (ttc)</span>
|
||||||
|
<span class="order-panel__total-value">${formatPrice(model.totalCents)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-panel__actions">
|
||||||
|
<button class="order-panel__abandon" type="button">Abandon</button>
|
||||||
|
<a
|
||||||
|
class="order-panel__pay"
|
||||||
|
href="payment.html"
|
||||||
|
role="button"
|
||||||
|
aria-disabled="${model.empty ? 'true' : 'false'}"
|
||||||
|
>Payer</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.querySelectorAll('.order-panel__remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
removeFromCart(parseInt(btn.dataset.index, 10));
|
||||||
|
renderOrderPanel(container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const abandon = container.querySelector('.order-panel__abandon');
|
||||||
|
if (abandon) {
|
||||||
|
abandon.addEventListener('click', () => {
|
||||||
|
clearCart();
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
|
||||||
|
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
|
||||||
|
const pay = container.querySelector('.order-panel__pay');
|
||||||
|
if (pay) {
|
||||||
|
pay.addEventListener('click', e => {
|
||||||
|
if (pay.getAttribute('aria-disabled') === 'true') e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-montage sur les ecrans qui exposent un conteneur [data-order-panel]. */
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('[data-order-panel]').forEach(renderOrderPanel);
|
||||||
|
});
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="order-layout">
|
||||||
<main class="product-main" aria-label="Detail du produit">
|
<main class="product-main" aria-label="Detail du produit">
|
||||||
|
|
||||||
<!-- Error block: hidden unless fetch fails or id invalid -->
|
<!-- Error block: hidden unless fetch fails or id invalid -->
|
||||||
|
|
@ -59,7 +60,11 @@
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/js/nav.js"></script>
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
<script type="module" src="assets/js/page-product.js"></script>
|
<script type="module" src="assets/js/page-product.js"></script>
|
||||||
|
<script type="module" src="assets/js/order-panel.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,11 @@
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="order-layout">
|
||||||
<main class="products-main" aria-label="Liste des produits">
|
<main class="products-main" aria-label="Liste des produits">
|
||||||
|
|
||||||
|
<nav class="category-strip" data-category-strip aria-label="Categories"></nav>
|
||||||
|
|
||||||
<div class="products-header">
|
<div class="products-header">
|
||||||
<!--
|
<!--
|
||||||
Heading is updated by page-products.js once the category
|
Heading is updated by page-products.js once the category
|
||||||
|
|
@ -62,7 +65,12 @@
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/js/nav.js"></script>
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
<script type="module" src="assets/js/page-products.js"></script>
|
<script type="module" src="assets/js/page-products.js"></script>
|
||||||
|
<script type="module" src="assets/js/category-strip.js"></script>
|
||||||
|
<script type="module" src="assets/js/order-panel.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
82
tests/js/category-strip.test.js
Normal file
82
tests/js/category-strip.test.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
* Tests du bandeau categories (node:test + jsdom).
|
||||||
|
*
|
||||||
|
* Import dynamique apres pose des globals jsdom (le module enregistre un
|
||||||
|
* DOMContentLoaded au chargement). Cible : buildStripModel (PUR) + renderStripInto
|
||||||
|
* (DOM sans fetch).
|
||||||
|
*/
|
||||||
|
import { test, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
let buildStripModel, renderStripInto;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||||
|
url: 'https://kiosk.test/products.html?category=3&mode=sur-place',
|
||||||
|
});
|
||||||
|
global.window = dom.window;
|
||||||
|
global.document = dom.window.document;
|
||||||
|
global.localStorage = dom.window.localStorage;
|
||||||
|
({ buildStripModel, renderStripInto } =
|
||||||
|
await import('../../src/public/borne/assets/js/category-strip.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const cats = () => ([
|
||||||
|
{ id: 1, title: 'menus', slug: 'menus', image: 'cat/menus.png' },
|
||||||
|
{ id: 3, title: 'burgers', slug: 'burgers', image: 'cat/burgers.png' },
|
||||||
|
{ id: 2, title: 'boissons', slug: 'boissons', image: 'cat/boissons.png' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* --- buildStripModel (pur) ----------------------------------------------- */
|
||||||
|
|
||||||
|
test('buildStripModel: marque active par id, preserve les champs', () => {
|
||||||
|
const m = buildStripModel(cats(), 3);
|
||||||
|
assert.equal(m.length, 3);
|
||||||
|
assert.deepEqual(m[1], { id: 3, slug: 'burgers', title: 'burgers', image: 'cat/burgers.png', active: true });
|
||||||
|
assert.equal(m[0].active, false);
|
||||||
|
assert.equal(m[2].active, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildStripModel: aucun id correspondant -> tout inactif', () => {
|
||||||
|
assert.equal(buildStripModel(cats(), 999).filter(c => c.active).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- renderStripInto (jsdom) --------------------------------------------- */
|
||||||
|
|
||||||
|
test('renderStripInto: une carte par categorie + 2 fleches', () => {
|
||||||
|
const el = document.createElement('nav');
|
||||||
|
renderStripInto(el, buildStripModel(cats(), 3), 'sur-place');
|
||||||
|
assert.equal(el.querySelectorAll('.category-strip__item').length, 3);
|
||||||
|
assert.ok(el.querySelector('.category-strip__arrow--prev'));
|
||||||
|
assert.ok(el.querySelector('.category-strip__arrow--next'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderStripInto: la categorie active porte is-active + aria-current', () => {
|
||||||
|
const el = document.createElement('nav');
|
||||||
|
renderStripInto(el, buildStripModel(cats(), 3), null);
|
||||||
|
const active = el.querySelectorAll('.category-strip__item.is-active');
|
||||||
|
assert.equal(active.length, 1);
|
||||||
|
assert.equal(active[0].getAttribute('aria-current'), 'true');
|
||||||
|
assert.match(active[0].getAttribute('aria-label'), /Burgers/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderStripInto: href = categorie par id, mode propage', () => {
|
||||||
|
const el = document.createElement('nav');
|
||||||
|
renderStripInto(el, buildStripModel(cats(), 1), 'a-emporter');
|
||||||
|
const first = el.querySelector('.category-strip__item');
|
||||||
|
assert.match(first.getAttribute('href'), /products\.html\?category=1&mode=a-emporter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderStripInto: sans mode, pas de &mode dans l href', () => {
|
||||||
|
const el = document.createElement('nav');
|
||||||
|
renderStripInto(el, buildStripModel(cats(), 1), null);
|
||||||
|
assert.doesNotMatch(el.querySelector('.category-strip__item').getAttribute('href'), /mode=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderStripInto: titre echappe (anti-XSS)', () => {
|
||||||
|
const el = document.createElement('nav');
|
||||||
|
renderStripInto(el, buildStripModel([{ id: 9, title: '<b>x</b>', slug: 'x', image: 'i.png' }], 9), null);
|
||||||
|
assert.match(el.innerHTML, /<b>/);
|
||||||
|
assert.equal(el.querySelectorAll('b').length, 0);
|
||||||
|
});
|
||||||
140
tests/js/order-panel.test.js
Normal file
140
tests/js/order-panel.test.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/*
|
||||||
|
* Tests du panneau de commande persistant (node:test + jsdom).
|
||||||
|
*
|
||||||
|
* order-panel.js touche le DOM au chargement (auto-montage DOMContentLoaded) et
|
||||||
|
* importe nav.js (idem) -> import DYNAMIQUE apres avoir pose les globals jsdom,
|
||||||
|
* sinon le module jette a l'import en environnement Node nu.
|
||||||
|
*
|
||||||
|
* Cible principale : les fonctions PURES (lineCents, compositionLabels,
|
||||||
|
* buildPanelModel). Plus un test de rendu via jsdom (etat vide, lignes, Payer
|
||||||
|
* desactive panier vide, retrait de ligne).
|
||||||
|
*/
|
||||||
|
import { test, before, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
let lineCents, compositionLabels, buildPanelModel, renderOrderPanel;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||||
|
url: 'https://kiosk.test/products.html', // une origine active localStorage
|
||||||
|
});
|
||||||
|
global.window = dom.window;
|
||||||
|
global.document = dom.window.document;
|
||||||
|
global.localStorage = dom.window.localStorage;
|
||||||
|
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
|
||||||
|
await import('../../src/public/borne/assets/js/order-panel.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const simple = (over = {}) => ({
|
||||||
|
id: 3, type: 'produit', categorie: 'burgers', libelle: 'Big Tasty',
|
||||||
|
prix_cents: 890, quantite: 1, image: 'x.png', ...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menu = (over = {}) => ({
|
||||||
|
id: 1, type: 'menu', libelle: 'Menu Best Of', prix_cents: 750, quantite: 1,
|
||||||
|
supplement_cents: 50, image: 'm.png',
|
||||||
|
composition: {
|
||||||
|
burger: { libelle: 'Big Mac', options: ['sans-oignon', 'avec-fromage'] },
|
||||||
|
accompagnement: { libelle: 'Frites', taille: 'G' },
|
||||||
|
boisson: { libelle: 'Coca', taille: 'M' },
|
||||||
|
sauce: { libelle: 'Ketchup' },
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- lineCents (pur) ----------------------------------------------------- */
|
||||||
|
|
||||||
|
test('lineCents: produit simple = prix * quantite', () => {
|
||||||
|
assert.equal(lineCents(simple({ prix_cents: 890, quantite: 3 })), 2670);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineCents: menu = (prix + supplement) * quantite', () => {
|
||||||
|
assert.equal(lineCents(menu({ prix_cents: 750, supplement_cents: 50, quantite: 2 })), 1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- compositionLabels (pur) --------------------------------------------- */
|
||||||
|
|
||||||
|
test('compositionLabels: undefined -> []', () => {
|
||||||
|
assert.deepEqual(compositionLabels(undefined), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compositionLabels: liste burger(options)/accompagnement(taille)/boisson/sauce', () => {
|
||||||
|
const labels = compositionLabels(menu().composition);
|
||||||
|
assert.deepEqual(labels, [
|
||||||
|
'Big Mac (sans oignon, avec fromage)',
|
||||||
|
'Frites grande',
|
||||||
|
'Coca',
|
||||||
|
'Ketchup',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compositionLabels: composants absents ignores sans jeter', () => {
|
||||||
|
assert.deepEqual(compositionLabels({ sauce: { libelle: 'Mayo' } }), ['Mayo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- buildPanelModel (pur) ----------------------------------------------- */
|
||||||
|
|
||||||
|
test('buildPanelModel: panier vide', () => {
|
||||||
|
const m = buildPanelModel([]);
|
||||||
|
assert.equal(m.empty, true);
|
||||||
|
assert.equal(m.totalCents, 0);
|
||||||
|
assert.equal(m.count, 0);
|
||||||
|
assert.deepEqual(m.lines, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPanelModel: total = somme des lignes, count = somme des quantites', () => {
|
||||||
|
const m = buildPanelModel([
|
||||||
|
simple({ prix_cents: 890, quantite: 2 }), // 1780
|
||||||
|
menu({ prix_cents: 750, supplement_cents: 50, quantite: 1 }), // 800
|
||||||
|
]);
|
||||||
|
assert.equal(m.empty, false);
|
||||||
|
assert.equal(m.totalCents, 2580);
|
||||||
|
assert.equal(m.count, 3);
|
||||||
|
assert.equal(m.lines.length, 2);
|
||||||
|
assert.equal(m.lines[0].lineCents, 1780);
|
||||||
|
assert.equal(m.lines[0].options.length, 0); // produit simple : pas d'options
|
||||||
|
assert.equal(m.lines[1].lineCents, 800);
|
||||||
|
assert.equal(m.lines[1].options.length, 4); // menu : 4 puces
|
||||||
|
assert.equal(m.lines[1].index, 1); // index preserve pour le retrait
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- renderOrderPanel (jsdom) -------------------------------------------- */
|
||||||
|
|
||||||
|
test('renderOrderPanel: panier vide -> message + Payer desactive', () => {
|
||||||
|
const el = document.createElement('aside');
|
||||||
|
renderOrderPanel(el);
|
||||||
|
assert.match(el.innerHTML, /vide/i);
|
||||||
|
assert.equal(el.querySelector('.order-panel__pay').getAttribute('aria-disabled'), 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderOrderPanel: lignes rendues + Payer actif + total affiche', () => {
|
||||||
|
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ prix_cents: 890, quantite: 2 })]));
|
||||||
|
const el = document.createElement('aside');
|
||||||
|
renderOrderPanel(el);
|
||||||
|
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
|
||||||
|
assert.equal(el.querySelector('.order-panel__pay').getAttribute('aria-disabled'), 'false');
|
||||||
|
assert.match(el.querySelector('.order-panel__total-value').textContent, /17,80/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => {
|
||||||
|
localStorage.setItem('wakdo_cart', JSON.stringify([simple(), menu()]));
|
||||||
|
const el = document.createElement('aside');
|
||||||
|
renderOrderPanel(el);
|
||||||
|
assert.equal(el.querySelectorAll('.order-panel__line').length, 2);
|
||||||
|
el.querySelector('.order-panel__remove').click(); // retire la 1re ligne
|
||||||
|
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
|
||||||
|
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
|
||||||
|
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
|
||||||
|
const el = document.createElement('aside');
|
||||||
|
renderOrderPanel(el);
|
||||||
|
assert.match(el.innerHTML, /<img/);
|
||||||
|
assert.equal(el.querySelectorAll('img[onerror]').length, 0);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue