feat(back-office): saisie commande comptoir/drive en POS tactile a tuiles
All checks were successful
CI / secret-scan (push) Successful in 20s
CI / php-lint (push) Successful in 49s
CI / static-tests (push) Successful in 1m48s
CI / js-tests (push) Successful in 59s
CI / secret-scan (pull_request) Successful in 20s
CI / php-lint (pull_request) Successful in 45s
CI / static-tests (pull_request) Successful in 1m39s
CI / js-tests (pull_request) Successful in 1m8s

Remplace le formulaire-liste (champs quantite) par un ecran de caisse facon
tablette : onglets categories + grille de tuiles produits/menus (tap = ajout)
+ panneau commande persistant a droite (lignes avec +/-, total, bouton
"Encaisser X,XX EUR"). Le contrat serveur ne change pas (items_json -> store ->
createStaffOrder) ; les acquis du lot precedent sont conserves (verrou drive,
service_tag dine_in, format Normal/Maxi, prix indicatifs cote client).

Accessibilite : pattern tablist clavier complet (roving tabindex + fleches +
Home/End + aria-controls/tabpanel), focus conserve au changement d'onglet,
region live concise (total + nb articles), tuiles ouvrant une modale annoncees
(aria-haspopup + libelle "a composer"). Quantite menu desormais ajustable
(facturee par quantite). Catalogue passe en scripts JSON inertes (JSON_HEX_*),
rendu via textContent. CSP-safe (aucun handler inline).

Tests : JS 135, PHP unit 406, PHPStan L6. Apercu valide par le commanditaire.
This commit is contained in:
Imugiii 2026-06-24 12:24:01 +00:00
parent 6f2aedc699
commit c2a0cfdd89
5 changed files with 1355 additions and 558 deletions

View file

