fix(borne): panier unique = panneau persistant (retrait cart.html + product.html) #101

Merged
Corentin merged 1 commit from fix/borne-panier-unique into dev 2026-06-24 12:18:32 +02:00
16 changed files with 166 additions and 950 deletions

View file

@ -292,7 +292,6 @@ button {
justify-self: center;
}
.site-header__cart,
.site-header__mode {
justify-self: end;
}
@ -454,49 +453,6 @@ button {
7. SHARED COMPONENTS header extensions + badges + buttons
============================================================ */
/* Cart link in header (products / product / cart pages) */
.site-header__cart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
font-size: 1.6rem;
color: var(--color-text-primary);
text-decoration: none;
}
.site-header__cart:hover,
.site-header__cart:focus-visible {
background: rgba(255, 199, 44, 0.18);
outline: none;
}
.cart-icon {
line-height: 1;
}
.cart-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: var(--radius-pill);
background: var(--color-brand-red);
color: #fff;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* Mode badge — shown in header for context */
.mode-badge {
display: inline-block;
@ -744,215 +700,9 @@ button {
}
/* ============================================================
9. COMPONENT PRODUCT DETAIL (product.html)
10. COMPONENT QUANTITY CONTROLS (modale options produit)
============================================================ */
.product-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.product-main {
flex: 1;
padding: var(--space-6);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.product-error {
color: var(--color-brand-red);
font-size: var(--font-size-md);
padding: var(--space-5);
}
.product-detail {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.product-detail__skeleton {
/* Placeholder shown while JS loads data */
width: 100%;
height: 300px;
background: var(--color-bg-card);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.product-detail__image-wrap {
width: 100%;
max-width: 480px;
margin: 0 auto;
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
overflow: hidden;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
}
.product-detail__image {
width: 100%;
height: 100%;
object-fit: contain;
padding: var(--space-5);
}
.product-detail__info {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.product-detail__name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
line-height: 1.2;
}
.product-detail__price {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-brand-dark);
}
.product-detail__composition {
background: var(--color-bg-card);
border-left: 4px solid var(--color-brand-yellow);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: var(--space-4) var(--space-5);
}
.product-detail__composition-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-2);
color: var(--color-text-primary);
}
.product-detail__composition-text {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
}
.product-detail__add {
width: 100%;
margin-top: var(--space-2);
}
/* ============================================================
10. COMPONENT CART (cart.html)
============================================================ */
.cart-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-bg-page);
}
.cart-main {
flex: 1;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.cart-main__heading {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.cart-empty {
text-align: center;
padding: var(--space-10) var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
}
.cart-empty__message {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
}
.cart-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
list-style: none;
padding: 0;
margin: 0;
}
/* One cart line */
.cart-line {
display: flex;
align-items: center;
gap: var(--space-4);
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-3) var(--space-4);
}
.cart-line__image {
width: 72px;
height: 72px;
object-fit: contain;
flex-shrink: 0;
border-radius: var(--radius-sm);
background: var(--color-bg-page);
}
.cart-line__info {
flex: 1;
min-width: 0;
}
.cart-line__name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-line__unit-price {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* Quantity controls */
.cart-line__qty {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.qty-btn {
width: 44px;
height: 44px;
@ -984,73 +734,6 @@ button {
font-weight: var(--font-weight-bold);
}
.cart-line__total {
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
flex-shrink: 0;
min-width: 80px;
text-align: right;
}
.cart-line__remove {
background: none;
border: none;
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background var(--transition-fast);
}
.cart-line__remove:hover,
.cart-line__remove:focus-visible {
background: rgba(218, 2, 14, 0.10);
outline: none;
}
/* Order summary block */
.cart-summary {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.cart-summary__line {
display: flex;
justify-content: space-between;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.cart-summary__line--total {
padding-top: var(--space-3);
border-top: 2px solid var(--color-border-default);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
/* Cart action row */
.cart-actions {
display: flex;
gap: var(--space-4);
justify-content: space-between;
flex-wrap: wrap;
padding-bottom: var(--space-8);
}
.cart-actions .btn {
flex: 1 1 200px;
}
/* ============================================================
11. COMPONENT PAYMENT (payment.html)
============================================================ */
@ -1268,15 +951,6 @@ button {
grid-template-columns: repeat(2, 1fr);
}
.cart-line {
flex-wrap: wrap;
gap: var(--space-3);
}
.cart-line__info {
flex: 1 1 calc(100% - 80px);
}
.payment-methods {
flex-direction: column;
align-items: stretch;
@ -1292,14 +966,10 @@ button {
.products-main {
padding: var(--space-4);
}
.cart-main {
padding: var(--space-4);
}
}
/* ============================================================
14. COMPONENT MENU COMPOSER MODAL (product.html, type=menu)
14. COMPONENT MENU COMPOSER MODAL (modale ouverte depuis la grille produits)
============================================================ */
/*
@ -1515,10 +1185,6 @@ button {
z-index: 2;
}
.product-detail__info .allergen-info-btn {
margin-top: var(--space-3);
}
.allergen-modal-overlay {
position: fixed;
inset: 0;
@ -1788,31 +1454,6 @@ button {
margin-right: auto;
}
/* ---------- Cart line composition display -------------------- */
.cart-line__composition {
list-style: none;
padding: var(--space-1) 0 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cart-line__comp-item {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
line-height: 1.4;
}
.cart-line__comp-suppl {
font-size: var(--font-size-xs);
color: var(--color-brand-yellow-dk);
font-weight: var(--font-weight-bold);
line-height: 1.4;
padding-top: var(--space-1);
}
/* ---------- Responsive — narrow screens --------------------- */
@media (max-width: 600px) {
@ -1920,8 +1561,7 @@ button {
}
.order-panel__line {
position: relative;
padding: var(--space-3) var(--space-6) var(--space-3) 0;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-default);
}
@ -1952,14 +1592,49 @@ button {
content: "+ ";
}
/* Ligne de controles : stepper de quantite a gauche, retrait a droite. */
.order-panel__line-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-2);
}
.order-panel__qty {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Boutons +/- : cible tactile confortable (borne). */
.order-panel__qty-btn {
min-width: 44px;
min-height: 44px;
border: 2px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
line-height: 1;
cursor: pointer;
}
.order-panel__qty-value {
min-width: 2ch;
text-align: center;
font-weight: var(--font-weight-bold);
}
.order-panel__remove {
position: absolute;
top: var(--space-3);
right: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: none;
background: none;
cursor: pointer;
padding: var(--space-1);
line-height: 0;
}

View file

@ -1,18 +1,18 @@
/*
* order-panel.js Panneau de commande persistant (maquette : recap a droite de
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
* que le panier reste visible en permanence, comme sur la maquette borne.
* l'ecran de commande). Rendu sur l'ecran de commande (products) pour que le panier
* reste visible en permanence, comme sur la maquette borne.
*
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
* compositionLabels) pour etre testable sans DOM.
* C'est l'UNIQUE vue panier : il montre lignes + total + Abandon/Payer, permet
* d'ajuster la quantite de chaque ligne (+/-) et de la retirer. La logique de mise
* en forme est extraite en fonctions PURES (buildPanelModel, compositionLabels)
* pour etre testable sans DOM.
*/
import {
getCart,
removeFromCart,
updateQuantity,
computeMenuLineCents,
clearCart,
formatPrice,
@ -35,8 +35,8 @@ export function lineCents(item) {
/**
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
* Sans le supplement (le panneau affiche le total de ligne, pas le detail TVA).
* Tolerant aux composants absents.
* @param {Object|undefined} c objet composition de l'item menu
* @returns {string[]}
*/
@ -93,7 +93,7 @@ function modeLabel() {
/**
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
* echappee (RG-T15 anti-XSS).
* @param {Object} line element de buildPanelModel().lines
* @returns {string}
*/
@ -106,18 +106,37 @@ function lineHtml(line) {
return `
<li class="order-panel__line">
<div class="order-panel__line-main">
<span class="order-panel__line-name">${line.quantite}&times; ${escHtml(line.libelle)}</span>
<span class="order-panel__line-name">${escHtml(line.libelle)}</span>
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
</div>
${options}
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
<div class="order-panel__line-controls">
<div class="order-panel__qty" role="group" aria-label="Quantite de ${escHtml(line.libelle)}">
<button
class="order-panel__qty-btn"
data-action="dec"
data-index="${line.index}"
type="button"
aria-label="Diminuer la quantite de ${escHtml(line.libelle)}"
>&minus;</button>
<span class="order-panel__qty-value">${line.quantite}</span>
<button
class="order-panel__qty-btn"
data-action="inc"
data-index="${line.index}"
type="button"
aria-label="Augmenter la quantite de ${escHtml(line.libelle)}"
>+</button>
</div>
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
</div>
</li>
`;
}
@ -165,6 +184,19 @@ export function renderOrderPanel(container) {
</div>
`;
// Stepper +/- : ajuste la quantite de la ligne. Decrementer a 0 retire la ligne
// (updateQuantity supprime quand qty <= 0). Couvre produits ET menus (un menu a
// quantite > 1 = N menus identiques, facture par quantite cote serveur).
container.querySelectorAll('.order-panel__qty-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.index, 10);
const cart = getCart();
const current = cart[index] ? cart[index].quantite : 0;
updateQuantity(index, btn.dataset.action === 'inc' ? current + 1 : current - 1);
renderOrderPanel(container);
});
});
container.querySelectorAll('.order-panel__remove').forEach(btn => {
btn.addEventListener('click', () => {
removeFromCart(parseInt(btn.dataset.index, 10));
@ -181,7 +213,7 @@ export function renderOrderPanel(container) {
}
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
// via aria-disabled (parade a11y, cf. fix E2E #45).
const pay = container.querySelector('.order-panel__pay');
if (pay) {
pay.addEventListener('click', e => {

View file

@ -1,194 +0,0 @@
/*
* page-cart.js Shopping cart screen.
*
* Displays all cart lines with quantity controls and totals.
* Handles two item shapes:
* - Simple product: { id, type, libelle, prix_cents, quantite, image }
* - Composed menu: { ...above, composition: {...}, supplement_cents: number }
*
* Menu lines render a composition breakdown beneath the product name.
* Simple product lines render as before (no composition block).
*
* TVA: 10% (taux normal restauration, France 2024 simplification MVP).
* TODO: verify exact applicable TVA rate with an accountant in P3.
* The real rate depends on sur-place vs a-emporter, alcohol content, etc.
*
* The total displayed is TTC (tax inclusive) because French consumer law
* requires prices shown to end-consumers to include all taxes.
*/
import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
/* TVA rate used for display breakdown only — stored prices are already TTC */
const TVA_RATE = 0.10;
const cartList = document.getElementById('cart-list');
const emptyBlock = document.getElementById('cart-empty');
const summaryBlock= document.getElementById('cart-summary');
const totalTTC = document.getElementById('total-ttc');
const totalHT = document.getElementById('total-ht');
const totalTVA = document.getElementById('total-tva');
const payBtn = document.getElementById('pay-btn');
const abandonBtn = document.getElementById('abandon-btn');
function renderCart() {
const items = getCart();
refreshCartBadge();
if (!items.length) {
cartList.innerHTML = '';
emptyBlock.hidden = false;
summaryBlock.hidden = true;
// pay-btn est un <a> : `.disabled` n'existe pas dessus, il faut piloter
// aria-disabled (sinon le bouton reste annonce desactive panier rempli).
if (payBtn) payBtn.setAttribute('aria-disabled', 'true');
return;
}
emptyBlock.hidden = true;
summaryBlock.hidden = false;
if (payBtn) payBtn.setAttribute('aria-disabled', 'false');
cartList.innerHTML = '';
items.forEach((item, index) => {
const isMenu = item.type === 'menu';
const lineTotalCents = isMenu
? computeMenuLineCents(item)
: item.prix_cents * item.quantite;
const row = document.createElement('li');
row.className = 'cart-line';
row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`);
row.innerHTML = `
<img
class="cart-line__image"
src="${escHtml(item.image)}"
alt="${escHtml(item.libelle)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
<div class="cart-line__info">
<span class="cart-line__name">${escHtml(item.libelle)}</span>
<span class="cart-line__unit-price">${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''}</span>
${isMenu && item.composition ? renderCompositionBlock(item) : ''}
</div>
<div class="cart-line__qty" role="group" aria-label="Quantite de ${escHtml(item.libelle)}">
<button
class="qty-btn qty-btn--minus"
data-index="${index}"
aria-label="Diminuer la quantite de ${escHtml(item.libelle)}"
type="button"
>-</button>
<span class="qty-value" aria-live="polite">${item.quantite}</span>
<button
class="qty-btn qty-btn--plus"
data-index="${index}"
aria-label="Augmenter la quantite de ${escHtml(item.libelle)}"
type="button"
>+</button>
</div>
<span class="cart-line__total">${formatPrice(lineTotalCents)}</span>
<button
class="cart-line__remove"
data-index="${index}"
aria-label="Supprimer ${escHtml(item.libelle)} du panier"
type="button"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="24" height="24">
</button>
`;
cartList.appendChild(row);
});
/* Attach event listeners after render */
cartList.querySelectorAll('.qty-btn--minus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite - 1);
renderCart();
});
});
cartList.querySelectorAll('.qty-btn--plus').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
const cart = getCart();
updateQuantity(idx, cart[idx].quantite + 1);
renderCart();
});
});
cartList.querySelectorAll('.cart-line__remove').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index, 10);
removeFromCart(idx);
renderCart();
});
});
/* Update totals */
const ttcCents = getTotalCents();
/* Back-calculate HT from TTC (prices assumed to be TTC already) */
const htCents = Math.round(ttcCents / (1 + TVA_RATE));
const tvaCents = ttcCents - htCents;
if (totalTTC) totalTTC.textContent = formatPrice(ttcCents);
if (totalHT) totalHT.textContent = formatPrice(htCents);
if (totalTVA) totalTVA.textContent = formatPrice(tvaCents);
}
/**
* Builds the composition breakdown HTML for a menu cart line.
* Renders burger (with personalisation options), accompagnement, boisson, sauce,
* and the supplement summary if applicable. Le format Maxi se lit dans le libelle de
* l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe.
*
* @param {Object} item cart item with type === 'menu' and composition object
* @returns {string} HTML string
*/
function renderCompositionBlock(item) {
const c = item.composition;
if (!c) return '';
// Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut
// ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence.
const parts = [];
if (c.burger) {
const burgerOpts = c.burger.options && c.burger.options.length
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
: '';
parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`);
}
// libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom
// ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite
// grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas).
if (c.accompagnement) {
parts.push(escHtml(c.accompagnement.libelle));
}
if (c.boisson) {
parts.push(escHtml(c.boisson.libelle));
}
if (c.sauce) {
parts.push(escHtml(c.sauce.libelle));
}
const supplTotal = item.supplement_cents ?? 0;
return `
<ul class="cart-line__composition" aria-label="Composition du menu">
${parts.map(t => `<li class="cart-line__comp-item">+ ${t}</li>`).join('')}
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Format Maxi : +${formatPrice(supplTotal)}</li>` : ''}
</ul>
`;
}
if (abandonBtn) {
abandonBtn.addEventListener('click', () => {
clearCart();
window.location.href = 'categories.html';
});
}
document.addEventListener('DOMContentLoaded', renderCart);

View file

@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => {
const items = getCart();
if (!items.length) {
window.location.href = 'cart.html';
window.location.href = 'categories.html';
return;
}
if (recap) {

View file

@ -1,7 +1,7 @@
/*
* page-product-menu.js Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
*
* Importe par page-product.js quand le produit charge est un menu (type === 'menu').
* Importe par page-products.js quand le produit clique est un menu (type === 'menu').
*
* Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
* frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
@ -12,9 +12,9 @@
* Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
*
* La forme de `composition` produite reste compatible avec page-cart.js et
* order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type
* mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
* La forme de `composition` produite reste compatible avec order-panel.js (burger /
* accompagnement / boisson / sauce + taille), le slot_type mappant vers le bon champ ;
* Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
*
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
*/
@ -155,7 +155,7 @@ export function composerIsViable(model) {
}
/* ------------------------------------------------------------------ */
/* Entree publique — appelee par page-product.js */
/* Entree publique — appelee par page-products.js */
/* ------------------------------------------------------------------ */
/**

View file

@ -1,142 +0,0 @@
/*
* page-product.js Product detail screen.
*
* Reads ?id=<int>&category=<slug> from the query string.
*
* Branch on product type:
* - type === 'menu' open the multi-step composer modal (page-product-menu.js).
* The standard detail layout is bypassed because a menu
* cannot be added to the cart without composition choices.
* - type === 'produit' render the standard detail card with "Ajouter au panier".
*
* After "Ajouter au panier" (simple product):
* 1. Item added to cart via state.addToCart()
* 2. Button changes to "Ajoute !" for 1 second (visual feedback)
* 3. Redirect to products.html?category=<slug>
*/
import { findProduct, loadAllergens } from './data.js';
import { addToCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
import { openMenuComposer } from './page-product-menu.js';
import { openProductOptions, productSizes } from './product-options.js';
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
const params = new URLSearchParams(window.location.search);
const productId = parseInt(params.get('id'), 10);
const categorySlug = params.get('category') ?? 'menus';
const container = document.getElementById('product-detail');
const errorBlock = document.getElementById('product-error');
const backBtn = document.getElementById('back-to-products');
if (backBtn) {
backBtn.href = `products.html?category=${categorySlug}`;
}
async function renderProduct() {
if (!productId) {
showError('Produit introuvable.');
return;
}
try {
const product = await findProduct(productId, categorySlug);
if (!product) {
showError('Ce produit n\'existe pas.');
return;
}
document.title = `Wakdo - ${product.nom}`;
if (product.type === 'menu') {
/* Hide the standard product detail area; the composer will overlay the page.
* The container stays in the DOM so the skeleton does not flash. */
container.hidden = true;
await openMenuComposer(product, categorySlug);
return;
}
/* Produit a tailles multiples (R4, ex. boisson 30/50 cl) : on delegue a la
* modale d'options (meme picker que la grille) plutot que de dupliquer la
* selection de taille dans la fiche -> un seul chemin pour choisir la taille. */
if (productSizes(product).length) {
container.hidden = true;
openProductOptions(product, categorySlug);
return;
}
container.innerHTML = `
<div class="product-detail__image-wrap">
<img
class="product-detail__image"
src="${escHtml(product.image)}"
alt="${escHtml(product.nom)}"
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
>
</div>
<div class="product-detail__info">
<h1 class="product-detail__name">${escHtml(product.nom)}</h1>
<p class="product-detail__price">${formatPrice(product.prix)}</p>
<button
class="btn btn--primary btn--large product-detail__add"
id="add-to-cart-btn"
aria-label="Ajouter ${escHtml(product.nom)} au panier"
type="button"
>
Ajouter au panier
</button>
</div>
`;
// Bouton "i" allergenes (modale generale) dans le bloc info de la fiche.
// Echec de chargement non bloquant : la fiche reste fonctionnelle.
try {
const allergens = await loadAllergens();
const info = container.querySelector('.product-detail__info');
if (info) {
info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens)));
}
} catch (e) {
console.error('loadAllergens error:', e);
}
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
addToCart({
id: product.id,
type: product.type,
categorie: product.categorie ?? categorySlug,
libelle: product.nom,
prix_cents: product.prix,
quantite: 1,
image: product.image
});
refreshCartBadge();
const btn = document.getElementById('add-to-cart-btn');
btn.textContent = 'Ajoute !';
btn.disabled = true;
/* Redirect after brief confirmation pause */
setTimeout(() => {
window.location.href = `products.html?category=${categorySlug}`;
}, 1000);
});
} catch (err) {
showError('Erreur lors du chargement du produit.');
console.error('renderProduct error:', err);
}
}
function showError(msg) {
if (errorBlock) {
errorBlock.hidden = false;
errorBlock.textContent = msg;
}
if (container) {
container.hidden = true;
}
}
document.addEventListener('DOMContentLoaded', renderProduct);

