From 6bf3597b5eed0d9749fea44524a443c874673da8 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 24 Jun 2026 12:18:30 +0200 Subject: [PATCH] fix(borne): panier unique = panneau persistant (retrait cart.html + product.html) (#101) --- src/public/borne/assets/css/style.css | 409 ++---------------- src/public/borne/assets/js/order-panel.js | 72 ++- src/public/borne/assets/js/page-cart.js | 194 --------- src/public/borne/assets/js/page-payment.js | 2 +- .../borne/assets/js/page-product-menu.js | 10 +- src/public/borne/assets/js/page-product.js | 142 ------ src/public/borne/assets/js/page-products.js | 15 +- src/public/borne/assets/js/product-options.js | 8 +- src/public/borne/cart.html | 102 ----- src/public/borne/payment.html | 6 +- src/public/borne/product.html | 73 ---- src/public/borne/products.html | 9 - tests/e2e/borne.spec.js | 52 ++- tests/js/composer-slots.test.js | 2 +- tests/js/nav.test.js | 2 +- tests/js/order-panel.test.js | 18 + 16 files changed, 166 insertions(+), 950 deletions(-) delete mode 100644 src/public/borne/assets/js/page-cart.js delete mode 100644 src/public/borne/assets/js/page-product.js delete mode 100644 src/public/borne/cart.html delete mode 100644 src/public/borne/product.html diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index ed68a70..0f7c3d3 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -292,7 +292,6 @@ button { justify-self: center; } -.site-header__cart, .site-header__mode { justify-self: end; } @@ -454,49 +453,6 @@ button { 7. SHARED COMPONENTS — header extensions + badges + buttons ============================================================ */ -/* Cart link in header (products / product / cart pages) */ -.site-header__cart { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - border-radius: var(--radius-sm); - transition: background var(--transition-fast); - font-size: 1.6rem; - color: var(--color-text-primary); - text-decoration: none; -} - -.site-header__cart:hover, -.site-header__cart:focus-visible { - background: rgba(255, 199, 44, 0.18); - outline: none; -} - -.cart-icon { - line-height: 1; -} - -.cart-badge { - position: absolute; - top: 4px; - right: 4px; - min-width: 20px; - height: 20px; - padding: 0 4px; - border-radius: var(--radius-pill); - background: var(--color-brand-red); - color: #fff; - font-size: var(--font-size-xs); - font-weight: var(--font-weight-bold); - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - /* Mode badge — shown in header for context */ .mode-badge { display: inline-block; @@ -744,215 +700,9 @@ button { } /* ============================================================ - 9. COMPONENT — PRODUCT DETAIL (product.html) + 10. COMPONENT — QUANTITY CONTROLS (modale options produit) ============================================================ */ -.product-page { - min-height: 100vh; - display: flex; - flex-direction: column; - background: var(--color-bg-page); -} - -.product-main { - flex: 1; - padding: var(--space-6); - max-width: 800px; - margin: 0 auto; - width: 100%; -} - -.product-error { - color: var(--color-brand-red); - font-size: var(--font-size-md); - padding: var(--space-5); -} - -.product-detail { - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -.product-detail__skeleton { - /* Placeholder shown while JS loads data */ - width: 100%; - height: 300px; - background: var(--color-bg-card); - border-radius: var(--radius-md); - animation: skeleton-pulse 1.4s ease-in-out infinite; -} - -@keyframes skeleton-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -.product-detail__image-wrap { - width: 100%; - max-width: 480px; - margin: 0 auto; - background: var(--color-bg-card); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - overflow: hidden; - aspect-ratio: 1 / 1; - display: flex; - align-items: center; - justify-content: center; -} - -.product-detail__image { - width: 100%; - height: 100%; - object-fit: contain; - padding: var(--space-5); -} - -.product-detail__info { - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -.product-detail__name { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - line-height: 1.2; -} - -.product-detail__price { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - color: var(--color-brand-dark); -} - -.product-detail__composition { - background: var(--color-bg-card); - border-left: 4px solid var(--color-brand-yellow); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - padding: var(--space-4) var(--space-5); -} - -.product-detail__composition-title { - font-size: var(--font-size-base); - font-weight: var(--font-weight-bold); - margin-bottom: var(--space-2); - color: var(--color-text-primary); -} - -.product-detail__composition-text { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - line-height: 1.5; -} - -.product-detail__add { - width: 100%; - margin-top: var(--space-2); -} - -/* ============================================================ - 10. COMPONENT — CART (cart.html) - ============================================================ */ - -.cart-page { - min-height: 100vh; - display: flex; - flex-direction: column; - background: var(--color-bg-page); -} - -.cart-main { - flex: 1; - padding: var(--space-6); - display: flex; - flex-direction: column; - gap: var(--space-5); - max-width: 900px; - margin: 0 auto; - width: 100%; -} - -.cart-main__heading { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); -} - -.cart-empty { - text-align: center; - padding: var(--space-10) var(--space-6); - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-5); -} - -.cart-empty__message { - font-size: var(--font-size-lg); - color: var(--color-text-secondary); -} - -.cart-list { - display: flex; - flex-direction: column; - gap: var(--space-3); - list-style: none; - padding: 0; - margin: 0; -} - -/* One cart line */ -.cart-line { - display: flex; - align-items: center; - gap: var(--space-4); - background: var(--color-bg-card); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - padding: var(--space-3) var(--space-4); -} - -.cart-line__image { - width: 72px; - height: 72px; - object-fit: contain; - flex-shrink: 0; - border-radius: var(--radius-sm); - background: var(--color-bg-page); -} - -.cart-line__info { - flex: 1; - min-width: 0; -} - -.cart-line__name { - display: block; - font-size: var(--font-size-base); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.cart-line__unit-price { - display: block; - font-size: var(--font-size-sm); - color: var(--color-text-muted); -} - -/* Quantity controls */ -.cart-line__qty { - display: flex; - align-items: center; - gap: var(--space-2); - flex-shrink: 0; -} - .qty-btn { width: 44px; height: 44px; @@ -984,73 +734,6 @@ button { font-weight: var(--font-weight-bold); } -.cart-line__total { - font-size: var(--font-size-md); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - flex-shrink: 0; - min-width: 80px; - text-align: right; -} - -.cart-line__remove { - background: none; - border: none; - cursor: pointer; - padding: var(--space-2); - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: background var(--transition-fast); -} - -.cart-line__remove:hover, -.cart-line__remove:focus-visible { - background: rgba(218, 2, 14, 0.10); - outline: none; -} - -/* Order summary block */ -.cart-summary { - background: var(--color-bg-card); - border-radius: var(--radius-md); - box-shadow: var(--shadow-card); - padding: var(--space-5); - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.cart-summary__line { - display: flex; - justify-content: space-between; - font-size: var(--font-size-base); - color: var(--color-text-secondary); -} - -.cart-summary__line--total { - padding-top: var(--space-3); - border-top: 2px solid var(--color-border-default); - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); -} - -/* Cart action row */ -.cart-actions { - display: flex; - gap: var(--space-4); - justify-content: space-between; - flex-wrap: wrap; - padding-bottom: var(--space-8); -} - -.cart-actions .btn { - flex: 1 1 200px; -} - /* ============================================================ 11. COMPONENT — PAYMENT (payment.html) ============================================================ */ @@ -1268,15 +951,6 @@ button { grid-template-columns: repeat(2, 1fr); } - .cart-line { - flex-wrap: wrap; - gap: var(--space-3); - } - - .cart-line__info { - flex: 1 1 calc(100% - 80px); - } - .payment-methods { flex-direction: column; align-items: stretch; @@ -1292,14 +966,10 @@ button { .products-main { padding: var(--space-4); } - - .cart-main { - padding: var(--space-4); - } } /* ============================================================ - 14. COMPONENT — MENU COMPOSER MODAL (product.html, type=menu) + 14. COMPONENT — MENU COMPOSER MODAL (modale ouverte depuis la grille produits) ============================================================ */ /* @@ -1515,10 +1185,6 @@ button { z-index: 2; } -.product-detail__info .allergen-info-btn { - margin-top: var(--space-3); -} - .allergen-modal-overlay { position: fixed; inset: 0; @@ -1788,31 +1454,6 @@ button { margin-right: auto; } -/* ---------- Cart line composition display -------------------- */ - -.cart-line__composition { - list-style: none; - padding: var(--space-1) 0 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.cart-line__comp-item { - font-size: var(--font-size-xs); - color: var(--color-text-muted); - line-height: 1.4; -} - -.cart-line__comp-suppl { - font-size: var(--font-size-xs); - color: var(--color-brand-yellow-dk); - font-weight: var(--font-weight-bold); - line-height: 1.4; - padding-top: var(--space-1); -} - /* ---------- Responsive — narrow screens --------------------- */ @media (max-width: 600px) { @@ -1920,8 +1561,7 @@ button { } .order-panel__line { - position: relative; - padding: var(--space-3) var(--space-6) var(--space-3) 0; + padding: var(--space-3) 0; border-bottom: 1px solid var(--color-border-default); } @@ -1952,14 +1592,49 @@ button { content: "+ "; } +/* Ligne de controles : stepper de quantite a gauche, retrait a droite. */ +.order-panel__line-controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--space-2); +} + +.order-panel__qty { + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Boutons +/- : cible tactile confortable (borne). */ +.order-panel__qty-btn { + min-width: 44px; + min-height: 44px; + border: 2px solid var(--color-border-default); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text-primary); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + line-height: 1; + cursor: pointer; +} + +.order-panel__qty-value { + min-width: 2ch; + text-align: center; + font-weight: var(--font-weight-bold); +} + .order-panel__remove { - position: absolute; - top: var(--space-3); - right: 0; + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; border: none; background: none; cursor: pointer; - padding: var(--space-1); line-height: 0; } diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js index ed57f00..9869ba0 100644 --- a/src/public/borne/assets/js/order-panel.js +++ b/src/public/borne/assets/js/order-panel.js @@ -1,18 +1,18 @@ /* * 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. + * l'ecran de commande). Rendu sur l'ecran de commande (products) 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. + * C'est l'UNIQUE vue panier : il montre lignes + total + Abandon/Payer, permet + * d'ajuster la quantite de chaque ligne (+/-) et de la retirer. La logique de mise + * en forme est extraite en fonctions PURES (buildPanelModel, compositionLabels) + * pour etre testable sans DOM. */ import { getCart, removeFromCart, + updateQuantity, computeMenuLineCents, clearCart, formatPrice, @@ -35,8 +35,8 @@ export function lineCents(item) { /** * 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. + * 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[]} */ @@ -93,7 +93,7 @@ function modeLabel() { /** * Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est - * echappee (RG-T15 anti-XSS), comme dans page-cart.js. + * echappee (RG-T15 anti-XSS). * @param {Object} line — element de buildPanelModel().lines * @returns {string} */ @@ -106,18 +106,37 @@ function lineHtml(line) { return `
  • - ${line.quantite}× ${escHtml(line.libelle)} + ${escHtml(line.libelle)} ${formatPrice(line.lineCents)}
    ${options} - +
    +
    + + ${line.quantite} + +
    + +
  • `; } @@ -165,6 +184,19 @@ export function renderOrderPanel(container) { `; + // Stepper +/- : ajuste la quantite de la ligne. Decrementer a 0 retire la ligne + // (updateQuantity supprime quand qty <= 0). Couvre produits ET menus (un menu a + // quantite > 1 = N menus identiques, facture par quantite cote serveur). + container.querySelectorAll('.order-panel__qty-btn').forEach(btn => { + btn.addEventListener('click', () => { + const index = parseInt(btn.dataset.index, 10); + const cart = getCart(); + const current = cart[index] ? cart[index].quantite : 0; + updateQuantity(index, btn.dataset.action === 'inc' ? current + 1 : current - 1); + renderOrderPanel(container); + }); + }); + container.querySelectorAll('.order-panel__remove').forEach(btn => { btn.addEventListener('click', () => { removeFromCart(parseInt(btn.dataset.index, 10)); @@ -181,7 +213,7 @@ export function renderOrderPanel(container) { } // 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). + // via aria-disabled (parade a11y, cf. fix E2E #45). const pay = container.querySelector('.order-panel__pay'); if (pay) { pay.addEventListener('click', e => { diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js deleted file mode 100644 index 505e633..0000000 --- a/src/public/borne/assets/js/page-cart.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * page-cart.js — Shopping cart screen. - * - * Displays all cart lines with quantity controls and totals. - * Handles two item shapes: - * - Simple product: { id, type, libelle, prix_cents, quantite, image } - * - Composed menu: { ...above, composition: {...}, supplement_cents: number } - * - * Menu lines render a composition breakdown beneath the product name. - * Simple product lines render as before (no composition block). - * - * TVA: 10% (taux normal restauration, France 2024 — simplification MVP). - * TODO: verify exact applicable TVA rate with an accountant in P3. - * The real rate depends on sur-place vs a-emporter, alcohol content, etc. - * - * The total displayed is TTC (tax inclusive) because French consumer law - * requires prices shown to end-consumers to include all taxes. - */ - -import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js'; -import { refreshCartBadge } from './nav.js'; - -/* TVA rate used for display breakdown only — stored prices are already TTC */ -const TVA_RATE = 0.10; - -const cartList = document.getElementById('cart-list'); -const emptyBlock = document.getElementById('cart-empty'); -const summaryBlock= document.getElementById('cart-summary'); -const totalTTC = document.getElementById('total-ttc'); -const totalHT = document.getElementById('total-ht'); -const totalTVA = document.getElementById('total-tva'); -const payBtn = document.getElementById('pay-btn'); -const abandonBtn = document.getElementById('abandon-btn'); - -function renderCart() { - const items = getCart(); - refreshCartBadge(); - - if (!items.length) { - cartList.innerHTML = ''; - emptyBlock.hidden = false; - summaryBlock.hidden = true; - // pay-btn est un : `.disabled` n'existe pas dessus, il faut piloter - // aria-disabled (sinon le bouton reste annonce desactive panier rempli). - if (payBtn) payBtn.setAttribute('aria-disabled', 'true'); - return; - } - - emptyBlock.hidden = true; - summaryBlock.hidden = false; - if (payBtn) payBtn.setAttribute('aria-disabled', 'false'); - - cartList.innerHTML = ''; - items.forEach((item, index) => { - const isMenu = item.type === 'menu'; - const lineTotalCents = isMenu - ? computeMenuLineCents(item) - : item.prix_cents * item.quantite; - - const row = document.createElement('li'); - row.className = 'cart-line'; - row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`); - - row.innerHTML = ` - ${escHtml(item.libelle)} -
    - ${escHtml(item.libelle)} - ${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''} - ${isMenu && item.composition ? renderCompositionBlock(item) : ''} -
    -
    - - ${item.quantite} - -
    - ${formatPrice(lineTotalCents)} - - `; - cartList.appendChild(row); - }); - - /* Attach event listeners after render */ - cartList.querySelectorAll('.qty-btn--minus').forEach(btn => { - btn.addEventListener('click', () => { - const idx = parseInt(btn.dataset.index, 10); - const cart = getCart(); - updateQuantity(idx, cart[idx].quantite - 1); - renderCart(); - }); - }); - - cartList.querySelectorAll('.qty-btn--plus').forEach(btn => { - btn.addEventListener('click', () => { - const idx = parseInt(btn.dataset.index, 10); - const cart = getCart(); - updateQuantity(idx, cart[idx].quantite + 1); - renderCart(); - }); - }); - - cartList.querySelectorAll('.cart-line__remove').forEach(btn => { - btn.addEventListener('click', () => { - const idx = parseInt(btn.dataset.index, 10); - removeFromCart(idx); - renderCart(); - }); - }); - - /* Update totals */ - const ttcCents = getTotalCents(); - /* Back-calculate HT from TTC (prices assumed to be TTC already) */ - const htCents = Math.round(ttcCents / (1 + TVA_RATE)); - const tvaCents = ttcCents - htCents; - - if (totalTTC) totalTTC.textContent = formatPrice(ttcCents); - if (totalHT) totalHT.textContent = formatPrice(htCents); - if (totalTVA) totalTVA.textContent = formatPrice(tvaCents); -} - -/** - * Builds the composition breakdown HTML for a menu cart line. - * Renders burger (with personalisation options), accompagnement, boisson, sauce, - * and the supplement summary if applicable. Le format Maxi se lit dans le libelle de - * l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe. - * - * @param {Object} item — cart item with type === 'menu' and composition object - * @returns {string} HTML string - */ -function renderCompositionBlock(item) { - const c = item.composition; - if (!c) return ''; - - // Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut - // ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence. - const parts = []; - if (c.burger) { - const burgerOpts = c.burger.options && c.burger.options.length - ? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})` - : ''; - parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`); - } - // libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom - // ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite - // grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas). - if (c.accompagnement) { - parts.push(escHtml(c.accompagnement.libelle)); - } - if (c.boisson) { - parts.push(escHtml(c.boisson.libelle)); - } - if (c.sauce) { - parts.push(escHtml(c.sauce.libelle)); - } - - const supplTotal = item.supplement_cents ?? 0; - - return ` - - `; -} - -if (abandonBtn) { - abandonBtn.addEventListener('click', () => { - clearCart(); - window.location.href = 'categories.html'; - }); -} - -document.addEventListener('DOMContentLoaded', renderCart); diff --git a/src/public/borne/assets/js/page-payment.js b/src/public/borne/assets/js/page-payment.js index 6fc18d5..e520e23 100644 --- a/src/public/borne/assets/js/page-payment.js +++ b/src/public/borne/assets/js/page-payment.js @@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => { const items = getCart(); if (!items.length) { - window.location.href = 'cart.html'; + window.location.href = 'categories.html'; return; } if (recap) { diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js index 36b88dd..fcf505d 100644 --- a/src/public/borne/assets/js/page-product-menu.js +++ b/src/public/borne/assets/js/page-product-menu.js @@ -1,7 +1,7 @@ /* * page-product-menu.js — Composeur de menu PILOTE PAR LES SLOTS (P5 L2). * - * Importe par page-product.js quand le produit charge est un menu (type === 'menu'). + * Importe par page-products.js quand le produit clique est un menu (type === 'menu'). * * Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers, * frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme @@ -12,9 +12,9 @@ * Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans * l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap. * - * La forme de `composition` produite reste compatible avec page-cart.js et - * order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type - * mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal. + * La forme de `composition` produite reste compatible avec order-panel.js (burger / + * accompagnement / boisson / sauce + taille), le slot_type mappant vers le bon champ ; + * Maxi pose taille 'G' + supplement = prix_maxi - prix_normal. * * A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif. */ @@ -155,7 +155,7 @@ export function composerIsViable(model) { } /* ------------------------------------------------------------------ */ -/* Entree publique — appelee par page-product.js */ +/* Entree publique — appelee par page-products.js */ /* ------------------------------------------------------------------ */ /** diff --git a/src/public/borne/assets/js/page-product.js b/src/public/borne/assets/js/page-product.js deleted file mode 100644 index 3ffee6a..0000000 --- a/src/public/borne/assets/js/page-product.js +++ /dev/null @@ -1,142 +0,0 @@ -/* - * page-product.js — Product detail screen. - * - * Reads ?id=&category= from the query string. - * - * Branch on product type: - * - type === 'menu' → open the multi-step composer modal (page-product-menu.js). - * The standard detail layout is bypassed because a menu - * cannot be added to the cart without composition choices. - * - type === 'produit' → render the standard detail card with "Ajouter au panier". - * - * After "Ajouter au panier" (simple product): - * 1. Item added to cart via state.addToCart() - * 2. Button changes to "Ajoute !" for 1 second (visual feedback) - * 3. Redirect to products.html?category= - */ - -import { findProduct, loadAllergens } from './data.js'; -import { addToCart, formatPrice, escHtml } from './state.js'; -import { refreshCartBadge } from './nav.js'; -import { openMenuComposer } from './page-product-menu.js'; -import { openProductOptions, productSizes } from './product-options.js'; -import { buildAllergenInfoButton, openAllergenModal } from './allergens.js'; - -const params = new URLSearchParams(window.location.search); -const productId = parseInt(params.get('id'), 10); -const categorySlug = params.get('category') ?? 'menus'; - -const container = document.getElementById('product-detail'); -const errorBlock = document.getElementById('product-error'); -const backBtn = document.getElementById('back-to-products'); - -if (backBtn) { - backBtn.href = `products.html?category=${categorySlug}`; -} - -async function renderProduct() { - if (!productId) { - showError('Produit introuvable.'); - return; - } - - try { - const product = await findProduct(productId, categorySlug); - if (!product) { - showError('Ce produit n\'existe pas.'); - return; - } - - document.title = `Wakdo - ${product.nom}`; - - if (product.type === 'menu') { - /* Hide the standard product detail area; the composer will overlay the page. - * The container stays in the DOM so the skeleton does not flash. */ - container.hidden = true; - await openMenuComposer(product, categorySlug); - return; - } - - /* Produit a tailles multiples (R4, ex. boisson 30/50 cl) : on delegue a la - * modale d'options (meme picker que la grille) plutot que de dupliquer la - * selection de taille dans la fiche -> un seul chemin pour choisir la taille. */ - if (productSizes(product).length) { - container.hidden = true; - openProductOptions(product, categorySlug); - return; - } - - container.innerHTML = ` -
    - ${escHtml(product.nom)} -
    -
    -

    ${escHtml(product.nom)}

    -

    ${formatPrice(product.prix)}

    - -
    - `; - - // Bouton "i" allergenes (modale generale) dans le bloc info de la fiche. - // Echec de chargement non bloquant : la fiche reste fonctionnelle. - try { - const allergens = await loadAllergens(); - const info = container.querySelector('.product-detail__info'); - if (info) { - info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens))); - } - } catch (e) { - console.error('loadAllergens error:', e); - } - - document.getElementById('add-to-cart-btn').addEventListener('click', () => { - addToCart({ - id: product.id, - type: product.type, - categorie: product.categorie ?? categorySlug, - libelle: product.nom, - prix_cents: product.prix, - quantite: 1, - image: product.image - }); - refreshCartBadge(); - - const btn = document.getElementById('add-to-cart-btn'); - btn.textContent = 'Ajoute !'; - btn.disabled = true; - - /* Redirect after brief confirmation pause */ - setTimeout(() => { - window.location.href = `products.html?category=${categorySlug}`; - }, 1000); - }); - - } catch (err) { - showError('Erreur lors du chargement du produit.'); - console.error('renderProduct error:', err); - } -} - -function showError(msg) { - if (errorBlock) { - errorBlock.hidden = false; - errorBlock.textContent = msg; - } - if (container) { - container.hidden = true; - } -} - -document.addEventListener('DOMContentLoaded', renderProduct); diff --git a/src/public/borne/assets/js/page-products.js b/src/public/borne/assets/js/page-products.js index bffe0db..c352b71 100644 --- a/src/public/borne/assets/js/page-products.js +++ b/src/public/borne/assets/js/page-products.js @@ -3,7 +3,8 @@ * * Reads ?category= from the query string, maps to a slug via * CATEGORY_ID_TO_SLUG, then fetches the matching product array. - * On product card click, navigates to product.html?id=&category=. + * On product card click, opens an in-page modal (composer for a menu, options + * for a simple product) above the grid ; the order panel reflects the addition. */ import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js'; @@ -68,7 +69,9 @@ async function renderProducts() { const orderable = product.commandable !== false; const card = document.createElement('a'); card.className = orderable ? 'product-card' : 'product-card product-card--unavailable'; - card.href = `product.html?id=${product.id}&category=${categorySlug}`; + // Le
    reste pour le focus/clavier (a11y) ; href='#' inerte, le handler + // click ci-dessous fait foi (preventDefault + ouverture de la modale). + card.href = '#'; card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`); if (!orderable) card.setAttribute('aria-disabled', 'true'); @@ -94,10 +97,10 @@ 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 reste un repli (lien direct / sans JS). Une tuile en - // rupture ne fait rien (ni navigation ni modale). + // Clic produit -> modale au-dessus de la grille (paradigme maquette) : + // menu -> composeur (L2), produit -> options (L3). Le panneau de droite est + // l'unique vue panier ; pas de navigation au clic. Une tuile en rupture ne + // fait rien (ni navigation ni modale). card.addEventListener('click', (e) => { e.preventDefault(); if (!orderable) return; diff --git a/src/public/borne/assets/js/product-options.js b/src/public/borne/assets/js/product-options.js index 9345f6a..4f15786 100644 --- a/src/public/borne/assets/js/product-options.js +++ b/src/public/borne/assets/js/product-options.js @@ -1,10 +1,10 @@ /* * product-options.js — Modale d'options produit (P5 L3, taille R4). * - * 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. + * Ouvre une modale d'options au clic produit, au lieu d'une navigation : 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. * * Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous * forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl, diff --git a/src/public/borne/cart.html b/src/public/borne/cart.html deleted file mode 100644 index c5d1db6..0000000 --- a/src/public/borne/cart.html +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - Wakdo - Panier - - - - - - - - - - -
    - -

    Votre panier

    - - - - - - - - - - - -
    - - - Valider ma commande - -
    - -
    - - - - - - diff --git a/src/public/borne/payment.html b/src/public/borne/payment.html index 927cc75..aec7832 100644 --- a/src/public/borne/payment.html +++ b/src/public/borne/payment.html @@ -21,10 +21,10 @@
    diff --git a/tests/e2e/borne.spec.js b/tests/e2e/borne.spec.js index cd48177..1ab6f27 100644 --- a/tests/e2e/borne.spec.js +++ b/tests/e2e/borne.spec.js @@ -1,6 +1,8 @@ -// Parcours E2E borne : welcome -> categories -> produit -> ajout panier -> panier -// -> paiement -> confirmation. La stack est montee a part (run.sh) ; le panier vit -// dans localStorage (meme origine), donc on peut naviguer par goto sans perdre l'etat. +// Parcours E2E borne : welcome -> categories -> produits (grille) -> modale d'options +// au clic produit -> ajout -> le panneau de commande persistant (unique vue panier) +// reflete la ligne -> paiement -> confirmation. La stack est montee a part (run.sh) ; +// le panier vit dans localStorage (meme origine), on navigue sans perdre l'etat. +// Il n'existe plus de page panier ni de page produit separees (lot F3 panier unique). const { test, expect } = require('@playwright/test'); test('parcours borne : de l\'accueil a la confirmation de commande', async ({ page }) => { @@ -16,40 +18,46 @@ test('parcours borne : de l\'accueil a la confirmation de commande', async ({ pa await test.step('categories -> produits', async () => { await expect(page.locator('h1.categories-main__heading')).toBeVisible(); - // Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui rendent - // un autre gabarit a slots, sans bouton d'ajout direct). + // Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui ouvrent + // le composeur a slots, un autre parcours). Un produit simple ouvre la modale options. await page.locator('a[href="products.html?category=2"]').click(); await expect(page).toHaveURL(/products\.html\?category=2/); }); - await test.step('produits -> fiche produit', async () => { - // Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte. - const firstCard = page.locator('#products-grid a.product-card').first(); + await test.step('clic produit -> modale options', async () => { + // Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte commandable. + const firstCard = page.locator('#products-grid a.product-card:not(.product-card--unavailable)').first(); await expect(firstCard).toBeVisible(); await firstCard.click(); - await expect(page).toHaveURL(/product\.html\?id=/); + // product-options.js monte une modale (.composer-overlay) avec le bouton d'ajout #po-add. + await expect(page.locator('.composer-overlay [role="dialog"]')).toBeVisible(); + await expect(page.locator('#po-add')).toBeVisible(); }); - await test.step('ajout au panier', async () => { - const addBtn = page.locator('#add-to-cart-btn'); - await expect(addBtn).toBeVisible(); - await addBtn.click(); - // Feedback visuel "Ajoute !" (page-product.js) ; l'ecriture localStorage est synchrone. - await expect(addBtn).toHaveText(/Ajoute/); + await test.step('ajout -> le panneau de commande reflete la ligne', async () => { + await page.locator('#po-add').click(); + // La modale se ferme et le panneau persistant (unique vue panier) montre la ligne. + await expect(page.locator('.composer-overlay')).toHaveCount(0); + const panel = page.locator('[data-order-panel]'); + await expect(panel.locator('.order-panel__line')).toHaveCount(1); + // Total calcule (le panneau affiche un montant, plus le placeholder vide). + await expect(panel.locator('.order-panel__total-value')).not.toHaveText(''); }); - await test.step('panier : recapitulatif', async () => { - await page.goto('/cart.html'); - await expect(page.locator('#cart-summary')).toBeVisible(); - await expect(page.locator('#cart-list li')).toHaveCount(1); - // Total calcule, plus le placeholder "—". - await expect(page.locator('#total-ttc')).not.toHaveText('—'); - await page.locator('#pay-btn').click(); + await test.step('panneau Payer -> paiement', async () => { + const payLink = page.locator('[data-order-panel] .order-panel__pay'); + await expect(payLink).toHaveAttribute('aria-disabled', 'false'); + await payLink.click(); await expect(page).toHaveURL(/payment\.html/); }); await test.step('paiement -> confirmation', async () => { await page.locator('#pay-card').click(); + // Sur-place : page-payment.js ouvre la modale chevalet (numero de table) avant de + // soumettre. On saisit un numero puis on enregistre pour declencher le checkout. + await expect(page.locator('#chevalet-input')).toBeVisible(); + await page.locator('#chevalet-input').fill('12'); + await page.locator('#chevalet-ok').click(); await expect(page).toHaveURL(/confirmation\.html/); await expect(page.locator('.confirmation-banner__title')).toHaveText(/Commande confirmee/); // Numero de commande genere (plus le placeholder). diff --git a/tests/js/composer-slots.test.js b/tests/js/composer-slots.test.js index c3315d5..27927ad 100644 --- a/tests/js/composer-slots.test.js +++ b/tests/js/composer-slots.test.js @@ -12,7 +12,7 @@ import { JSDOM } from 'jsdom'; let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel; before(async () => { - const dom = new JSDOM('', { url: 'https://kiosk.test/product.html' }); + const dom = new JSDOM('', { url: 'https://kiosk.test/products.html' }); global.window = dom.window; global.document = dom.window.document; global.localStorage = dom.window.localStorage; diff --git a/tests/js/nav.test.js b/tests/js/nav.test.js index eaca4df..18cbb73 100644 --- a/tests/js/nav.test.js +++ b/tests/js/nav.test.js @@ -27,7 +27,7 @@ test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)', test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => { assert.equal(needsModeRedirect('/payment.html', null), true); assert.equal(needsModeRedirect('/products.html', undefined), true); - assert.equal(needsModeRedirect('/cart.html', 'bidon'), true); + assert.equal(needsModeRedirect('/payment.html', 'bidon'), true); assert.equal(needsModeRedirect('/categories.html', ''), true); }); diff --git a/tests/js/order-panel.test.js b/tests/js/order-panel.test.js index c60217e..a71178c 100644 --- a/tests/js/order-panel.test.js +++ b/tests/js/order-panel.test.js @@ -138,6 +138,24 @@ test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => { assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1); }); +test('renderOrderPanel: stepper + augmente la quantite et re-rend', () => { + localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 })])); + const el = document.createElement('aside'); + renderOrderPanel(el); + el.querySelector('.order-panel__qty-btn[data-action="inc"]').click(); + assert.equal(JSON.parse(localStorage.getItem('wakdo_cart'))[0].quantite, 2); + assert.equal(el.querySelector('.order-panel__qty-value').textContent, '2'); +}); + +test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => { + localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 }), menu()])); + const el = document.createElement('aside'); + renderOrderPanel(el); + el.querySelector('.order-panel__qty-btn[data-action="dec"]').click(); // 1re ligne 1 -> 0 -> retiree + 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: '' })])); const el = document.createElement('aside');