@ -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_<id>` 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<array<string, mixed>> $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),
'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<array<string, mixed>> $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,37 +117,52 @@ $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 <script> (anti-XSS, RG-T15).
$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
<?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form"
data-products="<?= $attr($jsProducts) ?>"
data-menus="<?= $attr($jsMenus) ?>">
<form method="post" action="<?= $esc($action) ?>" class="pos" id="counter-order-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="items_json" id="items_json" value="">
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<?php /* Donnees du catalogue pour counter-order.js : script JSON inerte (CSP-safe). */ ?>
<script type="application/json" id="pos-products"><?= (string) json_encode($jsProducts, $jsonFlags) ?></script>
<script type="application/json" id="pos-menus"><?= (string) json_encode($jsMenus, $jsonFlags) ?></script>
<div class="pos__main">
<div class="pos__catalogue">
<?php /* Barre d'onglets categories (construite par le JS depuis le catalogue). */ ?>
<div class="pos__tabs" id="pos-tabs" role="tablist" aria-label="Categories"></div>
<?php if ($productRows === [] && $menuRows === []): ?>
<p class="admin-empty">Aucun produit ni menu commandable pour le moment.</p>
<?php else: ?>
<?php /* Grille de tuiles (remplie par le JS) + repli sans JS. role=tabpanel
relie au tablist (aria-labelledby pose par le JS vers l'onglet
actif). Pas d'aria-live ici : la grille est rebatie a chaque
changement de categorie, une re-annonce complete serait verbeuse. */ ?>
<div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0">
<p class="pos__nojs">Activez JavaScript pour saisir une commande sur cet ecran de caisse.</p>
</div>
<?php endif; ?>
</div>
<?php /* Panneau commande persistant (recap a droite, facon caisse). */ ?>
<aside class="pos__panel" aria-label="Commande en cours">
<div class="pos__panel-head">
<span class="pos__panel-title">Commande</span>
<div class="pos__service">
<?php if ($chan === 'drive'): ?>
<?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
transmet la valeur par un champ cache (un select readonly resterait
@ -149,122 +170,42 @@ foreach ($productRows as $p) {
<p class="form-static" id="service_mode_display">Drive</p>
<input type="hidden" name="service_mode" id="service_mode" value="drive">
<?php else: ?>
<label class="pos__service-label" for="service_mode">Mode</label>
<select class="form-input" id="service_mode" name="service_mode">
<option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
<option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
</select>
<?php endif; ?>
</div>
<?php if ($chan !== 'drive'): ?>
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
(toggle JS sur le mode) ; le champ reste soumis tel quel, persist()
l'ignore hors dine_in. */ ?>
<div class="form-group" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="form-label" for="service_tag">Numero de table</label>
hors dine_in (toggle JS sur le mode) ; le champ reste soumis tel quel,
persist() l'ignore hors dine_in. */ ?>
<div class="pos__service" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="pos__service-label" for="service_tag">Table</label>
<input class="form-input" type="text" id="service_tag" name="service_tag"
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
</div>
<?php endif; ?>
</div>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<?php foreach ($productGroups as $catName => $catProducts): ?>
<h3 class="order-group__title"><?= $esc($catName) ?></h3>
<table class="admin-table">
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
</tr>
</thead>
<tbody>
<?php foreach ($catProducts as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<?php /* 4 (progressive enhancement) : le champ quantite est
rendu EDITABLE pour TOUS les produits, pour que la saisie
marche SANS JS (qty de base, sans modificateurs). C'est
counter-order.js qui neutralise ce champ au cablage pour un
produit personnalisable et route la saisie vers la modale.
L'indice "via Personnaliser" est cache par defaut et revele
par le JS, pour ne pas perturber le rendu sans JS. */ ?>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
<?php if ($hasModifiers): ?>
<span class="order-qty-hint" data-qty-hint="<?= $pid ?>" hidden>via Personnaliser</span>
<?php endif; ?>
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Menus</legend>
<?php if ($menuRows === []): ?>
<p class="admin-empty">Aucun menu commandable pour le moment.</p>
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php
$mid = (int) ($m['id'] ?? 0);
$priceNormal = (int) ($m['price_normal_cents'] ?? 0);
$priceMaxi = (int) ($m['price_maxi_cents'] ?? 0);
?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<?php /* 6 : afficher les deux prix qualifies (Normal / Maxi) pour que
le choix de format soit lisible avant d'ouvrir la modale. */ ?>
<span class="menu-list__price">
Normal <?= $esc($euros($priceNormal)) ?> / Maxi <?= $esc($euros($priceMaxi)) ?>
</span>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Panier</legend>
<ul class="order-cart" id="order-cart" aria-live="polite">
<?php /* Pas d'aria-live sur la liste : elle est rebatie a chaque +/- (une
re-annonce de tout le panier serait verbeuse). Une region live dediee
(#pos-announce) annonce un message concis a chaque mutation. */ ?>
<ul class="order-cart" id="order-cart">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php /* 1 : total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<p class="order-total" id="order-total">Total : <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
<div class="pos__panel-foot">
<?php /* Total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<p class="order-total" id="order-total">Total <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
<button class="btn btn-primary pos__pay" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
</div>
<?php /* Region live concise (C) : recoit "Total X EUR, N articles" a chaque
mutation du panier. Visuellement discrete (classe sr-only). */ ?>
<span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>
</aside>
</div>
</form>

View file

@ -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. */

View file

@ -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".
*
* 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_<id> (repli sans JS conserve : le serveur accepte aussi qty_<id> 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_<id> 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_<id> (>= 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.
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
items.push({ type: 'product', product_id: productId, quantity: quantity });
}
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 };
}),
});
productLines.forEach(function (line) {
return;
}
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_<id> (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 + ')';
return modLabel ? (text + ' (' + modLabel + ')') : text;
}
var label = line.productName;
var pm = modifierLabel(line.proposable, line.modifiers);
return pm ? (label + ' (' + pm + ')') : label;
}
label.textContent = text;
li.appendChild(label);
// 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();
}
function removeLine(line) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
}
// 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(menuLineTotal(line));
li.appendChild(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.addEventListener('click', function () {
menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
removeBtn.setAttribute('aria-label', 'Retirer ' + lineLabel(line) + ' de la commande');
removeBtn.addEventListener('click', function () { removeLine(line); });
controls.appendChild(removeBtn);
cart.appendChild(li);
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 */
/* ----------------------------------------------------------------- */
@ -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();
}
@ -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,
@ -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) {
// 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;
}
// 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';
// 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');
}
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);
});
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) {
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);
});
}
});
});
// 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).
// 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();
}
}
input.addEventListener('input', updateTotal);
input.addEventListener('change', updateTotal);
});
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 () {

View file

@ -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_<id>. 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_<id>, 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 &quot;. On cherche les fragments echappes (forme reellement rendue).
self::assertStringContainsString('ingredient_id&quot;:3', $body);
self::assertStringContainsString('ingredient_id&quot;: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 <script> (un '<' deviendrait < ; pas de </script>
// 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_<id>).
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

View file

@ -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 => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
// qty_<id> 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
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
const hint = hasMods
? `<span class="order-qty-hint" data-qty-hint="${p.id}" hidden>via Personnaliser</span>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${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(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
'<form id="counter-order-form" method="post" action="/counter/orders">' +
' <input type="hidden" name="items_json" id="items_json" value="">' +
` <script type="application/json" id="pos-products">${JSON.stringify(products)}</script>` +
` <script type="application/json" id="pos-menus">${JSON.stringify(menus)}</script>` +
' <div class="pos__main">' +
' <div class="pos__catalogue">' +
' <div class="pos__tabs" id="pos-tabs" role="tablist"></div>' +
' <div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0"></div>' +
' </div>' +
' <aside class="pos__panel">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p id="order-total">Total : <span id="order-total-value">0,00 EUR</span></p>' +
' <ul class="order-cart" id="order-cart"><li class="order-cart__empty" id="order-cart-empty">Panier vide.</li></ul>' +
' <p id="order-total">Total <span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
' <span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>' +
' </aside>' +
' </div>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -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_<id>.
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_<id> 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_<id>.
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 <img>, 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(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRICEY)}' data-menus='[]'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <input class="order-qty" type="number" id="qty_99" name="qty_99" data-product-id="99" value="0">' +
' <ul id="menu-list"></ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p><span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
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');
});