Merge pull request #4 from AcadeNice/feat/p5-front-landing
feat(front): P5 kiosk complete flow with vanilla JS and JSON fallback
This commit is contained in:
commit
803b840536
18 changed files with 3923 additions and 10 deletions
1685
src/public/borne/assets/css/style.css
Normal file
1685
src/public/borne/assets/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
111
src/public/borne/assets/js/data.js
Normal file
111
src/public/borne/assets/js/data.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* data.js — Data loading layer for the Wakdo kiosk.
|
||||||
|
*
|
||||||
|
* P5 reads static JSON copies in /data/ (same origin).
|
||||||
|
* In P4, swap the BASE_URL constants to point to REST API endpoints.
|
||||||
|
* The function signatures and return shapes remain unchanged so that
|
||||||
|
* page scripts need no modification when the data source changes.
|
||||||
|
*
|
||||||
|
* Category-to-slug mapping (mirrors data/categories.json id field):
|
||||||
|
* 1=menus 2=boissons 3=burgers 4=frites 5=encas
|
||||||
|
* 6=wraps 7=salades 8=desserts 9=sauces
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --- P4 swap point -------------------------------------------------------
|
||||||
|
* TODO(P4): replace these two paths with API endpoints, e.g.:
|
||||||
|
* const CATEGORIES_URL = '/api/categories';
|
||||||
|
* const PRODUCTS_URL = '/api/products';
|
||||||
|
* The rest of this file is API-agnostic.
|
||||||
|
* ----------------------------------------------------------------------- */
|
||||||
|
const CATEGORIES_URL = 'data/categories.json';
|
||||||
|
const PRODUCTS_URL = 'data/produits.json';
|
||||||
|
|
||||||
|
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
|
||||||
|
let _categoriesCache = null;
|
||||||
|
|
||||||
|
/** @type {Object|null} */
|
||||||
|
let _productsCache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and caches the categories list.
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function loadCategories() {
|
||||||
|
if (_categoriesCache) return _categoriesCache;
|
||||||
|
const res = await fetch(CATEGORIES_URL);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`);
|
||||||
|
_categoriesCache = await res.json();
|
||||||
|
return _categoriesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and caches the full products object keyed by category slug.
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export async function loadProducts() {
|
||||||
|
if (_productsCache) return _productsCache;
|
||||||
|
const res = await fetch(PRODUCTS_URL);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`);
|
||||||
|
_productsCache = await res.json();
|
||||||
|
return _productsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the array of products for a given category slug.
|
||||||
|
* Returns [] if the slug is not found.
|
||||||
|
* @param {string} slug — e.g. "burgers", "menus"
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
export async function getProductsByCategory(slug) {
|
||||||
|
const data = await loadProducts();
|
||||||
|
return data[slug] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the category object for the given id.
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
export async function getCategoryById(id) {
|
||||||
|
const cats = await loadCategories();
|
||||||
|
return cats.find(c => c.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a product by its numeric id, searching all category slates.
|
||||||
|
* Returns null if not found.
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
export async function findProduct(id) {
|
||||||
|
const data = await loadProducts();
|
||||||
|
for (const slug of Object.keys(data)) {
|
||||||
|
const found = data[slug].find(p => p.id === id);
|
||||||
|
if (found) return { ...found, categorie: slug };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a category id integer to its slug string.
|
||||||
|
* Derived from data/categories.json — kept here as a convenience
|
||||||
|
* so page scripts can convert query-string ids without an extra fetch.
|
||||||
|
*/
|
||||||
|
export const CATEGORY_ID_TO_SLUG = {
|
||||||
|
1: 'menus',
|
||||||
|
2: 'boissons',
|
||||||
|
3: 'burgers',
|
||||||
|
4: 'frites',
|
||||||
|
5: 'encas',
|
||||||
|
6: 'wraps',
|
||||||
|
7: 'salades',
|
||||||
|
8: 'desserts',
|
||||||
|
9: 'sauces'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of the above: slug -> id.
|
||||||
|
*/
|
||||||
|
export const CATEGORY_SLUG_TO_ID = Object.fromEntries(
|
||||||
|
Object.entries(CATEGORY_ID_TO_SLUG).map(([id, slug]) => [slug, Number(id)])
|
||||||
|
);
|
||||||
56
src/public/borne/assets/js/nav.js
Normal file
56
src/public/borne/assets/js/nav.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* nav.js — Shared navigation helpers loaded on every page.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Inject the mode badge ("Sur place" / "A emporter") into any
|
||||||
|
* element with [data-mode-badge] on the page.
|
||||||
|
* - Sync the cart item count into any element with [data-cart-count].
|
||||||
|
* - Handle the mode query-string on page load (welcome -> categories handoff).
|
||||||
|
*
|
||||||
|
* Import this module in every page that has a header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getMode, setMode, getCartCount } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads ?mode= from the current URL and persists it if present.
|
||||||
|
* Called once on DOMContentLoaded so that the welcome -> categories
|
||||||
|
* navigation stores the chosen mode before any render.
|
||||||
|
*/
|
||||||
|
function syncModeFromURL() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const modeParam = params.get('mode');
|
||||||
|
if (modeParam === 'sur-place' || modeParam === 'a-emporter') {
|
||||||
|
setMode(modeParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the human-readable mode label into every [data-mode-badge] element.
|
||||||
|
*/
|
||||||
|
function renderModeBadge() {
|
||||||
|
const mode = getMode();
|
||||||
|
const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place';
|
||||||
|
document.querySelectorAll('[data-mode-badge]').forEach(el => {
|
||||||
|
el.textContent = label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the cart item count badge in every [data-cart-count] element.
|
||||||
|
* Called on load and after any cart mutation.
|
||||||
|
*/
|
||||||
|
export function refreshCartBadge() {
|
||||||
|
const count = getCartCount();
|
||||||
|
document.querySelectorAll('[data-cart-count]').forEach(el => {
|
||||||
|
el.textContent = count > 0 ? String(count) : '';
|
||||||
|
el.hidden = count === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initialise on DOM ready */
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
syncModeFromURL();
|
||||||
|
renderModeBadge();
|
||||||
|
refreshCartBadge();
|
||||||
|
});
|
||||||
180
src/public/borne/assets/js/page-cart.js
Normal file
180
src/public/borne/assets/js/page-cart.js
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/*
|
||||||
|
* page-cart.js — Shopping cart screen.
|
||||||
|
*
|
||||||
|
* Displays all cart lines with quantity controls and totals.
|
||||||
|
* Handles two item shapes:
|
||||||
|
* - Simple product: { id, type, libelle, prix_cents, quantite, image }
|
||||||
|
* - Composed menu: { ...above, composition: {...}, supplement_cents: number }
|
||||||
|
*
|
||||||
|
* Menu lines render a composition breakdown beneath the product name.
|
||||||
|
* Simple product lines render as before (no composition block).
|
||||||
|
*
|
||||||
|
* TVA: 10% (taux normal restauration, France 2024 — simplification MVP).
|
||||||
|
* TODO: verify exact applicable TVA rate with an accountant in P3.
|
||||||
|
* The real rate depends on sur-place vs a-emporter, alcohol content, etc.
|
||||||
|
*
|
||||||
|
* The total displayed is TTC (tax inclusive) because French consumer law
|
||||||
|
* requires prices shown to end-consumers to include all taxes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice } from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
|
||||||
|
/* TVA rate used for display breakdown only — stored prices are already TTC */
|
||||||
|
const TVA_RATE = 0.10;
|
||||||
|
|
||||||
|
const cartList = document.getElementById('cart-list');
|
||||||
|
const emptyBlock = document.getElementById('cart-empty');
|
||||||
|
const summaryBlock= document.getElementById('cart-summary');
|
||||||
|
const totalTTC = document.getElementById('total-ttc');
|
||||||
|
const totalHT = document.getElementById('total-ht');
|
||||||
|
const totalTVA = document.getElementById('total-tva');
|
||||||
|
const payBtn = document.getElementById('pay-btn');
|
||||||
|
const abandonBtn = document.getElementById('abandon-btn');
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
const items = getCart();
|
||||||
|
refreshCartBadge();
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
emptyBlock.hidden = false;
|
||||||
|
summaryBlock.hidden = true;
|
||||||
|
if (payBtn) payBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyBlock.hidden = true;
|
||||||
|
summaryBlock.hidden = false;
|
||||||
|
if (payBtn) payBtn.disabled = false;
|
||||||
|
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const isMenu = item.type === 'menu';
|
||||||
|
const lineTotalCents = isMenu
|
||||||
|
? computeMenuLineCents(item)
|
||||||
|
: item.prix_cents * item.quantite;
|
||||||
|
|
||||||
|
const row = document.createElement('li');
|
||||||
|
row.className = 'cart-line';
|
||||||
|
row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<img
|
||||||
|
class="cart-line__image"
|
||||||
|
src="${item.image}"
|
||||||
|
alt="${item.libelle}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
<div class="cart-line__info">
|
||||||
|
<span class="cart-line__name">${item.libelle}</span>
|
||||||
|
<span class="cart-line__unit-price">${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''}</span>
|
||||||
|
${isMenu && item.composition ? renderCompositionBlock(item) : ''}
|
||||||
|
</div>
|
||||||
|
<div class="cart-line__qty" role="group" aria-label="Quantite de ${item.libelle}">
|
||||||
|
<button
|
||||||
|
class="qty-btn qty-btn--minus"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Diminuer la quantite de ${item.libelle}"
|
||||||
|
type="button"
|
||||||
|
>-</button>
|
||||||
|
<span class="qty-value" aria-live="polite">${item.quantite}</span>
|
||||||
|
<button
|
||||||
|
class="qty-btn qty-btn--plus"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Augmenter la quantite de ${item.libelle}"
|
||||||
|
type="button"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
<span class="cart-line__total">${formatPrice(lineTotalCents)}</span>
|
||||||
|
<button
|
||||||
|
class="cart-line__remove"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Supprimer ${item.libelle} du panier"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="24" height="24">
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
cartList.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Attach event listeners after render */
|
||||||
|
cartList.querySelectorAll('.qty-btn--minus').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
const cart = getCart();
|
||||||
|
updateQuantity(idx, cart[idx].quantite - 1);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cartList.querySelectorAll('.qty-btn--plus').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
const cart = getCart();
|
||||||
|
updateQuantity(idx, cart[idx].quantite + 1);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cartList.querySelectorAll('.cart-line__remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
removeFromCart(idx);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Update totals */
|
||||||
|
const ttcCents = getTotalCents();
|
||||||
|
/* Back-calculate HT from TTC (prices assumed to be TTC already) */
|
||||||
|
const htCents = Math.round(ttcCents / (1 + TVA_RATE));
|
||||||
|
const tvaCents = ttcCents - htCents;
|
||||||
|
|
||||||
|
if (totalTTC) totalTTC.textContent = formatPrice(ttcCents);
|
||||||
|
if (totalHT) totalHT.textContent = formatPrice(htCents);
|
||||||
|
if (totalTVA) totalTVA.textContent = formatPrice(tvaCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the composition breakdown HTML for a menu cart line.
|
||||||
|
* Renders burger (with personalisation options), accompagnement with taille,
|
||||||
|
* boisson with taille, sauce, and the supplement summary if applicable.
|
||||||
|
*
|
||||||
|
* @param {Object} item — cart item with type === 'menu' and composition object
|
||||||
|
* @returns {string} HTML string
|
||||||
|
*/
|
||||||
|
function renderCompositionBlock(item) {
|
||||||
|
const c = item.composition;
|
||||||
|
if (!c) return '';
|
||||||
|
|
||||||
|
const burgerOpts = c.burger.options && c.burger.options.length
|
||||||
|
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const accompTailleLabel = c.accompagnement.taille === 'G' ? ' grande' : ' normale';
|
||||||
|
const boissonTailleLabel = c.boisson.taille === 'G' ? ' grande' : ' normale';
|
||||||
|
|
||||||
|
const nbGrandes = (c.accompagnement.taille === 'G' ? 1 : 0) + (c.boisson.taille === 'G' ? 1 : 0);
|
||||||
|
const supplTotal = item.supplement_cents ?? 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<ul class="cart-line__composition" aria-label="Composition du menu">
|
||||||
|
<li class="cart-line__comp-item">+ ${c.burger.libelle}${burgerOpts}</li>
|
||||||
|
<li class="cart-line__comp-item">+ ${c.accompagnement.libelle}${accompTailleLabel}</li>
|
||||||
|
<li class="cart-line__comp-item">+ ${c.boisson.libelle}${boissonTailleLabel}</li>
|
||||||
|
<li class="cart-line__comp-item">+ ${c.sauce.libelle}</li>
|
||||||
|
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Supplement ${nbGrandes} grande(s) : +${formatPrice(supplTotal)}</li>` : ''}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abandonBtn) {
|
||||||
|
abandonBtn.addEventListener('click', () => {
|
||||||
|
clearCart();
|
||||||
|
window.location.href = 'categories.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderCart);
|
||||||
42
src/public/borne/assets/js/page-confirmation.js
Normal file
42
src/public/borne/assets/js/page-confirmation.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* page-confirmation.js — Order confirmation screen.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clearCart, getTotalCents, formatPrice } from './state.js';
|
||||||
|
|
||||||
|
const orderNumberEl = document.getElementById('order-number');
|
||||||
|
const orderTotalEl = document.getElementById('order-total');
|
||||||
|
const newOrderBtn = document.getElementById('new-order-btn');
|
||||||
|
|
||||||
|
function generateOrderNumber() {
|
||||||
|
return 'WK-' + Date.now().toString(36).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
/* Capture total before clearing */
|
||||||
|
const totalCents = getTotalCents();
|
||||||
|
|
||||||
|
if (orderTotalEl) {
|
||||||
|
orderTotalEl.textContent = formatPrice(totalCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderNumberEl) {
|
||||||
|
orderNumberEl.textContent = generateOrderNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clear cart immediately — order is confirmed */
|
||||||
|
clearCart();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newOrderBtn) {
|
||||||
|
newOrderBtn.addEventListener('click', () => {
|
||||||
|
/* clearCart() already called on DOMContentLoaded, but guard anyway */
|
||||||
|
clearCart();
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
702
src/public/borne/assets/js/page-product-menu.js
Normal file
702
src/public/borne/assets/js/page-product-menu.js
Normal file
|
|
@ -0,0 +1,702 @@
|
||||||
|
/*
|
||||||
|
* page-product-menu.js — Multi-step menu composer for the Wakdo kiosk.
|
||||||
|
*
|
||||||
|
* Imported by page-product.js only when the loaded product has type === 'menu'.
|
||||||
|
* Keeping the composer in its own module avoids bloating page-product.js and
|
||||||
|
* makes future unit-testing of the composition logic straightforward.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1 — Burger selection + personalisation options (sans oignon / avec fromage)
|
||||||
|
* 2 — Accompagnement (frites or salades) + taille toggle
|
||||||
|
* 3 — Boisson + taille toggle
|
||||||
|
* 4 — Sauce
|
||||||
|
* 5 — Recap + "Ajouter au panier"
|
||||||
|
*
|
||||||
|
* Price rule: grande taille = +50 centimes per sized item (accompagnement + boisson).
|
||||||
|
*
|
||||||
|
* A11y: role=dialog, aria-modal=true, focus-trap (Tab cycles inside the modal),
|
||||||
|
* ESC closes/cancels, focus is moved to the first interactive element on each step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProductsByCategory } from './data.js';
|
||||||
|
import { addToCart, computeMenuLineCents, formatPrice } from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
|
||||||
|
const SUPPLEMENT_GRANDE_CENTS = 50;
|
||||||
|
const TOTAL_STEPS = 5;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Public entry-point — called from page-product.js */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises and opens the menu composer modal.
|
||||||
|
* Fetches required category products, builds the initial state, then renders.
|
||||||
|
*
|
||||||
|
* @param {Object} menu — product object with type === 'menu'
|
||||||
|
* @param {string} returnCategory — category slug to redirect to after add/cancel
|
||||||
|
*/
|
||||||
|
export async function openMenuComposer(menu, returnCategory) {
|
||||||
|
let burgers, frites, salades, boissons, sauces;
|
||||||
|
try {
|
||||||
|
[burgers, frites, salades, boissons, sauces] = await Promise.all([
|
||||||
|
getProductsByCategory('burgers'),
|
||||||
|
getProductsByCategory('frites'),
|
||||||
|
getProductsByCategory('salades'),
|
||||||
|
getProductsByCategory('boissons'),
|
||||||
|
getProductsByCategory('sauces')
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Menu composer: failed to load category products', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accompagnements = [...frites, ...salades];
|
||||||
|
|
||||||
|
/* Heuristic pre-selection: if the menu name contains a burger name, pre-select it.
|
||||||
|
* "Menu CBO" -> first burger whose nom equals "CBO".
|
||||||
|
* Fallback: first burger in the list. */
|
||||||
|
const menuNameUpper = menu.nom.toUpperCase();
|
||||||
|
const preselectedBurger =
|
||||||
|
burgers.find(b => menuNameUpper.includes(b.nom.toUpperCase())) ?? burgers[0] ?? null;
|
||||||
|
|
||||||
|
/* Composer internal state — single mutable object, re-read on each render. */
|
||||||
|
const state = {
|
||||||
|
currentStep: 1,
|
||||||
|
menu,
|
||||||
|
returnCategory,
|
||||||
|
burgers,
|
||||||
|
accompagnements,
|
||||||
|
boissons,
|
||||||
|
sauces,
|
||||||
|
/* Selections */
|
||||||
|
burger: preselectedBurger,
|
||||||
|
burgerOptions: [], // subset of ['sans-oignon', 'avec-fromage']
|
||||||
|
accompagnement: accompagnements[0] ?? null,
|
||||||
|
accompTaille: 'N', // 'N' or 'G'
|
||||||
|
boisson: boissons[0] ?? null,
|
||||||
|
boissonTaille: 'N',
|
||||||
|
sauce: sauces[0] ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = buildModalShell(menu);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.removeAttribute('hidden');
|
||||||
|
|
||||||
|
/* Prevent background scroll while composer is open. */
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
renderStep(modal, state);
|
||||||
|
trapFocus(modal);
|
||||||
|
|
||||||
|
/* ESC closes the modal and returns to product list. */
|
||||||
|
const escHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelComposer(modal, returnCategory, escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Modal shell builder */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function buildModalShell(menu) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'composer-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-labelledby', 'composer-title');
|
||||||
|
overlay.hidden = true;
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="composer-container" role="document">
|
||||||
|
<div class="composer-header">
|
||||||
|
<h2 class="composer-title" id="composer-title">${escHtml(menu.nom)}</h2>
|
||||||
|
<div class="composer-progress" aria-label="Progression">
|
||||||
|
<span class="composer-progress__text" id="composer-step-indicator" aria-live="polite">Etape 1 / ${TOTAL_STEPS}</span>
|
||||||
|
<div class="composer-progress__bar">
|
||||||
|
<div class="composer-progress__fill" id="composer-progress-fill" style="width: 20%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composer-body" id="composer-body">
|
||||||
|
<!-- step content injected here -->
|
||||||
|
</div>
|
||||||
|
<div class="composer-footer" id="composer-footer">
|
||||||
|
<!-- navigation buttons injected here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step renderer — decides which step to paint */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep(modal, state) {
|
||||||
|
const body = modal.querySelector('#composer-body');
|
||||||
|
const footer = modal.querySelector('#composer-footer');
|
||||||
|
const stepEl = modal.querySelector('#composer-step-indicator');
|
||||||
|
const fillEl = modal.querySelector('#composer-progress-fill');
|
||||||
|
|
||||||
|
stepEl.textContent = `Etape ${state.currentStep} / ${TOTAL_STEPS}`;
|
||||||
|
fillEl.style.width = `${(state.currentStep / TOTAL_STEPS) * 100}%`;
|
||||||
|
|
||||||
|
/* Each step renderer returns {bodyHTML, canAdvance()} and may attach
|
||||||
|
* its own event listeners after DOM insertion. */
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case 1: renderStep1(body, footer, modal, state); break;
|
||||||
|
case 2: renderStep2(body, footer, modal, state); break;
|
||||||
|
case 3: renderStep3(body, footer, modal, state); break;
|
||||||
|
case 4: renderStep4(body, footer, modal, state); break;
|
||||||
|
case 5: renderStep5(body, footer, modal, state); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Move focus to the first interactive element so keyboard users and
|
||||||
|
* screen readers start at the right place after each step transition. */
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const first = modal.querySelector(
|
||||||
|
'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
|
||||||
|
);
|
||||||
|
if (first) first.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step 1 — Burger + personalisation options */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep1(body, footer, modal, state) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="composer-step__subtitle">Choisissez votre burger</p>
|
||||||
|
<ul class="composer-grid" role="list" id="burger-grid">
|
||||||
|
${state.burgers.map(b => `
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="composer-card ${state.burger && state.burger.id === b.id ? 'composer-card--selected' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-id="${b.id}"
|
||||||
|
aria-pressed="${state.burger && state.burger.id === b.id ? 'true' : 'false'}"
|
||||||
|
aria-label="${escHtml(b.nom)}, ${formatPrice(b.prix)}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="composer-card__image"
|
||||||
|
src="${escHtml(b.image)}"
|
||||||
|
alt="${escHtml(b.nom)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png';"
|
||||||
|
>
|
||||||
|
<span class="composer-card__name">${escHtml(b.nom)}</span>
|
||||||
|
<span class="composer-card__price">${formatPrice(b.prix)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<fieldset class="composer-options" id="burger-options">
|
||||||
|
<legend class="composer-options__legend">Personnalisation</legend>
|
||||||
|
<label class="composer-option-label">
|
||||||
|
<input type="checkbox" name="burger-opt" value="sans-oignon"
|
||||||
|
${state.burgerOptions.includes('sans-oignon') ? 'checked' : ''}>
|
||||||
|
Sans oignon
|
||||||
|
</label>
|
||||||
|
<label class="composer-option-label">
|
||||||
|
<input type="checkbox" name="burger-opt" value="avec-fromage"
|
||||||
|
${state.burgerOptions.includes('avec-fromage') ? 'checked' : ''}>
|
||||||
|
Avec fromage
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/* Burger card selection */
|
||||||
|
body.querySelectorAll('#burger-grid .composer-card').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
state.burger = state.burgers.find(b => b.id === id) ?? state.burger;
|
||||||
|
/* Update pressed states without full re-render to preserve scroll position */
|
||||||
|
body.querySelectorAll('#burger-grid .composer-card').forEach(b => {
|
||||||
|
const active = parseInt(b.dataset.id, 10) === state.burger.id;
|
||||||
|
b.classList.toggle('composer-card--selected', active);
|
||||||
|
b.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Personalisation checkboxes */
|
||||||
|
body.querySelectorAll('input[name="burger-opt"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
state.burgerOptions = Array.from(
|
||||||
|
body.querySelectorAll('input[name="burger-opt"]:checked')
|
||||||
|
).map(el => el.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFooter(footer, modal, state, {
|
||||||
|
canAdvance: () => state.burger !== null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step 2 — Accompagnement + taille toggle */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep2(body, footer, modal, state) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="composer-step__subtitle">Choisissez votre accompagnement</p>
|
||||||
|
<ul class="composer-grid" role="list" id="accomp-grid">
|
||||||
|
${state.accompagnements.map(a => `
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="composer-card ${state.accompagnement && state.accompagnement.id === a.id ? 'composer-card--selected' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-id="${a.id}"
|
||||||
|
aria-pressed="${state.accompagnement && state.accompagnement.id === a.id ? 'true' : 'false'}"
|
||||||
|
aria-label="${escHtml(a.nom)}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="composer-card__image"
|
||||||
|
src="${escHtml(a.image)}"
|
||||||
|
alt="${escHtml(a.nom)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png';"
|
||||||
|
>
|
||||||
|
<span class="composer-card__name">${escHtml(a.nom)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
${renderTailleToggle('accomp', state.accompTaille)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.querySelectorAll('#accomp-grid .composer-card').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
state.accompagnement = state.accompagnements.find(a => a.id === id) ?? state.accompagnement;
|
||||||
|
body.querySelectorAll('#accomp-grid .composer-card').forEach(b => {
|
||||||
|
const active = parseInt(b.dataset.id, 10) === state.accompagnement.id;
|
||||||
|
b.classList.toggle('composer-card--selected', active);
|
||||||
|
b.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
attachTailleToggle(body, 'accomp', state, 'accompTaille');
|
||||||
|
|
||||||
|
renderFooter(footer, modal, state, {
|
||||||
|
canAdvance: () => state.accompagnement !== null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step 3 — Boisson + taille toggle */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep3(body, footer, modal, state) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="composer-step__subtitle">Choisissez votre boisson</p>
|
||||||
|
<ul class="composer-grid" role="list" id="boisson-grid">
|
||||||
|
${state.boissons.map(b => `
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="composer-card ${state.boisson && state.boisson.id === b.id ? 'composer-card--selected' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-id="${b.id}"
|
||||||
|
aria-pressed="${state.boisson && state.boisson.id === b.id ? 'true' : 'false'}"
|
||||||
|
aria-label="${escHtml(b.nom)}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="composer-card__image"
|
||||||
|
src="${escHtml(b.image)}"
|
||||||
|
alt="${escHtml(b.nom)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png';"
|
||||||
|
>
|
||||||
|
<span class="composer-card__name">${escHtml(b.nom)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
${renderTailleToggle('boisson', state.boissonTaille)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.querySelectorAll('#boisson-grid .composer-card').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
state.boisson = state.boissons.find(b => b.id === id) ?? state.boisson;
|
||||||
|
body.querySelectorAll('#boisson-grid .composer-card').forEach(b => {
|
||||||
|
const active = parseInt(b.dataset.id, 10) === state.boisson.id;
|
||||||
|
b.classList.toggle('composer-card--selected', active);
|
||||||
|
b.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
attachTailleToggle(body, 'boisson', state, 'boissonTaille');
|
||||||
|
|
||||||
|
renderFooter(footer, modal, state, {
|
||||||
|
canAdvance: () => state.boisson !== null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step 4 — Sauce */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep4(body, footer, modal, state) {
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="composer-step__subtitle">Choisissez votre sauce</p>
|
||||||
|
<ul class="composer-grid" role="list" id="sauce-grid">
|
||||||
|
${state.sauces.map(s => `
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="composer-card ${state.sauce && state.sauce.id === s.id ? 'composer-card--selected' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-id="${s.id}"
|
||||||
|
aria-pressed="${state.sauce && state.sauce.id === s.id ? 'true' : 'false'}"
|
||||||
|
aria-label="${escHtml(s.nom)}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="composer-card__image"
|
||||||
|
src="${escHtml(s.image)}"
|
||||||
|
alt="${escHtml(s.nom)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png';"
|
||||||
|
>
|
||||||
|
<span class="composer-card__name">${escHtml(s.nom)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.querySelectorAll('#sauce-grid .composer-card').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = parseInt(btn.dataset.id, 10);
|
||||||
|
state.sauce = state.sauces.find(s => s.id === id) ?? state.sauce;
|
||||||
|
body.querySelectorAll('#sauce-grid .composer-card').forEach(b => {
|
||||||
|
const active = parseInt(b.dataset.id, 10) === state.sauce.id;
|
||||||
|
b.classList.toggle('composer-card--selected', active);
|
||||||
|
b.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFooter(footer, modal, state, {
|
||||||
|
canAdvance: () => state.sauce !== null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step 5 — Recap + add to cart */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function renderStep5(body, footer, modal, state) {
|
||||||
|
const supplement = computeSupplement(state);
|
||||||
|
const baseItem = buildCartItem(state, supplement);
|
||||||
|
const totalLine = computeMenuLineCents(baseItem);
|
||||||
|
|
||||||
|
const optionsText = state.burgerOptions.length
|
||||||
|
? state.burgerOptions.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<p class="composer-step__subtitle">Recapitulatif de votre menu</p>
|
||||||
|
<ul class="composer-recap" aria-label="Composition du menu">
|
||||||
|
<li class="composer-recap__line">
|
||||||
|
<span class="composer-recap__icon" aria-hidden="true">■</span>
|
||||||
|
<span class="composer-recap__label">
|
||||||
|
${escHtml(state.burger.nom)}
|
||||||
|
${optionsText ? `<span class="composer-recap__opts">(${escHtml(optionsText)})</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="composer-recap__line">
|
||||||
|
<span class="composer-recap__icon" aria-hidden="true">■</span>
|
||||||
|
<span class="composer-recap__label">
|
||||||
|
${escHtml(state.accompagnement.nom)}
|
||||||
|
<span class="composer-recap__taille">${state.accompTaille === 'G' ? 'grande' : 'normale'}</span>
|
||||||
|
${state.accompTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="composer-recap__line">
|
||||||
|
<span class="composer-recap__icon" aria-hidden="true">■</span>
|
||||||
|
<span class="composer-recap__label">
|
||||||
|
${escHtml(state.boisson.nom)}
|
||||||
|
<span class="composer-recap__taille">${state.boissonTaille === 'G' ? 'grande' : 'normale'}</span>
|
||||||
|
${state.boissonTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="composer-recap__line">
|
||||||
|
<span class="composer-recap__icon" aria-hidden="true">■</span>
|
||||||
|
<span class="composer-recap__label">${escHtml(state.sauce.nom)}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="composer-recap__totals">
|
||||||
|
<span class="composer-recap__base">Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)}</span>
|
||||||
|
${supplement > 0 ? `<span class="composer-recap__suppl-total">Supplement grande(s) taille(s) : +${formatPrice(supplement)}</span>` : ''}
|
||||||
|
<span class="composer-recap__total-line">Total : <strong>${formatPrice(totalLine)}</strong></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
footer.innerHTML = `
|
||||||
|
<div class="composer-footer__row">
|
||||||
|
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
|
||||||
|
Precedent
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--primary composer-footer__add" type="button" id="composer-add">
|
||||||
|
Ajouter au panier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
footer.querySelector('#composer-cancel').addEventListener('click', () => {
|
||||||
|
cancelComposer(modal, state.returnCategory, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
footer.querySelector('#composer-prev').addEventListener('click', () => {
|
||||||
|
state.currentStep--;
|
||||||
|
renderStep(modal, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
footer.querySelector('#composer-add').addEventListener('click', () => {
|
||||||
|
addToCart(baseItem);
|
||||||
|
refreshCartBadge();
|
||||||
|
closeComposer(modal);
|
||||||
|
window.location.href = `products.html?category=${state.returnCategory}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Footer renderer (steps 1-4) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the navigation footer for steps 1 through 4.
|
||||||
|
* @param {HTMLElement} footer
|
||||||
|
* @param {HTMLElement} modal
|
||||||
|
* @param {Object} state
|
||||||
|
* @param {{ canAdvance: () => boolean }} opts
|
||||||
|
*/
|
||||||
|
function renderFooter(footer, modal, state, opts) {
|
||||||
|
const isFirst = state.currentStep === 1;
|
||||||
|
|
||||||
|
footer.innerHTML = `
|
||||||
|
<div class="composer-footer__row">
|
||||||
|
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
${!isFirst ? `
|
||||||
|
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
|
||||||
|
Precedent
|
||||||
|
</button>` : ''}
|
||||||
|
<button class="btn btn--primary composer-footer__next" type="button" id="composer-next">
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
footer.querySelector('#composer-cancel').addEventListener('click', () => {
|
||||||
|
cancelComposer(modal, state.returnCategory, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFirst) {
|
||||||
|
footer.querySelector('#composer-prev').addEventListener('click', () => {
|
||||||
|
state.currentStep--;
|
||||||
|
renderStep(modal, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
footer.querySelector('#composer-next').addEventListener('click', () => {
|
||||||
|
if (!opts.canAdvance()) return;
|
||||||
|
state.currentStep++;
|
||||||
|
renderStep(modal, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Taille toggle — shared between accompagnement and boisson steps */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the HTML for the Normale/Grande toggle.
|
||||||
|
* @param {string} prefix — 'accomp' or 'boisson', used for IDs
|
||||||
|
* @param {'N'|'G'} currentTaille
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function renderTailleToggle(prefix, currentTaille) {
|
||||||
|
return `
|
||||||
|
<div class="composer-taille" role="group" aria-label="Taille">
|
||||||
|
<button
|
||||||
|
class="composer-taille__btn ${currentTaille === 'N' ? 'composer-taille__btn--active' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-taille="N"
|
||||||
|
id="${prefix}-taille-n"
|
||||||
|
aria-pressed="${currentTaille === 'N' ? 'true' : 'false'}"
|
||||||
|
>
|
||||||
|
Normale
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="composer-taille__btn ${currentTaille === 'G' ? 'composer-taille__btn--active' : ''}"
|
||||||
|
type="button"
|
||||||
|
data-taille="G"
|
||||||
|
id="${prefix}-taille-g"
|
||||||
|
aria-pressed="${currentTaille === 'G' ? 'true' : 'false'}"
|
||||||
|
>
|
||||||
|
Grande <span class="composer-taille__price-hint">+0,50 EUR</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches click handlers to the taille toggle buttons and keeps state in sync.
|
||||||
|
* @param {HTMLElement} body
|
||||||
|
* @param {string} prefix
|
||||||
|
* @param {Object} state
|
||||||
|
* @param {'accompTaille'|'boissonTaille'} stateKey
|
||||||
|
*/
|
||||||
|
function attachTailleToggle(body, prefix, state, stateKey) {
|
||||||
|
body.querySelectorAll('.composer-taille__btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state[stateKey] = btn.dataset.taille;
|
||||||
|
body.querySelectorAll('.composer-taille__btn').forEach(b => {
|
||||||
|
const active = b.dataset.taille === state[stateKey];
|
||||||
|
b.classList.toggle('composer-taille__btn--active', active);
|
||||||
|
b.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Cart item assembly + supplement calculation */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts how many grande-taille choices were made (0, 1, or 2).
|
||||||
|
* @param {Object} state
|
||||||
|
* @returns {number} centimes
|
||||||
|
*/
|
||||||
|
function computeSupplement(state) {
|
||||||
|
let suppl = 0;
|
||||||
|
if (state.accompTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
|
||||||
|
if (state.boissonTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
|
||||||
|
return suppl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the cart item object from the current composer state.
|
||||||
|
* prix_cents is the base menu price; supplement_cents accumulates size upgrades.
|
||||||
|
*
|
||||||
|
* @param {Object} state
|
||||||
|
* @param {number} supplement
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function buildCartItem(state, supplement) {
|
||||||
|
/* Support both raw produits.json field (prix) and normalised (prix_cents) */
|
||||||
|
const prixCents = state.menu.prix_cents ?? state.menu.prix;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: state.menu.id,
|
||||||
|
type: 'menu',
|
||||||
|
categorie: 'menus',
|
||||||
|
libelle: state.menu.nom,
|
||||||
|
prix_cents: prixCents,
|
||||||
|
quantite: 1,
|
||||||
|
image: state.menu.image,
|
||||||
|
supplement_cents: supplement,
|
||||||
|
composition: {
|
||||||
|
burger: {
|
||||||
|
id: state.burger.id,
|
||||||
|
libelle: state.burger.nom,
|
||||||
|
options: [...state.burgerOptions]
|
||||||
|
},
|
||||||
|
accompagnement: {
|
||||||
|
id: state.accompagnement.id,
|
||||||
|
libelle: state.accompagnement.nom,
|
||||||
|
categorie: state.accompagnement.categorie ?? 'frites',
|
||||||
|
taille: state.accompTaille
|
||||||
|
},
|
||||||
|
boisson: {
|
||||||
|
id: state.boisson.id,
|
||||||
|
libelle: state.boisson.nom,
|
||||||
|
taille: state.boissonTaille
|
||||||
|
},
|
||||||
|
sauce: {
|
||||||
|
id: state.sauce.id,
|
||||||
|
libelle: state.sauce.nom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Focus trap */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traps Tab / Shift+Tab inside the modal container.
|
||||||
|
* The handler is attached to the modal element itself; it is removed
|
||||||
|
* automatically when the modal is removed from the DOM.
|
||||||
|
*/
|
||||||
|
function trapFocus(modal) {
|
||||||
|
modal.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Tab') return;
|
||||||
|
|
||||||
|
const focusable = Array.from(modal.querySelectorAll(
|
||||||
|
'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
|
||||||
|
)).filter(el => !el.closest('[hidden]'));
|
||||||
|
|
||||||
|
if (!focusable.length) return;
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Close helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function closeComposer(modal) {
|
||||||
|
modal.remove();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelComposer(modal, returnCategory, escHandler) {
|
||||||
|
if (escHandler) {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
closeComposer(modal);
|
||||||
|
window.location.href = `products.html?category=${returnCategory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Utilities */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal HTML escaping to prevent XSS when injecting product names/paths
|
||||||
|
* into innerHTML. Applied to all data-derived strings.
|
||||||
|
*/
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
119
src/public/borne/assets/js/page-product.js
Normal file
119
src/public/borne/assets/js/page-product.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/*
|
||||||
|
* page-product.js — Product detail screen.
|
||||||
|
*
|
||||||
|
* Reads ?id=<int>&category=<slug> from the query string.
|
||||||
|
*
|
||||||
|
* Branch on product type:
|
||||||
|
* - type === 'menu' → open the multi-step composer modal (page-product-menu.js).
|
||||||
|
* The standard detail layout is bypassed because a menu
|
||||||
|
* cannot be added to the cart without composition choices.
|
||||||
|
* - type === 'produit' → render the standard detail card with "Ajouter au panier".
|
||||||
|
*
|
||||||
|
* After "Ajouter au panier" (simple product):
|
||||||
|
* 1. Item added to cart via state.addToCart()
|
||||||
|
* 2. Button changes to "Ajoute !" for 1 second (visual feedback)
|
||||||
|
* 3. Redirect to products.html?category=<slug>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findProduct } from './data.js';
|
||||||
|
import { addToCart, formatPrice } from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
import { openMenuComposer } from './page-product-menu.js';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const productId = parseInt(params.get('id'), 10);
|
||||||
|
const categorySlug = params.get('category') ?? 'menus';
|
||||||
|
|
||||||
|
const container = document.getElementById('product-detail');
|
||||||
|
const errorBlock = document.getElementById('product-error');
|
||||||
|
const backBtn = document.getElementById('back-to-products');
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.href = `products.html?category=${categorySlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProduct() {
|
||||||
|
if (!productId) {
|
||||||
|
showError('Produit introuvable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await findProduct(productId);
|
||||||
|
if (!product) {
|
||||||
|
showError('Ce produit n\'existe pas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = `Wakdo - ${product.nom}`;
|
||||||
|
|
||||||
|
if (product.type === 'menu') {
|
||||||
|
/* Hide the standard product detail area; the composer will overlay the page.
|
||||||
|
* The container stays in the DOM so the skeleton does not flash. */
|
||||||
|
container.hidden = true;
|
||||||
|
await openMenuComposer(product, categorySlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="product-detail__image-wrap">
|
||||||
|
<img
|
||||||
|
class="product-detail__image"
|
||||||
|
src="${product.image}"
|
||||||
|
alt="${product.nom}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="product-detail__info">
|
||||||
|
<h1 class="product-detail__name">${product.nom}</h1>
|
||||||
|
<p class="product-detail__price">${formatPrice(product.prix)}</p>
|
||||||
|
<button
|
||||||
|
class="btn btn--primary btn--large product-detail__add"
|
||||||
|
id="add-to-cart-btn"
|
||||||
|
aria-label="Ajouter ${product.nom} au panier"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ajouter au panier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
|
||||||
|
addToCart({
|
||||||
|
id: product.id,
|
||||||
|
type: product.type,
|
||||||
|
categorie: product.categorie ?? categorySlug,
|
||||||
|
libelle: product.nom,
|
||||||
|
prix_cents: product.prix,
|
||||||
|
quantite: 1,
|
||||||
|
image: product.image
|
||||||
|
});
|
||||||
|
refreshCartBadge();
|
||||||
|
|
||||||
|
const btn = document.getElementById('add-to-cart-btn');
|
||||||
|
btn.textContent = 'Ajoute !';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
/* Redirect after brief confirmation pause */
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `products.html?category=${categorySlug}`;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erreur lors du chargement du produit.');
|
||||||
|
console.error('renderProduct error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
if (errorBlock) {
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorBlock.textContent = msg;
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderProduct);
|
||||||
86
src/public/borne/assets/js/page-products.js
Normal file
86
src/public/borne/assets/js/page-products.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* page-products.js — Products list screen.
|
||||||
|
*
|
||||||
|
* Reads ?category=<id> from the query string, maps to a slug via
|
||||||
|
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
|
||||||
|
* On product card click, navigates to product.html?id=<id>&category=<slug>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG } from './data.js';
|
||||||
|
import { formatPrice } from './state.js';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const categoryId = parseInt(params.get('category'), 10) || 1;
|
||||||
|
const categorySlug = CATEGORY_ID_TO_SLUG[categoryId] ?? 'menus';
|
||||||
|
|
||||||
|
const grid = document.getElementById('products-grid');
|
||||||
|
const heading = document.getElementById('products-heading');
|
||||||
|
const backBtn = document.getElementById('back-to-categories');
|
||||||
|
const errorBlock = document.getElementById('products-error');
|
||||||
|
|
||||||
|
/* Build back URL preserving mode query param if present */
|
||||||
|
const modeParam = params.get('mode');
|
||||||
|
|
||||||
|
function buildBackURL() {
|
||||||
|
const base = 'categories.html';
|
||||||
|
return modeParam ? `${base}?mode=${modeParam}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.href = buildBackURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProducts() {
|
||||||
|
try {
|
||||||
|
const [products, category] = await Promise.all([
|
||||||
|
getProductsByCategory(categorySlug),
|
||||||
|
getCategoryById(categoryId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (heading && category) {
|
||||||
|
/* Capitalize first letter of the category title */
|
||||||
|
const title = category.title.charAt(0).toUpperCase() + category.title.slice(1);
|
||||||
|
heading.textContent = `Nos ${title}`;
|
||||||
|
document.title = `Wakdo - ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!products.length) {
|
||||||
|
grid.innerHTML = '<p class="products-empty">Aucun produit disponible dans cette categorie.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
products.forEach(product => {
|
||||||
|
const card = document.createElement('a');
|
||||||
|
card.className = 'product-card';
|
||||||
|
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
|
||||||
|
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="product-card__image-wrap">
|
||||||
|
<img
|
||||||
|
class="product-card__image"
|
||||||
|
src="${product.image}"
|
||||||
|
alt="${product.nom}"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="product-card__body">
|
||||||
|
<span class="product-card__name">${product.nom}</span>
|
||||||
|
<span class="product-card__price">${formatPrice(product.prix)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (errorBlock) {
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorBlock.textContent = 'Impossible de charger les produits. Veuillez reessayer.';
|
||||||
|
}
|
||||||
|
console.error('renderProducts error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderProducts);
|
||||||
174
src/public/borne/assets/js/state.js
Normal file
174
src/public/borne/assets/js/state.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/*
|
||||||
|
* state.js — Global client-side state for the Wakdo kiosk.
|
||||||
|
*
|
||||||
|
* Persists via localStorage so that navigation between pages does not
|
||||||
|
* lose the cart or the consumption mode.
|
||||||
|
*
|
||||||
|
* Price convention: all values stored and computed in INTEGER CENTIMES.
|
||||||
|
* Formatting for display is handled by formatPrice().
|
||||||
|
*
|
||||||
|
* TVA note: 10% applied at display time in cart/payment pages only.
|
||||||
|
* This is a simplified rate for restaurant consumption (France 2024).
|
||||||
|
* TODO: verify exact applicable rate with an accountant in P3 — the real
|
||||||
|
* rate depends on sur-place vs a-emporter, alcohol content, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY_MODE = 'wakdo_mode';
|
||||||
|
const STORAGE_KEY_CART = 'wakdo_cart';
|
||||||
|
|
||||||
|
/* --- Consumption mode ---------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored consumption mode string or null if not yet chosen.
|
||||||
|
* @returns {'sur-place'|'a-emporter'|null}
|
||||||
|
*/
|
||||||
|
export function getMode() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the consumption mode chosen on the welcome screen.
|
||||||
|
* @param {'sur-place'|'a-emporter'} mode
|
||||||
|
*/
|
||||||
|
export function setMode(mode) {
|
||||||
|
localStorage.setItem(STORAGE_KEY_MODE, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cart state ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current cart array.
|
||||||
|
* Each item shape:
|
||||||
|
* { id, type: 'produit'|'menu', categorie, libelle, prix_cents, quantite, image }
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function getCart() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY_CART)) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the entire cart.
|
||||||
|
* @param {Array} items
|
||||||
|
*/
|
||||||
|
export function setCart(items) {
|
||||||
|
localStorage.setItem(STORAGE_KEY_CART, JSON.stringify(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a product or menu to the cart.
|
||||||
|
*
|
||||||
|
* For simple products (type !== 'menu'), merges with an existing line
|
||||||
|
* of the same id — matching real kiosk behavior where two identical
|
||||||
|
* sandwiches become one line with qty 2.
|
||||||
|
*
|
||||||
|
* For composed menus, each call always creates a new line because two
|
||||||
|
* menus with identical base id may have different compositions (different
|
||||||
|
* burger options, sizes, sauces). This prevents silent composition loss.
|
||||||
|
*
|
||||||
|
* Item shapes:
|
||||||
|
* Simple: { id, type, categorie, libelle, prix_cents, quantite, image }
|
||||||
|
* Menu: { ...above, composition: {...}, supplement_cents: number }
|
||||||
|
*
|
||||||
|
* @param {Object} item
|
||||||
|
*/
|
||||||
|
export function addToCart(item) {
|
||||||
|
const cart = getCart();
|
||||||
|
if (item.type !== 'menu') {
|
||||||
|
const existing = cart.find(c => c.id === item.id && c.type === item.type);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantite += item.quantite ?? 1;
|
||||||
|
setCart(cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cart.push({ quantite: 1, ...item });
|
||||||
|
setCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the item at the given index from the cart.
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
export function removeFromCart(index) {
|
||||||
|
const cart = getCart();
|
||||||
|
cart.splice(index, 1);
|
||||||
|
setCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the quantity for the item at the given index.
|
||||||
|
* If qty reaches 0, the item is removed.
|
||||||
|
* @param {number} index
|
||||||
|
* @param {number} qty
|
||||||
|
*/
|
||||||
|
export function updateQuantity(index, qty) {
|
||||||
|
const cart = getCart();
|
||||||
|
if (qty <= 0) {
|
||||||
|
cart.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
cart[index].quantite = qty;
|
||||||
|
}
|
||||||
|
setCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties the cart completely.
|
||||||
|
*/
|
||||||
|
export function clearCart() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CART);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Totals -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the line total in centimes for a menu item including size supplements.
|
||||||
|
* For simple product items the caller should use (prix_cents * quantite) directly.
|
||||||
|
* @param {{ prix_cents: number, supplement_cents: number, quantite: number }} item
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function computeMenuLineCents(item) {
|
||||||
|
return (item.prix_cents + (item.supplement_cents ?? 0)) * item.quantite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the sum of all line totals in centimes.
|
||||||
|
* Menu items include their size supplements; simple items do not carry supplements.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getTotalCents() {
|
||||||
|
return getCart().reduce((sum, item) => {
|
||||||
|
if (item.type === 'menu') {
|
||||||
|
return sum + computeMenuLineCents(item);
|
||||||
|
}
|
||||||
|
return sum + item.prix_cents * item.quantite;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Formatting helpers -------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a centimes integer into a French locale price string.
|
||||||
|
* Example: 490 -> "4,90 EUR"
|
||||||
|
* @param {number} cents
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatPrice(cents) {
|
||||||
|
const euros = cents / 100;
|
||||||
|
return euros.toLocaleString('fr-FR', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}) + ' EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the item count (sum of all quantities) in the cart.
|
||||||
|
* Used to show a badge on the cart button.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getCartCount() {
|
||||||
|
return getCart().reduce((sum, item) => sum + item.quantite, 0);
|
||||||
|
}
|
||||||
99
src/public/borne/cart.html
Normal file
99
src/public/borne/cart.html
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Votre panier">
|
||||||
|
<title>Wakdo - Panier</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="cart-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
cart.html — Shopping cart.
|
||||||
|
page-cart.js renders all cart lines, handles qty controls and removal.
|
||||||
|
|
||||||
|
TVA: 10% (restauration France 2024 — simplified rate).
|
||||||
|
TODO: verify exact rate with accountant in P3 — actual rate depends
|
||||||
|
on sur-place vs a-emporter and product type (alcohol, etc.).
|
||||||
|
|
||||||
|
The stored prices are TTC. HT is back-calculated at display time only.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
class="site-header__back"
|
||||||
|
href="categories.html"
|
||||||
|
aria-label="Continuer mes achats"
|
||||||
|
>
|
||||||
|
← Continuer
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="cart-main" aria-label="Votre panier">
|
||||||
|
|
||||||
|
<h1 class="cart-main__heading">Votre panier</h1>
|
||||||
|
|
||||||
|
<!-- Empty cart state -->
|
||||||
|
<div id="cart-empty" class="cart-empty" hidden>
|
||||||
|
<p class="cart-empty__message">Votre panier est vide.</p>
|
||||||
|
<a class="btn btn--secondary" href="categories.html">Decouvrir nos produits</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart lines -->
|
||||||
|
<ul id="cart-list" class="cart-list" aria-label="Lignes du panier">
|
||||||
|
<!-- Filled by page-cart.js -->
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Order summary -->
|
||||||
|
<aside id="cart-summary" class="cart-summary" hidden aria-label="Recapitulatif de commande">
|
||||||
|
<div class="cart-summary__line">
|
||||||
|
<span>Total HT</span>
|
||||||
|
<span id="total-ht">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary__line">
|
||||||
|
<!-- TVA 10% — taux restauration FR 2024 (simplifie, voir commentaire ci-dessus) -->
|
||||||
|
<span>TVA (10%)</span>
|
||||||
|
<span id="total-tva">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary__line cart-summary__line--total">
|
||||||
|
<span>Total TTC</span>
|
||||||
|
<strong id="total-ttc">—</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="cart-actions">
|
||||||
|
<button
|
||||||
|
id="abandon-btn"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
type="button"
|
||||||
|
aria-label="Abandonner la commande et retourner aux categories"
|
||||||
|
>
|
||||||
|
Abandonner
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
id="pay-btn"
|
||||||
|
class="btn btn--primary"
|
||||||
|
href="payment.html"
|
||||||
|
role="button"
|
||||||
|
aria-label="Passer au paiement"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Valider ma commande
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-cart.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
179
src/public/borne/categories.html
Normal file
179
src/public/borne/categories.html
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Choisissez une categorie de produits">
|
||||||
|
<title>Wakdo - Categories</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="categories-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Categories screen.
|
||||||
|
Data source: docs/merise/_sources/categories.json (9 categories).
|
||||||
|
Image paths: assets/images/categories/{title}.png — verified against filesystem.
|
||||||
|
In P4 this page will be generated dynamically from GET /api/categories.
|
||||||
|
For now it is a static scaffold that matches the data contract exactly.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a href="index.html" class="site-header__back" aria-label="Retour a l'accueil">
|
||||||
|
← Retour
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Empty div keeps the logo centered via space-between.
|
||||||
|
When the cart icon is added in P5, it replaces this placeholder.
|
||||||
|
-->
|
||||||
|
<div aria-hidden="true" style="width: 80px;"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="categories-main" aria-label="Categories de produits">
|
||||||
|
<h1 class="categories-main__heading">Que souhaitez-vous commander ?</h1>
|
||||||
|
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
9 categories from categories.json, in the same order as the source.
|
||||||
|
Each card links to a product page (products.html?category=<id>) — stub URL
|
||||||
|
for future P5 implementation. The link is functional HTML; no JS needed.
|
||||||
|
title field from JSON used as alt text and visible label.
|
||||||
|
-->
|
||||||
|
<nav class="category-grid" aria-label="Navigation par categorie">
|
||||||
|
|
||||||
|
<!-- id: 1 | title: menus -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=1"
|
||||||
|
aria-label="Voir les menus"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/menus.png"
|
||||||
|
alt="Menus"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Menus</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 2 | title: boissons -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=2"
|
||||||
|
aria-label="Voir les boissons"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/boissons.png"
|
||||||
|
alt="Boissons"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Boissons</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 3 | title: burgers -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=3"
|
||||||
|
aria-label="Voir les burgers"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/burgers.png"
|
||||||
|
alt="Burgers"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Burgers</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 4 | title: frites -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=4"
|
||||||
|
aria-label="Voir les frites"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/frites.png"
|
||||||
|
alt="Frites"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Frites</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 5 | title: encas -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=5"
|
||||||
|
aria-label="Voir les encas"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/encas.png"
|
||||||
|
alt="Encas"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Encas</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 6 | title: wraps -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=6"
|
||||||
|
aria-label="Voir les wraps"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/wraps.png"
|
||||||
|
alt="Wraps"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Wraps</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 7 | title: salades -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=7"
|
||||||
|
aria-label="Voir les salades"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/salades.png"
|
||||||
|
alt="Salades"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Salades</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 8 | title: desserts -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=8"
|
||||||
|
aria-label="Voir les desserts"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/desserts.png"
|
||||||
|
alt="Desserts"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Desserts</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- id: 9 | title: sauces -->
|
||||||
|
<a
|
||||||
|
class="category-card"
|
||||||
|
href="products.html?category=9"
|
||||||
|
aria-label="Voir les sauces"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="category-card__image"
|
||||||
|
src="assets/images/categories/sauces.png"
|
||||||
|
alt="Sauces"
|
||||||
|
>
|
||||||
|
<span class="category-card__label">Sauces</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
src/public/borne/confirmation.html
Normal file
69
src/public/borne/confirmation.html
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Commande confirmee">
|
||||||
|
<title>Wakdo - Confirmation</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="confirmation-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
confirmation.html — Order confirmed screen.
|
||||||
|
Order number: "WK-" + Date.now().toString(36).toUpperCase()
|
||||||
|
Cart is cleared on page load by page-confirmation.js.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header site-header--minimal">
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="confirmation-main" aria-label="Confirmation de commande">
|
||||||
|
|
||||||
|
<div class="confirmation-banner" role="status" aria-live="polite">
|
||||||
|
|
||||||
|
<!-- Checkmark SVG (inline, no external dependency) -->
|
||||||
|
<svg class="confirmation-banner__check" viewBox="0 0 80 80" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="40" cy="40" r="38" fill="#FFC72C"/>
|
||||||
|
<polyline points="22,40 34,54 58,28" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h1 class="confirmation-banner__title">Commande confirmee !</h1>
|
||||||
|
|
||||||
|
<p class="confirmation-banner__sub">Votre commande est en preparation</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="confirmation-banner__total">
|
||||||
|
Montant regle : <strong id="order-total">—</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="confirmation-banner__delay">
|
||||||
|
Temps d'attente estime : <strong>5 - 10 minutes</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="new-order-btn"
|
||||||
|
class="btn btn--primary btn--large confirmation-new-order"
|
||||||
|
type="button"
|
||||||
|
aria-label="Demarrer une nouvelle commande"
|
||||||
|
>
|
||||||
|
Nouvelle commande
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/page-confirmation.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
src/public/borne/data/categories.json
Normal file
11
src/public/borne/data/categories.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{ "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" },
|
||||||
|
{ "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" },
|
||||||
|
{ "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" },
|
||||||
|
{ "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" },
|
||||||
|
{ "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" },
|
||||||
|
{ "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" },
|
||||||
|
{ "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" },
|
||||||
|
{ "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" },
|
||||||
|
{ "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" }
|
||||||
|
]
|
||||||
86
src/public/borne/data/produits.json
Normal file
86
src/public/borne/data/produits.json
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
{
|
||||||
|
"menus": [
|
||||||
|
{ "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" },
|
||||||
|
{ "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" },
|
||||||
|
{ "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" },
|
||||||
|
{ "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" },
|
||||||
|
{ "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" },
|
||||||
|
{ "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" },
|
||||||
|
{ "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" },
|
||||||
|
{ "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" },
|
||||||
|
{ "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" },
|
||||||
|
{ "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" },
|
||||||
|
{ "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" },
|
||||||
|
{ "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" },
|
||||||
|
{ "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" }
|
||||||
|
],
|
||||||
|
"burgers": [
|
||||||
|
{ "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" },
|
||||||
|
{ "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" },
|
||||||
|
{ "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" },
|
||||||
|
{ "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" },
|
||||||
|
{ "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" },
|
||||||
|
{ "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" },
|
||||||
|
{ "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" },
|
||||||
|
{ "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" },
|
||||||
|
{ "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" },
|
||||||
|
{ "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" },
|
||||||
|
{ "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" },
|
||||||
|
{ "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" },
|
||||||
|
{ "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" }
|
||||||
|
],
|
||||||
|
"boissons": [
|
||||||
|
{ "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" },
|
||||||
|
{ "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" },
|
||||||
|
{ "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" },
|
||||||
|
{ "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" },
|
||||||
|
{ "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" },
|
||||||
|
{ "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" },
|
||||||
|
{ "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" },
|
||||||
|
{ "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"frites": [
|
||||||
|
{ "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" },
|
||||||
|
{ "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" },
|
||||||
|
{ "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" },
|
||||||
|
{ "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" },
|
||||||
|
{ "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"encas": [
|
||||||
|
{ "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" },
|
||||||
|
{ "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" },
|
||||||
|
{ "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" },
|
||||||
|
{ "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"desserts": [
|
||||||
|
{ "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" },
|
||||||
|
{ "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" },
|
||||||
|
{ "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" },
|
||||||
|
{ "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" },
|
||||||
|
{ "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" },
|
||||||
|
{ "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" },
|
||||||
|
{ "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" },
|
||||||
|
{ "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" },
|
||||||
|
{ "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"sauces": [
|
||||||
|
{ "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" },
|
||||||
|
{ "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" },
|
||||||
|
{ "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" },
|
||||||
|
{ "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" },
|
||||||
|
{ "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" },
|
||||||
|
{ "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" },
|
||||||
|
{ "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"salades": [
|
||||||
|
{ "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" },
|
||||||
|
{ "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" },
|
||||||
|
{ "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"wraps": [
|
||||||
|
{ "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" },
|
||||||
|
{ "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" },
|
||||||
|
{ "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" },
|
||||||
|
{ "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,79 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<!--
|
||||||
|
noindex: this kiosk is not a public website; no SEO indexing needed.
|
||||||
|
nofollow: prevents link-following by bots reaching the kiosk URL.
|
||||||
|
-->
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Wakdo - borne client</title>
|
<meta name="description" content="Borne de commande Wakdo - choisissez votre mode de consommation">
|
||||||
<style>
|
<title>Wakdo - Bienvenue</title>
|
||||||
body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
img { max-height: 80px; }
|
|
||||||
small { color: #666; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<img src="/assets/images/ui/logo.png" alt="Wakdo">
|
|
||||||
<h1>Wakdo - borne client</h1>
|
<!--
|
||||||
<p>En construction.</p>
|
Welcome / landing screen.
|
||||||
<p><small>Phase P1 - conception Merise en cours. Le front borne sera implemente en phase P5.</small></p>
|
Matches maquette page 1: background photo, white card, two choice buttons.
|
||||||
|
Navigation is purely HTML <a> links — no JS required for this screen.
|
||||||
|
-->
|
||||||
|
<main class="welcome" aria-label="Ecran d'accueil Wakdo">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
mc-landing-banner.png is the background photo (M arches + food).
|
||||||
|
alt="" because it is purely decorative — the content lives in the card.
|
||||||
|
-->
|
||||||
|
<img
|
||||||
|
class="welcome__bg"
|
||||||
|
src="assets/images/ui/mc-landing-banner.png"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
<section class="welcome__card" aria-labelledby="welcome-heading">
|
||||||
|
<h1 class="welcome__greeting" id="welcome-heading">Bonjour,</h1>
|
||||||
|
<p class="welcome__question">
|
||||||
|
Souhaitez-vous consommer votre menu sur place<br>
|
||||||
|
ou preferez-vous l'emporter ?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav class="welcome__choices" aria-label="Mode de consommation">
|
||||||
|
<!--
|
||||||
|
href passes the choice via query string.
|
||||||
|
The categories page reads it to display the correct mode label.
|
||||||
|
No JS required for the navigation itself.
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
class="choice-btn"
|
||||||
|
href="categories.html?mode=sur-place"
|
||||||
|
role="button"
|
||||||
|
aria-label="Commander sur place"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="choice-btn__image"
|
||||||
|
src="assets/images/ui/illustration-sur-place.png"
|
||||||
|
alt="Table et chaises - Sur place"
|
||||||
|
>
|
||||||
|
<span class="choice-btn__label">Sur Place</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="choice-btn"
|
||||||
|
href="categories.html?mode=a-emporter"
|
||||||
|
role="button"
|
||||||
|
aria-label="Commander a emporter"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="choice-btn__image"
|
||||||
|
src="assets/images/ui/illustration-a-emporter.png"
|
||||||
|
alt="Sac a emporter"
|
||||||
|
>
|
||||||
|
<span class="choice-btn__label">A Emporter</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
120
src/public/borne/payment.html
Normal file
120
src/public/borne/payment.html
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Choisissez votre mode de paiement">
|
||||||
|
<title>Wakdo - Paiement</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="payment-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
payment.html — Payment method selection.
|
||||||
|
MVP: both buttons simulate payment and redirect to confirmation.html.
|
||||||
|
No real payment integration (kiosk demo — out of scope permanently).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
class="site-header__back"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Retour au panier"
|
||||||
|
>
|
||||||
|
← Panier
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="payment-main" aria-label="Choix du mode de paiement">
|
||||||
|
|
||||||
|
<h1 class="payment-main__heading">Comment souhaitez-vous payer ?</h1>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-methods" role="group" aria-label="Modes de paiement">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Carte bancaire — simulates payment for MVP.
|
||||||
|
Both methods redirect to confirmation.html.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
class="payment-choice"
|
||||||
|
id="pay-card"
|
||||||
|
type="button"
|
||||||
|
aria-label="Payer par carte bancaire"
|
||||||
|
>
|
||||||
|
<!-- Card SVG icon (inline, no external dependency) -->
|
||||||
|
<svg class="payment-choice__icon" viewBox="0 0 64 64" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="14" width="56" height="36" rx="6" ry="6" fill="#FFC72C"/>
|
||||||
|
<rect x="4" y="24" width="56" height="8" fill="#1A1A1A"/>
|
||||||
|
<rect x="12" y="36" width="16" height="6" rx="2" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
<span class="payment-choice__label">Carte bancaire</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Especes -->
|
||||||
|
<button
|
||||||
|
class="payment-choice"
|
||||||
|
id="pay-cash"
|
||||||
|
type="button"
|
||||||
|
aria-label="Payer en especes"
|
||||||
|
>
|
||||||
|
<!-- Cash SVG icon (inline) -->
|
||||||
|
<svg class="payment-choice__icon" viewBox="0 0 64 64" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="20" width="56" height="32" rx="4" ry="4" fill="#FFC72C"/>
|
||||||
|
<circle cx="32" cy="36" r="8" fill="#fff"/>
|
||||||
|
<circle cx="32" cy="36" r="4" fill="#FFC72C"/>
|
||||||
|
<rect x="8" y="12" width="48" height="6" rx="2" fill="#E6A800"/>
|
||||||
|
</svg>
|
||||||
|
<span class="payment-choice__label">Especes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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/nav.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
src/public/borne/product.html
Normal file
65
src/public/borne/product.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Detail du produit">
|
||||||
|
<title>Wakdo - Produit</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="product-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
product.html — Product detail screen.
|
||||||
|
Reads ?id=<int>&category=<slug>.
|
||||||
|
JS (page-product.js) fetches the product, renders detail and handles
|
||||||
|
the "Ajouter au panier" action.
|
||||||
|
Menu composition is shown as a fixed note (MVP: no composition selection).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
id="back-to-products"
|
||||||
|
class="site-header__back"
|
||||||
|
href="products.html"
|
||||||
|
aria-label="Retour a la liste des produits"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="product-main" aria-label="Detail du produit">
|
||||||
|
|
||||||
|
<!-- Error block: hidden unless fetch fails or id invalid -->
|
||||||
|
<p id="product-error" class="product-error" hidden role="alert"></p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Container filled by page-product.js.
|
||||||
|
The JS replaces innerHTML once data is ready.
|
||||||
|
-->
|
||||||
|
<div id="product-detail" class="product-detail" aria-live="polite">
|
||||||
|
<!-- Skeleton placeholder visible during fetch -->
|
||||||
|
<div class="product-detail__skeleton" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-product.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
src/public/borne/products.html
Normal file
68
src/public/borne/products.html
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Produits de la categorie selectionnee">
|
||||||
|
<title>Wakdo - Produits</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="products-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
products.html — List of products in a category.
|
||||||
|
Category is determined at runtime from ?category=<id>.
|
||||||
|
JS (page-products.js) fetches data/produits.json and renders cards.
|
||||||
|
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
id="back-to-categories"
|
||||||
|
class="site-header__back"
|
||||||
|
href="categories.html"
|
||||||
|
aria-label="Retour aux categories"
|
||||||
|
>
|
||||||
|
← Categories
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="products-main" aria-label="Liste des produits">
|
||||||
|
|
||||||
|
<div class="products-header">
|
||||||
|
<!--
|
||||||
|
Heading is updated by page-products.js once the category
|
||||||
|
data is loaded — default text shown during load.
|
||||||
|
-->
|
||||||
|
<h1 id="products-heading" class="products-main__heading">Nos produits</h1>
|
||||||
|
<span class="mode-badge" data-mode-badge aria-label="Mode de consommation">Sur place</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error block: hidden by default, shown if fetch fails -->
|
||||||
|
<p id="products-error" class="products-error" hidden role="alert"></p>
|
||||||
|
|
||||||
|
<ul id="products-grid" class="products-grid" aria-label="Grille de produits">
|
||||||
|
<!-- Product cards injected by page-products.js -->
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-products.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue