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%
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