feat(borne): panneau commande persistant + bandeau categories (P5 L1) (#64)
This commit is contained in:
parent
f2fdaea89a
commit
c73afdf471
7 changed files with 804 additions and 0 deletions
|
|
@ -1809,3 +1809,275 @@ button {
|
|||
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>
|
||||
</header>
|
||||
|
||||
<div class="order-layout">
|
||||
<main class="product-main" aria-label="Detail du produit">
|
||||
|
||||
<!-- Error block: hidden unless fetch fails or id invalid -->
|
||||
|
|
@ -59,7 +60,11 @@
|
|||
|
||||
</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/page-product.js"></script>
|
||||
<script type="module" src="assets/js/order-panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,11 @@
|
|||
</a>
|
||||
</header>
|
||||
|
||||
<div class="order-layout">
|
||||
<main class="products-main" aria-label="Liste des produits">
|
||||
|
||||
<nav class="category-strip" data-category-strip aria-label="Categories"></nav>
|
||||
|
||||
<div class="products-header">
|
||||
<!--
|
||||
Heading is updated by page-products.js once the category
|
||||
|
|
@ -62,7 +65,12 @@
|
|||
|
||||
</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/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>
|
||||
</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