feat(front): vanilla JS state management, data loader, and nav helpers

state.js  - cart (localStorage) + mode + price formatting in centimes
data.js   - fetch wrapper over static JSON with in-memory cache; P4 swap points marked
nav.js    - mode badge injection and cart count badge across pages
This commit is contained in:
Imugiii 2026-05-09 07:59:31 +00:00
parent 6f5daca679
commit 43b6e7a309
3 changed files with 311 additions and 0 deletions

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,144 @@
/*
* 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.
* If an identical item (same id + type) already exists, increments quantity.
* @param {{ id: number, type: string, categorie: string, libelle: string, prix_cents: number, quantite: number, image: string }} item
*/
export function addToCart(item) {
const cart = getCart();
const existing = cart.find(c => c.id === item.id && c.type === item.type);
if (existing) {
existing.quantite += item.quantite ?? 1;
} else {
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 -------------------------------------------------------------- */
/**
* Returns the sum of (quantite * prix_cents) for all items in the cart.
* Result is in integer centimes, TTC before any display formatting.
* @returns {number}
*/
export function getTotalCents() {
return getCart().reduce((sum, item) => 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);
}