feat(back-office): saisie commande comptoir/drive en POS tactile a tuiles (#104)
This commit is contained in:
parent
6f2aedc699
commit
9bdd53120c
5 changed files with 1355 additions and 558 deletions
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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 ". On cherche les fragments echappes (forme reellement rendue).
|
// #pos-products (type="application/json"). json_encode avec JSON_HEX_TAG protege
|
||||||
self::assertStringContainsString('ingredient_id":3', $body);
|
// l'insertion dans un <script> (un '<' deviendrait < ; pas de </script>
|
||||||
self::assertStringContainsString('ingredient_id":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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue