feat(borne): soumission reelle de commande + ecran chevalet (P5 L4) (#68)
All checks were successful
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 23s
CI / static-tests (push) Successful in 54s
CI / js-tests (push) Successful in 28s

This commit is contained in:
Corentin JOGUET 2026-06-19 20:02:01 +02:00
parent dfbf1bbe0f
commit 7575d0458a
7 changed files with 502 additions and 48 deletions

View file

@ -2110,3 +2110,42 @@ button {
align-items: center;
gap: var(--space-4);
}
/* === Modale chevalet (sur-place, L4) + erreur paiement === */
.chevalet__hint {
color: var(--color-text-secondary);
text-align: center;
margin-bottom: var(--space-4);
}
.chevalet__input {
display: block;
width: 100%;
max-width: 220px;
margin: 0 auto;
padding: var(--space-3);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
text-align: center;
letter-spacing: 0.2em;
border: 2px solid var(--color-border-default);
border-radius: var(--radius-md);
}
.chevalet__input:focus-visible {
outline: 3px solid var(--color-brand-yellow-dk);
outline-offset: 2px;
}
.chevalet__error {
color: var(--color-brand-red);
text-align: center;
margin-top: var(--space-3);
}
.payment-error {
color: var(--color-brand-red);
font-weight: var(--font-weight-bold);
text-align: center;
margin: var(--space-4) 0;
}

View file

@ -0,0 +1,161 @@
/*
* checkout.js Soumission reelle de la commande a l'API (P5 L4).
*
* Avant : payment.html simulait (redirection directe vers confirmation). Desormais
* le panier est traduit vers le contrat /api/orders et POSTe (creation pending_payment
* puis encaissement -> paid + decrement stock RG-T20).
*
* Traduction panier borne -> contrat API :
* - produit simple -> { type:'product', product_id, quantity }
* - menu -> { type:'menu', menu_id, quantity, format, selections }
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
* - service_tag (numero de chevalet) : requis en dine_in.
*
* Les fonctions de traduction sont PURES (testables) ; submitOrder fait les I/O.
*/
import { getCart, getMode } from './state.js';
import { loadMenu } from './data.js';
const MODE_MAP = { 'sur-place': 'dine_in', 'a-emporter': 'takeaway' };
/**
* Mode de consommation borne -> service_mode du contrat API. null si inconnu.
* @param {string|null} mode
* @returns {string|null}
*/
export function mapServiceMode(mode) {
return MODE_MAP[mode] ?? null;
}
/**
* Reconstruit les selections [{menu_slot_id, product_id}] d'un menu a partir de sa
* composition (produits choisis) et de ses slots reels (option_product_ids). Pur.
* Un produit choisi est rattache au slot dont les options le contiennent.
* @param {Object|undefined} composition
* @param {Array<{id:number, option_product_ids:number[]}>} slots
* @returns {Array<{menu_slot_id:number, product_id:number}>}
*/
export function buildSelections(composition, slots) {
const out = [];
if (!composition) return out;
const chosenIds = ['accompagnement', 'boisson', 'sauce']
.map(k => composition[k]?.id)
.filter(id => id != null);
for (const pid of chosenIds) {
const slot = (slots || []).find(s => (s.option_product_ids || []).includes(pid));
if (slot) out.push({ menu_slot_id: slot.id, product_id: pid });
}
return out;
}
/**
* Traduit une ligne de panier en item du contrat API. Pur.
* @param {Object} cartItem
* @param {Object<number, Array>} menuSlotsById slots par id de menu (pour les menus)
* @returns {Object}
*/
export function buildOrderItem(cartItem, menuSlotsById) {
if (cartItem.type === 'menu') {
return {
type: 'menu',
menu_id: cartItem.id,
quantity: cartItem.quantite,
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
};
}
return { type: 'product', product_id: cartItem.id, quantity: cartItem.quantite };
}
/**
* Construit la charge utile complete du POST /api/orders. Pur.
* service_tag n'est inclus qu'en dine_in.
* @returns {Object}
*/
export function buildOrderPayload(cart, mode, serviceTag, menuSlotsById, idempotencyKey) {
const serviceMode = mapServiceMode(mode);
const payload = {
idempotency_key: idempotencyKey,
service_mode: serviceMode,
items: cart.map(it => buildOrderItem(it, menuSlotsById)),
};
if (serviceMode === 'dine_in') {
payload.service_tag = serviceTag;
}
return payload;
}
/** Cle d'idempotence brute : crypto.randomUUID si dispo, repli horodate. */
function newIdempotencyKey() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return `k-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/**
* Cle d'idempotence STABLE pour la tentative de paiement courante : memorisee en
* sessionStorage. Un retry (apres echec reseau du pay) reutilise la MEME cle ->
* l'API renvoie la commande pending existante (findByIdempotencyKey) au lieu d'en
* creer un doublon (RG-T19). Effacee au succes ; regeneree a chaque entree sur la
* page de paiement (page-payment.js efface la cle au chargement).
*/
function checkoutKey() {
try {
let k = sessionStorage.getItem('wakdo_order_key');
if (!k) {
k = newIdempotencyKey();
sessionStorage.setItem('wakdo_order_key', k);
}
return k;
} catch {
return newIdempotencyKey();
}
}
/** POST JSON avec enveloppe ; jette une Error(code) en cas d'echec, payload attache. */
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => null);
if (!res.ok) {
const err = new Error(json?.error?.code || `HTTP ${res.status}`);
err.payload = json;
throw err;
}
return json;
}
/**
* Soumet la commande : re-fetch les slots des menus du panier, construit la charge,
* POST /api/orders (creation) puis POST /api/orders/{number}/pay (encaissement).
* @param {{ serviceTag?: string }} [opts]
* @returns {Promise<{order_number: string, total_ttc_cents: number|null}>}
*/
export async function submitOrder({ serviceTag = '' } = {}) {
const cart = getCart();
if (!cart.length) throw new Error('EMPTY_CART');
const menuIds = [...new Set(cart.filter(i => i.type === 'menu').map(i => i.id))];
const menuSlotsById = {};
for (const id of menuIds) {
const detail = await loadMenu(id);
menuSlotsById[id] = detail?.slots ?? [];
}
const payload = buildOrderPayload(cart, getMode(), serviceTag, menuSlotsById, checkoutKey());
const created = await postJson('/api/orders', payload);
const number = created?.data?.order_number;
if (!number) throw new Error(created?.error?.code || 'ORDER_FAILED');
const paid = await postJson(`/api/orders/${encodeURIComponent(number)}/pay`, {});
// Succes : la cle d'idempotence a joue son role, on la libere pour la commande suivante.
try { sessionStorage.removeItem('wakdo_order_key'); } catch { /* sessionStorage indispo : noop */ }
return { order_number: number, total_ttc_cents: paid?.data?.total_ttc_cents ?? null };
}

View file

@ -1,10 +1,10 @@
/*
* page-confirmation.js Order confirmation screen.
* page-confirmation.js Ecran de commande confirmee.
*
* Generates a short order number: "WK-" + Date.now() encoded in base 36.
* This is session-unique and human-readable at the counter.
*
* Clears the cart on load so that "Nouvelle commande" starts fresh.
* Affiche le numero de commande REEL et le montant regle, transmis par l'ecran de
* paiement via sessionStorage ('wakdo_last_order' = { order_number, total_ttc_cents }
* pose par checkout.submitOrder). Repli (visite directe sans commande) : numero
* genere localement + total du panier. Vide le panier au chargement.
*/
import { clearCart, getTotalCents, formatPrice } from './state.js';
@ -13,29 +13,34 @@ const orderNumberEl = document.getElementById('order-number');
const orderTotalEl = document.getElementById('order-total');
const newOrderBtn = document.getElementById('new-order-btn');
function generateOrderNumber() {
/** Repli si on arrive ici sans commande soumise (visite directe). */
function fallbackOrderNumber() {
return 'WK-' + Date.now().toString(36).toUpperCase();
}
document.addEventListener('DOMContentLoaded', () => {
/* Capture total before clearing */
const totalCents = getTotalCents();
let order = null;
try {
order = JSON.parse(sessionStorage.getItem('wakdo_last_order'));
} catch {
order = null;
}
const totalCents = order && order.total_ttc_cents != null ? order.total_ttc_cents : getTotalCents();
if (orderTotalEl) {
orderTotalEl.textContent = formatPrice(totalCents);
}
if (orderNumberEl) {
orderNumberEl.textContent = generateOrderNumber();
orderNumberEl.textContent = order && order.order_number ? order.order_number : fallbackOrderNumber();
}
/* Clear cart immediately — order is confirmed */
/* La commande est passee : on nettoie le panier et la trace de commande. */
sessionStorage.removeItem('wakdo_last_order');
clearCart();
});
if (newOrderBtn) {
newOrderBtn.addEventListener('click', () => {
/* clearCart() already called on DOMContentLoaded, but guard anyway */
clearCart();
window.location.href = 'index.html';
});

View file

@ -0,0 +1,162 @@
/*
* page-payment.js Ecran de paiement : recap + soumission REELLE de la commande (L4).
*
* Remplace l'ancienne simulation (redirection directe). Au choix d'un mode de
* paiement : en sur-place, ouvre la modale chevalet (saisie du numero de table,
* maquette ecran 9) puis soumet ; en a-emporter, soumet directement. La soumission
* (checkout.submitOrder) cree la commande puis l'encaisse (decrement stock RG-T20).
* Succes -> numero de commande memorise (sessionStorage) + cap sur la confirmation.
* Echec -> message, et on reste sur la page.
*/
import { getTotalCents, formatPrice, getCart, getMode, clearCart, escHtml } from './state.js';
import { submitOrder } from './checkout.js';
const recap = document.getElementById('payment-recap');
const errorEl = document.getElementById('payment-error');
const cardBtn = document.getElementById('pay-card');
const cashBtn = document.getElementById('pay-cash');
/** Message utilisateur (generique) a partir d'un code d'erreur API. */
function messageFor(code) {
if (code === 'PRODUCT_UNAVAILABLE' || code === 'MENU_UNAVAILABLE') {
return 'Un article de votre commande n\'est plus disponible. Modifiez votre panier.';
}
if (code === 'EMPTY_CART' || code === 'EMPTY_ORDER') {
return 'Votre panier est vide.';
}
return 'Le paiement n\'a pas pu aboutir. Veuillez reessayer.';
}
function showError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.hidden = false;
}
function setBusy(busy) {
[cardBtn, cashBtn].forEach(b => { if (b) b.disabled = busy; });
}
/* Anti double-clic : une seule tentative de checkout a la fois (sinon, en sur-place,
* on pourrait empiler plusieurs modales chevalet). Relache sur erreur / annulation. */
let checkingOut = false;
async function doSubmit(serviceTag) {
if (errorEl) errorEl.hidden = true;
setBusy(true);
try {
const res = await submitOrder({ serviceTag });
sessionStorage.setItem('wakdo_last_order', JSON.stringify(res));
clearCart();
window.location.href = 'confirmation.html';
} catch (e) {
checkingOut = false;
setBusy(false);
showError(messageFor(e.message));
console.error('checkout error:', e);
}
}
function startCheckout() {
if (checkingOut) return;
checkingOut = true;
if (getMode() === 'sur-place') {
openChevalet(tag => doSubmit(tag), () => { checkingOut = false; });
} else {
doSubmit('');
}
}
/* --- Modale chevalet (sur-place) : saisie du numero de table -------------- */
function openChevalet(onValidate, onDismiss) {
const overlay = document.createElement('div');
overlay.className = 'composer-overlay';
overlay.innerHTML = `
<div class="composer-container chevalet" role="dialog" aria-modal="true" aria-labelledby="chevalet-title">
<div class="composer-header">
<h2 class="composer-title" id="chevalet-title">Pour etre servi a table</h2>
</div>
<div class="composer-body">
<p class="chevalet__hint">Recuperez un chevalet et indiquez ici le numero inscrit dessus.</p>
<input class="chevalet__input" id="chevalet-input" inputmode="numeric" pattern="[0-9]*"
maxlength="4" aria-label="Numero du chevalet" autocomplete="off">
<p class="chevalet__error" id="chevalet-error" role="alert" hidden>Indiquez le numero du chevalet.</p>
</div>
<div class="composer-footer">
<div class="composer-footer__row">
<button class="btn btn--secondary" type="button" id="chevalet-cancel">Annuler</button>
<button class="btn btn--primary" type="button" id="chevalet-ok">Enregistrer le numero</button>
</div>
</div>
</div>
`;
const prevOverflow = document.body.style.overflow;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const bg = Array.from(document.body.children).filter(el => el !== overlay);
bg.forEach(el => el.setAttribute('aria-hidden', 'true'));
const input = overlay.querySelector('#chevalet-input');
const errBox = overlay.querySelector('#chevalet-error');
const teardown = () => {
document.removeEventListener('keydown', esc);
bg.forEach(el => el.removeAttribute('aria-hidden'));
overlay.remove();
document.body.style.overflow = prevOverflow;
};
const dismiss = () => { teardown(); if (onDismiss) onDismiss(); };
const esc = (e) => { if (e.key === 'Escape') dismiss(); };
document.addEventListener('keydown', esc);
// Focus-trap : Tab/Shift+Tab cyclent dans la modale (coherent L2/L3).
overlay.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const f = Array.from(overlay.querySelectorAll('input, button:not([disabled])'));
if (!f.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
overlay.querySelector('#chevalet-cancel').addEventListener('click', dismiss);
overlay.querySelector('#chevalet-ok').addEventListener('click', () => {
const tag = (input.value || '').trim();
if (!/^[0-9]{1,4}$/.test(tag)) {
errBox.hidden = false;
input.focus();
return;
}
teardown();
onValidate(tag);
});
requestAnimationFrame(() => input.focus());
}
/* --- Init ---------------------------------------------------------------- */
document.addEventListener('DOMContentLoaded', () => {
// Nouvelle visite paiement = nouvelle commande : on repart d'une cle d'idempotence
// neuve (les retries d'une meme tentative la reutilisent, cf. checkout.checkoutKey).
try { sessionStorage.removeItem('wakdo_order_key'); } catch { /* noop */ }
const items = getCart();
if (!items.length) {
window.location.href = 'cart.html';
return;
}
if (recap) {
const modeLabel = getMode() === 'a-emporter' ? 'A emporter' : 'Sur place';
recap.innerHTML = `
<p class="payment-recap__mode">${escHtml(modeLabel)}</p>
<p class="payment-recap__items">${items.length} article${items.length > 1 ? 's' : ''}</p>
<p class="payment-recap__total">Total : <strong>${formatPrice(getTotalCents())}</strong></p>
`;
}
if (cardBtn) cardBtn.addEventListener('click', startCheckout);
if (cashBtn) cashBtn.addEventListener('click', startCheckout);
});

View file

@ -40,7 +40,7 @@
<div class="confirmation-banner__number-block">
<span class="confirmation-banner__number-label">Votre numero de commande</span>
<strong class="confirmation-banner__number" id="order-number" aria-live="polite"></strong>
<strong class="confirmation-banner__number" id="order-number"></strong>
</div>
<p class="confirmation-banner__total">

View file

@ -38,14 +38,16 @@
<!-- Mini order recap injected by inline script using localStorage -->
<div class="payment-recap" id="payment-recap" aria-label="Recapitulatif de commande">
<!-- Filled by inline module below -->
<!-- Filled by page-payment.js -->
</div>
<p class="payment-error" id="payment-error" role="alert" hidden></p>
<div class="payment-methods" role="group" aria-label="Modes de paiement">
<!--
Carte bancaire — simulates payment for MVP.
Both methods redirect to confirmation.html.
Carte bancaire. Paiement simule (pas de PSP), mais la commande est
REELLEMENT creee + encaissee : page-payment.js -> checkout.submitOrder.
-->
<button
class="payment-choice"
@ -83,38 +85,7 @@
</main>
<script type="module">
import { getTotalCents, formatPrice, getCart } from './assets/js/state.js';
import { getMode } from './assets/js/state.js';
/* Show mini recap */
const recap = document.getElementById('payment-recap');
const total = getTotalCents();
const items = getCart();
const mode = getMode() === 'a-emporter' ? 'A emporter' : 'Sur place';
if (recap) {
if (!items.length) {
/* Guard: redirect back to cart if somehow empty */
window.location.href = 'cart.html';
} else {
recap.innerHTML = `
<p class="payment-recap__mode">${mode}</p>
<p class="payment-recap__items">${items.length} article${items.length > 1 ? 's' : ''}</p>
<p class="payment-recap__total">Total : <strong>${formatPrice(total)}</strong></p>
`;
}
}
/* Both payment methods redirect to confirmation — MVP simulation */
function simulatePay() {
window.location.href = 'confirmation.html';
}
document.getElementById('pay-card').addEventListener('click', simulatePay);
document.getElementById('pay-cash').addEventListener('click', simulatePay);
</script>
<script type="module" src="assets/js/page-payment.js"></script>
<script type="module" src="assets/js/nav.js"></script>
</body>
</html>

116
tests/js/checkout.test.js Normal file
View file

@ -0,0 +1,116 @@
/*
* Tests de checkout.js (P5 L4), node:test. checkout.js + state.js + data.js n'ont
* aucun acces DOM au chargement -> import statique. Cible : traduction PURE
* panier->contrat /api/orders (mapServiceMode, buildSelections, buildOrderItem,
* buildOrderPayload) + submitOrder (fetch + localStorage mockes).
*/
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
mapServiceMode, buildSelections, buildOrderItem, buildOrderPayload, submitOrder,
} from '../../src/public/borne/assets/js/checkout.js';
/* --- mapServiceMode ------------------------------------------------------ */
test('mapServiceMode: borne -> contrat (dine_in / takeaway), inconnu -> null', () => {
assert.equal(mapServiceMode('sur-place'), 'dine_in');
assert.equal(mapServiceMode('a-emporter'), 'takeaway');
assert.equal(mapServiceMode(null), null);
});
/* --- buildSelections ----------------------------------------------------- */
const slots = () => ([
{ id: 1, option_product_ids: [14, 15] }, // boisson
{ id: 16, option_product_ids: [22, 23] }, // accompagnement
{ id: 31, option_product_ids: [47] }, // sauce
]);
test('buildSelections: mappe les produits choisis a leur slot', () => {
const comp = { accompagnement: { id: 22 }, boisson: { id: 14 }, sauce: { id: 47 } };
assert.deepEqual(buildSelections(comp, slots()), [
{ menu_slot_id: 16, product_id: 22 },
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 31, product_id: 47 },
]);
});
test('buildSelections: produit hors slots ignore ; composition vide -> []', () => {
assert.deepEqual(buildSelections({ boisson: { id: 999 } }, slots()), []);
assert.deepEqual(buildSelections(undefined, slots()), []);
});
/* --- buildOrderItem ------------------------------------------------------ */
test('buildOrderItem: produit simple', () => {
assert.deepEqual(buildOrderItem({ id: 14, type: 'produit', quantite: 3 }, {}), {
type: 'product', product_id: 14, quantity: 3,
});
});
test('buildOrderItem: menu normal vs maxi (format + selections)', () => {
const menuItem = { id: 1, type: 'menu', quantite: 1, supplement_cents: 0,
composition: { accompagnement: { id: 22 }, boisson: { id: 14 } } };
const normal = buildOrderItem(menuItem, { 1: slots() });
assert.equal(normal.format, 'normal');
assert.equal(normal.menu_id, 1);
assert.equal(normal.selections.length, 2);
const maxi = buildOrderItem({ ...menuItem, supplement_cents: 150 }, { 1: slots() });
assert.equal(maxi.format, 'maxi');
});
/* --- buildOrderPayload --------------------------------------------------- */
test('buildOrderPayload: dine_in inclut service_tag ; takeaway l omet', () => {
const cart = [{ id: 14, type: 'produit', quantite: 1 }];
const din = buildOrderPayload(cart, 'sur-place', '261', {}, 'key-1');
assert.equal(din.service_mode, 'dine_in');
assert.equal(din.service_tag, '261');
assert.equal(din.idempotency_key, 'key-1');
assert.equal(din.items.length, 1);
const take = buildOrderPayload(cart, 'a-emporter', '', {}, 'key-2');
assert.equal(take.service_mode, 'takeaway');
assert.equal('service_tag' in take, false);
});
/* --- submitOrder (mocks) ------------------------------------------------- */
function stubEnv(cart, mode) {
const store = { wakdo_cart: JSON.stringify(cart), wakdo_mode: mode };
global.localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
};
}
test('submitOrder: re-fetch slots, POST create puis pay, renvoie order_number', async () => {
stubEnv([{ id: 1, type: 'menu', quantite: 1, supplement_cents: 0, composition: { boisson: { id: 14 }, accompagnement: { id: 22 } } }], 'sur-place');
const calls = [];
global.fetch = async (url, opts) => {
calls.push({ url, method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : null });
if (url === '/api/menus/1') return { ok: true, json: async () => ({ data: { slots: slots() } }) };
if (url === '/api/orders') return { ok: true, json: async () => ({ data: { order_number: 'K12', total_ttc_cents: 800, status: 'pending_payment' } }) };
if (url === '/api/orders/K12/pay') return { ok: true, json: async () => ({ data: { order_number: 'K12', total_ttc_cents: 800, status: 'paid' } }) };
throw new Error(`URL inattendue: ${url}`);
};
const res = await submitOrder({ serviceTag: '261' });
assert.equal(res.order_number, 'K12');
assert.equal(res.total_ttc_cents, 800);
const create = calls.find(c => c.url === '/api/orders');
assert.equal(create.method, 'POST');
assert.equal(create.body.service_mode, 'dine_in');
assert.equal(create.body.service_tag, '261');
assert.equal(create.body.items[0].type, 'menu');
assert.equal(create.body.items[0].selections.length, 2);
assert.ok(calls.some(c => c.url === '/api/orders/K12/pay' && c.method === 'POST'));
});
test('submitOrder: panier vide -> jette EMPTY_CART', async () => {
stubEnv([], 'a-emporter');
await assert.rejects(() => submitOrder(), /EMPTY_CART/);
});