From 43b6e7a3091e6792b1e10cd31cb8fc5392ab24ca Mon Sep 17 00:00:00 2001 From: Imugiii Date: Sat, 9 May 2026 07:59:31 +0000 Subject: [PATCH] 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 --- src/public/borne/assets/js/data.js | 111 +++++++++++++++++++++ src/public/borne/assets/js/nav.js | 56 +++++++++++ src/public/borne/assets/js/state.js | 144 ++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/public/borne/assets/js/data.js create mode 100644 src/public/borne/assets/js/nav.js create mode 100644 src/public/borne/assets/js/state.js diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js new file mode 100644 index 0000000..38f03b1 --- /dev/null +++ b/src/public/borne/assets/js/data.js @@ -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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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)]) +); diff --git a/src/public/borne/assets/js/nav.js b/src/public/borne/assets/js/nav.js new file mode 100644 index 0000000..5b431b8 --- /dev/null +++ b/src/public/borne/assets/js/nav.js @@ -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(); +}); diff --git a/src/public/borne/assets/js/state.js b/src/public/borne/assets/js/state.js new file mode 100644 index 0000000..d90206e --- /dev/null +++ b/src/public/borne/assets/js/state.js @@ -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); +}