View file

@ -3,7 +3,8 @@
*
* Reads ?category=<id> from the query string, maps to a slug via
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
* On product card click, navigates to product.html?id=<id>&category=<slug>.
* On product card click, opens an in-page modal (composer for a menu, options
* for a simple product) above the grid ; the order panel reflects the addition.
*/
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
@ -68,7 +69,9 @@ async function renderProducts() {
const orderable = product.commandable !== false;
const card = document.createElement('a');
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
// Le <a> reste pour le focus/clavier (a11y) ; href='#' inerte, le handler
// click ci-dessous fait foi (preventDefault + ouverture de la modale).
card.href = '#';
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
if (!orderable) card.setAttribute('aria-disabled', 'true');
@ -94,10 +97,10 @@ async function renderProducts() {
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
// (L3). Le <a href> reste un repli (lien direct / sans JS). Une tuile en
// rupture ne fait rien (ni navigation ni modale).
// Clic produit -> modale au-dessus de la grille (paradigme maquette) :
// menu -> composeur (L2), produit -> options (L3). Le panneau de droite est
// l'unique vue panier ; pas de navigation au clic. Une tuile en rupture ne
// fait rien (ni navigation ni modale).
card.addEventListener('click', (e) => {
e.preventDefault();
if (!orderable) return;

View file

@ -1,10 +1,10 @@
/*
* product-options.js Modale d'options produit (P5 L3, taille R4).
*
* Remplace la navigation vers product.html : cliquer un produit simple ouvre une
* modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille,
* facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant
* (L1) est re-rendu pour refleter immediatement la commande -> pas de navigation.
* Ouvre une modale d'options au clic produit, au lieu d'une navigation : cliquer un
* produit simple ouvre une modale (image, prix unitaire, stepper de quantite, total)
* au-dessus de la grille, facon maquette ("Une petite soif ?"). A l'ajout, le panneau
* de commande persistant (L1) est re-rendu pour refleter immediatement la commande.
*
* Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous
* forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl,

View file

@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Votre panier">
<title>Wakdo - Panier</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/cart.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="cart-page">
<!--
cart.html — Shopping cart.
page-cart.js renders all cart lines, handles qty controls and removal.
TVA: 10% (restauration France 2024 — simplified rate).
TODO: verify exact rate with accountant in P3 — actual rate depends
on sur-place vs a-emporter and product type (alcohol, etc.).
The stored prices are TTC. HT is back-calculated at display time only.
-->
<header class="site-header">
<a
class="site-header__back"
href="categories.html"
aria-label="Continuer mes achats"
>
&#8592; Continuer
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
</header>
<main class="cart-main" aria-label="Votre panier">
<h1 class="cart-main__heading">Votre panier</h1>
<!-- Empty cart state -->
<div id="cart-empty" class="cart-empty" hidden>
<p class="cart-empty__message">Votre panier est vide.</p>
<a class="btn btn--secondary" href="categories.html">Decouvrir nos produits</a>
</div>
<!-- Cart lines -->
<ul id="cart-list" class="cart-list" aria-label="Lignes du panier">
<!-- Filled by page-cart.js -->
</ul>
<!-- Order summary -->
<aside id="cart-summary" class="cart-summary" hidden aria-label="Recapitulatif de commande">
<div class="cart-summary__line">
<span>Total HT</span>
<span id="total-ht"></span>
</div>
<div class="cart-summary__line">
<!-- TVA 10% — taux restauration FR 2024 (simplifie, voir commentaire ci-dessus) -->
<span>TVA (10%)</span>
<span id="total-tva"></span>
</div>
<div class="cart-summary__line cart-summary__line--total">
<span>Total TTC</span>
<strong id="total-ttc"></strong>
</div>
</aside>
<!-- Actions -->
<div class="cart-actions">
<button
id="abandon-btn"
class="btn btn--secondary"
type="button"
aria-label="Abandonner la commande et retourner aux categories"
>
Abandonner
</button>
<a
id="pay-btn"
class="btn btn--primary"
href="payment.html"
role="button"
aria-label="Passer au paiement"
aria-disabled="true"
>
Valider ma commande
</a>
</div>
</main>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-cart.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -21,10 +21,10 @@
<header class="site-header">
<a
class="site-header__back"
href="cart.html"
aria-label="Retour au panier"
href="categories.html"
aria-label="Retour aux categories"
>
&#8592; Panier
&#8592; Retour
</a>
<img
class="site-header__logo"

View file

@ -1,73 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Wakdo - Detail du produit">
<title>Wakdo - Produit</title>
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/product.html">
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="product-page">
<!--
product.html — Product detail screen.
Reads ?id=<int>&category=<slug>.
JS (page-product.js) fetches the product, renders detail and handles
the "Ajouter au panier" action.
Menu composition is shown as a fixed note (MVP: no composition selection).
-->
<header class="site-header">
<a
id="back-to-products"
class="site-header__back"
href="products.html"
aria-label="Retour a la liste des produits"
>
&#8592; Retour
</a>
<img
class="site-header__logo"
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">
<main class="product-main" aria-label="Detail du produit">
<!-- Error block: hidden unless fetch fails or id invalid -->
<p id="product-error" class="product-error" hidden role="alert"></p>
<!--
Container filled by page-product.js.
The JS replaces innerHTML once data is ready.
-->
<div id="product-detail" class="product-detail" aria-live="polite">
<!-- Skeleton placeholder visible during fetch -->
<div class="product-detail__skeleton" aria-hidden="true"></div>
</div>
</main>
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
</div>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-product.js"></script>
<script type="module" src="assets/js/order-panel.js"></script>
<script type="module" src="assets/js/a11y.js"></script>
</body>
</html>

View file

@ -33,15 +33,6 @@
src="assets/images/ui/logo.png"
alt="Wakdo"
>
<a
class="site-header__cart"
href="cart.html"
aria-label="Voir le panier"
>
<span class="cart-icon" aria-hidden="true">&#128722;</span>
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
<span class="sr-only">Panier</span>
</a>
</header>
<div class="order-layout">

View file

@ -1,6 +1,8 @@
// Parcours E2E borne : welcome -> categories -> produit -> ajout panier -> panier
// -> paiement -> confirmation. La stack est montee a part (run.sh) ; le panier vit
// dans localStorage (meme origine), donc on peut naviguer par goto sans perdre l'etat.
// Parcours E2E borne : welcome -> categories -> produits (grille) -> modale d'options
// au clic produit -> ajout -> le panneau de commande persistant (unique vue panier)
// reflete la ligne -> paiement -> confirmation. La stack est montee a part (run.sh) ;
// le panier vit dans localStorage (meme origine), on navigue sans perdre l'etat.
// Il n'existe plus de page panier ni de page produit separees (lot F3 panier unique).
const { test, expect } = require('@playwright/test');
test('parcours borne : de l\'accueil a la confirmation de commande', async ({ page }) => {
@ -16,40 +18,46 @@ test('parcours borne : de l\'accueil a la confirmation de commande', async ({ pa
await test.step('categories -> produits', async () => {
await expect(page.locator('h1.categories-main__heading')).toBeVisible();
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui rendent
// un autre gabarit a slots, sans bouton d'ajout direct).
// Categorie 2 = boissons : produits SIMPLES (la categorie 1 = menus, qui ouvrent
// le composeur a slots, un autre parcours). Un produit simple ouvre la modale options.
await page.locator('a[href="products.html?category=2"]').click();
await expect(page).toHaveURL(/products\.html\?category=2/);
});
await test.step('produits -> fiche produit', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte.
const firstCard = page.locator('#products-grid a.product-card').first();
await test.step('clic produit -> modale options', async () => {
// Cartes rendues par JS depuis le JSON : auto-wait sur la 1re carte commandable.
const firstCard = page.locator('#products-grid a.product-card:not(.product-card--unavailable)').first();
await expect(firstCard).toBeVisible();
await firstCard.click();
await expect(page).toHaveURL(/product\.html\?id=/);
// product-options.js monte une modale (.composer-overlay) avec le bouton d'ajout #po-add.
await expect(page.locator('.composer-overlay [role="dialog"]')).toBeVisible();
await expect(page.locator('#po-add')).toBeVisible();
});
await test.step('ajout au panier', async () => {
const addBtn = page.locator('#add-to-cart-btn');
await expect(addBtn).toBeVisible();
await addBtn.click();
// Feedback visuel "Ajoute !" (page-product.js) ; l'ecriture localStorage est synchrone.
await expect(addBtn).toHaveText(/Ajoute/);
await test.step('ajout -> le panneau de commande reflete la ligne', async () => {
await page.locator('#po-add').click();
// La modale se ferme et le panneau persistant (unique vue panier) montre la ligne.
await expect(page.locator('.composer-overlay')).toHaveCount(0);
const panel = page.locator('[data-order-panel]');
await expect(panel.locator('.order-panel__line')).toHaveCount(1);
// Total calcule (le panneau affiche un montant, plus le placeholder vide).
await expect(panel.locator('.order-panel__total-value')).not.toHaveText('');
});
await test.step('panier : recapitulatif', async () => {
await page.goto('/cart.html');
await expect(page.locator('#cart-summary')).toBeVisible();
await expect(page.locator('#cart-list li')).toHaveCount(1);
// Total calcule, plus le placeholder "—".
await expect(page.locator('#total-ttc')).not.toHaveText('—');
await page.locator('#pay-btn').click();
await test.step('panneau Payer -> paiement', async () => {
const payLink = page.locator('[data-order-panel] .order-panel__pay');
await expect(payLink).toHaveAttribute('aria-disabled', 'false');
await payLink.click();
await expect(page).toHaveURL(/payment\.html/);
});
await test.step('paiement -> confirmation', async () => {
await page.locator('#pay-card').click();
// Sur-place : page-payment.js ouvre la modale chevalet (numero de table) avant de
// soumettre. On saisit un numero puis on enregistre pour declencher le checkout.
await expect(page.locator('#chevalet-input')).toBeVisible();
await page.locator('#chevalet-input').fill('12');
await page.locator('#chevalet-ok').click();
await expect(page).toHaveURL(/confirmation\.html/);
await expect(page.locator('.confirmation-banner__title')).toHaveText(/Commande confirmee/);
// Numero de commande genere (plus le placeholder).

View file

@ -12,7 +12,7 @@ import { JSDOM } from 'jsdom';
let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/product.html' });
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;

View file

@ -27,7 +27,7 @@ test('modeLabel: libelle humain ; vide si mode absent ou inconnu (ne ment pas)',
test('needsModeRedirect: page profonde SANS mode valide -> redirige', () => {
assert.equal(needsModeRedirect('/payment.html', null), true);
assert.equal(needsModeRedirect('/products.html', undefined), true);
assert.equal(needsModeRedirect('/cart.html', 'bidon'), true);
assert.equal(needsModeRedirect('/payment.html', 'bidon'), true);
assert.equal(needsModeRedirect('/categories.html', ''), true);
});

View file

@ -138,6 +138,24 @@ test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => {
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: stepper + augmente la quantite et re-rend', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 })]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="inc"]').click();
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart'))[0].quantite, 2);
assert.equal(el.querySelector('.order-panel__qty-value').textContent, '2');
});
test('renderOrderPanel: stepper - a quantite 1 retire la ligne', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ quantite: 1 }), menu()]));
const el = document.createElement('aside');
renderOrderPanel(el);
el.querySelector('.order-panel__qty-btn[data-action="dec"]').click(); // 1re ligne 1 -> 0 -> retiree
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
const el = document.createElement('aside');