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:
parent
6f5daca679
commit
43b6e7a309
3 changed files with 311 additions and 0 deletions
111
src/public/borne/assets/js/data.js
Normal file
111
src/public/borne/assets/js/data.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* data.js — Data loading layer for the Wakdo kiosk.
|
||||
*
|
||||
* P5 reads static JSON copies in /data/ (same origin).
|
||||
* In P4, swap the BASE_URL constants to point to REST API endpoints.
|
||||
* The function signatures and return shapes remain unchanged so that
|
||||
* page scripts need no modification when the data source changes.
|
||||
*
|
||||
* Category-to-slug mapping (mirrors data/categories.json id field):
|
||||
* 1=menus 2=boissons 3=burgers 4=frites 5=encas
|
||||
* 6=wraps 7=salades 8=desserts 9=sauces
|
||||
*/
|
||||
|
||||
/* --- P4 swap point -------------------------------------------------------
|
||||
* TODO(P4): replace these two paths with API endpoints, e.g.:
|
||||
* const CATEGORIES_URL = '/api/categories';
|
||||
* const PRODUCTS_URL = '/api/products';
|
||||
* The rest of this file is API-agnostic.
|
||||
* ----------------------------------------------------------------------- */
|
||||
const CATEGORIES_URL = 'data/categories.json';
|
||||
const PRODUCTS_URL = 'data/produits.json';
|
||||
|
||||
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
|
||||
let _categoriesCache = null;
|
||||
|
||||
/** @type {Object|null} */
|
||||
let _productsCache = null;
|
||||
|
||||
/**
|
||||
* Fetches and caches the categories list.
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function loadCategories() {
|
||||
if (_categoriesCache) return _categoriesCache;
|
||||
const res = await fetch(CATEGORIES_URL);
|
||||
if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`);
|
||||
_categoriesCache = await res.json();
|
||||
return _categoriesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and caches the full products object keyed by category slug.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function loadProducts() {
|
||||
if (_productsCache) return _productsCache;
|
||||
const res = await fetch(PRODUCTS_URL);
|
||||
if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`);
|
||||
_productsCache = await res.json();
|
||||
return _productsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the array of products for a given category slug.
|
||||
* Returns [] if the slug is not found.
|
||||
* @param {string} slug — e.g. "burgers", "menus"
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getProductsByCategory(slug) {
|
||||
const data = await loadProducts();
|
||||
return data[slug] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category object for the given id.
|
||||
* @param {number} id
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getCategoryById(id) {
|
||||
const cats = await loadCategories();
|
||||
return cats.find(c => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a product by its numeric id, searching all category slates.
|
||||
* Returns null if not found.
|
||||
* @param {number} id
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function findProduct(id) {
|
||||
const data = await loadProducts();
|
||||
for (const slug of Object.keys(data)) {
|
||||
const found = data[slug].find(p => p.id === id);
|
||||
if (found) return { ...found, categorie: slug };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a category id integer to its slug string.
|
||||
* Derived from data/categories.json — kept here as a convenience
|
||||
* so page scripts can convert query-string ids without an extra fetch.
|
||||
*/
|
||||
export const CATEGORY_ID_TO_SLUG = {
|
||||
1: 'menus',
|
||||
2: 'boissons',
|
||||
3: 'burgers',
|
||||
4: 'frites',
|
||||
5: 'encas',
|
||||
6: 'wraps',
|
||||
7: 'salades',
|
||||
8: 'desserts',
|
||||
9: 'sauces'
|
||||
};
|
||||
|
||||
/**
|
||||
* Inverse of the above: slug -> id.
|
||||
*/
|
||||
export const CATEGORY_SLUG_TO_ID = Object.fromEntries(
|
||||
Object.entries(CATEGORY_ID_TO_SLUG).map(([id, slug]) => [slug, Number(id)])
|
||||
);
|
||||
56
src/public/borne/assets/js/nav.js
Normal file
56
src/public/borne/assets/js/nav.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* nav.js — Shared navigation helpers loaded on every page.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Inject the mode badge ("Sur place" / "A emporter") into any
|
||||
* element with [data-mode-badge] on the page.
|
||||
* - Sync the cart item count into any element with [data-cart-count].
|
||||
* - Handle the mode query-string on page load (welcome -> categories handoff).
|
||||
*
|
||||
* Import this module in every page that has a header.
|
||||
*/
|
||||
|
||||
import { getMode, setMode, getCartCount } from './state.js';
|
||||
|
||||
/**
|
||||
* Reads ?mode= from the current URL and persists it if present.
|
||||
* Called once on DOMContentLoaded so that the welcome -> categories
|
||||
* navigation stores the chosen mode before any render.
|
||||
*/
|
||||
function syncModeFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const modeParam = params.get('mode');
|
||||
if (modeParam === 'sur-place' || modeParam === 'a-emporter') {
|
||||
setMode(modeParam);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the human-readable mode label into every [data-mode-badge] element.
|
||||
*/
|
||||
function renderModeBadge() {
|
||||
const mode = getMode();
|
||||
const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place';
|
||||
document.querySelectorAll('[data-mode-badge]').forEach(el => {
|
||||
el.textContent = label;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cart item count badge in every [data-cart-count] element.
|
||||
* Called on load and after any cart mutation.
|
||||
*/
|
||||
export function refreshCartBadge() {
|
||||
const count = getCartCount();
|
||||
document.querySelectorAll('[data-cart-count]').forEach(el => {
|
||||
el.textContent = count > 0 ? String(count) : '';
|
||||
el.hidden = count === 0;
|
||||
});
|
||||
}
|
||||
|
||||
/* Initialise on DOM ready */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
syncModeFromURL();
|
||||
renderModeBadge();
|
||||
refreshCartBadge();
|
||||
});
|
||||
144
src/public/borne/assets/js/state.js
Normal file
144
src/public/borne/assets/js/state.js
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue