feat(borne): soumission reelle de commande + ecran chevalet (P5 L4) (#68)
This commit is contained in:
parent
dfbf1bbe0f
commit
7575d0458a
7 changed files with 502 additions and 48 deletions
|
|
@ -2110,3 +2110,42 @@ button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
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.
|
* Affiche le numero de commande REEL et le montant regle, transmis par l'ecran de
|
||||||
* This is session-unique and human-readable at the counter.
|
* paiement via sessionStorage ('wakdo_last_order' = { order_number, total_ttc_cents }
|
||||||
*
|
* pose par checkout.submitOrder). Repli (visite directe sans commande) : numero
|
||||||
* Clears the cart on load so that "Nouvelle commande" starts fresh.
|
* genere localement + total du panier. Vide le panier au chargement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { clearCart, getTotalCents, formatPrice } from './state.js';
|
import { clearCart, getTotalCents, formatPrice } from './state.js';
|
||||||
|
|
@ -13,29 +13,34 @@ const orderNumberEl = document.getElementById('order-number');
|
||||||
const orderTotalEl = document.getElementById('order-total');
|
const orderTotalEl = document.getElementById('order-total');
|
||||||
const newOrderBtn = document.getElementById('new-order-btn');
|
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();
|
return 'WK-' + Date.now().toString(36).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
/* Capture total before clearing */
|
let order = null;
|
||||||
const totalCents = getTotalCents();
|
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) {
|
if (orderTotalEl) {
|
||||||
orderTotalEl.textContent = formatPrice(totalCents);
|
orderTotalEl.textContent = formatPrice(totalCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderNumberEl) {
|
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();
|
clearCart();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newOrderBtn) {
|
if (newOrderBtn) {
|
||||||
newOrderBtn.addEventListener('click', () => {
|
newOrderBtn.addEventListener('click', () => {
|
||||||
/* clearCart() already called on DOMContentLoaded, but guard anyway */
|
|
||||||
clearCart();
|
clearCart();
|
||||||
window.location.href = 'index.html';
|
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">
|
<div class="confirmation-banner__number-block">
|
||||||
<span class="confirmation-banner__number-label">Votre numero de commande</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="confirmation-banner__total">
|
<p class="confirmation-banner__total">
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,16 @@
|
||||||
|
|
||||||
<!-- Mini order recap injected by inline script using localStorage -->
|
<!-- Mini order recap injected by inline script using localStorage -->
|
||||||
<div class="payment-recap" id="payment-recap" aria-label="Recapitulatif de commande">
|
<div class="payment-recap" id="payment-recap" aria-label="Recapitulatif de commande">
|
||||||
<!-- Filled by inline module below -->
|
<!-- Filled by page-payment.js -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="payment-error" id="payment-error" role="alert" hidden></p>
|
||||||
|
|
||||||
<div class="payment-methods" role="group" aria-label="Modes de paiement">
|
<div class="payment-methods" role="group" aria-label="Modes de paiement">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Carte bancaire — simulates payment for MVP.
|
Carte bancaire. Paiement simule (pas de PSP), mais la commande est
|
||||||
Both methods redirect to confirmation.html.
|
REELLEMENT creee + encaissee : page-payment.js -> checkout.submitOrder.
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
class="payment-choice"
|
class="payment-choice"
|
||||||
|
|
@ -83,38 +85,7 @@
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module" src="assets/js/page-payment.js"></script>
|
||||||
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/nav.js"></script>
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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