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/Closing.tsx : « En bref », CTA sticky mobile, aside desktop et5// « Demander à Ka » (widget KA Agent partagé) de la fiche produit.6// ---------------------------------------------------------------------------7import { useEffect, useState } from 'react'8import { ProductDetail, formatPrice } from '../api'9import { Ico, IcoHeart } from '../components/KaIcons'10import OriginBadge from '../components/OriginBadge'11import BottomSheet from './BottomSheet'12import { Constat, rabais } from './synthese'13import { SectionCard, SkeletonLines } from './ui'1415export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {16 const glyph = (t: Constat['tone']) => (t === 'good' ? '✓' : t === 'bad' ? '✕' : t === 'warn' ? '!' : '○')17 return (18 <ul className="fb-brief">19 {items.map((c) => (20 <li key={c.cle}>21 <span className={`fb-brief-ico ${c.tone === 'info' ? 'neutral' : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>22 <div><div className="fb-brief-t">{c.titre}</div>{!compact && c.detail && <div className="fb-brief-d">{c.detail}</div>}</div>23 </li>24 ))}25 </ul>26 )27}2829export function Summary({ items }: { items: Constat[] }) {30 return (31 <SectionCard id="resume" title="En bref">32 {items.length > 0 ? <BriefList items={items} /> : <SkeletonLines n={3} />}33 </SectionCard>34 )35}3637export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {38 const [past, setPast] = useState(false)39 useEffect(() => {40 const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0) }41 check()42 window.addEventListener('scroll', check, { passive: true })43 window.addEventListener('resize', check)44 return () => { window.removeEventListener('scroll', check); window.removeEventListener('resize', check) }45 }, [watch])46 return past47}4849const prixTxt = (p: ProductDetail) => (p.price != null ? formatPrice(p.price, p.currency) : p.price_on_request ? 'Sur devis' : 'Prix en boutique')5051export function StickyCTA({ p, show }: { p: ProductDetail; show: boolean }) {52 const r = rabais(p)53 return (54 <div className={`fb-cta-bar ${show ? 'show' : ''}`} aria-hidden={!show}>55 <div className="fb-cta-txt">56 <div className="fb-cta-price">{prixTxt(p)}</div>57 <div className={`fb-cta-sub ${r ? 'good' : ''}`}>{r ? `Solde −${r.pct} %` : p.store_name}</div>58 </div>59 <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>Voir chez {p.store_name} <Ico name="external" size={15} /></a>60 </div>61 )62}6364export function DesktopAside({ p, brief, fav, onFav, onShare }: {65 p: ProductDetail; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void66}) {67 const r = rabais(p)68 return (69 <aside className="fb-aside" aria-label="Résumé et actions">70 <div className="fb-card fb-aside-card">71 <div>72 <div className="fb-price" style={p.price == null ? { fontSize: 20 } : undefined}>{prixTxt(p)}{r && <small style={{ textDecoration: 'line-through', opacity: 0.7 }}>{formatPrice(r.avant, p.currency)}</small>}</div>73 <div className="fb-price-row" style={{ marginTop: 8 }}><span className="fb-capsule fb-capsule-origin"><OriginBadge origin={p.origin_class} withLabel /></span>{r && <span className="fb-capsule sale"><b>−{r.pct} %</b></span>}</div>74 </div>75 <div className="fb-aside-addr">{p.title}<br />{[p.store_name, p.store_region].filter(Boolean).join(' · ')}</div>76 <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">Voir chez {p.store_name} <Ico name="external" size={16} /></a>77 <div className="fb-actions">78 <button type="button" className={`fb-btn fb-btn-ghost ${fav ? 'on' : ''}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? 'Favori' : 'Favoris'}</button>79 <button type="button" className="fb-btn fb-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>80 </div>81 <button type="button" className="fb-btn fb-btn-ghost fb-ka-inline" onClick={() => window.dispatchEvent(new Event('fb:askka'))}><Ico name="sparkles" size={16} /> Demander à Ka</button>82 {brief.length > 0 && <><hr className="fb-aside-sep" /><BriefList items={brief.slice(0, 4)} compact /></>}83 <div className="fb-aside-meta">Fiche synchronisée depuis {p.store_name}</div>84 </div>85 </aside>86 )87}8889const SUGGESTIONS = ['Ce produit est-il fabriqué au Québec ?', 'Trouve des produits semblables d\'ici', 'Explique la classe d\'origine', 'Compare le prix à d\'autres boutiques', 'Idée cadeau dans la même catégorie']90function envoyerAKa(question: string): boolean {91 const btn = document.querySelector<HTMLButtonElement>('.kaa-btn')92 const ta = document.querySelector<HTMLTextAreaElement>('.kaa-panel textarea')93 const send = document.querySelector<HTMLButtonElement>('.kaa-in button')94 if (!btn || !ta || !send) return false95 btn.click(); ta.value = question; setTimeout(() => send.click(), 60)96 return true97}98export function KaAssistant({ p, hidden }: { p: ProductDetail; hidden?: boolean }) {99 const [open, setOpen] = useState(false)100 const [q, setQ] = useState('')101 const [err, setErr] = useState(false)102 useEffect(() => { const on = () => setOpen(true); window.addEventListener('fb:askka', on); return () => window.removeEventListener('fb:askka', on) }, [])103 const contexte = () => `(Produit consulté sur Fabri-Ka : ${p.title} chez ${p.store_name}${p.price != null ? ` — ${formatPrice(p.price, p.currency)}` : ''} — https://www.fabri-ka.com/produits/${encodeURIComponent(p.uid)})`104 const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(''); setErr(false) } else setErr(true) }105 return (106 <>107 <button type="button" className={`fb-ka-btn ${hidden ? 'hide' : ''}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>108 <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"109 footer={<form className="fb-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()) }}>110 <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce produit…" aria-label="Votre question" />111 <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>112 <div className="fb-ka-intro"><span className="fb-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>113 <p>Je peux expliquer l'origine du produit, trouver des alternatives fabriquées au Québec et comparer les boutiques d'ici.</p></div>114 <div className="fb-ka-ctx">{p.images?.[0] && <img src={p.images[0]} alt="" />}<span style={{ minWidth: 0 }}><b>{p.title}</b>{[p.store_name, p.price != null ? formatPrice(p.price, p.currency) : null].filter(Boolean).join(' · ')}</span></div>115 <div className="fb-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="fb-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>116 {err && <p className="fb-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}117 </BottomSheet>118 </>119 )120}121