SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
5 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%
8.0 KB · 133 lines tsx
Raw Blame History
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