From c73afdf471f251e23288c77f92d891d1b6dbe505 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Fri, 19 Jun 2026 16:54:47 +0200 Subject: [PATCH] feat(borne): panneau commande persistant + bandeau categories (P5 L1) (#64) --- src/public/borne/assets/css/style.css | 272 +++++++++++++++++++ src/public/borne/assets/js/category-strip.js | 104 +++++++ src/public/borne/assets/js/order-panel.js | 193 +++++++++++++ src/public/borne/product.html | 5 + src/public/borne/products.html | 8 + tests/js/category-strip.test.js | 82 ++++++ tests/js/order-panel.test.js | 140 ++++++++++ 7 files changed, 804 insertions(+) create mode 100644 src/public/borne/assets/js/category-strip.js create mode 100644 src/public/borne/assets/js/order-panel.js create mode 100644 tests/js/category-strip.test.js create mode 100644 tests/js/order-panel.test.js diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index 2bf359c..05c2ad3 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -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; +} diff --git a/src/public/borne/assets/js/category-strip.js b/src/public/borne/assets/js/category-strip.js new file mode 100644 index 0000000..ad90e9d --- /dev/null +++ b/src/public/borne/assets/js/category-strip.js @@ -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 => ` + + + ${escHtml(cap(c.title))} + + `).join(''); + + container.innerHTML = ` + +
${cards}
+ + `; + + 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); +}); diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js new file mode 100644 index 0000000..b0726e5 --- /dev/null +++ b/src/public/borne/assets/js/order-panel.js @@ -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 + ? `` + : ''; + return ` +
  • +
    + ${line.quantite}× ${escHtml(line.libelle)} + ${formatPrice(line.lineCents)} +
    + ${options} + +
  • + `; +} + +/** + * 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 + ? '

    Votre commande est vide.
    Ajoutez un produit pour commencer.

    ' + : ``; + + container.innerHTML = ` +
    + + Ma commande + ${escHtml(modeLabel())} +
    +
    ${body}
    +
    +
    + TOTAL (ttc) + ${formatPrice(model.totalCents)} +
    +
    + + Payer +
    +
    + `; + + 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 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); +}); diff --git a/src/public/borne/product.html b/src/public/borne/product.html index 60a44e6..0c5e23f 100644 --- a/src/public/borne/product.html +++ b/src/public/borne/product.html @@ -43,6 +43,7 @@ +
    @@ -59,7 +60,11 @@
    + +
    + + diff --git a/src/public/borne/products.html b/src/public/borne/products.html index dc586f9..969f872 100644 --- a/src/public/borne/products.html +++ b/src/public/borne/products.html @@ -42,8 +42,11 @@ +
    + +