diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index b4ce655..ead40cc 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -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; +} diff --git a/src/public/borne/assets/js/checkout.js b/src/public/borne/assets/js/checkout.js new file mode 100644 index 0000000..b758e3a --- /dev/null +++ b/src/public/borne/assets/js/checkout.js @@ -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} 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 }; +} diff --git a/src/public/borne/assets/js/page-confirmation.js b/src/public/borne/assets/js/page-confirmation.js index 9173527..aa640fa 100644 --- a/src/public/borne/assets/js/page-confirmation.js +++ b/src/public/borne/assets/js/page-confirmation.js @@ -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'; }); diff --git a/src/public/borne/assets/js/page-payment.js b/src/public/borne/assets/js/page-payment.js new file mode 100644 index 0000000..1da259d --- /dev/null +++ b/src/public/borne/assets/js/page-payment.js @@ -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 = ` + + `; + 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 = ` +

${escHtml(modeLabel)}

+

${items.length} article${items.length > 1 ? 's' : ''}

+

Total : ${formatPrice(getTotalCents())}

+ `; + } + if (cardBtn) cardBtn.addEventListener('click', startCheckout); + if (cashBtn) cashBtn.addEventListener('click', startCheckout); +}); diff --git a/src/public/borne/confirmation.html b/src/public/borne/confirmation.html index 4c86263..e4be215 100644 --- a/src/public/borne/confirmation.html +++ b/src/public/borne/confirmation.html @@ -40,7 +40,7 @@
Votre numero de commande - +

diff --git a/src/public/borne/payment.html b/src/public/borne/payment.html index d43d3f3..efb8525 100644 --- a/src/public/borne/payment.html +++ b/src/public/borne/payment.html @@ -38,14 +38,16 @@

- +
+ +