feat(borne): soumission reelle de commande + ecran chevalet (P5 L4)
All checks were successful
CI / secret-scan (pull_request) Successful in 17s
CI / js-tests (pull_request) Successful in 25s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 50s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 48s
CI / js-tests (push) Successful in 27s
All checks were successful
CI / secret-scan (pull_request) Successful in 17s
CI / js-tests (pull_request) Successful in 25s
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 50s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 48s
CI / js-tests (push) Successful in 27s
La borne POSTe desormais une vraie commande a l'API (avant : payment.html simulait
une redirection). Au paiement : le panier est traduit vers le contrat /api/orders,
la commande est creee puis encaissee (pay -> decrement stock RG-T20).
- checkout.js : traduction PURE panier -> contrat (produit/menu, format normal/maxi,
selections [{menu_slot_id, product_id}] reconstruites depuis la composition + les
slots re-fetches ; service_mode dine_in/takeaway ; service_tag chevalet). submitOrder
fait create puis pay. Cle d'idempotence STABLE en sessionStorage : un retry reutilise
la meme cle -> pas de commande doublon (RG-T19) ; effacee au succes.
- page-payment.js : recap + flux ; modale chevalet (sur-place, saisie du numero de
table, maquette ecran 9) avec focus-trap/ESC ; verrou anti double-clic ; messages
d'erreur. Cle d'idempotence fraiche a chaque visite paiement.
- page-confirmation.js : numero de commande REEL (via sessionStorage) au lieu d'un
numero genere ; repli si visite directe.
- tests : checkout.test.js (traduction + submitOrder mocke). 64/64 verts.
Verifie e2e contre l'API reelle : commande creee + payee, stock decremente exactement
(Menu Le 280 + Big Mac x2 -> -5 Steak hache). Revue adversariale (9 findings) :
CRITIQUE (migration service_tag dupliquee) ecarte -- la colonne est livree par
0003_order_service_tag.sql (#55), juste non appliquee sur la stack dev (reconcilie
dans schema_migrations) ; MEDIUM idempotency + double-clic corriges ; aria-live
imbrique + commentaires obsoletes nettoyes. Differes (LOW notes) : format deduit du
supplement (theorique, le seed met toujours +150), selection sans slot silencieuse.
This commit is contained in:
parent
22a4bacc22
commit
7835ae08c6
7 changed files with 502 additions and 48 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
161
src/public/borne/assets/js/checkout.js
Normal file
161
src/public/borne/assets/js/checkout.js
Normal 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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
});
|
||||
|
|
|
|||
162
src/public/borne/assets/js/page-payment.js
Normal file
162
src/public/borne/assets/js/page-payment.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
116
tests/js/checkout.test.js
Normal 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/);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue