diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php index bceb5d9..4ca0ab0 100644 --- a/src/app/Views/admin/counter/new.php +++ b/src/app/Views/admin/counter/new.php @@ -3,23 +3,22 @@ declare(strict_types=1); /** - * Composeur de commande comptoir/drive COMPLET (sous-lot 3c + refonte saisie), - * injecte dans admin/layout.php. Produits commandables ET menus composes (slots - * accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients). + * POS tactile a tuiles (comptoir / drive), injecte dans admin/layout.php. Refonte de + * la saisie : a la place du formulaire-liste, un ecran de caisse facon borne client + * (onglets categories en haut, grille de tuiles produits/menus a gauche, panneau + * commande persistant a droite). Pensee pour la tablette : grandes cibles tactiles, + * un tap sur une tuile ajoute le produit a la commande (qty 1), un produit a + * modificateurs ou un menu ouvre la modale de composition. * * Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS, - * zero handler inline) : il lit produits et menus depuis les data-* de - * #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger - * de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les - * items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide - * tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Les prix affiches - * cote client (par ligne + total + libelle du bouton) sont INDICATIFS : le serveur - * reste seul juge. Le tableau de quantites produit `qty_` reste present comme - * repli sans JS (3a) : le champ quantite est rendu EDITABLE pour TOUS les produits. - * Pour un produit personnalisable, c'est counter-order.js qui neutralise le champ au - * cablage (desactivation + indice "via Personnaliser") et route la saisie vers la - * modale ; sans JS, le champ qty de base fonctionne (commande sans modificateurs, ce - * que legacyQuantities sait traiter). La gestion des modificateurs depend donc de JS. + * zero handler inline) : il lit produits et menus depuis un script JSON inerte + * (type="application/json"), construit les onglets, rend la grille, gere le panneau + * commande, et serialise les items en JSON dans le champ cache #items_json a la + * soumission. Le serveur revalide tout (RG-T18, resolveModifiers) et recalcule les + * prix (RG-T16) : les prix affiches cote client (par ligne + total + libelle du + * bouton) sont INDICATIFS, le serveur reste seul juge. Le contrat de soumission est + * inchange (items_json + service_mode + service_tag + _csrf). Sans JS, la grille ne + * s'affiche pas : un message invite a activer JS (le POS est interactif par nature). * * Partage par les deux canaux ; la source/landing viennent du controleur. Au canal * drive, service_mode est FIGE a 'drive' (affichage non editable + input cache, @@ -38,14 +37,6 @@ declare(strict_types=1); $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; -// Donnees pour counter-order.js, passees en attributs data-* (CSP 'self' : pas de -// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. -$attr = static fn (mixed $data): string => htmlspecialchars( - (string) json_encode($data, JSON_UNESCAPED_UNICODE), - ENT_QUOTES, - 'UTF-8', -); - $csrf = $esc($csrfToken ?? ''); $chan = isset($source) && $source === 'drive' ? 'drive' : 'counter'; $action = $chan === 'drive' ? '/drive/orders' : '/counter/orders'; @@ -59,10 +50,11 @@ $productRows = isset($products) && is_array($products) ? $products : []; /** @var list> $menuRows */ $menuRows = isset($menus) && is_array($menus) ? $menus : []; -// Projection compacte pour le JS : seules les cles utiles a la composition. Les -// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16). -// modifiers : ingredients retirables / ajoutables proposables (le client les affiche -// en cases a cocher ; resolveModifiers revalide chacun cote serveur). +// Projection compacte pour le JS : seules les cles utiles a la composition, l'affichage +// (tuiles : nom, prix, image, categorie) et le calcul local. Les prix sont passes pour +// l'affichage local (le serveur reste seul juge, RG-T16). modifiers : ingredients +// retirables / ajoutables proposables (cases a cocher cote client ; resolveModifiers +// revalide chacun cote serveur). $jsModifiers = static fn (mixed $rows): array => array_map( static fn (array $r): array => [ 'ingredient_id' => (int) ($r['ingredient_id'] ?? 0), @@ -73,17 +65,28 @@ $jsModifiers = static fn (mixed $rows): array => array_map( ], is_array($rows) ? $rows : [], ); + +// Nom de categorie d'une ligne : category_name si fourni, sinon repli "Autres" pour ne +// pas creer d'onglet a libelle vide. +$catNameOf = static fn (array $r): string => isset($r['category_name']) + && is_string($r['category_name']) && $r['category_name'] !== '' + ? $r['category_name'] + : 'Autres'; + $jsProducts = array_map( static fn (array $p): array => [ - 'id' => (int) ($p['id'] ?? 0), - 'name' => (string) ($p['name'] ?? ''), - 'price' => (int) ($p['price_cents'] ?? 0), - 'modifiers' => $jsModifiers($p['modifiers'] ?? null), + 'id' => (int) ($p['id'] ?? 0), + 'name' => (string) ($p['name'] ?? ''), + 'price' => (int) ($p['price_cents'] ?? 0), + 'image' => (string) ($p['image_path'] ?? ''), + 'category_id' => (int) ($p['category_id'] ?? 0), + 'category_name' => $catNameOf($p), + 'modifiers' => $jsModifiers($p['modifiers'] ?? null), ], $productRows, ); $jsMenus = array_map( - static function (array $m) use ($jsModifiers): array { + static function (array $m) use ($jsModifiers, $catNameOf): array { /** @var list> $slots */ $slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : []; @@ -92,6 +95,9 @@ $jsMenus = array_map( 'name' => (string) ($m['name'] ?? ''), 'price_normal' => (int) ($m['price_normal_cents'] ?? 0), 'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0), + 'image' => (string) ($m['image_path'] ?? ''), + 'category_id' => (int) ($m['category_id'] ?? 0), + 'category_name' => $catNameOf($m), // Modificateurs du burger support : la selection d'un menu cible le burger // (resolveModifiers cote serveur le resout sur burger_product_id). 'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null), @@ -111,160 +117,95 @@ $jsMenus = array_map( $menuRows, ); -// Regroupement des produits par categorie (7b) : sous-titre par categorie pour que -// l'equipier scanne plus vite. availableForCatalogue trie deja par categorie puis -// display_order ; on conserve cet ordre et on agrege les lignes consecutives de meme -// categorie. category_name absent -> groupe "Autres" (evite une cle vide a l'ecran). -$productGroups = []; -foreach ($productRows as $p) { - $catName = isset($p['category_name']) && is_string($p['category_name']) && $p['category_name'] !== '' - ? $p['category_name'] - : 'Autres'; - if (!isset($productGroups[$catName])) { - $productGroups[$catName] = []; - } - $productGroups[$catName][] = $p; -} +// JSON inerte (type="application/json") plutot que data-* : la charge (compo de chaque +// produit + slots de chaque menu) peut etre volumineuse ; un script JSON reste CSP-safe +// (non execute) et plus lisible qu'un long attribut data-*. JSON_HEX_* echappe < > & ' +// pour que la sortie soit sure a l'interieur d'un + - - -
> - - +
+
+ +
+ + +

Aucun produit ni menu commandable pour le moment.

+ + +
+

Activez JavaScript pour saisir une commande sur cet ecran de caisse.

+
+
- -
- Produits - -

Aucun produit commandable pour le moment.

- - $catProducts): ?> -

