spb/food-ka Public
Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 57.7%
TypeScript 24.9%
CSS 16.7%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : accueil — héro, statistiques, filtres, grille de produits5// Filtres : recherche, catégorie, bannière, marque, prix min/max, en solde,6// tri — avec pastilles de filtres actifs et pagination « Charger plus ».7// La même page sert la vue « Aubaines » (/aubaines), pré-filtrée en solde.8// -----------------------------------------------------------------------------9import { useEffect, useMemo, useState } from "react";10import { useSearchParams } from "react-router-dom";11import {12 Facets, Product, ProductFilters, Stats,13 fetchFacets, fetchProducts, fetchSources, fetchStats, fmtPrice,14 registerSourceNames, sourceName,15} from "../api";16import ProductCard from "../components/ProductCard";17import SourceLogo from "../components/SourceLogo";1819const PAGE_SIZE = 60;20const PRICE_STEPS = [1, 2, 3, 5, 8, 10, 15, 20, 30, 50];2122const SORT_CHOICES: { key: string; label: string }[] = [23 { key: "price_asc", label: "Prix croissant" },24 { key: "price_desc", label: "Prix décroissant" },25 { key: "unit_price", label: "Prix unitaire" },26 { key: "discount", label: "Meilleur rabais" },27 { key: "name", label: "Nom A→Z" },28 { key: "recent", label: "Nouveautés" },29];3031export default function Home({ aubaines = false }: { aubaines?: boolean }) {32 const [products, setProducts] = useState<Product[] | null>(null);33 const [total, setTotal] = useState(0);34 const [offset, setOffset] = useState(0);35 const [loadingMore, setLoadingMore] = useState(false);36 const [facets, setFacets] = useState<Facets | null>(null);37 const [brands, setBrands] = useState<{ brand: string; n: number }[]>([]);38 const [stats, setStats] = useState<Stats | null>(null);39 const [error, setError] = useState<string | null>(null);4041 // filtres (pré-remplis depuis l'URL, ex. /?category=… — liens de la page Stats)42 const [params] = useSearchParams();43 const [q, setQ] = useState(params.get("q") ?? "");44 const [category, setCategory] = useState(params.get("category") ?? "");45 const [source, setSource] = useState(params.get("source") ?? "");46 const [brand, setBrand] = useState(params.get("brand") ?? "");47 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");48 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");49 const [onSale, setOnSale] = useState(aubaines || params.get("on_sale") === "1");50 const [sort, setSort] = useState(params.get("sort") ?? (aubaines ? "discount" : "price_asc"));51 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop52 const [sheetOpen, setSheetOpen] = useState(false);53 const [advOpen, setAdvOpen] = useState(false);54 const saleForced = aubaines; // la page Aubaines impose « en solde »55 const activeFilters = [q, category, source, brand, priceMin, priceMax,56 !saleForced && onSale ? "1" : ""].filter(Boolean).length;57 const advCount = [brand, source].filter(Boolean).length;5859 useEffect(() => {60 // /aubaines : force le filtre solde (et un tri utile par défaut)61 setOnSale(aubaines || params.get("on_sale") === "1");62 }, [aubaines, params]);6364 const filters: ProductFilters = useMemo(() => ({65 q, category, source, brand,66 price_min: priceMin, price_max: priceMax,67 on_sale: onSale ? "1" : "",68 sort, limit: String(PAGE_SIZE),69 }), [q, category, source, brand, priceMin, priceMax, onSale, sort]);7071 useEffect(() => {72 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});73 fetchFacets().then(setFacets).catch(() => {});74 fetchStats().then(setStats).catch(() => {});75 }, []);7677 // marques dépendantes de la catégorie choisie78 useEffect(() => {79 fetchFacets(category || undefined)80 .then((f) => setBrands(f.brands))81 .catch(() => setBrands([]));82 }, [category]);8384 useEffect(() => {85 let cancelled = false;86 setProducts(null);87 setOffset(0);88 setError(null);89 fetchProducts(filters)90 .then((r) => {91 if (!cancelled) {92 setProducts(r.products);93 setTotal(r.total);94 }95 })96 .catch((e) => !cancelled && setError(String(e)));97 return () => {98 cancelled = true;99 };100 }, [filters]);101102 const loadMore = () => {103 const next = offset + PAGE_SIZE;104 setLoadingMore(true);105 fetchProducts({ ...filters, offset: String(next) })106 .then((r) => {107 setProducts((prev) => [...(prev ?? []), ...r.products]);108 setTotal(r.total);109 setOffset(next);110 })111 .catch((e) => setError(String(e)))112 .finally(() => setLoadingMore(false));113 };114115 const resetAll = () => {116 setQ(""); setCategory(""); setSource(""); setBrand("");117 setPriceMin(""); setPriceMax("");118 if (!saleForced) setOnSale(false);119 };120121 // pastilles « filtres actifs » — libellé + action de retrait (+ logo bannière)122 const pills: { label: string; clear: () => void; logo?: string }[] = [];123 if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });124 if (category) pills.push({ label: category, clear: () => { setCategory(""); setBrand(""); } });125 if (brand) pills.push({ label: brand, clear: () => setBrand("") });126 if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") });127 if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });128 if (onSale && !saleForced) pills.push({ label: "En solde", clear: () => setOnSale(false) });129 if (source) pills.push({ label: sourceName(source), clear: () => setSource(""), logo: source });130131 const shown = products?.length ?? 0;132133 return (134 <div className="container">135 <section className="hero">136 <span className={`kicker ${aubaines ? "kicker-sale" : ""}`}>137 {aubaines ? "Aubaines — les meilleurs rabais du moment" : "Agrégateur — épicerie du Québec"}138 </span>139 {aubaines ? (140 <h1>141 Les <span className="hl hl-tomato">aubaines</span> de toutes<br />142 les <span className="outline">circulaires</span>.143 </h1>144 ) : (145 <h1>146 Tous les prix <span className="outline">d'épicerie</span><br />147 du Québec. <span className="hl">Un seul</span> endroit.148 </h1>149 )}150 <p className="lede">151 {aubaines152 ? "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."153 : "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."}154 </p>155 <div className="stat-row">156 <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>157 {stats && (158 <>159 <span className="stat-chip"><b>{stats.total}</b> produits suivis</span>160 <span className="stat-chip"><b>{stats.sources}</b> bannières</span>161 <span className="stat-chip"><b>{stats.on_sale}</b> soldes actifs</span>162 {stats.avg_price != null && (163 <span className="stat-chip">164 prix moyen <b>{fmtPrice(stats.avg_price)}</b>165 </span>166 )}167 </>168 )}169 </div>170 </section>171172 {sheetOpen && (173 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />174 )}175 <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">176 <div className="sheet-head">177 <span>Filtres</span>178 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">179 ✕180 </button>181 </div>182183 {/* — rangée principale : recherche, catégorie, prix, tri, + filtres — */}184 <div className="f-primary">185 <div className="f-search">186 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">187 <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />188 </svg>189 <input190 id="f-q" placeholder="Produit, marque, rayon…" value={q}191 onChange={(e) => setQ(e.target.value)}192 aria-label="Recherche"193 />194 {q && (195 <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>196 )}197 </div>198 <label className="f-ctl">199 <span>Catégorie</span>200 <select value={category} onChange={(e) => { setCategory(e.target.value); setBrand(""); }}>201 <option value="">Toutes</option>202 {(facets?.categories ?? []).map((c) => (203 <option key={c.category} value={c.category}>{c.category}</option>204 ))}205 </select>206 </label>207 <div className="f-ctl">208 <span>Prix</span>209 <div className="range-pair">210 <select aria-label="Prix minimum" value={priceMin}211 onChange={(e) => setPriceMin(e.target.value)}>212 <option value="">Min</option>213 {PRICE_STEPS.map((p) => (214 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>215 {p} $216 </option>217 ))}218 </select>219 <span className="range-sep">—</span>220 <select aria-label="Prix maximum" value={priceMax}221 onChange={(e) => setPriceMax(e.target.value)}>222 <option value="">Max</option>223 {PRICE_STEPS.map((p) => (224 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>225 {p} $226 </option>227 ))}228 </select>229 </div>230 </div>231 <label className="f-ctl">232 <span>Trier par</span>233 <select value={sort} onChange={(e) => setSort(e.target.value)}>234 {SORT_CHOICES.map((s) => (235 <option key={s.key} value={s.key}>{s.label}</option>236 ))}237 </select>238 </label>239 <button240 className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}241 onClick={() => setAdvOpen(!advOpen)}242 aria-expanded={advOpen}243 >244 Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}245 </button>246 </div>247248 {/* — panneau avancé : bannière, marque, solde — */}249 {(advOpen || sheetOpen) && (250 <div className="f-adv">251 <div className="f-group">252 <label>Bannière</label>253 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>254 <option value="">Toutes</option>255 {(facets?.sources ?? []).map((s) => (256 <option key={s.source} value={s.source}>257 {sourceName(s.source)} ({s.n})258 </option>259 ))}260 </select>261 </div>262 <div className="f-group">263 <label>Marque</label>264 <select className="f-native" value={brand} onChange={(e) => setBrand(e.target.value)}>265 <option value="">Toutes</option>266 {brands.map((b) => (267 <option key={b.brand} value={b.brand}>268 {b.brand} ({b.n})269 </option>270 ))}271 </select>272 </div>273 {!saleForced && (274 <div className="f-group">275 <label>Soldes</label>276 <div className="seg" role="group">277 <button className={!onSale ? "on" : ""} onClick={() => setOnSale(false)}>278 Tous les produits279 </button>280 <button className={onSale ? "on" : ""} onClick={() => setOnSale(true)}>281 🔥 En solde{facets ? ` (${facets.on_sale})` : ""}282 </button>283 </div>284 </div>285 )}286 <div className="f-group f-group-end">287 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>288 Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}289 </button>290 </div>291 </div>292 )}293294 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>295 Voir les résultats {products ? `(${total})` : ""}296 </button>297 </section>298299 <div className="chips" role="group" aria-label="Filtres rapides">300 {(facets?.categories ?? []).slice(0, 8).map((c) => (301 <button302 key={c.category}303 className={`chip ${category === c.category ? "on" : ""}`}304 onClick={() => { setCategory(category === c.category ? "" : c.category); setBrand(""); }}305 >306 {c.category}307 </button>308 ))}309 {!saleForced && (310 <>311 <span className="chip-sep" aria-hidden="true" />312 <button313 className={`chip ${onSale ? "on" : ""}`}314 onClick={() => setOnSale(!onSale)}315 >316 🔥 En solde317 </button>318 </>319 )}320 </div>321322 {pills.length > 0 && (323 <div className="pills" aria-label="Filtres actifs">324 {pills.map((p) => (325 <button key={p.label} className="pill" onClick={p.clear}326 aria-label={`Retirer le filtre ${p.label}`}>327 {p.logo && <SourceLogo source={p.logo} size={18} fallback="hide" />}328 {p.label} <span className="pill-x">✕</span>329 </button>330 ))}331 <button className="pill pill-clear" onClick={resetAll}>332 Tout effacer333 </button>334 </div>335 )}336337 <div className="results-head">338 <h2>{aubaines ? "Aubaines en cours" : "Produits"}</h2>339 {products && <span>{total} résultat{total > 1 ? "s" : ""}</span>}340 </div>341342 {error && (343 <div className="notice">344 <div className="big">⚠️</div>345 <h2>Impossible de charger les produits</h2>346 <p>{error}</p>347 <button className="btn btn-primary" onClick={() => window.location.reload()}>348 Réessayer349 </button>350 </div>351 )}352353 {!error && products === null && (354 <div className="grid" aria-busy="true">355 {Array.from({ length: 8 }).map((_, i) => (356 <div className="skel" key={i}>357 <div className="sk-img" />358 <div className="sk-line" />359 <div className="sk-line short" />360 </div>361 ))}362 </div>363 )}364365 {!error && products !== null && products.length === 0 && (366 <div className="notice">367 <div className="big">🔍</div>368 <h2>Aucun produit ne correspond</h2>369 <p>370 Essayez d'élargir vos critères371 {activeFilters > 0 && (372 <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>373 )}.374 </p>375 </div>376 )}377378 {!error && products !== null && products.length > 0 && (379 <>380 <div className="grid">381 {products.map((p) => (382 <ProductCard key={p.uid} p={p} />383 ))}384 </div>385 {shown < total && (386 <div className="load-more">387 <button className="btn btn-primary" onClick={loadMore} disabled={loadingMore}>388 {loadingMore ? "Chargement…" : `Charger plus (${shown} / ${total})`}389 </button>390 </div>391 )}392 </>393 )}394395 {/* Bouton flottant mobile — ouvre la feuille de filtres */}396 <button397 className="fab"398 onClick={() => setSheetOpen(true)}399 aria-label="Ouvrir les filtres"400 >401 ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}402 </button>403 </div>404 );405}406