Agrégateur de produits québécois — www.fabri-ka.com
Python 38.4%
HTML 30.3%
TypeScript 17.2%
CSS 11%
JavaScript 3.2%
1// ---------------------------------------------------------------------------2// Fabri-Ka — Agrégateur de produits fabriqués au Québec3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/ProductHero.tsx : héro de la fiche — boutique (logo) · lieu, prix +5// prix barré + fourchette, capsules (origine, solde, dispo), titre (H1) +6// résumé, galerie (balayage natif, compteur, plein écran, vignettes ≥ 768),7// actions (favoris · partager · Voir chez la boutique). Ordre DOM = visuel.8// ---------------------------------------------------------------------------9import { useEffect, useRef, useState } from 'react'10import { ProductDetail, formatPrice } from '../api'11import { Ico, IcoHeart } from '../components/KaIcons'12import OriginBadge from '../components/OriginBadge'13import StoreLogo from '../components/StoreLogo'14import { ligneResume, rabais } from './synthese'1516function Lightbox({ images, start, titre, onClose }: { images: string[]; start: number; titre: string; onClose: () => void }) {17 const [idx, setIdx] = useState(start)18 const track = useRef<HTMLDivElement>(null)19 useEffect(() => {20 track.current?.scrollTo({ left: start * track.current.clientWidth })21 document.documentElement.classList.add('ka-scroll-lock')22 const onKey = (e: KeyboardEvent) => {23 if (e.key === 'Escape') onClose()24 if (e.key === 'ArrowRight') go(1)25 if (e.key === 'ArrowLeft') go(-1)26 }27 window.addEventListener('keydown', onKey)28 return () => { document.documentElement.classList.remove('ka-scroll-lock'); window.removeEventListener('keydown', onKey) }29 // eslint-disable-next-line react-hooks/exhaustive-deps30 }, [])31 const go = (d: number) => {32 const el = track.current; if (!el) return33 const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d))34 el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })35 }36 return (37 <div className="fb-lightbox" role="dialog" aria-modal="true" aria-label={`Images — ${titre}`}>38 <button type="button" className="fb-lightbox-close" aria-label="Fermer" onClick={onClose}><Ico name="close" size={20} /></button>39 <span className="fb-lightbox-count" aria-live="polite">{idx + 1} / {images.length}</span>40 <div className="fb-lightbox-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)) }}>41 {images.map((u, i) => (42 <div className="fb-lightbox-cell" key={u}><img src={u} alt={`${titre} — image ${i + 1} de ${images.length}`} draggable={false} /></div>43 ))}44 </div>45 {idx > 0 && <button type="button" className="fb-gallery-nav prev" aria-label="Image précédente" onClick={() => go(-1)}><Ico name="chevleft" size={20} /></button>}46 {idx < images.length - 1 && <button type="button" className="fb-gallery-nav next" aria-label="Image suivante" onClick={() => go(1)}><Ico name="chevright" size={20} /></button>}47 </div>48 )49}5051export function ProductGallery({ images, titre }: { images: string[]; titre: string }) {52 const [idx, setIdx] = useState(0)53 const [zoom, setZoom] = useState(false)54 const [dead, setDead] = useState<Set<string>>(new Set())55 const track = useRef<HTMLDivElement>(null)56 const alive = images.filter((u) => !dead.has(u))57 const cur = Math.min(idx, Math.max(0, alive.length - 1))58 const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: 'smooth' })59 if (alive.length === 0)60 return <div className="fb-gallery fb-gallery-empty" aria-label="Images"><Ico name="store" size={44} /><span>Aucune image fournie par la boutique</span></div>61 return (62 <>63 <div className="fb-gallery packshot" aria-roledescription="carrousel" aria-label="Images du produit">64 <div className="fb-gallery-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)) }}>65 {alive.map((u, i) => (66 <img key={u} src={u} loading={i === 0 ? 'eager' : 'lazy'} decoding="async" alt={`${titre} — image ${i + 1} de ${alive.length}`}67 onError={() => setDead((d) => new Set(d).add(u))} onClick={() => setZoom(true)} />68 ))}69 </div>70 {alive.length > 1 && <span className="fb-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>}71 <button type="button" className="fb-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}><Ico name="expand" size={17} /></button>72 {cur > 0 && <button type="button" className="fb-gallery-nav prev" aria-label="Image précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>}73 {cur < alive.length - 1 && <button type="button" className="fb-gallery-nav next" aria-label="Image suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>}74 </div>75 {alive.length > 1 && (76 <div className="fb-thumbs" role="list">77 {alive.slice(0, 12).map((u, i) => (78 <button type="button" key={u} className={i === cur ? 'on' : ''} onClick={() => goto(i)} aria-label={`Image ${i + 1}`} aria-current={i === cur} role="listitem">79 <img src={u} alt="" loading="lazy" decoding="async" />80 </button>81 ))}82 </div>83 )}84 {zoom && <Lightbox images={alive} start={cur} titre={titre} onClose={() => setZoom(false)} />}85 </>86 )87}8889export default function ProductHero({ p, fav, onFav, onShare, actionsRef }: {90 p: ProductDetail; fav: boolean; onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>91}) {92 const r = rabais(p)93 const resume = ligneResume(p)94 return (95 <header className="fb-hero" aria-label="Résumé du produit">96 <div className="fb-kicker" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>97 <StoreLogo storeId={p.store_id} name={p.store_name} size="sm" /> {p.store_name}{p.store_city || p.store_region ? ` — ${[p.store_city, p.store_region].filter(Boolean).join(', ')}` : ''}98 </div>99 <div className="fb-price">100 {p.price != null ? (101 <>102 {formatPrice(p.price, p.currency)}103 {p.price_max != null && p.price_max > p.price && <small>à {formatPrice(p.price_max, p.currency)}</small>}104 {r && <small style={{ textDecoration: 'line-through', opacity: 0.7 }}>{formatPrice(r.avant, p.currency)}</small>}105 </>106 ) : (107 <span style={{ fontSize: '0.62em', fontWeight: 600, color: 'var(--fb-text-2)' }}>{p.price_on_request ? 'Sur devis' : 'Prix affiché en boutique'}</span>108 )}109 </div>110 <div className="fb-price-row" style={{ marginTop: 8 }}>111 <span className="fb-capsule fb-capsule-origin"><OriginBadge origin={p.origin_class} withLabel /></span>112 {r && <span className="fb-capsule sale"><b>Solde</b>−{r.pct} %</span>}113 {!p.available && <span className="fb-capsule high">Non disponible</span>}114 {p.currency && p.currency !== 'CAD' && <span className="fb-capsule neutral">Devise {p.currency}</span>}115 {p.ka_reco && <span className="fb-capsule brand">Recommandé pour vous</span>}116 </div>117 <h1 className="fb-h1">118 {p.title}119 {resume.length > 0 && <span className="fb-city">{resume.join(' · ')}</span>}120 </h1>121 <ProductGallery images={p.images ?? []} titre={p.title} />122 <div className="fb-actions" ref={actionsRef}>123 <button type="button" className={`fb-btn fb-btn-ghost fb-btn-icon ${fav ? 'on' : ''}`} aria-pressed={fav}124 aria-label={fav ? 'Retirer des favoris' : 'Ajouter aux favoris'} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>125 <button type="button" className="fb-btn fb-btn-ghost fb-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>126 <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">127 Voir chez {p.store_name} <Ico name="external" size={16} />128 </a>129 </div>130 </header>131 )132}133