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:
Imugiii 2026-06-04 17:28:13 +02:00 committed by GitHub
commit 803b840536
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3923 additions and 10 deletions

File diff suppressed because it is too large Load diff

View 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)])
);

View 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();
});

View 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);

View 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';
});
}

View 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">&#9632;</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">&#9632;</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">&#9632;</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">&#9632;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View 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);

View 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);

View 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);
}

View 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"
>
&#8592; 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>

View 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">
&#8592; 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&nbsp;?</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>

View 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>

View 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" }
]

View 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" }
]
}

View file

@ -3,18 +3,79 @@
<head>
<meta charset="UTF-8">
<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">
<title>Wakdo - borne client</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
img { max-height: 80px; }
small { color: #666; }
</style>
<meta name="description" content="Borne de commande Wakdo - choisissez votre mode de consommation">
<title>Wakdo - Bienvenue</title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<img src="/assets/images/ui/logo.png" alt="Wakdo">
<h1>Wakdo - borne client</h1>
<p>En construction.</p>
<p><small>Phase P1 - conception Merise en cours. Le front borne sera implemente en phase P5.</small></p>
<!--
Welcome / landing screen.
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&nbsp;?
</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>
</html>

View 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"
>
&#8592; 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>

View 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"
>
&#8592; 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">&#128722;</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>

View 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"
>
&#8592; 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">&#128722;</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>