// ----------------------------------------------------------------------------- // Food-Ka — Agrégateur de produits d'épicerie (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Home.tsx : accueil — héro, statistiques, filtres, grille de produits // Filtres : recherche, catégorie, bannière, marque, prix min/max, en solde, // tri — avec pastilles de filtres actifs et pagination « Charger plus ». // La même page sert la vue « Aubaines » (/aubaines), pré-filtrée en solde. // ----------------------------------------------------------------------------- import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Facets, Product, ProductFilters, Stats, fetchFacets, fetchProducts, fetchSources, fetchStats, fmtPrice, registerSourceNames, sourceName, } from "../api"; import ProductCard from "../components/ProductCard"; import SourceLogo from "../components/SourceLogo"; const PAGE_SIZE = 60; const PRICE_STEPS = [1, 2, 3, 5, 8, 10, 15, 20, 30, 50]; const SORT_CHOICES: { key: string; label: string }[] = [ { key: "price_asc", label: "Prix croissant" }, { key: "price_desc", label: "Prix décroissant" }, { key: "unit_price", label: "Prix unitaire" }, { key: "discount", label: "Meilleur rabais" }, { key: "name", label: "Nom A→Z" }, { key: "recent", label: "Nouveautés" }, ]; export default function Home({ aubaines = false }: { aubaines?: boolean }) { const [products, setProducts] = useState(null); const [total, setTotal] = useState(0); const [offset, setOffset] = useState(0); const [loadingMore, setLoadingMore] = useState(false); const [facets, setFacets] = useState(null); const [brands, setBrands] = useState<{ brand: string; n: number }[]>([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); // filtres (pré-remplis depuis l'URL, ex. /?category=… — liens de la page Stats) const [params] = useSearchParams(); const [q, setQ] = useState(params.get("q") ?? ""); const [category, setCategory] = useState(params.get("category") ?? ""); const [source, setSource] = useState(params.get("source") ?? ""); const [brand, setBrand] = useState(params.get("brand") ?? ""); const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); const [onSale, setOnSale] = useState(aubaines || params.get("on_sale") === "1"); const [sort, setSort] = useState(params.get("sort") ?? (aubaines ? "discount" : "price_asc")); // feuille de filtres mobile (bottom sheet) + panneau avancé desktop const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); const saleForced = aubaines; // la page Aubaines impose « en solde » const activeFilters = [q, category, source, brand, priceMin, priceMax, !saleForced && onSale ? "1" : ""].filter(Boolean).length; const advCount = [brand, source].filter(Boolean).length; useEffect(() => { // /aubaines : force le filtre solde (et un tri utile par défaut) setOnSale(aubaines || params.get("on_sale") === "1"); }, [aubaines, params]); const filters: ProductFilters = useMemo(() => ({ q, category, source, brand, price_min: priceMin, price_max: priceMax, on_sale: onSale ? "1" : "", sort, limit: String(PAGE_SIZE), }), [q, category, source, brand, priceMin, priceMax, onSale, sort]); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); fetchFacets().then(setFacets).catch(() => {}); fetchStats().then(setStats).catch(() => {}); }, []); // marques dépendantes de la catégorie choisie useEffect(() => { fetchFacets(category || undefined) .then((f) => setBrands(f.brands)) .catch(() => setBrands([])); }, [category]); useEffect(() => { let cancelled = false; setProducts(null); setOffset(0); setError(null); fetchProducts(filters) .then((r) => { if (!cancelled) { setProducts(r.products); setTotal(r.total); } }) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; }, [filters]); const loadMore = () => { const next = offset + PAGE_SIZE; setLoadingMore(true); fetchProducts({ ...filters, offset: String(next) }) .then((r) => { setProducts((prev) => [...(prev ?? []), ...r.products]); setTotal(r.total); setOffset(next); }) .catch((e) => setError(String(e))) .finally(() => setLoadingMore(false)); }; const resetAll = () => { setQ(""); setCategory(""); setSource(""); setBrand(""); setPriceMin(""); setPriceMax(""); if (!saleForced) setOnSale(false); }; // pastilles « filtres actifs » — libellé + action de retrait (+ logo bannière) const pills: { label: string; clear: () => void; logo?: string }[] = []; if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") }); if (category) pills.push({ label: category, clear: () => { setCategory(""); setBrand(""); } }); if (brand) pills.push({ label: brand, clear: () => setBrand("") }); if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") }); if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") }); if (onSale && !saleForced) pills.push({ label: "En solde", clear: () => setOnSale(false) }); if (source) pills.push({ label: sourceName(source), clear: () => setSource(""), logo: source }); const shown = products?.length ?? 0; return (
{aubaines ? "Aubaines — les meilleurs rabais du moment" : "Agrégateur — épicerie du Québec"} {aubaines ? (

Les aubaines de toutes
les circulaires.

) : (

Tous les prix d'épicerie
du Québec. Un seul endroit.

)}

{aubaines ? "Food·Ka repère les produits en solde chez toutes les bannières d'épicerie du Québec et les classe par rabais — comparez avant de remplir le panier." : "Food·Ka rassemble les produits affichés par les bannières d'épicerie du Québec — Metro, IGA, Maxi, Super C, Provigo et plus — mis à jour automatiquement, avec prix unitaires et lien direct vers la fiche originale."}

Données synchronisées en continu {stats && ( <> {stats.total} produits suivis {stats.sources} bannières {stats.on_sale} soldes actifs {stats.avg_price != null && ( prix moyen {fmtPrice(stats.avg_price)} )} )}
{sheetOpen && (
setSheetOpen(false)} aria-hidden="true" /> )}
Filtres
{/* — rangée principale : recherche, catégorie, prix, tri, + filtres — */}
setQ(e.target.value)} aria-label="Recherche" /> {q && ( )}
Prix
{/* — panneau avancé : bannière, marque, solde — */} {(advOpen || sheetOpen) && (
{!saleForced && (
)}
)}
{(facets?.categories ?? []).slice(0, 8).map((c) => ( ))} {!saleForced && ( <>
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

{aubaines ? "Aubaines en cours" : "Produits"}

{products && {total} résultat{total > 1 ? "s" : ""}}
{error && (
⚠️

Impossible de charger les produits

{error}

)} {!error && products === null && (
{Array.from({ length: 8 }).map((_, i) => (
))}
)} {!error && products !== null && products.length === 0 && (
🔍

Aucun produit ne correspond

Essayez d'élargir vos critères {activeFilters > 0 && ( <> — ou )}.

)} {!error && products !== null && products.length > 0 && ( <>
{products.map((p) => ( ))}
{shown < total && (
)} )} {/* Bouton flottant mobile — ouvre la feuille de filtres */}
); }