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%
12.5 KB · 316 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/Product.tsx : fiche produit — galerie packshot, bloc prix (solde,5//   prix unitaire), description, étiquettes, historique de prix et6//   « Comparer les prix » (même produit chez les autres bannières).7//   Mobile-first : galerie → prix → comparaison ; CTA source sticky en bas.8// -----------------------------------------------------------------------------9import { useEffect, useRef, useState } from "react";10import { Link, useParams } from "react-router-dom";11import {12  Product, ProductDetail,13  discountPct, fetchProduct, fetchSources, fmtPrice, fmtTs,14  registerSourceNames, sourceName,15} from "../api";16import SourceLogo from "../components/SourceLogo";1718const NBSP = " ";1920// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----21function Galerie({ images, titre }: { images: string[]; titre: string }) {22  const [idx, setIdx] = useState(0);23  const [zoom, setZoom] = useState(false);24  const track = useRef<HTMLDivElement>(null);2526  const onScroll = () => {27    const el = track.current;28    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));29  };30  const goto = (i: number) =>31    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });3233  if (images.length === 0)34    return <div className="carousel"><div className="noimg carousel-empty">🛒</div></div>;3536  return (37    <>38      <div className="carousel packshot">39        <div className="carousel-track" ref={track} onScroll={onScroll}>40          {images.map((u, i) => (41            <img42              key={u} src={u} loading={i === 0 ? "eager" : "lazy"}43              alt={`${titre} — image ${i + 1} de ${images.length}`}44              onClick={() => setZoom(true)}45            />46          ))}47        </div>48        {images.length > 1 && (49          <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>50        )}51        {idx > 0 && (52          <button className="carousel-nav prev" aria-label="Image précédente" onClick={() => goto(idx - 1)}>‹</button>53        )}54        {idx < images.length - 1 && (55          <button className="carousel-nav next" aria-label="Image suivante" onClick={() => goto(idx + 1)}>›</button>56        )}57      </div>58      {images.length > 1 && (59        <div className="thumbs">60          {images.map((u, i) => (61            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}62                    aria-label={`Image ${i + 1}`}>63              <img src={u} alt="" loading="lazy" />64            </button>65          ))}66        </div>67      )}68      {zoom && (69        <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Image agrandie">70          <img src={images[idx]} alt="" />71        </div>72      )}73    </>74  );75}7677// --- Rangée de comparaison inter-bannières -----------------------------------78function CompareRow({ p, best }: { p: Product; best: boolean }) {79  const pct = discountPct(p);80  const img = p.images && p.images.length > 0 ? p.images[0] : null;81  return (82    <li className={best ? "best" : ""}>83      <Link to={`/produit/${encodeURIComponent(p.uid)}`} className="cmp-link">84        <span className="cmp-img packshot">85          {img ? <img src={img} alt="" loading="lazy" /> : <span className="noimg">🛒</span>}86        </span>87        <span className="cmp-main">88          <span className="cmp-name">{p.name}</span>89          <span className="cmp-meta">90            {[p.brand, p.size_label].filter(Boolean).join(" · ")}91          </span>92          <SourceLogo source={p.source} size={18} name="short" />93        </span>94        <span className="cmp-price">95          <b>{fmtPrice(p.price, p.price_label)}</b>96          {p.on_sale && p.regular_price != null && (97            <s className="price-old">{fmtPrice(p.regular_price)}</s>98          )}99          {pct != null && <em className="baisse-pct">−{pct} %</em>}100          {best && <span className="cmp-best">meilleur prix</span>}101          {p.unit_price_label && <span className="cmp-unit">{p.unit_price_label}</span>}102        </span>103      </Link>104    </li>105  );106}107108export default function ProductPage() {109  const { uid } = useParams<{ uid: string }>();110  const [p, setP] = useState<ProductDetail | null>(null);111  const [error, setError] = useState<string | null>(null);112113  useEffect(() => {114    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});115    if (!uid) return;116    setP(null);117    setError(null);118    fetchProduct(uid).then(setP).catch((e) => setError(String(e)));119    window.scrollTo(0, 0);120  }, [uid]);121122  if (error)123    return (124      <div className="notice container">125        <div className="big">⚠️</div>126        <h2>Produit introuvable</h2>127        <p>{error}</p>128        <Link className="btn btn-primary" to="/">Retour aux produits</Link>129      </div>130    );131132  if (!p)133    return (134      <div className="container detail">135        <div className="fiche" aria-busy="true">136          <div className="skel"><div className="sk-img" /></div>137          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>138        </div>139      </div>140    );141142  const pct = discountPct(p);143  const updated = p.updated_at144    ? new Date(p.updated_at * 1000).toLocaleDateString("fr-CA", {145        day: "numeric", month: "long", year: "numeric" }) : null;146147  // chips clés148  const chips: string[] = [];149  if (p.size_label) chips.push(p.size_label);150  if (p.category) chips.push(p.category);151  if (p.on_sale) chips.push(pct != null ? `Solde −${pct}${NBSP}%` : "En solde");152  if (p.in_stock === true) chips.push("En stock");153  if (p.in_stock === false) chips.push("Rupture de stock");154155  // comparaison triée du moins cher au plus cher (prix inconnus à la fin)156  const compare = [...(p.compare ?? [])].sort((a, b) => {157    if (a.price == null) return 1;158    if (b.price == null) return -1;159    return a.price - b.price;160  });161  const bestPrice = compare.find((c) => c.price != null)?.price ?? null;162163  const hist = (p.price_history ?? []).filter((h) => h.price != null);164  const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price165    ? { de: hist[1].price!, a: hist[0].price! } : null;166167  return (168    <div className="container detail">169      <nav className="crumbs" aria-label="Fil d'Ariane">170        <Link to="/">Produits</Link> ›171        {p.category && (172          <Link to={`/?category=${encodeURIComponent(p.category)}`}>{p.category}</Link>173        )} ›174        <span>{p.name}</span>175      </nav>176177      <div className="fiche">178        {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}179        <div className="f-col">180          <section className="f-bloc f-galerie" aria-label="Images du produit">181            <Galerie images={p.images ?? []} titre={p.name} />182          </section>183184          <section className="f-bloc f-desc" id="description">185            <h2>Description</h2>186            {p.description187              ? <p style={{ color: "var(--ink-2)" }}>{p.description}</p>188              : <p className="fine">La bannière ne fournit pas de description pour ce produit.</p>}189            {p.keywords.length > 0 && (190              <div className="amenity-row" style={{ marginTop: 12 }}>191                {p.keywords.map((k) => (192                  <span className="amenity confirmed" key={k}>{k}</span>193                ))}194              </div>195            )}196          </section>197198          <section className="f-bloc f-pratique">199            <h2>Détails pratiques</h2>200            <div className="kv">201              <div className="cell"><div className="k">Bannière</div><div className="v">{sourceName(p.source)}</div></div>202              {p.brand && (203                <div className="cell"><div className="k">Marque</div><div className="v">{p.brand}</div></div>204              )}205              {p.size_label && (206                <div className="cell"><div className="k">Format</div><div className="v">{p.size_label}</div></div>207              )}208              {p.category_raw && (209                <div className="cell"><div className="k">Rayon (source)</div><div className="v">{p.category_raw}</div></div>210              )}211              {p.price_label && (212                <div className="cell"><div className="k">Prix affiché</div><div className="v">{p.price_label}</div></div>213              )}214              {updated && (215                <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>216              )}217            </div>218            {baissePrix && (219              <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>220                {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}221                {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>222              </div>223            )}224            {hist.length > 1 && (225              <details className="texte-original">226                <summary>Historique de prix ({hist.length} relevés)</summary>227                <ul className="histo-prix-liste">228                  {hist.map((h) => (229                    <li key={h.ts}>230                      <span className="hp-date">{fmtTs(h.ts)}</span>231                      <b className="hp-prix">{fmtPrice(h.price)}</b>232                    </li>233                  ))}234                </ul>235              </details>236            )}237          </section>238        </div>239240        {/* ------- colonne droite (desktop) : prix, comparaison --------------- */}241        <div className="f-col">242          <section className="f-bloc f-hero">243            <div className="price">244              {fmtPrice(p.price, p.price_label)}245              {p.on_sale && p.regular_price != null && (246                <s className="price-old big">{fmtPrice(p.regular_price)}</s>247              )}248            </div>249            {p.on_sale && (250              <div className="deal-badge deal-good">251                🔥 En solde{pct != null && <> — épargnez {pct}{NBSP}%</>}252              </div>253            )}254            {p.unit_price_label && <div className="unit-price">{p.unit_price_label}</div>}255            <h1>{p.name}</h1>256            <div className="loc">257              {[p.brand, p.size_label].filter(Boolean).join(" · ")}258            </div>259            <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">260              {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}261            </div>262            <a className="cta cta-desktop" href={p.url}263               target="_blank" rel="noopener noreferrer">264              <SourceLogo source={p.source} size={24} fallback="hide" />265              Voir chez {sourceName(p.source)} ↗266            </a>267            <p className="fine">268              Prix affiché par la bannière lors de la dernière synchronisation —269              vérifiez en magasin ou sur la fiche originale.270            </p>271          </section>272273          <section className="f-bloc f-compare" id="comparer">274            <h2>Comparer les prix</h2>275            {compare.length > 0 ? (276              <>277                <p className="q-sub">278                  Produits similaires chez les autres bannières, du moins cher au plus cher.279                </p>280                <ul className="cmp-list">281                  {compare.map((c) => (282                    <CompareRow key={c.uid} p={c}283                                best={bestPrice != null && c.price === bestPrice} />284                  ))}285                </ul>286              </>287            ) : (288              <p className="fine">289                Aucun produit comparable trouvé chez les autres bannières pour l'instant.290              </p>291            )}292          </section>293        </div>294      </div>295296      <div className="fine f-foot">297        {updated && <>Dernière synchronisation : {updated}. </>}298        Les prix et disponibilités sont ceux affichés par la source — chaque fiche299        renvoie à la page produit originale.300      </div>301302      {/* CTA sticky mobile — toujours visible */}303      <div className="cta-sticky">304        <span className="cta-sticky-prix">305          {fmtPrice(p.price, p.price_label)}306          {p.unit_price_label && <small> {p.unit_price_label}</small>}307        </span>308        <a className="cta" href={p.url} target="_blank" rel="noopener noreferrer">309          <SourceLogo source={p.source} size={20} fallback="hide" />310          Voir chez {sourceName(p.source)} ↗311        </a>312      </div>313    </div>314  );315}316