feat(front): products list and product detail pages
products.html - dynamic grid from ?category=<id>, JS fetch from data/produits.json product.html - detail view; menus show fixed composition note (MVP: no selection) Both pages: cart badge, mode badge, keyboard/RGAA accessible cards
This commit is contained in:
parent
43b6e7a309
commit
cd6e05c353
4 changed files with 345 additions and 0 deletions
126
src/public/borne/assets/js/page-product.js
Normal file
126
src/public/borne/assets/js/page-product.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
* page-product.js — Product detail screen.
|
||||||
|
*
|
||||||
|
* Reads ?id=<int>&category=<slug> from the query string.
|
||||||
|
* For menus (type === 'menu'): shows a fixed composition note rather than
|
||||||
|
* a detailed breakdown — the school JSON does not include composition data.
|
||||||
|
* Decision: composition_libre_pour_MVP=non (fixed menu composition message).
|
||||||
|
*
|
||||||
|
* After "Ajouter au panier":
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
const isMenu = product.type === 'menu';
|
||||||
|
|
||||||
|
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>
|
||||||
|
${isMenu ? renderMenuComposition() : ''}
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the HTML block for the menu composition note.
|
||||||
|
* The school JSON does not contain detailed composition — this is the
|
||||||
|
* intentional simplification for MVP (composition_libre_pour_MVP=non).
|
||||||
|
*/
|
||||||
|
function renderMenuComposition() {
|
||||||
|
return `
|
||||||
|
<div class="product-detail__composition">
|
||||||
|
<h2 class="product-detail__composition-title">Composition du menu</h2>
|
||||||
|
<p class="product-detail__composition-text">
|
||||||
|
Menu compose : choix burger + accompagnement + boisson — composition fixe pour ce MVP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
if (errorBlock) {
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorBlock.textContent = msg;
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderProduct);
|
||||||
86
src/public/borne/assets/js/page-products.js
Normal file
86
src/public/borne/assets/js/page-products.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* page-products.js — Products list screen.
|
||||||
|
*
|
||||||
|
* Reads ?category=<id> from the query string, maps to a slug via
|
||||||
|
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
|
||||||
|
* On product card click, navigates to product.html?id=<id>&category=<slug>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG } from './data.js';
|
||||||
|
import { formatPrice } from './state.js';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const categoryId = parseInt(params.get('category'), 10) || 1;
|
||||||
|
const categorySlug = CATEGORY_ID_TO_SLUG[categoryId] ?? 'menus';
|
||||||
|
|
||||||
|
const grid = document.getElementById('products-grid');
|
||||||
|
const heading = document.getElementById('products-heading');
|
||||||
|
const backBtn = document.getElementById('back-to-categories');
|
||||||
|
const errorBlock = document.getElementById('products-error');
|
||||||
|
|
||||||
|
/* Build back URL preserving mode query param if present */
|
||||||
|
const modeParam = params.get('mode');
|
||||||
|
|
||||||
|
function buildBackURL() {
|
||||||
|
const base = 'categories.html';
|
||||||
|
return modeParam ? `${base}?mode=${modeParam}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.href = buildBackURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProducts() {
|
||||||
|
try {
|
||||||
|
const [products, category] = await Promise.all([
|
||||||
|
getProductsByCategory(categorySlug),
|
||||||
|
getCategoryById(categoryId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (heading && category) {
|
||||||
|
/* Capitalize first letter of the category title */
|
||||||
|
const title = category.title.charAt(0).toUpperCase() + category.title.slice(1);
|
||||||
|
heading.textContent = `Nos ${title}`;
|
||||||
|
document.title = `Wakdo - ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!products.length) {
|
||||||
|
grid.innerHTML = '<p class="products-empty">Aucun produit disponible dans cette categorie.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
products.forEach(product => {
|
||||||
|
const card = document.createElement('a');
|
||||||
|
card.className = 'product-card';
|
||||||
|
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
|
||||||
|
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="product-card__image-wrap">
|
||||||
|
<img
|
||||||
|
class="product-card__image"
|
||||||
|
src="${product.image}"
|
||||||
|
alt="${product.nom}"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="product-card__body">
|
||||||
|
<span class="product-card__name">${product.nom}</span>
|
||||||
|
<span class="product-card__price">${formatPrice(product.prix)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (errorBlock) {
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorBlock.textContent = 'Impossible de charger les produits. Veuillez reessayer.';
|
||||||
|
}
|
||||||
|
console.error('renderProducts error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderProducts);
|
||||||
65
src/public/borne/product.html
Normal file
65
src/public/borne/product.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Detail du produit">
|
||||||
|
<title>Wakdo - Produit</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="product-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
product.html — Product detail screen.
|
||||||
|
Reads ?id=<int>&category=<slug>.
|
||||||
|
JS (page-product.js) fetches the product, renders detail and handles
|
||||||
|
the "Ajouter au panier" action.
|
||||||
|
Menu composition is shown as a fixed note (MVP: no composition selection).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
id="back-to-products"
|
||||||
|
class="site-header__back"
|
||||||
|
href="products.html"
|
||||||
|
aria-label="Retour a la liste des produits"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="product-main" aria-label="Detail du produit">
|
||||||
|
|
||||||
|
<!-- Error block: hidden unless fetch fails or id invalid -->
|
||||||
|
<p id="product-error" class="product-error" hidden role="alert"></p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Container filled by page-product.js.
|
||||||
|
The JS replaces innerHTML once data is ready.
|
||||||
|
-->
|
||||||
|
<div id="product-detail" class="product-detail" aria-live="polite">
|
||||||
|
<!-- Skeleton placeholder visible during fetch -->
|
||||||
|
<div class="product-detail__skeleton" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-product.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
68
src/public/borne/products.html
Normal file
68
src/public/borne/products.html
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Produits de la categorie selectionnee">
|
||||||
|
<title>Wakdo - Produits</title>
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="products-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
products.html — List of products in a category.
|
||||||
|
Category is determined at runtime from ?category=<id>.
|
||||||
|
JS (page-products.js) fetches data/produits.json and renders cards.
|
||||||
|
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
id="back-to-categories"
|
||||||
|
class="site-header__back"
|
||||||
|
href="categories.html"
|
||||||
|
aria-label="Retour aux categories"
|
||||||
|
>
|
||||||
|
← Categories
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="products-main" aria-label="Liste des produits">
|
||||||
|
|
||||||
|
<div class="products-header">
|
||||||
|
<!--
|
||||||
|
Heading is updated by page-products.js once the category
|
||||||
|
data is loaded — default text shown during load.
|
||||||
|
-->
|
||||||
|
<h1 id="products-heading" class="products-main__heading">Nos produits</h1>
|
||||||
|
<span class="mode-badge" data-mode-badge aria-label="Mode de consommation">Sur place</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error block: hidden by default, shown if fetch fails -->
|
||||||
|
<p id="products-error" class="products-error" hidden role="alert"></p>
|
||||||
|
|
||||||
|
<ul id="products-grid" class="products-grid" aria-label="Grille de produits">
|
||||||
|
<!-- Product cards injected by page-products.js -->
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-products.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue