feat(back-office): saisie commande comptoir/drive en POS tactile a tuiles (#104)
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 50s
CI / static-tests (push) Successful in 1m44s
CI / js-tests (push) Successful in 52s

This commit is contained in:
Corentin JOGUET 2026-06-24 14:32:11 +02:00
parent 6f2aedc699
commit 9bdd53120c
5 changed files with 1355 additions and 558 deletions

View file

@ -3,23 +3,22 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c + refonte saisie), * POS tactile a tuiles (comptoir / drive), injecte dans admin/layout.php. Refonte de
* injecte dans admin/layout.php. Produits commandables ET menus composes (slots * la saisie : a la place du formulaire-liste, un ecran de caisse facon borne client
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients). * (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, * 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 * zero handler inline) : il lit produits et menus depuis un script JSON inerte
* #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger * (type="application/json"), construit les onglets, rend la grille, gere le panneau
* de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les * commande, et serialise les items en JSON dans le champ cache #items_json a la
* items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide * soumission. Le serveur revalide tout (RG-T18, resolveModifiers) et recalcule les
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Les prix affiches * prix (RG-T16) : les prix affiches cote client (par ligne + total + libelle du
* cote client (par ligne + total + libelle du bouton) sont INDICATIFS : le serveur * bouton) sont INDICATIFS, le serveur reste seul juge. Le contrat de soumission est
* reste seul juge. Le tableau de quantites produit `qty_<id>` reste present comme * inchange (items_json + service_mode + service_tag + _csrf). Sans JS, la grille ne
* repli sans JS (3a) : le champ quantite est rendu EDITABLE pour TOUS les produits. * s'affiche pas : un message invite a activer JS (le POS est interactif par nature).
* 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.
* *
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal * 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, * 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'); $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'; $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 ?? ''); $csrf = $esc($csrfToken ?? '');
$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter'; $chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders'; $action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
@ -59,10 +50,11 @@ $productRows = isset($products) && is_array($products) ? $products : [];
/** @var list<array<string, mixed>> $menuRows */ /** @var list<array<string, mixed>> $menuRows */
$menuRows = isset($menus) && is_array($menus) ? $menus : []; $menuRows = isset($menus) && is_array($menus) ? $menus : [];
// Projection compacte pour le JS : seules les cles utiles a la composition. Les // Projection compacte pour le JS : seules les cles utiles a la composition, l'affichage
// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16). // (tuiles : nom, prix, image, categorie) et le calcul local. Les prix sont passes pour
// modifiers : ingredients retirables / ajoutables proposables (le client les affiche // l'affichage local (le serveur reste seul juge, RG-T16). modifiers : ingredients
// en cases a cocher ; resolveModifiers revalide chacun cote serveur). // retirables / ajoutables proposables (cases a cocher cote client ; resolveModifiers
// revalide chacun cote serveur).
$jsModifiers = static fn (mixed $rows): array => array_map( $jsModifiers = static fn (mixed $rows): array => array_map(
static fn (array $r): array => [ static fn (array $r): array => [
'ingredient_id' => (int) ($r['ingredient_id'] ?? 0), 'ingredient_id' => (int) ($r['ingredient_id'] ?? 0),
@ -73,17 +65,28 @@ $jsModifiers = static fn (mixed $rows): array => array_map(
], ],
is_array($rows) ? $rows : [], 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( $jsProducts = array_map(
static fn (array $p): array => [ static fn (array $p): array => [
'id' => (int) ($p['id'] ?? 0), 'id' => (int) ($p['id'] ?? 0),
'name' => (string) ($p['name'] ?? ''), 'name' => (string) ($p['name'] ?? ''),
'price' => (int) ($p['price_cents'] ?? 0), 'price' => (int) ($p['price_cents'] ?? 0),
'modifiers' => $jsModifiers($p['modifiers'] ?? null), 'image' => (string) ($p['image_path'] ?? ''),
'category_id' => (int) ($p['category_id'] ?? 0),
'category_name' => $catNameOf($p),
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
], ],
$productRows, $productRows,
); );
$jsMenus = array_map( $jsMenus = array_map(
static function (array $m) use ($jsModifiers): array { static function (array $m) use ($jsModifiers, $catNameOf): array {
/** @var list<array<string, mixed>> $slots */ /** @var list<array<string, mixed>> $slots */
$slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : []; $slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : [];
@ -92,6 +95,9 @@ $jsMenus = array_map(
'name' => (string) ($m['name'] ?? ''), 'name' => (string) ($m['name'] ?? ''),
'price_normal' => (int) ($m['price_normal_cents'] ?? 0), 'price_normal' => (int) ($m['price_normal_cents'] ?? 0),
'price_maxi' => (int) ($m['price_maxi_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 // Modificateurs du burger support : la selection d'un menu cible le burger
// (resolveModifiers cote serveur le resout sur burger_product_id). // (resolveModifiers cote serveur le resout sur burger_product_id).
'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null), 'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null),
@ -111,160 +117,95 @@ $jsMenus = array_map(
$menuRows, $menuRows,
); );
// Regroupement des produits par categorie (7b) : sous-titre par categorie pour que // JSON inerte (type="application/json") plutot que data-* : la charge (compo de chaque
// l'equipier scanne plus vite. availableForCatalogue trie deja par categorie puis // produit + slots de chaque menu) peut etre volumineuse ; un script JSON reste CSP-safe
// display_order ; on conserve cet ordre et on agrege les lignes consecutives de meme // (non execute) et plus lisible qu'un long attribut data-*. JSON_HEX_* echappe < > & '
// categorie. category_name absent -> groupe "Autres" (evite une cle vide a l'ecran). // pour que la sortie soit sure a l'interieur d'un <script> (anti-XSS, RG-T15).
$productGroups = []; $jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
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;
}
?> ?>
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1> <h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div> </div>
<?php if ($errorMessage !== null): ?> <?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p> <p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form" <form method="post" action="<?= $esc($action) ?>" class="pos" id="counter-order-form">
data-products="<?= $attr($jsProducts) ?>"
data-menus="<?= $attr($jsMenus) ?>">
<input type="hidden" name="_csrf" value="<?= $csrf ?>"> <input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="items_json" id="items_json" value=""> <input type="hidden" name="items_json" id="items_json" value="">
<div class="form-group"> <?php /* Donnees du catalogue pour counter-order.js : script JSON inerte (CSP-safe). */ ?>
<label class="form-label" for="service_mode">Mode de service</label> <script type="application/json" id="pos-products"><?= (string) json_encode($jsProducts, $jsonFlags) ?></script>
<?php if ($chan === 'drive'): ?> <script type="application/json" id="pos-menus"><?= (string) json_encode($jsMenus, $jsonFlags) ?></script>
<?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
editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
<p class="form-static" id="service_mode_display">Drive</p>
<input type="hidden" name="service_mode" id="service_mode" value="drive">
<?php else: ?>
<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'): ?> <div class="pos__main">
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut <div class="pos__catalogue">
(toggle JS sur le mode) ; le champ reste soumis tel quel, persist() <?php /* Barre d'onglets categories (construite par le JS depuis le catalogue). */ ?>
l'ignore hors dine_in. */ ?> <div class="pos__tabs" id="pos-tabs" role="tablist" aria-label="Categories"></div>
<div class="form-group" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="form-label" for="service_tag">Numero de table</label> <?php if ($productRows === [] && $menuRows === []): ?>
<input class="form-input" type="text" id="service_tag" name="service_tag" <p class="admin-empty">Aucun produit ni menu commandable pour le moment.</p>
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off"> <?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> </div>
<?php endif; ?>
<fieldset class="form-group"> <?php /* Panneau commande persistant (recap a droite, facon caisse). */ ?>
<legend>Produits</legend> <aside class="pos__panel" aria-label="Commande en cours">
<?php if ($productRows === []): ?> <div class="pos__panel-head">
<p class="admin-empty">Aucun produit commandable pour le moment.</p> <span class="pos__panel-title">Commande</span>
<?php else: ?> <div class="pos__service">
<?php foreach ($productGroups as $catName => $catProducts): ?> <?php if ($chan === 'drive'): ?>
<h3 class="order-group__title"><?= $esc($catName) ?></h3> <?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
<table class="admin-table"> transmet la valeur par un champ cache (un select readonly resterait
<thead> editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
<tr> <p class="form-static" id="service_mode_display">Drive</p>
<th>Produit</th> <input type="hidden" name="service_mode" id="service_mode" value="drive">
<th>Prix</th> <?php else: ?>
<th>Quantite</th> <label class="pos__service-label" for="service_mode">Mode</label>
<th>Personnaliser</th> <select class="form-input" id="service_mode" name="service_mode">
</tr> <option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
</thead> <option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
<tbody> </select>
<?php foreach ($catProducts as $p): ?> <?php endif; ?>
<?php </div>
$pid = (int) ($p['id'] ?? 0); <?php if ($chan !== 'drive'): ?>
// Un produit ne porte un bouton "Personnaliser" que si sa recette <?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
// offre au moins un ingredient retirable/ajoutable (data-* modifiers). hors dine_in (toggle JS sur le mode) ; le champ reste soumis tel quel,
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== []; persist() l'ignore hors dine_in. */ ?>
?> <div class="pos__service" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<tr> <label class="pos__service-label" for="service_tag">Table</label>
<td><?= $esc($p['name'] ?? '') ?></td> <input class="form-input" type="text" id="service_tag" name="service_tag"
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td> maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
<td> </div>
<?php /* 4 (progressive enhancement) : le champ quantite est <?php endif; ?>
rendu EDITABLE pour TOUS les produits, pour que la saisie </div>
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"> <?php /* Pas d'aria-live sur la liste : elle est rebatie a chaque +/- (une
<legend>Menus</legend> re-annonce de tout le panier serait verbeuse). Une region live dediee
<?php if ($menuRows === []): ?> (#pos-announce) annonce un message concis a chaque mutation. */ ?>
<p class="admin-empty">Aucun menu commandable pour le moment.</p> <ul class="order-cart" id="order-cart">
<?php else: ?> <li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
<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> </ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group"> <div class="pos__panel-foot">
<legend>Panier</legend> <?php /* Total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<ul class="order-cart" id="order-cart" aria-live="polite"> <p class="order-total" id="order-total">Total <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li> <button class="btn btn-primary pos__pay" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
</ul> </div>
<?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"> <?php /* Region live concise (C) : recoit "Total X EUR, N articles" a chaque
<button class="btn btn-primary" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button> mutation du panier. Visuellement discrete (classe sr-only). */ ?>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a> <span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>
</aside>
</div> </div>
</form> </form>

View file

@ -85,6 +85,36 @@ button {
cursor: pointer; 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 --- */ /* --- Layout Shell --- */
.admin-layout { .admin-layout {
display: grid; display: grid;
@ -1433,8 +1463,10 @@ tbody td.mono {
} }
/* ============================================================================= /* =============================================================================
Saisie de commande comptoir/drive (counter-order.js + admin/counter/new.php) POS tactile a tuiles comptoir/drive (counter-order.js + admin/counter/new.php)
Composeur : groupes produits, liste menus, panier chiffre, total, modale. 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. */ /* Mode de service fige (drive) : affichage non editable a la place du select. */
@ -1445,60 +1477,290 @@ tbody td.mono {
margin: 0; margin: 0;
} }
/* Sous-titre de groupe de produits (regroupement par categorie). */ /* Disposition POS : catalogue a gauche (flex 1), panneau commande a droite (fixe). */
.order-group__title { .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-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--color-yellow-ink); color: var(--color-text);
margin: 14px 0 6px; line-height: 1.25;
} }
.pos-tile__price {
/* 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;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); font-weight: 700;
font-style: italic; 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. */ /* Panneau commande persistant a droite (facon caisse). */
.menu-list { list-style: none; padding: 0; margin: 0; } .pos__panel {
.menu-list__item { 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; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
} }
.menu-list__name { font-weight: 600; flex: 1; } .pos__service-label {
.menu-list__price { color: var(--color-text-sec); font-variant-numeric: tabular-nums; } 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. */ /* Lignes du panier (corps scrollable du panneau). */
.order-cart { list-style: none; padding: 0; margin: 0; } .order-cart {
.order-cart__empty { color: var(--color-text-muted); padding: 8px 0; } 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 { .order-cart__line {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 12px; gap: 8px;
padding: 8px 0; padding: 12px 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.order-cart__label { flex: 1; } .order-cart__main {
.order-cart__price { font-weight: 700; font-variant-numeric: tabular-nums; } display: flex;
align-items: baseline;
/* Total indicatif du panier. */ justify-content: space-between;
.order-total { gap: 10px;
text-align: right; }
.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-size: 16px;
font-weight: 800; 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. */ /* 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 * CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees
* (produits commandables + leurs modificateurs, menus + slots + format + modificateurs * (produits commandables + leurs modificateurs, menus + slots + format + modificateurs
* du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier * du burger) sont lues depuis deux scripts JSON inertes (#pos-products, #pos-menus,
* ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/ * type="application/json") du formulaire #counter-order-form. L'ecran imite la borne
* ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le * client : des onglets de categories en haut, une grille de tuiles a gauche, un panneau
* burger). A la soumission, le panier est serialise en JSON dans le champ cache * commande persistant a droite. Un tap sur une tuile de produit simple ajoute le produit
* #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le * (qty 1) ; un tap sur un menu ou un produit a modificateurs ouvre la modale de
* passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque * composition (slots + format + retrait / ajout d'ingredients). Le panneau commande
* modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/ * affiche les lignes (qty x nom + prix de ligne) avec +/- et retrait, le total, et un
* prix affiches ici sont indicatifs, jamais source de verite. * bouton "Encaisser X,XX EUR".
* *
* La logique de slots (un pas par slot, requis/optionnel, format) calque * A la soumission, le panier est serialise en JSON dans le champ cache #items_json
* (Request::formBody cote serveur ne garde que les scalaires, d'ou le passage par une
* chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque modificateur
* metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles / prix
* affiches ici restent indicatifs, pas une source de verite.
*
* La logique de slots (un pas par slot, requis / optionnel, format) calque
* page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les * 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 * ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX borne ;
* borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes * le panneau commande calque order-panel.js (lignes, stepper +/-, total). Seul le rendu
* configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans * differe (idiome back-office, palette admin).
* 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.
* *
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est * Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est
* exporte pour les tests et auto-appele au DOMContentLoaded en production. * 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). // aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse).
var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' }; 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 { try {
var v = JSON.parse(form.dataset[key] || fallback); var v = JSON.parse(node.textContent || '[]');
return Array.isArray(v) ? v : JSON.parse(fallback); return Array.isArray(v) ? v : [];
} catch (e) { } catch (e) {
return JSON.parse(fallback); return [];
} }
} }
// Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des // Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des
// vues : virgule decimale ET espace separateur de milliers. Aligne l'affichage // 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 // 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) { function moneyParts(cents) {
var fixed = (Number(cents) / 100).toFixed(2); var fixed = (Number(cents) / 100).toFixed(2);
var dot = fixed.indexOf('.'); 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) { function init(doc) {
var form = doc.getElementById('counter-order-form'); var form = doc.getElementById('counter-order-form');
var hidden = doc.getElementById('items_json'); var hidden = doc.getElementById('items_json');
@ -123,17 +151,28 @@
return; 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. // 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 totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit'); 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). // 7a : champ numero de table, visible seulement en sur place (toggle au mode).
var serviceMode = doc.getElementById('service_mode'); var serviceMode = doc.getElementById('service_mode');
var serviceTagGroup = doc.getElementById('service_tag_group'); var serviceTagGroup = doc.getElementById('service_tag_group');
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}] // [{id, name, price, image, category_id, category_name, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}] 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 // Index produit par id : resolution des libelles d'options de slot + acces aux
// modificateurs proposables d'un produit a la carte. // modificateurs proposables d'un produit a la carte.
@ -142,33 +181,19 @@
productById[Number(p.id)] = p; productById[Number(p.id)] = p;
}); });
// Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap. // Panier unifie : une liste de lignes. Chaque ligne porte un kind :
// menuLines : menus configures ; productLines : produits personnalises (modifiers). // - 'product' simple : { kind, localId, productId, productName, quantity }
var menuLines = []; // - 'product' modifie : ... + proposable, modifiers (config par la modale)
var productLines = []; // - '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; var lineSeq = 0;
// Produits routes par la modale (ils portent un bouton "Personnaliser") : leur // Categorie active (filtre la grille) : 1er onglet par defaut.
// quantite directe qty_<id> est ignoree a la serialisation pour eviter le double var activeCategory = null;
// 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;
}
});
function el(tag, className) { function el(tag, className) {
var e = doc.createElement(tag); var e = doc.createElement(tag);
@ -267,28 +292,37 @@
return parts.join(', '); 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 */ /* Serialisation du panier -> #items_json */
/* ----------------------------------------------------------------- */ /* ----------------------------------------------------------------- */
// Produits sans modificateur : derives des champs qty_<id> (>= 1) NON routes par // Forme calquee sur ce qu'attend OrderRepository::resolveLine (revalide cote
// la modale. Produits personnalises : productLines. Menus : menuLines. La forme // serveur). Produits (simples ou personnalises) -> {type:'product', ...} ;
// calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur). // 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() { function serialize() {
var items = []; var items = [];
cartLines.forEach(function (line) {
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { if (line.kind === 'menu') {
var productId = Number(input.dataset.productId); items.push({
if (configurableIds[productId]) { type: 'menu',
return; // route par la modale -> pas de double comptage. menu_id: line.menuId,
quantity: line.quantity,
format: line.format,
selections: line.selections.map(function (s) {
return { menu_slot_id: s.slotId, product_id: s.productId };
}),
modifiers: line.modifiers.map(function (m) {
return { ingredient_id: m.ingredient_id, action: m.action };
}),
});
return;
} }
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
items.push({ type: 'product', product_id: productId, quantity: quantity });
}
});
productLines.forEach(function (line) {
items.push({ items.push({
type: 'product', type: 'product',
product_id: line.productId, 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); hidden.value = JSON.stringify(items);
} }
@ -321,8 +339,8 @@
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */ /* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */ /* ----------------------------------------------------------------- */
// Prix d'une ligne PRODUIT (configuree par la modale) : prix de base + surcout // Prix d'une ligne PRODUIT : prix de base + surcout des ajouts, le tout
// des ajouts, le tout multiplie par la quantite. Indicatif (RG-T16 serveur). // multiplie par la quantite. Indicatif (RG-T16 serveur).
function productLineTotal(line) { function productLineTotal(line) {
var base = (productById[Number(line.productId)] || {}).price || 0; var base = (productById[Number(line.productId)] || {}).price || 0;
var extra = modifiersExtra(line.proposable, line.modifiers); 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 // 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 // surcout des ajouts sur le burger, multiplie par la quantite. Les selections de
// prix du menu est forfaitaire cote serveur). Indicatif. // slot n'ajoutent rien (le prix du menu est forfaitaire cote serveur). Indicatif.
function menuLineTotal(line) { function menuLineTotal(line) {
var menu = menus.filter(function (m) { return Number(m.id) === Number(line.menuId); })[0] || {}; 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 base = line.format === 'maxi' ? (menu.price_maxi || 0) : (menu.price_normal || 0);
var extra = modifiersExtra(line.proposable, line.modifiers); 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) + function lineTotal(line) {
// des lignes configurees (produits personnalises + menus). Met a jour le pied return line.kind === 'menu' ? menuLineTotal(line) : productLineTotal(line);
// de panier ET le libelle du bouton ("Encaisser X,XX EUR"). }
// 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() { function updateTotal() {
var total = 0; 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);
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); });
if (totalValue) { if (totalValue) {
totalValue.textContent = formatEuros(total); totalValue.textContent = formatEuros(total);
} }
if (submitBtn) { if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total); 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() { // Libelle d'une ligne du panneau (nom + composition recap).
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) { function lineLabel(line) {
node.parentNode.removeChild(node); if (line.kind === 'menu') {
});
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');
var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')']; var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')'];
line.selections.forEach(function (s) { line.selections.forEach(function (s) {
var p = productById[Number(s.productId)]; var p = productById[Number(s.productId)];
@ -417,35 +397,122 @@
}); });
var text = parts.join(' - '); var text = parts.join(' - ');
var modLabel = modifierLabel(line.proposable, line.modifiers); var modLabel = modifierLabel(line.proposable, line.modifiers);
if (modLabel) { return modLabel ? (text + ' (' + modLabel + ')') : text;
text += ' (' + modLabel + ')'; }
} var label = line.productName;
label.textContent = text; var pm = modifierLabel(line.proposable, line.modifiers);
li.appendChild(label); return pm ? (label + ' (' + pm + ')') : label;
}
var price = el('span', 'order-cart__price'); // Ajuste la quantite d'une ligne (delta +1 / -1). Tomber a 0 retire la ligne
price.textContent = formatEuros(menuLineTotal(line)); // (comme order-panel.js borne : decrementer a zero = retrait).
li.appendChild(price); function adjustQuantity(line, delta) {
var next = Number(line.quantity || 1) + delta;
if (next <= 0) {
cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
} else {
line.quantity = next;
}
renderCart();
}
var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); function removeLine(line) {
removeBtn.type = 'button'; cartLines = cartLines.filter(function (l) { return l.localId !== line.localId; });
removeBtn.textContent = 'Retirer'; renderCart();
removeBtn.addEventListener('click', function () { }
menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; });
renderCart();
});
li.appendChild(removeBtn);
cart.appendChild(li); // Construit une ligne du panneau : libelle + prix + stepper (-/qty/+) + retrait.
function cartLineNode(line) {
var li = el('li', 'order-cart__line');
var main = el('div', 'order-cart__main');
var label = el('span', 'order-cart__label');
label.textContent = lineLabel(line);
main.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(lineTotal(line));
main.appendChild(price);
li.appendChild(main);
var controls = el('div', 'order-cart__controls');
var stepper = el('div', 'order-cart__qty');
stepper.setAttribute('role', 'group');
stepper.setAttribute('aria-label', 'Quantite de ' + lineLabel(line));
var dec = el('button', 'order-cart__qty-btn');
dec.type = 'button';
dec.textContent = ''; // signe moins
dec.setAttribute('aria-label', 'Diminuer la quantite de ' + lineLabel(line));
dec.addEventListener('click', function () { adjustQuantity(line, -1); });
stepper.appendChild(dec);
var qty = el('span', 'order-cart__qty-value');
qty.textContent = String(line.quantity);
stepper.appendChild(qty);
var inc = el('button', 'order-cart__qty-btn');
inc.type = 'button';
inc.textContent = '+';
inc.setAttribute('aria-label', 'Augmenter la quantite de ' + lineLabel(line));
inc.addEventListener('click', function () { adjustQuantity(line, 1); });
stepper.appendChild(inc);
controls.appendChild(stepper);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.setAttribute('aria-label', 'Retirer ' + lineLabel(line) + ' de la commande');
removeBtn.addEventListener('click', function () { removeLine(line); });
controls.appendChild(removeBtn);
li.appendChild(controls);
return li;
}
function renderCart() {
Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) {
node.parentNode.removeChild(node);
});
cartLines.forEach(function (line) {
cart.appendChild(cartLineNode(line));
}); });
if (cartEmpty) { if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : ''; cartEmpty.style.display = cartLines.length ? 'none' : '';
} }
updateTotal(); 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 */ /* Modales de configuration */
/* ----------------------------------------------------------------- */ /* ----------------------------------------------------------------- */
@ -459,7 +526,7 @@
var lastFocused = null; var lastFocused = null;
// Selecteur des controles focusables d'une modale (boutons, champs, selects ; // Selecteur des controles focusables d'une modale (boutons, champs, selects ;
// les champs desactives/caches sont exclus). Le trap cycle sur cet ensemble. // les champs desactives / caches sont exclus). Le trap cycle sur cet ensemble.
var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
function focusableIn(root) { function focusableIn(root) {
@ -478,7 +545,7 @@
modalHost.textContent = ''; modalHost.textContent = '';
modalHost.setAttribute('hidden', ''); 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') { if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus(); lastFocused.focus();
} }
@ -551,7 +618,7 @@
} }
} }
// Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout). // Modale d'un produit a la carte : quantite + modificateurs (retrait / ajout).
function openProductComposer(product) { function openProductComposer(product) {
var proposable = product.modifiers || []; var proposable = product.modifiers || [];
var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} }; var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} };
@ -576,6 +643,9 @@
qtyInput.addEventListener('change', function () { qtyInput.addEventListener('change', function () {
var v = parseInt(qtyInput.value, 10); var v = parseInt(qtyInput.value, 10);
state.quantity = v >= 1 ? v : 1; 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); qtyBlock.appendChild(qtyInput);
panel.appendChild(qtyBlock); panel.appendChild(qtyBlock);
@ -588,7 +658,8 @@
addBtn.type = 'button'; addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier'; addBtn.textContent = 'Ajouter au panier';
addBtn.addEventListener('click', function () { addBtn.addEventListener('click', function () {
productLines.push({ cartLines.push({
kind: 'product',
localId: ++lineSeq, localId: ++lineSeq,
productId: Number(product.id), productId: Number(product.id),
productName: product.name, productName: product.name,
@ -692,7 +763,7 @@
panel.appendChild(block); panel.appendChild(block);
}); });
// Modificateurs du burger support (retrait/ajout d'ingredients). // Modificateurs du burger support (retrait / ajout d'ingredients).
panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd));
// 7c : message inline au lieu d'un return muet quand un slot requis n'est pas // 7c : message inline au lieu d'un return muet quand un slot requis n'est pas
@ -704,12 +775,24 @@
inlineError.textContent = ''; inlineError.textContent = '';
panel.appendChild(inlineError); 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. // Actions : ajouter (si tous les requis choisis) / annuler.
var actions = el('div', 'menu-composer__actions'); var actions = el('div', 'menu-composer__actions');
var addBtn = el('button', 'btn btn-primary menu-composer__add'); var addBtn = el('button', 'btn btn-primary menu-composer__add');
addBtn.type = 'button'; addBtn.type = 'button';
addBtn.textContent = 'Ajouter au panier'; 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 () { addBtn.addEventListener('click', function () {
if (deadEnd) {
return;
}
var allRequired = steps.filter(function (s) { return s.isRequired; }) var allRequired = steps.filter(function (s) { return s.isRequired; })
.every(function (s) { return state.selections[s.id] != null; }); .every(function (s) { return state.selections[s.id] != null; });
if (!allRequired) { if (!allRequired) {
@ -723,10 +806,12 @@
selections.push({ slotId: step.id, productId: chosen }); selections.push({ slotId: step.id, productId: chosen });
} }
}); });
menuLines.push({ cartLines.push({
kind: 'menu',
localId: ++lineSeq, localId: ++lineSeq,
menuId: Number(menu.id), menuId: Number(menu.id),
menuName: menu.name, menuName: menu.name,
quantity: 1,
format: state.format, format: state.format,
selections: selections, selections: selections,
proposable: proposable, proposable: proposable,
@ -748,38 +833,221 @@
} }
/* ----------------------------------------------------------------- */ /* ----------------------------------------------------------------- */
/* Cablage */ /* Grille de tuiles + onglets categories */
/* ----------------------------------------------------------------- */ /* ----------------------------------------------------------------- */
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { // Pastille de repli : initiale du nom sur fond colore, quand aucune image
btn.addEventListener('click', function () { // exploitable n'est disponible cote back-office (image_path vide ou injoignable).
var productId = Number(btn.dataset.productId); function buildPastille(name) {
var product = productById[productId]; var pastille = el('span', 'pos-tile__pastille');
if (product) { pastille.setAttribute('aria-hidden', 'true');
openProductComposer(product); var initial = (String(name || '').trim().charAt(0) || '?').toUpperCase();
} pastille.textContent = initial;
}); return pastille;
}); }
Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) { // Construit une tuile. kind : 'product' | 'menu'. Le tap declenche onTap. Une
btn.addEventListener('click', function () { // image n'est tentee que si image_path est non vide ; sur erreur de chargement,
var menuId = Number(btn.dataset.menuId); // un listener (CSP-safe, pas d'onerror inline) masque l'image et revele la
var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0]; // pastille de repli (le back-office n'a pas garantie d'image exploitable).
if (menu) { function buildTile(entry, kind, priceLabel, onTap) {
openComposer(menu); var tile = el('button', 'pos-tile');
} tile.type = 'button';
});
});
// 1 : le total et le libelle du bouton suivent la saisie des quantites des // Une tuile qui ouvre la modale (menu ou produit a modificateurs) annonce
// produits simples (les lignes configurees rafraichissent via renderCart). // l'intention dans son nom accessible (D) et porte aria-haspopup=dialog : le
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { // lecteur d'ecran sait qu'un tap ouvre une boite de dialogue de composition,
if (configurableIds[Number(input.dataset.productId)]) { // pas un ajout sec. Le badge visuel "Menu"/"A composer" reste decoratif.
return; // champ desactive (route par la modale). var opensModal = kind === 'menu' || (entry.modifiers && entry.modifiers.length);
var intent = opensModal ? (kind === 'menu' ? ', menu a composer' : ', a composer') : '';
tile.setAttribute('aria-label', entry.name + ', ' + priceLabel + intent);
if (opensModal) {
tile.setAttribute('aria-haspopup', 'dialog');
} }
input.addEventListener('input', updateTotal);
input.addEventListener('change', updateTotal); var media = el('span', 'pos-tile__media');
}); var pastille = buildPastille(entry.name);
media.appendChild(pastille);
var src = String(entry.image || '');
if (src !== '') {
var img = el('img', 'pos-tile__image');
img.src = src;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.setAttribute('loading', 'lazy');
// CSP-safe : pas d'onerror inline. Sur echec, masque l'image (la
// pastille dessous redevient visible).
img.addEventListener('error', function () {
img.style.display = 'none';
});
media.appendChild(img);
}
tile.appendChild(media);
var body = el('span', 'pos-tile__body');
var nameEl = el('span', 'pos-tile__name');
nameEl.textContent = entry.name;
body.appendChild(nameEl);
var priceEl = el('span', 'pos-tile__price');
priceEl.textContent = priceLabel;
body.appendChild(priceEl);
tile.appendChild(body);
// Badge visuel "Menu"/"A composer" (decoratif : l'intention est deja dans
// l'aria-label ci-dessus ; aria-hidden evite la double annonce).
if (opensModal) {
var badge = el('span', 'pos-tile__badge');
badge.setAttribute('aria-hidden', 'true');
badge.textContent = kind === 'menu' ? 'Menu' : 'A composer';
tile.appendChild(badge);
}
tile.addEventListener('click', onTap);
return tile;
}
// Rend la grille pour la categorie active : produits puis menus de cette
// categorie. Un produit simple -> ajout direct ; un produit a modificateurs ou
// un menu -> modale.
function renderGrid() {
if (!grid) {
return;
}
grid.textContent = '';
var catProducts = products.filter(function (p) { return (Number(p.category_id) || 0) === activeCategory; });
var catMenus = menus.filter(function (m) { return (Number(m.category_id) || 0) === activeCategory; });
if (!catProducts.length && !catMenus.length) {
var empty = el('p', 'pos__nojs');
empty.textContent = 'Aucun produit dans cette categorie.';
grid.appendChild(empty);
return;
}
catProducts.forEach(function (product) {
var tile = buildTile(product, 'product', formatEuros(product.price), function () {
if (product.modifiers && product.modifiers.length) {
openProductComposer(product);
} else {
addSimpleProduct(product);
}
});
grid.appendChild(tile);
});
catMenus.forEach(function (menu) {
var label = 'Normal ' + formatEuros(menu.price_normal) + ' / Maxi ' + formatEuros(menu.price_maxi);
var tile = buildTile(menu, 'menu', label, function () {
openComposer(menu);
});
grid.appendChild(tile);
});
}
// Boutons d'onglet (references stables), construits UNE fois au demarrage. On les
// garde pour MUTER l'etat actif (A) plutot que de reconstruire la barre au clic :
// reconstruire detruisait le bouton focalise et faisait retomber le focus sur body.
var tabButtons = [];
// Bascule la categorie active : mute les boutons existants (classe is-active +
// aria-selected + roving tabindex), met a jour activeCategory et le tabpanel
// (aria-labelledby vers l'onglet actif), rerend la grille. Si moveFocus, pose le
// focus sur l'onglet actif (navigation clavier : le focus suit la selection).
function setActiveCategory(catId, moveFocus) {
activeCategory = catId;
tabButtons.forEach(function (btn) {
var selected = Number(btn.dataset.categoryId) === Number(catId);
btn.classList.toggle('is-active', selected);
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// Roving tabindex (B) : seul l'onglet actif est dans l'ordre de tabulation ;
// les autres sont atteints par les fleches une fois la barre focalisee.
btn.tabIndex = selected ? 0 : -1;
if (selected) {
// Le tabpanel (grille) est libelle par l'onglet actif (B).
if (grid) {
grid.setAttribute('aria-labelledby', btn.id);
}
if (moveFocus && typeof btn.focus === 'function') {
btn.focus();
}
}
});
renderGrid();
}
// Navigation clavier WAI-ARIA tablist (B) : Fleche gauche/droite (cycliques) +
// Home/Fin deplacent le focus ET activent l'onglet (le focus suit la selection).
function onTabsKeydown(event) {
var idx = tabButtons.indexOf(event.target);
if (idx < 0 || !tabButtons.length) {
return;
}
var next = null;
var key = event.key;
if (key === 'ArrowRight' || key === 'ArrowDown') {
next = (idx + 1) % tabButtons.length;
} else if (key === 'ArrowLeft' || key === 'ArrowUp') {
next = (idx - 1 + tabButtons.length) % tabButtons.length;
} else if (key === 'Home') {
next = 0;
} else if (key === 'End') {
next = tabButtons.length - 1;
}
if (next === null) {
return;
}
event.preventDefault();
setActiveCategory(Number(tabButtons[next].dataset.categoryId), true);
}
// Construit la barre d'onglets UNE fois (A) et cable clic + clavier.
function buildTabs() {
if (!tabsHost) {
return;
}
tabsHost.textContent = '';
tabButtons = [];
var tabs = buildCategoryTabs(products, menus);
if (!tabs.length) {
return;
}
if (activeCategory === null) {
activeCategory = tabs[0].id;
}
tabs.forEach(function (tab, i) {
var selected = tab.id === activeCategory;
var btn = el('button', 'pos__tab' + (selected ? ' is-active' : ''));
btn.type = 'button';
btn.id = 'pos-tab-' + tab.id;
btn.dataset.categoryId = String(tab.id);
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
// aria-controls relie l'onglet au tabpanel unique (la grille filtree, B).
if (grid && grid.id) {
btn.setAttribute('aria-controls', grid.id);
}
btn.tabIndex = selected ? 0 : -1;
btn.textContent = tab.name;
btn.addEventListener('click', function () {
setActiveCategory(tab.id, false);
});
tabButtons.push(btn);
tabsHost.appendChild(btn);
});
tabsHost.addEventListener('keydown', onTabsKeydown);
// Pose le libelle initial du tabpanel sur l'onglet actif.
if (grid) {
grid.setAttribute('aria-labelledby', 'pos-tab-' + activeCategory);
}
}
/* ----------------------------------------------------------------- */
/* Cablage */
/* ----------------------------------------------------------------- */
// 7a : le numero de table n'a de sens qu'en sur place -> visible seulement quand // 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). // service_mode = dine_in (au comptoir ; au drive le champ n'existe pas).
@ -803,11 +1071,13 @@
serialize(); serialize();
}); });
buildTabs();
renderGrid();
renderCart(); renderCart();
} }
if (typeof module !== 'undefined' && module.exports) { 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) { if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {

View file

@ -184,7 +184,11 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
self::assertStringContainsString('Cheeseburger', $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); self::assertStringContainsString('service_mode', $body);
} }
@ -249,7 +253,10 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(200, $response->status()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
self::assertStringContainsString('Menu Cheeseburger', $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('items_json', $body); // champ cache du panier
self::assertStringContainsString('counter-order.js', $body); // script du composeur self::assertStringContainsString('counter-order.js', $body); // script du composeur
} }
@ -290,6 +297,39 @@ final class CounterOrderControllerTest extends TestCase
self::assertSame(22, $selInsert['pid']); 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 public function testStoreCreatesProductOrderViaItemsJson(): void
{ {
// Chemin unifie : items_json est prefere a qty_<id>, et un produit y passe aussi. // 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()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
// data-products encode le JSON avec htmlspecialchars : les guillemets sont // POS tactile : la composition PROPOSABLE est embarquee dans le script JSON inerte
// echappes en &quot;. On cherche les fragments echappes (forme reellement rendue). // #pos-products (type="application/json"). json_encode avec JSON_HEX_TAG protege
self::assertStringContainsString('ingredient_id&quot;:3', $body); // l'insertion dans un <script> (un '<' deviendrait < ; pas de </script>
self::assertStringContainsString('ingredient_id&quot;:8', $body); // 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('Oignon', $body);
self::assertStringContainsString('Bacon', $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 public function testStoreCreatesProductOrderWithModifiers(): void
@ -518,12 +559,11 @@ final class CounterOrderControllerTest extends TestCase
self::assertStringContainsString('A emporter', $body); self::assertStringContainsString('A emporter', $body);
} }
public function testCreateRendersEditableQuantityWithHintForConfigurableProduct(): void public function testCreateExposesConfigurableProductModifiersInJson(): void
{ {
// 4 (progressive enhancement) : le champ quantite d'un produit personnalisable // POS tactile : un produit a modificateurs est expose dans #pos-products avec sa
// est rendu EDITABLE en HTML (repli sans JS marche : commande de base sans // composition proposable. Le client en rend une tuile "A composer" qui ouvre la
// modificateurs). Le PHP ne pose PAS readonly (c'est le JS qui neutralise au // modale au tap (la saisie de la quantite et des modificateurs se fait en modale).
// cablage). Un indice "via Personnaliser" (cache en HTML) accompagne le champ.
$db = $this->permittedDb(); $db = $this->permittedDb();
$db->productsRows = [ $db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], ['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()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
// Le champ qty est present et EDITABLE (aucun readonly pose par le PHP). // Le produit et sa composition sont dans le JSON inerte (pas de champ qty_<id>).
self::assertStringContainsString('name="qty_12"', $body); self::assertStringContainsString('id="pos-products"', $body);
self::assertDoesNotMatchRegularExpression('/id="qty_12"[^>]*readonly/', $body); self::assertStringContainsString('"id":12', $body);
// Indice de redirection vers la modale (revele par le JS). self::assertStringContainsString('"ingredient_id":3', $body);
self::assertStringContainsString('data-qty-hint="12"', $body); self::assertStringContainsString('Oignon', $body);
self::assertStringContainsString('via Personnaliser', $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 = $this->permittedDb();
$db->productsRows = [ $db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], ['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()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
self::assertStringContainsString('Burgers', $body); self::assertStringContainsString('id="pos-tabs"', $body);
self::assertStringContainsString('Accompagnements', $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 = $this->permittedDb();
$db->menusRows = [ $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], ['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()); self::assertSame(200, $response->status());
$body = $response->body(); $body = $response->body();
self::assertStringContainsString('9,90 EUR', $body); self::assertStringContainsString('id="pos-menus"', $body);
self::assertStringContainsString('11,90 EUR', $body); self::assertStringContainsString('"price_normal":990', $body);
self::assertStringContainsString('Maxi', $body); self::assertStringContainsString('"price_maxi":1190', $body);
} }
public function testStorePassesServiceTagInDineIn(): void public function testStorePassesServiceTagInDineIn(): void

View file

@ -1,9 +1,12 @@
/* /*
* Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c). * Tests du POS tactile de commande comptoir/drive (counter-order.js). node:test + jsdom.
* node:test + jsdom. Couvre la serialisation du panier dans #items_json : * Couvre la logique pure (serialisation du panier dans #items_json, calcul prix/total,
* - ajout produit (champ quantite) -> item {type:'product', ...} * onglets categories) et l'UI a tuiles :
* - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...] * - tap d'une tuile produit simple -> item {type:'product', quantity}, fusion sur re-tap
* - configuration menu (slots + format Maxi + modificateurs burger) -> item menu * - 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) * - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse)
* *
* Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers) * 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 = [ const PRODUCTS = [
{ {
id: 12, name: 'Cheeseburger', price: 890, id: 12, name: 'Cheeseburger', price: 890, image: '', category_id: 1, category_name: 'Burgers',
modifiers: [ modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, { 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 }, { ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
], ],
}, },
{ id: 22, name: 'Frites', price: 250, modifiers: [] }, { id: 22, name: 'Frites', price: 250, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
{ id: 14, name: 'Coca', price: 200, modifiers: [] }, { id: 14, name: 'Coca', price: 200, image: '', category_id: 3, category_name: 'Boissons', modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, modifiers: [] }, { id: 47, name: 'Ketchup', price: 0, image: '', category_id: 2, category_name: 'Accompagnements', modifiers: [] },
]; ];
const MENUS = [ const MENUS = [
@ -37,6 +40,9 @@ const MENUS = [
name: 'Menu Cheeseburger', name: 'Menu Cheeseburger',
price_normal: 990, price_normal: 990,
price_maxi: 1190, price_maxi: 1190,
image: '',
category_id: 4,
category_name: 'Menus',
burger_modifiers: [ burger_modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, { 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 }, { 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) { function setup(products = PRODUCTS, menus = MENUS) {
const menuItems = menus // Le catalogue est embarque dans deux scripts JSON inertes (CSP-safe), lus par le JS.
.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('');
const dom = new JSDOM( const dom = new JSDOM(
'<!DOCTYPE html><html><body>' + '<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' + '<form id="counter-order-form" method="post" action="/counter/orders">' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' + ' <input type="hidden" name="items_json" id="items_json" value="">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' + ` <script type="application/json" id="pos-products">${JSON.stringify(products)}</script>` +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' + ` <script type="application/json" id="pos-menus">${JSON.stringify(menus)}</script>` +
productRows + ' <div class="pos__main">' +
' <ul id="menu-list">' + menuItems + '</ul>' + ' <div class="pos__catalogue">' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' + ' <div class="pos__tabs" id="pos-tabs" role="tablist"></div>' +
' <p id="order-total">Total : <span id="order-total-value">0,00 EUR</span></p>' + ' <div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0"></div>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' + ' </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>' +
' <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>' + '</form>' +
'<div id="menu-composer-modal" hidden></div>' + '<div id="menu-composer-modal" hidden></div>' +
'</body></html>', '</body></html>',
@ -98,30 +93,71 @@ function itemsJson(dom) {
return JSON.parse(dom.window.document.getElementById('items_json').value || '[]'); 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(); const dom = setup();
counterOrder.init(dom.window.document); counterOrder.init(dom.window.document);
// Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_<id>. const labels = Array.prototype.map.call(
const qty = dom.window.document.getElementById('qty_22'); dom.window.document.querySelectorAll('.pos__tab'),
qty.value = '2'; t => t.textContent,
fireSubmit(dom); );
assert.deepEqual(labels, ['Burgers', 'Accompagnements', 'Boissons', 'Menus']);
const items = itemsJson(dom);
assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]);
}); });
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 dom = setup();
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
// Cheeseburger (12) porte un bouton Personnaliser : son qty_<id> est ignore par le activateCategory(dom, 'Accompagnements'); // Frites (22), Ketchup (47)
// JS pour eviter le double comptage avec la ligne configuree. const frites = tileByName(dom, 'Frites');
doc.getElementById('qty_12').value = '3'; click(dom, frites);
fireSubmit(dom); 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]', () => { 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; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
// Ouvre la modale du produit 12 (Cheeseburger). activateCategory(dom, 'Burgers');
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); click(dom, tileByName(dom, 'Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal'); const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false); assert.equal(modal.hasAttribute('hidden'), false);
@ -143,7 +179,7 @@ test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[
addBox.checked = true; addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: 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.equal(modal.hasAttribute('hidden'), true);
assert.ok(doc.querySelector('.order-cart__line')); 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(); const dom = setup();
counterOrder.init(dom.window.document); counterOrder.init(dom.window.document);
@ -168,13 +204,48 @@ test('quantite 0 ignoree -> panier vide serialise []', () => {
assert.deepEqual(itemsJson(dom), []); 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}', () => { test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => {
const dom = setup(); const dom = setup();
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
// Ouvre la modale du menu 5. activateCategory(dom, 'Menus');
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); click(dom, tileByName(dom, 'Menu Cheeseburger'));
const modal = doc.getElementById('menu-composer-modal'); const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false); 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.value = '47';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); 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. // Modale fermee, panier recap mis a jour.
assert.equal(modal.hasAttribute('hidden'), true); 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', () => { test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => {
const dom = setup(); const dom = setup();
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
// Laisse la sauce a "Sans" (valeur vide) ; ajoute directement. // 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.value = '';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); 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); fireSubmit(dom);
const items = itemsJson(dom); const items = itemsJson(dom);
@ -248,12 +370,14 @@ test('produit + menu combines -> items_json contient les deux lignes', () => {
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
// Frites (22) sans modificateur -> chemin qty_<id>. // Frites (22) sans modificateur -> ajout direct par tap.
doc.getElementById('qty_22').value = '1'; 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'); 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); fireSubmit(dom);
const items = itemsJson(dom); const items = itemsJson(dom);
@ -267,7 +391,8 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
// Retire l'oignon du burger (ingredient 3, is_removable). // 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.checked = true;
removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: 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); fireSubmit(dom);
const items = itemsJson(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; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
const qty = doc.getElementById('qty_22'); // Frites, 250c activateCategory(dom, 'Accompagnements');
qty.value = '2'; const frites = tileByName(dom, 'Frites'); // 250c
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true })); click(dom, frites);
click(dom, frites); // qty 2
assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR'); assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 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; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]'); const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true; addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: 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. // Prix de ligne affiche dans le panier.
assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR'); 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; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call( const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'), 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.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: 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.querySelector('.order-cart__price').textContent, '11,90 EUR');
assert.equal(doc.getElementById('order-total-value').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; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force // Vide un slot requis (drink, slot 1) : un slot requis n'a pas d'option Sans, mais
// l'etat en deselectionnant via un select dont l'option vide n'existe pas. On // jsdom autorise l'affectation d'une value vide -> change supprime la selection.
// 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).
const drinkSelect = Array.prototype.find.call( const drinkSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'), modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '1', 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.textContent, '');
assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y) 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. // Modale encore ouverte, message inline renseigne (textContent), aucune ligne.
assert.equal(modal.hasAttribute('hidden'), false); 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); 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 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) activateCategory(dom, 'Burgers');
// et l'indice "via Personnaliser" est cache. const tile = tileByName(dom, 'Cheeseburger');
const qty = doc.getElementById('qty_12'); // image vide -> aucune <img>, une pastille (initiale C) a la place.
const hint = doc.querySelector('[data-qty-hint="12"]'); assert.equal(tile.querySelector('.pos-tile__image'), null);
assert.equal(qty.disabled, false); assert.equal(tile.querySelector('.pos-tile__pastille').textContent, 'C');
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);
}); });
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 dom = setup();
const doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]'); activateCategory(dom, 'Menus');
const trigger = tileByName(dom, 'Menu Cheeseburger');
trigger.focus(); trigger.focus();
assert.equal(doc.activeElement, trigger); assert.equal(doc.activeElement, trigger);
trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true })); click(dom, trigger);
const modal = doc.getElementById('menu-composer-modal'); 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); assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); 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); 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; const doc = dom.window.document;
counterOrder.init(doc); 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'); const panel = doc.querySelector('.menu-composer');
assert.equal(panel.getAttribute('role'), 'dialog'); assert.equal(panel.getAttribute('role'), 'dialog');
assert.equal(panel.getAttribute('aria-modal'), 'true'); 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)', () => { 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. // 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 PRICEY = [{ id: 99, name: 'Plateau', price: 61725, image: '', category_id: 1, category_name: 'Plateaux', modifiers: [] }];
const dom = new JSDOM( const dom = setup(PRICEY, []);
'<!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 doc = dom.window.document; const doc = dom.window.document;
counterOrder.init(doc); counterOrder.init(doc);
const qty = doc.getElementById('qty_99'); activateCategory(dom, 'Plateaux');
qty.value = '2'; const tile = tileByName(dom, 'Plateau');
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true })); 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-total-value').textContent, '1 234,50 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 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; const doc = dom.window.document;
counterOrder.init(doc); 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 modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false); assert.equal(modal.hasAttribute('hidden'), false);
@ -487,6 +602,14 @@ test('modale : touche Echap ferme la modale', () => {
assert.equal(modal.hasAttribute('hidden'), true); 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', () => { test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {}; const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; }); 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); const steps = counterOrder.composerSteps(menu, productById);
assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order 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');
});