- - - - - - - - - - - - - - - - - - - - -
ProduitPrixQuantitePersonnaliser
- - - - - - - - - -
- - -
+ +
diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index f4c27a0..7e77e48 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -85,6 +85,36 @@ button { cursor: pointer; } +/* Utilitaire lecteur d'ecran : retire du flux visuel, lu par les technologies + d'assistance (regions live discretes type #pos-announce). */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +/* Message d'erreur de formulaire (global admin : erreurs de validation / serveur). */ +.form-error { + color: var(--color-danger-text); + background: var(--color-danger-bg); + border-radius: var(--radius-md); + padding: 8px 12px; + margin: 6px 0; + font-size: 14px; +} + +/* Etat vide d'une liste / d'un catalogue (global admin). */ +.admin-empty { + color: var(--color-text-muted); + padding: 16px 0; +} + /* --- Layout Shell --- */ .admin-layout { display: grid; @@ -1433,8 +1463,10 @@ tbody td.mono { } /* ============================================================================= - Saisie de commande comptoir/drive (counter-order.js + admin/counter/new.php) - Composeur : groupes produits, liste menus, panier chiffre, total, modale. + POS tactile a tuiles comptoir/drive (counter-order.js + admin/counter/new.php) + Ecran de caisse facon borne : onglets categories, grille de tuiles, panneau + commande persistant (lignes + stepper + total), modale de composition. + Cibles tactiles superieures ou egales a 44px (usage tablette). ============================================================================= */ /* Mode de service fige (drive) : affichage non editable a la place du select. */ @@ -1445,60 +1477,290 @@ tbody td.mono { margin: 0; } -/* Sous-titre de groupe de produits (regroupement par categorie). */ -.order-group__title { +/* Disposition POS : catalogue a gauche (flex 1), panneau commande a droite (fixe). */ +.pos { margin: 0; } +.pos__main { + display: flex; + gap: 20px; + align-items: flex-start; +} +.pos__catalogue { + flex: 1 1 auto; + min-width: 0; +} + +/* Onglets categories : bandeau scrollable horizontal (un onglet par categorie). */ +.pos__tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 16px; + border-bottom: 1px solid var(--color-border); + -webkit-overflow-scrolling: touch; +} +.pos__tab { + flex: 0 0 auto; + min-height: 44px; + padding: 10px 18px; + border: 2px solid var(--color-border-dark); + border-radius: var(--radius-pill, 9999px); + background: var(--color-white); + color: var(--color-text-sec); + font-size: 15px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; +} +.pos__tab:hover, +.pos__tab:focus-visible { + border-color: var(--color-yellow); + color: var(--color-text); + outline: none; +} +.pos__tab.is-active { + border-color: var(--color-yellow); + background: var(--color-yellow); + color: var(--color-text); +} + +/* Grille de tuiles produits/menus : auto-fit, grandes cibles tactiles. */ +.pos__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 14px; +} +.pos__nojs { + grid-column: 1 / -1; + color: var(--color-text-muted); + padding: 16px 0; +} + +/* Tuile : image/pastille + nom + prix. Bouton plein (tap = ajout ou modale). */ +.pos-tile { + display: flex; + flex-direction: column; + min-height: 44px; + padding: 0; + background: var(--color-white); + border: 2px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-card); + overflow: hidden; + cursor: pointer; + text-align: left; + position: relative; + transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; +} +.pos-tile:hover, +.pos-tile:focus-visible { + border-color: var(--color-yellow); + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); + outline: none; +} +.pos-tile:active { transform: translateY(0); box-shadow: var(--shadow-card); } + +.pos-tile__media { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + background: var(--color-surface); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.pos-tile__image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + padding: 10px; + background: var(--color-surface); +} +/* Pastille de repli (initiale) quand aucune image n'est disponible cote back-office. */ +.pos-tile__pastille { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--color-yellow-soft, #FFF3D1); + color: var(--color-yellow-ink, #C8920A); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: 800; +} +.pos-tile__body { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px 12px; + flex: 1; +} +.pos-tile__name { font-size: 14px; font-weight: 700; - color: var(--color-yellow-ink); - margin: 14px 0 6px; + color: var(--color-text); + line-height: 1.25; } - -/* Champ quantite desactive pour un produit personnalisable (saisie en modale). */ -.order-qty--disabled { - background: var(--color-surface); - color: var(--color-text-muted); - cursor: not-allowed; -} -/* Indice "via Personnaliser" revele par JS a cote du champ qty neutralise. */ -.order-qty-hint { - display: inline-block; - margin-left: 8px; +.pos-tile__price { font-size: 13px; - color: var(--color-text-muted); - font-style: italic; + font-weight: 700; + color: var(--color-text-sec); + font-variant-numeric: tabular-nums; +} +/* Badge "Menu" / "A composer" sur une tuile qui ouvre la modale au tap. */ +.pos-tile__badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--color-text); + color: var(--color-white); + font-size: 11px; + font-weight: 700; } -/* Deux prix d'un menu (Normal / Maxi) dans la liste. */ -.menu-list { list-style: none; padding: 0; margin: 0; } -.menu-list__item { +/* Panneau commande persistant a droite (facon caisse). */ +.pos__panel { + flex: 0 0 340px; + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + max-height: calc(100vh - 32px); + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + overflow: hidden; +} +.pos__panel-head { + padding: 16px; + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 10px; +} +.pos__panel-title { + font-size: 17px; + font-weight: 800; + color: var(--color-text); +} +.pos__service { display: flex; align-items: center; - gap: 12px; - padding: 8px 0; - border-bottom: 1px solid var(--color-border); + gap: 8px; } -.menu-list__name { font-weight: 600; flex: 1; } -.menu-list__price { color: var(--color-text-sec); font-variant-numeric: tabular-nums; } +.pos__service-label { + font-size: 14px; + font-weight: 700; + color: var(--color-text-sec); + min-width: 48px; +} +.pos__service .form-input { flex: 1; min-height: 44px; } -/* Panier : une ligne = libelle + prix + bouton retirer. */ -.order-cart { list-style: none; padding: 0; margin: 0; } -.order-cart__empty { color: var(--color-text-muted); padding: 8px 0; } +/* Lignes du panier (corps scrollable du panneau). */ +.order-cart { + list-style: none; + padding: 0 16px; + margin: 0; + overflow-y: auto; + flex: 1 1 auto; +} +.order-cart__empty { color: var(--color-text-muted); padding: 16px 0; } .order-cart__line { display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; + flex-direction: column; + gap: 8px; + padding: 12px 0; border-bottom: 1px solid var(--color-border); } -.order-cart__label { flex: 1; } -.order-cart__price { font-weight: 700; font-variant-numeric: tabular-nums; } - -/* Total indicatif du panier. */ -.order-total { - text-align: right; +.order-cart__main { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +.order-cart__label { font-weight: 600; flex: 1; } +.order-cart__price { font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; } +.order-cart__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.order-cart__qty { + display: inline-flex; + align-items: center; + gap: 6px; +} +.order-cart__qty-btn { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + border: 2px solid var(--color-border-dark); + background: var(--color-white); + font-size: 20px; + font-weight: 800; + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.12s ease, background 0.12s ease; +} +.order-cart__qty-btn:hover, +.order-cart__qty-btn:focus-visible { + border-color: var(--color-yellow); + background: var(--color-yellow-bg); + outline: none; +} +.order-cart__qty-value { + min-width: 32px; + text-align: center; font-size: 16px; font-weight: 800; - margin: 12px 0 0; + font-variant-numeric: tabular-nums; +} + +/* Pied du panneau : total indicatif + bouton d'encaissement (pleine largeur). */ +.pos__panel-foot { + padding: 16px; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} +.order-total { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 18px; + font-weight: 800; + margin: 0 0 12px; +} +.order-total span { font-variant-numeric: tabular-nums; } +.pos__pay { + width: 100%; + min-height: 52px; + font-size: 17px; +} + +/* Tablette etroite / portrait : le panneau passe sous le catalogue. */ +@media (max-width: 860px) { + .pos__main { flex-direction: column; } + .pos__panel { + flex-basis: auto; + width: 100%; + position: static; + max-height: none; + } + .order-cart { max-height: 320px; } } /* Modale de composition : overlay + panneau centre. */ diff --git a/src/public/admin/assets/js/counter-order.js b/src/public/admin/assets/js/counter-order.js index f78cc43..0c99421 100644 --- a/src/public/admin/assets/js/counter-order.js +++ b/src/public/admin/assets/js/counter-order.js @@ -1,26 +1,28 @@ /* - * counter-order.js — Composeur de commande comptoir/drive (back-office, sous-lot 3c). + * counter-order.js — POS tactile a tuiles (comptoir / drive, back-office). * * CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees * (produits commandables + leurs modificateurs, menus + slots + format + modificateurs - * du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier - * ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/ - * ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le - * burger). A la soumission, le panier est serialise en JSON dans le champ cache - * #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le - * passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque - * modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/ - * prix affiches ici sont indicatifs, jamais source de verite. + * du burger) sont lues depuis deux scripts JSON inertes (#pos-products, #pos-menus, + * type="application/json") du formulaire #counter-order-form. L'ecran imite la borne + * client : des onglets de categories en haut, une grille de tuiles a gauche, un panneau + * commande persistant a droite. Un tap sur une tuile de produit simple ajoute le produit + * (qty 1) ; un tap sur un menu ou un produit a modificateurs ouvre la modale de + * composition (slots + format + retrait / ajout d'ingredients). Le panneau commande + * affiche les lignes (qty x nom + prix de ligne) avec +/- et retrait, le total, et un + * bouton "Encaisser X,XX EUR". * - * La logique de slots (un pas par slot, requis/optionnel, format) calque + * A la soumission, le panier est serialise en JSON dans le champ cache #items_json + * (Request::formBody cote serveur ne garde que les scalaires, d'ou le passage par une + * chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque modificateur + * metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles / prix + * affiches ici restent indicatifs, pas une source de verite. + * + * La logique de slots (un pas par slot, requis / optionnel, format) calque * page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les - * ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX - * borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes - * configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans - * le panier ; les produits sans modificateur sont derives a la soumission depuis les - * champs qty_ (repli sans JS conserve : le serveur accepte aussi qty_ si - * #items_json est vide). Un produit personnalisable est routé par la modale (sa - * quantite directe est ignoree quand JS s'execute) pour ne pas le compter deux fois. + * ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX borne ; + * le panneau commande calque order-panel.js (lignes, stepper +/-, total). Seul le rendu + * differe (idiome back-office, palette admin). * * Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est * exporte pour les tests et auto-appele au DOMContentLoaded en production. @@ -32,19 +34,25 @@ // aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse). var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' }; - function parseData(form, key, fallback) { + // Lit un script JSON inerte (type="application/json") par id et retourne le tableau + // decode. Tolerant : un script absent / mal forme retombe sur un tableau vide. + function parseJsonScript(doc, id) { + var node = doc.getElementById(id); + if (!node) { + return []; + } try { - var v = JSON.parse(form.dataset[key] || fallback); - return Array.isArray(v) ? v : JSON.parse(fallback); + var v = JSON.parse(node.textContent || '[]'); + return Array.isArray(v) ? v : []; } catch (e) { - return JSON.parse(fallback); + return []; } } // Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des // vues : virgule decimale ET espace separateur de milliers. Aligne l'affichage // client sur le rendu serveur (ex. 1 234,50 EUR) pour eviter une divergence visible - // sur les montants >= 1000. Indicatif : le serveur recalcule tout (RG-T16). + // sur les montants superieurs a 1000. Indicatif : le serveur recalcule tout (RG-T16). function moneyParts(cents) { var fixed = (Number(cents) / 100).toFixed(2); var dot = fixed.indexOf('.'); @@ -113,6 +121,26 @@ }); } + // Onglets de categories construits depuis les produits ET menus : une entree par + // categorie distincte, dans l'ordre d'apparition du catalogue (deja trie par + // categorie / display_order cote serveur). Pur ; chaque entree porte le libelle et + // le nombre de tuiles. La cle est l'id de categorie (0 = "Autres" par defaut). + function buildCategoryTabs(products, menus) { + var order = []; + var byKey = {}; + function add(row) { + var key = Number(row.category_id) || 0; + if (!byKey[key]) { + byKey[key] = { id: key, name: row.category_name || 'Autres', count: 0 }; + order.push(key); + } + byKey[key].count += 1; + } + (products || []).forEach(add); + (menus || []).forEach(add); + return order.map(function (key) { return byKey[key]; }); + } + function init(doc) { var form = doc.getElementById('counter-order-form'); var hidden = doc.getElementById('items_json'); @@ -123,17 +151,28 @@ return; } + // Conteneurs du POS : onglets categories + grille de tuiles. Optionnels (rendu + // degrade sans eux) -> gardes au moment d'ecrire. + var tabsHost = doc.getElementById('pos-tabs'); + var grid = doc.getElementById('pos-grid'); + // Elements de prix (1) : valeur du total + libelle du bouton d'encaissement. - // Optionnels (le rendu degrade sans eux) -> garde-fous au moment d'ecrire. var totalValue = doc.getElementById('order-total-value'); var submitBtn = doc.getElementById('order-submit'); + // Region live concise (C) : un message court (total + nombre d'articles) annonce + // a chaque mutation du panier, sans deballer toute la liste au lecteur d'ecran. + var announce = doc.getElementById('pos-announce'); + // 7a : champ numero de table, visible seulement en sur place (toggle au mode). var serviceMode = doc.getElementById('service_mode'); var serviceTagGroup = doc.getElementById('service_tag_group'); - var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}] - var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}] + // [{id, name, price, image, category_id, category_name, modifiers:[...]}] + var products = parseJsonScript(doc, 'pos-products'); + // [{id, name, price_normal, price_maxi, image, category_id, category_name, + // burger_modifiers:[...], slots:[...]}] + var menus = parseJsonScript(doc, 'pos-menus'); // Index produit par id : resolution des libelles d'options de slot + acces aux // modificateurs proposables d'un produit a la carte. @@ -142,33 +181,19 @@ productById[Number(p.id)] = p; }); - // Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap. - // menuLines : menus configures ; productLines : produits personnalises (modifiers). - var menuLines = []; - var productLines = []; + // Panier unifie : une liste de lignes. Chaque ligne porte un kind : + // - 'product' simple : { kind, localId, productId, productName, quantity } + // - 'product' modifie : ... + proposable, modifiers (config par la modale) + // - 'menu' : { kind, localId, menuId, menuName, format, + // selections, proposable, modifiers } (quantity ajustable) + // Le tap d'une tuile simple FUSIONNE avec une ligne simple existante (meme + // produit) en incrementant la quantite, comme une caisse ; les lignes + // configurees (modifiers / menu) restent distinctes (compositions differentes). + var cartLines = []; var lineSeq = 0; - // Produits routes par la modale (ils portent un bouton "Personnaliser") : leur - // quantite directe qty_ est ignoree a la serialisation pour eviter le double - // comptage. Progressive enhancement (4) : le champ qty est EDITABLE dans le HTML - // (repli sans JS) ; ici, en presence de JS, on le neutralise et on revele - // l'indice "via Personnaliser" pour que l'equipier sache ou saisir la quantite. - var configurableIds = {}; - Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { - var pid = Number(btn.dataset.productId); - configurableIds[pid] = true; - - var qtyInput = doc.getElementById('qty_' + pid); - if (qtyInput) { - qtyInput.disabled = true; - qtyInput.classList.add('order-qty--disabled'); - qtyInput.setAttribute('aria-label', (qtyInput.getAttribute('aria-label') || 'Quantite') + ' (via Personnaliser)'); - } - var hint = doc.querySelector('[data-qty-hint="' + pid + '"]'); - if (hint) { - hint.hidden = false; - } - }); + // Categorie active (filtre la grille) : 1er onglet par defaut. + var activeCategory = null; function el(tag, className) { var e = doc.createElement(tag); @@ -267,28 +292,37 @@ return parts.join(', '); } + // Vrai si la ligne porte au moins un modificateur (produit personnalise). + function hasMods(line) { + return line.modifiers && line.modifiers.length; + } + /* ----------------------------------------------------------------- */ /* Serialisation du panier -> #items_json */ /* ----------------------------------------------------------------- */ - // Produits sans modificateur : derives des champs qty_ (>= 1) NON routes par - // la modale. Produits personnalises : productLines. Menus : menuLines. La forme - // calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur). + // Forme calquee sur ce qu'attend OrderRepository::resolveLine (revalide cote + // serveur). Produits (simples ou personnalises) -> {type:'product', ...} ; + // menus -> {type:'menu', ...}. La quantite d'un menu vaut sa quantite de ligne + // (N menus identiques = un menu x N, facture par quantite cote serveur). function serialize() { var items = []; - - Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { - var productId = Number(input.dataset.productId); - if (configurableIds[productId]) { - return; // route par la modale -> pas de double comptage. + cartLines.forEach(function (line) { + if (line.kind === 'menu') { + items.push({ + type: 'menu', + menu_id: line.menuId, + quantity: line.quantity, + format: line.format, + selections: line.selections.map(function (s) { + return { menu_slot_id: s.slotId, product_id: s.productId }; + }), + modifiers: line.modifiers.map(function (m) { + return { ingredient_id: m.ingredient_id, action: m.action }; + }), + }); + return; } - var quantity = parseInt(input.value, 10); - if (productId > 0 && quantity >= 1) { - items.push({ type: 'product', product_id: productId, quantity: quantity }); - } - }); - - productLines.forEach(function (line) { items.push({ type: 'product', product_id: line.productId, @@ -298,22 +332,6 @@ }), }); }); - - menuLines.forEach(function (line) { - items.push({ - type: 'menu', - menu_id: line.menuId, - quantity: 1, - format: line.format, - selections: line.selections.map(function (s) { - return { menu_slot_id: s.slotId, product_id: s.productId }; - }), - modifiers: line.modifiers.map(function (m) { - return { ingredient_id: m.ingredient_id, action: m.action }; - }), - }); - }); - hidden.value = JSON.stringify(items); } @@ -321,8 +339,8 @@ /* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */ /* ----------------------------------------------------------------- */ - // Prix d'une ligne PRODUIT (configuree par la modale) : prix de base + surcout - // des ajouts, le tout multiplie par la quantite. Indicatif (RG-T16 serveur). + // Prix d'une ligne PRODUIT : prix de base + surcout des ajouts, le tout + // multiplie par la quantite. Indicatif (RG-T16 serveur). function productLineTotal(line) { var base = (productById[Number(line.productId)] || {}).price || 0; var extra = modifiersExtra(line.proposable, line.modifiers); @@ -330,84 +348,46 @@ } // Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le - // surcout des ajouts sur le burger. Les selections de slot n'ajoutent rien (le - // prix du menu est forfaitaire cote serveur). Indicatif. + // surcout des ajouts sur le burger, multiplie par la quantite. Les selections de + // slot n'ajoutent rien (le prix du menu est forfaitaire cote serveur). Indicatif. function menuLineTotal(line) { var menu = menus.filter(function (m) { return Number(m.id) === Number(line.menuId); })[0] || {}; var base = line.format === 'maxi' ? (menu.price_maxi || 0) : (menu.price_normal || 0); var extra = modifiersExtra(line.proposable, line.modifiers); - return Number(base) + extra; + return (Number(base) + extra) * Number(line.quantity || 1); } - // Total indicatif du panier : derive des champs qty_ (produits simples) + - // des lignes configurees (produits personnalises + menus). Met a jour le pied - // de panier ET le libelle du bouton ("Encaisser X,XX EUR"). + function lineTotal(line) { + return line.kind === 'menu' ? menuLineTotal(line) : productLineTotal(line); + } + + // Total indicatif du panier : somme des lignes. Met a jour le pied de panier, le + // libelle du bouton ("Encaisser X,XX EUR") ET la region live concise (C) : un + // message court "Total X EUR, N articles" tient le lecteur d'ecran informe de + // l'essentiel a chaque mutation, sans re-annoncer toute la liste du panier. function updateTotal() { - var total = 0; - - Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { - var productId = Number(input.dataset.productId); - if (configurableIds[productId]) { - return; // route par la modale -> compte plus bas (pas de double comptage). - } - var quantity = parseInt(input.value, 10); - if (productId > 0 && quantity >= 1) { - total += ((productById[productId] || {}).price || 0) * quantity; - } - }); - - productLines.forEach(function (line) { total += productLineTotal(line); }); - menuLines.forEach(function (line) { total += menuLineTotal(line); }); - + var total = cartLines.reduce(function (sum, line) { return sum + lineTotal(line); }, 0); + var count = cartLines.reduce(function (sum, line) { return sum + Number(line.quantity || 0); }, 0); if (totalValue) { totalValue.textContent = formatEuros(total); } if (submitBtn) { submitBtn.textContent = 'Encaisser ' + formatEuros(total); } + if (announce) { + announce.textContent = count === 0 + ? 'Panier vide' + : 'Total ' + formatEuros(total) + ', ' + count + (count > 1 ? ' articles' : ' article'); + } } /* ----------------------------------------------------------------- */ - /* Rendu du panier (recap des lignes configurees) */ + /* Panier (panneau commande : lignes + stepper +/- + retrait) */ /* ----------------------------------------------------------------- */ - function renderCart() { - Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) { - node.parentNode.removeChild(node); - }); - - productLines.forEach(function (line) { - var li = el('li', 'order-cart__line'); - - var label = el('span', 'order-cart__label'); - var text = line.productName + ' x' + line.quantity; - var modLabel = modifierLabel(line.proposable, line.modifiers); - if (modLabel) { - text += ' (' + modLabel + ')'; - } - label.textContent = text; - li.appendChild(label); - - var price = el('span', 'order-cart__price'); - price.textContent = formatEuros(productLineTotal(line)); - li.appendChild(price); - - var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); - removeBtn.type = 'button'; - removeBtn.textContent = 'Retirer'; - removeBtn.addEventListener('click', function () { - productLines = productLines.filter(function (l) { return l.localId !== line.localId; }); - renderCart(); - }); - li.appendChild(removeBtn); - - cart.appendChild(li); - }); - - menuLines.forEach(function (line) { - var li = el('li', 'order-cart__line'); - - var label = el('span', 'order-cart__label'); + // Libelle d'une ligne du panneau (nom + composition recap). + function lineLabel(line) { + if (line.kind === 'menu') { var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')']; line.selections.forEach(function (s) { var p = productById[Number(s.productId)]; @@ -417,35 +397,122 @@ }); var text = parts.join(' - '); var modLabel = modifierLabel(line.proposable, line.modifiers); - if (modLabel) { - text += ' (' + modLabel + ')'; - } - label.textContent = text; - li.appendChild(label); + return modLabel ? (text + ' (' + modLabel + ')') : text; + } + var label = line.productName; + var pm = modifierLabel(line.proposable, line.modifiers); + return pm ? (label + ' (' + pm + ')') : label; + } - var price = el('span', 'order-cart__price'); - price.textContent = formatEuros(menuLineTotal(line)); - li.appendChild(price); + // Ajuste la quantite d'une ligne (delta +1 / -1). Tomber a 0 retire la ligne + // (comme order-panel.js borne : decrementer a zero = retrait). + function adjustQuantity(line, delta) { + var next = Number(line.quantity || 1) + delta; + if (next <= 0) { + cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; }); + } else { + line.quantity = next; + } + renderCart(); + } - var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); - removeBtn.type = 'button'; - removeBtn.textContent = 'Retirer'; - removeBtn.addEventListener('click', function () { - menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; }); - renderCart(); - }); - li.appendChild(removeBtn); + function removeLine(line) { + cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; }); + renderCart(); + } - cart.appendChild(li); + // Construit une ligne du panneau : libelle + prix + stepper (-/qty/+) + retrait. + function cartLineNode(line) { + var li = el('li', 'order-cart__line'); + + var main = el('div', 'order-cart__main'); + var label = el('span', 'order-cart__label'); + label.textContent = lineLabel(line); + main.appendChild(label); + var price = el('span', 'order-cart__price'); + price.textContent = formatEuros(lineTotal(line)); + main.appendChild(price); + li.appendChild(main); + + var controls = el('div', 'order-cart__controls'); + + var stepper = el('div', 'order-cart__qty'); + stepper.setAttribute('role', 'group'); + stepper.setAttribute('aria-label', 'Quantite de ' + lineLabel(line)); + + var dec = el('button', 'order-cart__qty-btn'); + dec.type = 'button'; + dec.textContent = '−'; // signe moins + dec.setAttribute('aria-label', 'Diminuer la quantite de ' + lineLabel(line)); + dec.addEventListener('click', function () { adjustQuantity(line, -1); }); + stepper.appendChild(dec); + + var qty = el('span', 'order-cart__qty-value'); + qty.textContent = String(line.quantity); + stepper.appendChild(qty); + + var inc = el('button', 'order-cart__qty-btn'); + inc.type = 'button'; + inc.textContent = '+'; + inc.setAttribute('aria-label', 'Augmenter la quantite de ' + lineLabel(line)); + inc.addEventListener('click', function () { adjustQuantity(line, 1); }); + stepper.appendChild(inc); + + controls.appendChild(stepper); + + var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.setAttribute('aria-label', 'Retirer ' + lineLabel(line) + ' de la commande'); + removeBtn.addEventListener('click', function () { removeLine(line); }); + controls.appendChild(removeBtn); + + li.appendChild(controls); + return li; + } + + function renderCart() { + Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) { + node.parentNode.removeChild(node); + }); + + cartLines.forEach(function (line) { + cart.appendChild(cartLineNode(line)); }); if (cartEmpty) { - cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : ''; + cartEmpty.style.display = cartLines.length ? 'none' : ''; } updateTotal(); } + /* ----------------------------------------------------------------- */ + /* Ajout au panier */ + /* ----------------------------------------------------------------- */ + + // Tap d'une tuile produit simple (sans modificateur) : fusionne avec une ligne + // simple existante du meme produit (increment), sinon cree une ligne qty 1. + function addSimpleProduct(product) { + var existing = cartLines.filter(function (l) { + return l.kind === 'product' && l.productId === Number(product.id) && !hasMods(l); + })[0]; + if (existing) { + existing.quantity += 1; + } else { + cartLines.push({ + kind: 'product', + localId: ++lineSeq, + productId: Number(product.id), + productName: product.name, + quantity: 1, + proposable: product.modifiers || [], + modifiers: [], + }); + } + renderCart(); + } + /* ----------------------------------------------------------------- */ /* Modales de configuration */ /* ----------------------------------------------------------------- */ @@ -459,7 +526,7 @@ var lastFocused = null; // Selecteur des controles focusables d'une modale (boutons, champs, selects ; - // les champs desactives/caches sont exclus). Le trap cycle sur cet ensemble. + // les champs desactives / caches sont exclus). Le trap cycle sur cet ensemble. var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; function focusableIn(root) { @@ -478,7 +545,7 @@ modalHost.textContent = ''; modalHost.setAttribute('hidden', ''); - // Restaure le focus sur l'element declencheur (bouton Personnaliser/Configurer). + // Restaure le focus sur l'element declencheur (tuile produit / menu). if (lastFocused && typeof lastFocused.focus === 'function') { lastFocused.focus(); } @@ -551,7 +618,7 @@ } } - // Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout). + // Modale d'un produit a la carte : quantite + modificateurs (retrait / ajout). function openProductComposer(product) { var proposable = product.modifiers || []; var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} }; @@ -576,6 +643,9 @@ qtyInput.addEventListener('change', function () { var v = parseInt(qtyInput.value, 10); state.quantity = v >= 1 ? v : 1; + // E : une saisie invalide (0 / vide / non numerique) est ramenee a 1 ; on + // reaffiche la valeur corrigee pour que l'equipier voie ce qui sera ajoute. + qtyInput.value = String(state.quantity); }); qtyBlock.appendChild(qtyInput); panel.appendChild(qtyBlock); @@ -588,7 +658,8 @@ addBtn.type = 'button'; addBtn.textContent = 'Ajouter au panier'; addBtn.addEventListener('click', function () { - productLines.push({ + cartLines.push({ + kind: 'product', localId: ++lineSeq, productId: Number(product.id), productName: product.name, @@ -692,7 +763,7 @@ panel.appendChild(block); }); - // Modificateurs du burger support (retrait/ajout d'ingredients). + // Modificateurs du burger support (retrait / ajout d'ingredients). panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); // 7c : message inline au lieu d'un return muet quand un slot requis n'est pas @@ -704,12 +775,24 @@ inlineError.textContent = ''; panel.appendChild(inlineError); + // Impasse : un slot requis sans aucune option resoluble rend le menu non + // composable. On desactive l'ajout et on affiche un message clair plutot que + // de laisser l'equipier buter sur "options obligatoires" sans pouvoir corriger. + var deadEnd = steps.some(function (s) { return s.isRequired && !s.options.length; }); + // Actions : ajouter (si tous les requis choisis) / annuler. var actions = el('div', 'menu-composer__actions'); var addBtn = el('button', 'btn btn-primary menu-composer__add'); addBtn.type = 'button'; addBtn.textContent = 'Ajouter au panier'; + if (deadEnd) { + addBtn.disabled = true; + inlineError.textContent = 'Ce menu n\'est pas composable : une option obligatoire est indisponible.'; + } addBtn.addEventListener('click', function () { + if (deadEnd) { + return; + } var allRequired = steps.filter(function (s) { return s.isRequired; }) .every(function (s) { return state.selections[s.id] != null; }); if (!allRequired) { @@ -723,10 +806,12 @@ selections.push({ slotId: step.id, productId: chosen }); } }); - menuLines.push({ + cartLines.push({ + kind: 'menu', localId: ++lineSeq, menuId: Number(menu.id), menuName: menu.name, + quantity: 1, format: state.format, selections: selections, proposable: proposable, @@ -748,38 +833,221 @@ } /* ----------------------------------------------------------------- */ - /* Cablage */ + /* Grille de tuiles + onglets categories */ /* ----------------------------------------------------------------- */ - Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { - btn.addEventListener('click', function () { - var productId = Number(btn.dataset.productId); - var product = productById[productId]; - if (product) { - openProductComposer(product); - } - }); - }); + // Pastille de repli : initiale du nom sur fond colore, quand aucune image + // exploitable n'est disponible cote back-office (image_path vide ou injoignable). + function buildPastille(name) { + var pastille = el('span', 'pos-tile__pastille'); + pastille.setAttribute('aria-hidden', 'true'); + var initial = (String(name || '').trim().charAt(0) || '?').toUpperCase(); + pastille.textContent = initial; + return pastille; + } - Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) { - btn.addEventListener('click', function () { - var menuId = Number(btn.dataset.menuId); - var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0]; - if (menu) { - openComposer(menu); - } - }); - }); + // Construit une tuile. kind : 'product' | 'menu'. Le tap declenche onTap. Une + // image n'est tentee que si image_path est non vide ; sur erreur de chargement, + // un listener (CSP-safe, pas d'onerror inline) masque l'image et revele la + // pastille de repli (le back-office n'a pas garantie d'image exploitable). + function buildTile(entry, kind, priceLabel, onTap) { + var tile = el('button', 'pos-tile'); + tile.type = 'button'; - // 1 : le total et le libelle du bouton suivent la saisie des quantites des - // produits simples (les lignes configurees rafraichissent via renderCart). - Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { - if (configurableIds[Number(input.dataset.productId)]) { - return; // champ desactive (route par la modale). + // Une tuile qui ouvre la modale (menu ou produit a modificateurs) annonce + // l'intention dans son nom accessible (D) et porte aria-haspopup=dialog : le + // lecteur d'ecran sait qu'un tap ouvre une boite de dialogue de composition, + // pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif. + var opensModal = kind === 'menu' || (entry.modifiers && entry.modifiers.length); + var intent = opensModal ? (kind === 'menu' ? ', menu a composer' : ', a composer') : ''; + tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent); + if (opensModal) { + tile.setAttribute('aria-haspopup', 'dialog'); } - input.addEventListener('input', updateTotal); - input.addEventListener('change', updateTotal); - }); + + var media = el('span', 'pos-tile__media'); + var pastille = buildPastille(entry.name); + media.appendChild(pastille); + + var src = String(entry.image || ''); + if (src !== '') { + var img = el('img', 'pos-tile__image'); + img.src = src; + img.alt = ''; + img.setAttribute('aria-hidden', 'true'); + img.setAttribute('loading', 'lazy'); + // CSP-safe : pas d'onerror inline. Sur echec, masque l'image (la + // pastille dessous redevient visible). + img.addEventListener('error', function () { + img.style.display = 'none'; + }); + media.appendChild(img); + } + tile.appendChild(media); + + var body = el('span', 'pos-tile__body'); + var nameEl = el('span', 'pos-tile__name'); + nameEl.textContent = entry.name; + body.appendChild(nameEl); + var priceEl = el('span', 'pos-tile__price'); + priceEl.textContent = priceLabel; + body.appendChild(priceEl); + tile.appendChild(body); + + // Badge visuel "Menu"/"A composer" (decoratif : l'intention est deja dans + // l'aria-label ci-dessus ; aria-hidden evite la double annonce). + if (opensModal) { + var badge = el('span', 'pos-tile__badge'); + badge.setAttribute('aria-hidden', 'true'); + badge.textContent = kind === 'menu' ? 'Menu' : 'A composer'; + tile.appendChild(badge); + } + + tile.addEventListener('click', onTap); + return tile; + } + + // Rend la grille pour la categorie active : produits puis menus de cette + // categorie. Un produit simple -> ajout direct ; un produit a modificateurs ou + // un menu -> modale. + function renderGrid() { + if (!grid) { + return; + } + grid.textContent = ''; + + var catProducts = products.filter(function (p) { return (Number(p.category_id) || 0) === activeCategory; }); + var catMenus = menus.filter(function (m) { return (Number(m.category_id) || 0) === activeCategory; }); + + if (!catProducts.length && !catMenus.length) { + var empty = el('p', 'pos__nojs'); + empty.textContent = 'Aucun produit dans cette categorie.'; + grid.appendChild(empty); + return; + } + + catProducts.forEach(function (product) { + var tile = buildTile(product, 'product', formatEuros(product.price), function () { + if (product.modifiers && product.modifiers.length) { + openProductComposer(product); + } else { + addSimpleProduct(product); + } + }); + grid.appendChild(tile); + }); + + catMenus.forEach(function (menu) { + var label = 'Normal ' + formatEuros(menu.price_normal) + ' / Maxi ' + formatEuros(menu.price_maxi); + var tile = buildTile(menu, 'menu', label, function () { + openComposer(menu); + }); + grid.appendChild(tile); + }); + } + + // Boutons d'onglet (references stables), construits UNE fois au demarrage. On les + // garde pour MUTER l'etat actif (A) plutot que de reconstruire la barre au clic : + // reconstruire detruisait le bouton focalise et faisait retomber le focus sur body. + var tabButtons = []; + + // Bascule la categorie active : mute les boutons existants (classe is-active + + // aria-selected + roving tabindex), met a jour activeCategory et le tabpanel + // (aria-labelledby vers l'onglet actif), rerend la grille. Si moveFocus, pose le + // focus sur l'onglet actif (navigation clavier : le focus suit la selection). + function setActiveCategory(catId, moveFocus) { + activeCategory = catId; + tabButtons.forEach(function (btn) { + var selected = Number(btn.dataset.categoryId) === Number(catId); + btn.classList.toggle('is-active', selected); + btn.setAttribute('aria-selected', selected ? 'true' : 'false'); + // Roving tabindex (B) : seul l'onglet actif est dans l'ordre de tabulation ; + // les autres sont atteints par les fleches une fois la barre focalisee. + btn.tabIndex = selected ? 0 : -1; + if (selected) { + // Le tabpanel (grille) est libelle par l'onglet actif (B). + if (grid) { + grid.setAttribute('aria-labelledby', btn.id); + } + if (moveFocus && typeof btn.focus === 'function') { + btn.focus(); + } + } + }); + renderGrid(); + } + + // Navigation clavier WAI-ARIA tablist (B) : Fleche gauche/droite (cycliques) + + // Home/Fin deplacent le focus ET activent l'onglet (le focus suit la selection). + function onTabsKeydown(event) { + var idx = tabButtons.indexOf(event.target); + if (idx < 0 || !tabButtons.length) { + return; + } + var next = null; + var key = event.key; + if (key === 'ArrowRight' || key === 'ArrowDown') { + next = (idx + 1) % tabButtons.length; + } else if (key === 'ArrowLeft' || key === 'ArrowUp') { + next = (idx - 1 + tabButtons.length) % tabButtons.length; + } else if (key === 'Home') { + next = 0; + } else if (key === 'End') { + next = tabButtons.length - 1; + } + if (next === null) { + return; + } + event.preventDefault(); + setActiveCategory(Number(tabButtons[next].dataset.categoryId), true); + } + + // Construit la barre d'onglets UNE fois (A) et cable clic + clavier. + function buildTabs() { + if (!tabsHost) { + return; + } + tabsHost.textContent = ''; + tabButtons = []; + var tabs = buildCategoryTabs(products, menus); + if (!tabs.length) { + return; + } + if (activeCategory === null) { + activeCategory = tabs[0].id; + } + + tabs.forEach(function (tab, i) { + var selected = tab.id === activeCategory; + var btn = el('button', 'pos__tab' + (selected ? ' is-active' : '')); + btn.type = 'button'; + btn.id = 'pos-tab-' + tab.id; + btn.dataset.categoryId = String(tab.id); + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', selected ? 'true' : 'false'); + // aria-controls relie l'onglet au tabpanel unique (la grille filtree, B). + if (grid && grid.id) { + btn.setAttribute('aria-controls', grid.id); + } + btn.tabIndex = selected ? 0 : -1; + btn.textContent = tab.name; + btn.addEventListener('click', function () { + setActiveCategory(tab.id, false); + }); + tabButtons.push(btn); + tabsHost.appendChild(btn); + }); + tabsHost.addEventListener('keydown', onTabsKeydown); + + // Pose le libelle initial du tabpanel sur l'onglet actif. + if (grid) { + grid.setAttribute('aria-labelledby', 'pos-tab-' + activeCategory); + } + } + + /* ----------------------------------------------------------------- */ + /* Cablage */ + /* ----------------------------------------------------------------- */ // 7a : le numero de table n'a de sens qu'en sur place -> visible seulement quand // service_mode = dine_in (au comptoir ; au drive le champ n'existe pas). @@ -803,11 +1071,13 @@ serialize(); }); + buildTabs(); + renderGrid(); renderCart(); } if (typeof module !== 'undefined' && module.exports) { - module.exports = { init: init, composerSteps: composerSteps }; + module.exports = { init: init, composerSteps: composerSteps, buildCategoryTabs: buildCategoryTabs }; } if (typeof document !== 'undefined' && document.addEventListener) { document.addEventListener('DOMContentLoaded', function () { diff --git a/tests/Unit/Admin/CounterOrderControllerTest.php b/tests/Unit/Admin/CounterOrderControllerTest.php index 6fb2e31..e3066bc 100644 --- a/tests/Unit/Admin/CounterOrderControllerTest.php +++ b/tests/Unit/Admin/CounterOrderControllerTest.php @@ -184,7 +184,11 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); self::assertStringContainsString('Cheeseburger', $body); - self::assertStringContainsString('qty_12', $body); // champ quantite par produit + // POS tactile : le catalogue est embarque dans un script JSON inerte (id/prix), + // pas en champs qty_. La grille de tuiles est rendue cote client. + self::assertStringContainsString('id="pos-products"', $body); + self::assertStringContainsString('"id":12', $body); + self::assertStringContainsString('id="pos-grid"', $body); self::assertStringContainsString('service_mode', $body); } @@ -249,7 +253,10 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); self::assertStringContainsString('Menu Cheeseburger', $body); - self::assertStringContainsString('data-menu-id="5"', $body); // bouton configurer + // POS tactile : les menus + slots sont embarques dans un script JSON inerte + // (la tuile menu et la modale sont rendues cote client par counter-order.js). + self::assertStringContainsString('id="pos-menus"', $body); + self::assertStringContainsString('"id":5', $body); self::assertStringContainsString('items_json', $body); // champ cache du panier self::assertStringContainsString('counter-order.js', $body); // script du composeur } @@ -290,6 +297,39 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(22, $selInsert['pid']); } + public function testStoreCreatesMenuOrderWithQuantityTwo(): void + { + // G : un item menu via items_json avec quantity:2 persiste qty=2 sur order_item ; + // les selections de slot ne sont pas dupliquees par la quantite (un seul INSERT). + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Menu Cheeseburger', 'burger_product_id' => 12, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'is_available' => 1]; + $db->productRow = ['id' => 22, 'name' => 'Frites', 'price_cents' => 250, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + $db->menuSlotRows = [ + ['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22], + ]; + $db->lastInsertId = 100; + $db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 1980, 'status' => 'pending_payment']; + + $items = json_encode([ + ['type' => 'menu', 'menu_id' => 5, 'quantity' => 2, 'format' => 'normal', 'selections' => [['menu_slot_id' => 16, 'product_id' => 22]]], + ]); + $request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => (string) $items], '/counter/orders'); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(302, $response->status()); + $itemInsert = $this->writeParams($db, 'INSERT INTO order_item '); + self::assertSame('menu', $itemInsert['type']); + self::assertSame(5, $itemInsert['mid']); + self::assertSame(2, $itemInsert['qty']); + // La selection de slot est persistee UNE fois (independante de la quantite). + $selectionWrites = array_values(array_filter( + $db->writes, + static fn (array $w): bool => str_contains($w['sql'], 'INSERT INTO order_item_selection'), + )); + self::assertCount(1, $selectionWrites); + } + public function testStoreCreatesProductOrderViaItemsJson(): void { // Chemin unifie : items_json est prefere a qty_, et un produit y passe aussi. @@ -357,15 +397,16 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); - // data-products encode le JSON avec htmlspecialchars : les guillemets sont - // echappes en ". On cherche les fragments echappes (forme reellement rendue). - self::assertStringContainsString('ingredient_id":3', $body); - self::assertStringContainsString('ingredient_id":8', $body); + // POS tactile : la composition PROPOSABLE est embarquee dans le script JSON inerte + // #pos-products (type="application/json"). json_encode avec JSON_HEX_TAG protege + // l'insertion dans un + // injectable). On cherche les fragments JSON reellement rendus (guillemets bruts, + // surs dans un script). La tuile "A composer" et la modale sont rendues client-side. + self::assertStringContainsString('id="pos-products"', $body); + self::assertStringContainsString('"ingredient_id":3', $body); + self::assertStringContainsString('"ingredient_id":8', $body); self::assertStringContainsString('Oignon', $body); self::assertStringContainsString('Bacon', $body); - // Bouton de personnalisation expose pour le produit a modificateurs. - self::assertStringContainsString('product-configure', $body); - self::assertStringContainsString('Personnaliser', $body); } public function testStoreCreatesProductOrderWithModifiers(): void @@ -518,12 +559,11 @@ final class CounterOrderControllerTest extends TestCase self::assertStringContainsString('A emporter', $body); } - public function testCreateRendersEditableQuantityWithHintForConfigurableProduct(): void + public function testCreateExposesConfigurableProductModifiersInJson(): void { - // 4 (progressive enhancement) : le champ quantite d'un produit personnalisable - // est rendu EDITABLE en HTML (repli sans JS marche : commande de base sans - // modificateurs). Le PHP ne pose PAS readonly (c'est le JS qui neutralise au - // cablage). Un indice "via Personnaliser" (cache en HTML) accompagne le champ. + // POS tactile : un produit a modificateurs est expose dans #pos-products avec sa + // composition proposable. Le client en rend une tuile "A composer" qui ouvre la + // modale au tap (la saisie de la quantite et des modificateurs se fait en modale). $db = $this->permittedDb(); $db->productsRows = [ ['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], @@ -536,17 +576,19 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); - // Le champ qty est present et EDITABLE (aucun readonly pose par le PHP). - self::assertStringContainsString('name="qty_12"', $body); - self::assertDoesNotMatchRegularExpression('/id="qty_12"[^>]*readonly/', $body); - // Indice de redirection vers la modale (revele par le JS). - self::assertStringContainsString('data-qty-hint="12"', $body); - self::assertStringContainsString('via Personnaliser', $body); + // Le produit et sa composition sont dans le JSON inerte (pas de champ qty_). + self::assertStringContainsString('id="pos-products"', $body); + self::assertStringContainsString('"id":12', $body); + self::assertStringContainsString('"ingredient_id":3', $body); + self::assertStringContainsString('Oignon', $body); + // Plus de champ quantite par produit (la saisie passe par les tuiles + modale). + self::assertStringNotContainsString('name="qty_12"', $body); } - public function testCreateGroupsProductsByCategory(): void + public function testCreateExposesCategoryNamesForTabs(): void { - // 7b : les produits sont regroupes par categorie (sous-titre = category_name). + // POS tactile : les onglets de categories sont construits cote client a partir + // de category_name embarque dans le JSON de chaque produit/menu. $db = $this->permittedDb(); $db->productsRows = [ ['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], @@ -557,13 +599,15 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); - self::assertStringContainsString('Burgers', $body); - self::assertStringContainsString('Accompagnements', $body); + self::assertStringContainsString('id="pos-tabs"', $body); + self::assertStringContainsString('"category_name":"Burgers"', $body); + self::assertStringContainsString('"category_name":"Accompagnements"', $body); } - public function testCreateShowsBothMenuPrices(): void + public function testCreateExposesBothMenuPrices(): void { - // 6 : la liste des menus affiche les deux prix (Normal / Maxi). + // 6 : les deux prix d'un menu (Normal / Maxi, en centimes) sont exposes dans le + // JSON inerte ; le client affiche "Normal X / Maxi Y" sur la tuile et la modale. $db = $this->permittedDb(); $db->menusRows = [ ['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1], @@ -573,9 +617,9 @@ final class CounterOrderControllerTest extends TestCase self::assertSame(200, $response->status()); $body = $response->body(); - self::assertStringContainsString('9,90 EUR', $body); - self::assertStringContainsString('11,90 EUR', $body); - self::assertStringContainsString('Maxi', $body); + self::assertStringContainsString('id="pos-menus"', $body); + self::assertStringContainsString('"price_normal":990', $body); + self::assertStringContainsString('"price_maxi":1190', $body); } public function testStorePassesServiceTagInDineIn(): void diff --git a/tests/js/counter-order.test.js b/tests/js/counter-order.test.js index 7c87b5e..a7320cb 100644 --- a/tests/js/counter-order.test.js +++ b/tests/js/counter-order.test.js @@ -1,9 +1,12 @@ /* - * Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c). - * node:test + jsdom. Couvre la serialisation du panier dans #items_json : - * - ajout produit (champ quantite) -> item {type:'product', ...} - * - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...] - * - configuration menu (slots + format Maxi + modificateurs burger) -> item menu + * Tests du POS tactile de commande comptoir/drive (counter-order.js). node:test + jsdom. + * Couvre la logique pure (serialisation du panier dans #items_json, calcul prix/total, + * onglets categories) et l'UI a tuiles : + * - tap d'une tuile produit simple -> item {type:'product', quantity}, fusion sur re-tap + * - tap d'une tuile produit a modificateurs -> modale -> modifiers:[...] + * - tap d'une tuile menu -> modale (slots + format Maxi + modificateurs burger) + * - stepper +/- du panneau commande (ajuste qty, retire a 0) + * - slot requis non choisi -> message inline (pas d'ajout muet) * - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse) * * Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers) @@ -20,15 +23,15 @@ import counterOrder from '../../src/public/admin/assets/js/counter-order.js'; const PRODUCTS = [ { - id: 12, name: 'Cheeseburger', price: 890, + id: 12, name: 'Cheeseburger', price: 890, image: '', category_id: 1, category_name: 'Burgers', modifiers: [ { ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, { ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 }, ], }, - { id: 22, name: 'Frites', price: 250, modifiers: [] }, - { id: 14, name: 'Coca', price: 200, modifiers: [] }, - { id: 47, name: 'Ketchup', price: 0, modifiers: [] }, + { id: 22, name: 'Frites', price: 250, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] }, + { id: 14, name: 'Coca', price: 200, image: '', category_id: 3, category_name: 'Boissons', modifiers: [] }, + { id: 47, name: 'Ketchup', price: 0, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] }, ]; const MENUS = [ @@ -37,6 +40,9 @@ const MENUS = [ name: 'Menu Cheeseburger', price_normal: 990, price_maxi: 1190, + image: '', + category_id: 4, + category_name: 'Menus', burger_modifiers: [ { ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, { ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 }, @@ -49,39 +55,28 @@ const MENUS = [ }, ]; -function setup(menus = MENUS) { - const menuItems = menus - .map(m => `
  • `) - .join(''); - // qty_ pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour - // ceux dont la recette offre des modificateurs (calque la vue new.php). Progressive - // enhancement (etape 4) : le champ qty est rendu EDITABLE en HTML pour TOUS les - // produits ; c'est le JS qui neutralise le champ d'un produit configurable au cablage - // et revele l'indice "via Personnaliser" (span data-qty-hint, hidden en HTML). - const productRows = PRODUCTS - .map(p => { - const hasMods = p.modifiers && p.modifiers.length; - const configure = hasMods - ? `` - : ''; - const hint = hasMods - ? `` - : ''; - return `${hint}${configure}`; - }) - .join(''); +function setup(products = PRODUCTS, menus = MENUS) { + // Le catalogue est embarque dans deux scripts JSON inertes (CSP-safe), lus par le JS. const dom = new JSDOM( '' + - '
    ` + + '' + ' ' + - ' ' + - '
    ' + - productRows + - ' ' + - '
    • Panier vide.
    ' + - '

    Total : 0,00 EUR

    ' + - ' ' + + ` ` + + ` ` + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + '
    ' + '' + '', @@ -98,30 +93,71 @@ function itemsJson(dom) { return JSON.parse(dom.window.document.getElementById('items_json').value || '[]'); } -test('ajout produit sans modificateur (quantite) -> items_json contient {type:product}', () => { +function click(dom, node) { + node.dispatchEvent(new dom.window.Event('click', { bubbles: true })); +} + +// Active l'onglet d'une categorie par son libelle (les tuiles d'une seule categorie sont +// rendues a la fois). Renvoie la liste des tuiles affichees apres activation. +function activateCategory(dom, label) { + const doc = dom.window.document; + const tab = Array.prototype.find.call( + doc.querySelectorAll('.pos__tab'), + t => t.textContent === label, + ); + assert.ok(tab, 'onglet "' + label + '" present'); + click(dom, tab); + return Array.prototype.slice.call(doc.querySelectorAll('.pos-tile')); +} + +// Tuile par nom de produit/menu (dans la grille de la categorie active). +function tileByName(dom, name) { + const doc = dom.window.document; + return Array.prototype.find.call( + doc.querySelectorAll('.pos-tile'), + t => t.querySelector('.pos-tile__name') && t.querySelector('.pos-tile__name').textContent === name, + ); +} + +test('onglets categories : un onglet par categorie distincte (produits + menus)', () => { const dom = setup(); counterOrder.init(dom.window.document); - // Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_. - const qty = dom.window.document.getElementById('qty_22'); - qty.value = '2'; - fireSubmit(dom); - - const items = itemsJson(dom); - assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]); + const labels = Array.prototype.map.call( + dom.window.document.querySelectorAll('.pos__tab'), + t => t.textContent, + ); + assert.deepEqual(labels, ['Burgers', 'Accompagnements', 'Boissons', 'Menus']); }); -test('produit personnalisable : qty directe ignoree (route par la modale, anti-double-comptage)', () => { +test('tuile produit simple : tap ajoute {type:product, quantity:1} ; re-tap fusionne (qty 2)', () => { const dom = setup(); const doc = dom.window.document; counterOrder.init(doc); - // Cheeseburger (12) porte un bouton Personnaliser : son qty_ est ignore par le - // JS pour eviter le double comptage avec la ligne configuree. - doc.getElementById('qty_12').value = '3'; - fireSubmit(dom); + activateCategory(dom, 'Accompagnements'); // Frites (22), Ketchup (47) + const frites = tileByName(dom, 'Frites'); + click(dom, frites); + assert.ok(doc.querySelector('.order-cart__line')); - assert.deepEqual(itemsJson(dom), []); + click(dom, frites); // re-tap -> fusion (qty 2), pas une 2e ligne. + assert.equal(doc.querySelectorAll('.order-cart__line').length, 1); + + fireSubmit(dom); + assert.deepEqual(itemsJson(dom), [{ type: 'product', product_id: 22, quantity: 2, modifiers: [] }]); +}); + +test('tuile produit a modificateurs : tap ouvre la modale (pas d ajout direct)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Burgers'); // Cheeseburger (12) a modificateurs + click(dom, tileByName(dom, 'Cheeseburger')); + + const modal = doc.getElementById('menu-composer-modal'); + assert.equal(modal.hasAttribute('hidden'), false); // modale ouverte + assert.equal(doc.querySelector('.order-cart__line'), null); // rien ajoute sans validation }); test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[remove, add]', () => { @@ -129,8 +165,8 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[ const doc = dom.window.document; counterOrder.init(doc); - // Ouvre la modale du produit 12 (Cheeseburger). - doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Burgers'); + click(dom, tileByName(dom, 'Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); assert.equal(modal.hasAttribute('hidden'), false); @@ -143,7 +179,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[ addBox.checked = true; addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); assert.equal(modal.hasAttribute('hidden'), true); assert.ok(doc.querySelector('.order-cart__line')); @@ -160,7 +196,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[ ]); }); -test('quantite 0 ignoree -> panier vide serialise []', () => { +test('panier vide -> items_json serialise []', () => { const dom = setup(); counterOrder.init(dom.window.document); @@ -168,13 +204,48 @@ test('quantite 0 ignoree -> panier vide serialise []', () => { assert.deepEqual(itemsJson(dom), []); }); +test('stepper +/- : + incremente, - decremente, 0 retire la ligne', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Accompagnements'); + click(dom, tileByName(dom, 'Frites')); + + const inc = doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]'); + click(dom, inc); // qty 1 -> 2 + assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2'); + + const dec = doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]'); + click(dom, dec); // 2 -> 1 + assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '1'); + + click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Diminuer"]')); // 1 -> 0 = retrait + assert.equal(doc.querySelector('.order-cart__line'), null); + fireSubmit(dom); + assert.deepEqual(itemsJson(dom), []); +}); + +test('retirer une ligne via le bouton Retirer', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Accompagnements'); + click(dom, tileByName(dom, 'Frites')); + assert.ok(doc.querySelector('.order-cart__line')); + + click(dom, doc.querySelector('.order-cart__remove')); + assert.equal(doc.querySelector('.order-cart__line'), null); +}); + test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => { const dom = setup(); const doc = dom.window.document; counterOrder.init(doc); - // Ouvre la modale du menu 5. - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); assert.equal(modal.hasAttribute('hidden'), false); @@ -195,7 +266,7 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu sauceSelect.value = '47'; sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); // Modale fermee, panier recap mis a jour. assert.equal(modal.hasAttribute('hidden'), true); @@ -216,12 +287,63 @@ test('configuration menu (format Maxi + slots) -> items_json contient {type:menu ]); }); +test('quantite MENU : stepper + sur une ligne menu -> items_json porte quantity:2, un seul jeu de selections', () => { + // G : la quantite d'une ligne menu est ajustable au panneau (stepper) et serialisee + // dans quantity ; les selections de slot ne sont PAS dupliquees par la quantite. + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); + const modal = doc.getElementById('menu-composer-modal'); + click(dom, modal.querySelector('.menu-composer__add')); // ajoute le menu (requis pre-selectionnes) + + // Stepper + sur la ligne menu : qty 1 -> 2. + click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]')); + assert.equal(doc.querySelector('.order-cart__qty-value').textContent, '2'); + + fireSubmit(dom); + const items = itemsJson(dom); + assert.equal(items.length, 1); + assert.equal(items[0].type, 'menu'); + assert.equal(items[0].quantity, 2); + // Un SEUL jeu de selections (requis : drink + side), pas duplique par la quantite. + assert.deepEqual(items[0].selections, [ + { menu_slot_id: 1, product_id: 14 }, + { menu_slot_id: 16, product_id: 22 }, + ]); +}); + +test('total : menu Maxi (11,90) x2 -> 23,80 EUR (quantite multipliee)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); + const modal = doc.getElementById('menu-composer-modal'); + const maxiRadio = Array.prototype.find.call( + modal.querySelectorAll('.menu-composer__format-input'), + r => r.value === 'maxi', + ); + maxiRadio.checked = true; + maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); + + click(dom, doc.querySelector('.order-cart__qty-btn[aria-label^="Augmenter"]')); // x2 + + assert.equal(doc.querySelector('.order-cart__price').textContent, '23,80 EUR'); + assert.equal(doc.getElementById('order-total-value').textContent, '23,80 EUR'); +}); + test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => { const dom = setup(); const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); // Laisse la sauce a "Sans" (valeur vide) ; ajoute directement. @@ -232,7 +354,7 @@ test('menu Normal sans la sauce optionnelle -> selections ne contient que les re sauceSelect.value = ''; sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); fireSubmit(dom); const items = itemsJson(dom); @@ -248,12 +370,14 @@ test('produit + menu combines -> items_json contient les deux lignes', () => { const doc = dom.window.document; counterOrder.init(doc); - // Frites (22) sans modificateur -> chemin qty_. - doc.getElementById('qty_22').value = '1'; + // Frites (22) sans modificateur -> ajout direct par tap. + activateCategory(dom, 'Accompagnements'); + click(dom, tileByName(dom, 'Frites')); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); fireSubmit(dom); const items = itemsJson(dom); @@ -267,7 +391,8 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[ const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); // Retire l'oignon du burger (ingredient 3, is_removable). @@ -275,7 +400,7 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[ removeBox.checked = true; removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); fireSubmit(dom); const items = itemsJson(dom); @@ -288,9 +413,10 @@ test('total + bouton : produit simple (Frites 2,50 x2) -> 5,00 EUR affiche', () const doc = dom.window.document; counterOrder.init(doc); - const qty = doc.getElementById('qty_22'); // Frites, 250c - qty.value = '2'; - qty.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + activateCategory(dom, 'Accompagnements'); + const frites = tileByName(dom, 'Frites'); // 250c + click(dom, frites); + click(dom, frites); // qty 2 assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR'); assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 5,00 EUR'); @@ -301,12 +427,13 @@ test('total : produit personnalise avec ajout (Cheeseburger 8,90 + Bacon 0,50) - const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Burgers'); + click(dom, tileByName(dom, 'Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]'); addBox.checked = true; addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); // Prix de ligne affiche dans le panier. assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR'); @@ -318,7 +445,8 @@ test('total : menu Maxi (11,90) inclus dans le total de ligne', () => { const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); const maxiRadio = Array.prototype.find.call( modal.querySelectorAll('.menu-composer__format-input'), @@ -326,7 +454,7 @@ test('total : menu Maxi (11,90) inclus dans le total de ligne', () => { ); maxiRadio.checked = true; maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true })); - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); assert.equal(doc.querySelector('.order-cart__price').textContent, '11,90 EUR'); assert.equal(doc.getElementById('order-total-value').textContent, '11,90 EUR'); @@ -357,16 +485,12 @@ test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); - // Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force - // l'etat en deselectionnant via un select dont l'option vide n'existe pas. On - // simule en retirant la selection requise par un slot side (16) mis a vide n'est pas - // possible (requis) ; on retire plutot la pre-selection en posant une valeur hors options. - // Plus simple : on supprime l'option pre-cochee du slot requis 'drink' (1) en le - // forcant a une chaine vide via le select (un slot requis n'a pas d'option Sans, mais - // jsdom autorise l'affectation d'une value vide -> change supprime la selection). + // Vide un slot requis (drink, slot 1) : un slot requis n'a pas d'option Sans, mais + // jsdom autorise l'affectation d'une value vide -> change supprime la selection. const drinkSelect = Array.prototype.find.call( modal.querySelectorAll('.menu-composer__slot-select'), s => s.dataset.slotId === '1', @@ -381,7 +505,7 @@ test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', assert.equal(errAtOpen.textContent, ''); assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y) - modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, modal.querySelector('.menu-composer__add')); // Modale encore ouverte, message inline renseigne (textContent), aucune ligne. assert.equal(modal.hasAttribute('hidden'), false); @@ -389,44 +513,45 @@ test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', assert.equal(doc.querySelector('.order-cart__line'), null); }); -test('produit personnalisable : champ qty editable en HTML, desactive par JS + indice revele', () => { +test('tuile : pastille de repli quand aucune image (image vide)', () => { const dom = setup(); - const doc = dom.window.document; + counterOrder.init(dom.window.document); - // Avant init : le champ qty d'un produit a modificateurs est editable (repli sans JS) - // et l'indice "via Personnaliser" est cache. - const qty = doc.getElementById('qty_12'); - const hint = doc.querySelector('[data-qty-hint="12"]'); - assert.equal(qty.disabled, false); - assert.equal(hint.hidden, true); - - counterOrder.init(doc); - - // Apres init (JS present) : champ neutralise et indice revele. - assert.equal(qty.disabled, true); - assert.equal(hint.hidden, false); - - // Un produit SANS modificateur reste editable (pas d'indice). - assert.equal(doc.getElementById('qty_22').disabled, false); - assert.equal(doc.querySelector('[data-qty-hint="22"]'), null); + activateCategory(dom, 'Burgers'); + const tile = tileByName(dom, 'Cheeseburger'); + // image vide -> aucune , une pastille (initiale C) a la place. + assert.equal(tile.querySelector('.pos-tile__image'), null); + assert.equal(tile.querySelector('.pos-tile__pastille').textContent, 'C'); }); -test('modale : focus restaure sur le bouton declencheur a la fermeture', () => { +test('tuile : image rendue quand image fournie', () => { + const withImg = [{ id: 99, name: 'Special', price: 500, image: '/img/special.png', category_id: 1, category_name: 'Burgers', modifiers: [] }]; + const dom = setup(withImg, []); + counterOrder.init(dom.window.document); + + activateCategory(dom, 'Burgers'); + const img = tileByName(dom, 'Special').querySelector('.pos-tile__image'); + assert.ok(img); + assert.equal(img.getAttribute('src'), '/img/special.png'); +}); + +test('modale : focus restaure sur la tuile declencheuse a la fermeture', () => { const dom = setup(); const doc = dom.window.document; counterOrder.init(doc); - const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]'); + activateCategory(dom, 'Menus'); + const trigger = tileByName(dom, 'Menu Cheeseburger'); trigger.focus(); assert.equal(doc.activeElement, trigger); - trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + click(dom, trigger); const modal = doc.getElementById('menu-composer-modal'); - // Le focus est entre dans la modale (plus sur le bouton declencheur). + // Le focus est entre dans la modale (plus sur la tuile declencheuse). assert.notEqual(doc.activeElement, trigger); doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - // Ferme -> focus restaure sur le declencheur. + // Ferme -> focus restaure sur la tuile. assert.equal(doc.activeElement, trigger); }); @@ -435,7 +560,8 @@ test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const panel = doc.querySelector('.menu-composer'); assert.equal(panel.getAttribute('role'), 'dialog'); assert.equal(panel.getAttribute('aria-modal'), 'true'); @@ -448,27 +574,15 @@ test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', test('total : separateur de milliers aligne sur PHP (1 234,50 EUR)', () => { // Produit a 617,25 EUR (61725c) x2 = 1 234,50 EUR -> espace separateur de milliers. - const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, modifiers: [] }]; - const dom = new JSDOM( - '' + - '
    ` + - ' ' + - ' ' + - ' ' + - '
    • Panier vide.
    ' + - '

    0,00 EUR

    ' + - ' ' + - '
    ' + - '' + - '', - ); + const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, image: '', category_id: 1, category_name: 'Plateaux', modifiers: [] }]; + const dom = setup(PRICEY, []); const doc = dom.window.document; counterOrder.init(doc); - const qty = doc.getElementById('qty_99'); - qty.value = '2'; - qty.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + activateCategory(dom, 'Plateaux'); + const tile = tileByName(dom, 'Plateau'); + click(dom, tile); + click(dom, tile); // qty 2 assert.equal(doc.getElementById('order-total-value').textContent, '1 234,50 EUR'); assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 1 234,50 EUR'); @@ -479,7 +593,8 @@ test('modale : touche Echap ferme la modale', () => { const doc = dom.window.document; counterOrder.init(doc); - doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + activateCategory(dom, 'Menus'); + click(dom, tileByName(dom, 'Menu Cheeseburger')); const modal = doc.getElementById('menu-composer-modal'); assert.equal(modal.hasAttribute('hidden'), false); @@ -487,6 +602,14 @@ test('modale : touche Echap ferme la modale', () => { assert.equal(modal.hasAttribute('hidden'), true); }); +test('buildCategoryTabs: une entree par categorie, comptage cumule produits+menus', () => { + const tabs = counterOrder.buildCategoryTabs(PRODUCTS, MENUS); + assert.deepEqual(tabs.map(t => t.name), ['Burgers', 'Accompagnements', 'Boissons', 'Menus']); + // Accompagnements regroupe Frites + Ketchup. + assert.equal(tabs.find(t => t.name === 'Accompagnements').count, 2); + assert.equal(tabs.find(t => t.name === 'Menus').count, 1); +}); + test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => { const productById = {}; PRODUCTS.forEach(p => { productById[p.id] = p; }); @@ -500,3 +623,160 @@ test('composerSteps: slot_type non gere (dessert) ignore, slots tries par displa const steps = counterOrder.composerSteps(menu, productById); assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order }); + +test('A : changer d onglet conserve le focus clavier sur l onglet actif (pas de retour vers body)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + const tabs = doc.querySelectorAll('.pos__tab'); + const second = tabs[1]; // Accompagnements + second.focus(); + assert.equal(doc.activeElement, second); + + click(dom, second); + // Le bouton n'est PAS detruit (pas de reconstruction de la barre) : focus preserve. + assert.equal(doc.activeElement, second); + assert.equal(second.classList.contains('is-active'), true); + // Les autres onglets existent encore (memes references, simplement mutees). + assert.equal(doc.querySelectorAll('.pos__tab').length, tabs.length); +}); + +test('B : roving tabindex (actif=0, autres=-1) et aria-selected coherents', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab')); + // Au depart : 1er onglet actif (tabindex 0), les autres -1. + assert.equal(tabs[0].tabIndex, 0); + assert.equal(tabs[0].getAttribute('aria-selected'), 'true'); + tabs.slice(1).forEach(t => { + assert.equal(t.tabIndex, -1); + assert.equal(t.getAttribute('aria-selected'), 'false'); + }); + + // Apres activation du 3e : le roving tabindex suit. + click(dom, tabs[2]); + assert.equal(tabs[2].tabIndex, 0); + assert.equal(tabs[2].getAttribute('aria-selected'), 'true'); + assert.equal(tabs[0].tabIndex, -1); +}); + +test('B : Fleche droite/gauche deplace le focus ET active l onglet (cyclique)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab')); + tabs[0].focus(); + + // L'event remonte (bubbles) jusqu'au conteneur tablist ; event.target est l'onglet + // focalise. On dispatche depuis l'element actif pour refleter le focus clavier reel. + function arrowFromActive(key) { + doc.activeElement.dispatchEvent( + new dom.window.KeyboardEvent('keydown', { key, bubbles: true }), + ); + } + + arrowFromActive('ArrowRight'); // 0 -> 1 + assert.equal(doc.activeElement, tabs[1]); + assert.equal(tabs[1].getAttribute('aria-selected'), 'true'); + + arrowFromActive('ArrowLeft'); // 1 -> 0 + assert.equal(doc.activeElement, tabs[0]); + + arrowFromActive('ArrowLeft'); // 0 -> dernier (cyclique) + assert.equal(doc.activeElement, tabs[tabs.length - 1]); + + arrowFromActive('Home'); // -> premier + assert.equal(doc.activeElement, tabs[0]); + + arrowFromActive('End'); // -> dernier + assert.equal(doc.activeElement, tabs[tabs.length - 1]); +}); + +test('B : onglets relies au tabpanel (aria-controls vers la grille, grille labellisee par l onglet actif)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + const grid = doc.getElementById('pos-grid'); + const tabs = Array.prototype.slice.call(doc.querySelectorAll('.pos__tab')); + tabs.forEach(t => assert.equal(t.getAttribute('aria-controls'), 'pos-grid')); + + // La grille (tabpanel) est libellee par l'onglet actif. + assert.equal(grid.getAttribute('aria-labelledby'), tabs[0].id); + click(dom, tabs[1]); + assert.equal(grid.getAttribute('aria-labelledby'), tabs[1].id); +}); + +test('C : region live concise mise a jour a chaque mutation (total + nombre d articles)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + const announce = doc.getElementById('pos-announce'); + + // Init : panier vide. + assert.equal(announce.textContent, 'Panier vide'); + + activateCategory(dom, 'Accompagnements'); + const frites = tileByName(dom, 'Frites'); // 250c + click(dom, frites); + assert.equal(announce.textContent, 'Total 2,50 EUR, 1 article'); + + click(dom, frites); // qty 2 + assert.equal(announce.textContent, 'Total 5,00 EUR, 2 articles'); +}); + +test('C : ni #order-cart ni #pos-grid ne portent aria-live (eviter la verbosite)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + assert.equal(doc.getElementById('order-cart').hasAttribute('aria-live'), false); + assert.equal(doc.getElementById('pos-grid').hasAttribute('aria-live'), false); +}); + +test('D : tuile qui ouvre la modale porte aria-haspopup=dialog et l intention dans l aria-label', () => { + const dom = setup(); + counterOrder.init(dom.window.document); + + activateCategory(dom, 'Burgers'); + const burger = tileByName(dom, 'Cheeseburger'); // a modificateurs -> modale + assert.equal(burger.getAttribute('aria-haspopup'), 'dialog'); + assert.match(burger.getAttribute('aria-label'), /a composer/); + + activateCategory(dom, 'Menus'); + const menu = tileByName(dom, 'Menu Cheeseburger'); + assert.equal(menu.getAttribute('aria-haspopup'), 'dialog'); + assert.match(menu.getAttribute('aria-label'), /menu a composer/); +}); + +test('D : tuile produit simple n a PAS aria-haspopup (ajout direct au tap)', () => { + const dom = setup(); + counterOrder.init(dom.window.document); + + activateCategory(dom, 'Accompagnements'); + const frites = tileByName(dom, 'Frites'); // sans modificateur + assert.equal(frites.hasAttribute('aria-haspopup'), false); +}); + +test('E : quantite invalide dans la modale produit -> ramenee a 1 et reaffichee dans l input', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + activateCategory(dom, 'Burgers'); + click(dom, tileByName(dom, 'Cheeseburger')); + const modal = doc.getElementById('menu-composer-modal'); + const qtyInput = modal.querySelector('#composer-product-qty'); + + qtyInput.value = '0'; + qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + assert.equal(qtyInput.value, '1'); // valeur corrigee reaffichee + + qtyInput.value = ''; + qtyInput.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + assert.equal(qtyInput.value, '1'); +});