SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
16.1 KB · 406 lines tsx
Raw Blame History
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">179180          </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