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/PriceCard.tsx : « Prix et déclinaisons » — KPI (prix, prix barré ou5// fourchette, disponibilité), options (tailles, couleurs, formats) en puces,6// liste des déclinaisons avec prix et rupture (8 + « Voir les N »), note7// moyenne, méthodologie. Toutes les données viennent de la boutique.8// ---------------------------------------------------------------------------9import { useState } from 'react'10import { ProductDetail, ProductOption, formatPrice } from '../api'11import { Ico } from '../components/KaIcons'12import { rabais, variantsOf } from './synthese'13import { Accordion, MoreButton, SectionCard, StatTile, NBSP } from './ui'1415const optName = (o: ProductOption) => o.name || o.title || ''16const optValues = (o: ProductOption) => (o.values || o.selections || []).filter(Boolean)1718export default function PriceCard({ p }: { p: ProductDetail }) {19 const [all, setAll] = useState(false)20 const det = p.details ?? {}21 const r = rabais(p)22 const options = (det.options ?? []).filter((o) => optName(o) && optValues(o).length > 0)23 const variants = variantsOf(p)24 const LIMIT = 825 const dispo = !p.available ? 'Non disponible' : det.low_stock_remaining ? `Plus que ${det.low_stock_remaining}` : det.is_on_backorder ? 'Précommande' : det.inventory_quantity != null && det.inventory_quantity > 0 ? `${det.inventory_quantity} en stock` : det.availability_text || 'Disponible'26 const rating = det.average_rating27 return (28 <SectionCard id="prix" title="Prix et déclinaisons" icon={<Ico name="scale" size={18} />}>29 <div className="fb-kpis cols-3">30 <StatTile accent value={p.price != null ? formatPrice(p.price, p.currency) : (p.price_on_request ? 'Sur devis' : '—')} label={p.price != null ? (r ? 'Prix en solde' : 'Prix en boutique') : 'Prix non publié en ligne'} />31 {r ? <StatTile value={formatPrice(r.avant, p.currency)} label={`Prix régulier · −${r.pct}${NBSP}%`} />32 : p.price_max != null && p.price != null && p.price_max > p.price ? <StatTile value={`${formatPrice(p.price, p.currency)} – ${formatPrice(p.price_max, p.currency)}`} label="Fourchette selon la déclinaison" />33 : <StatTile value={variants.length > 1 ? variants.length : 1} label={variants.length > 1 ? 'Déclinaisons' : 'Déclinaison unique'} />}34 <StatTile value={dispo} label="Disponibilité (boutique)" />35 </div>36 {rating != null && rating > 0 && (37 <p className="fb-note good" style={{ marginTop: 12 }}>38 <b>Note {rating.toFixed(1).replace('.', ',')} / 5</b>{det.review_count ? ` — ${det.review_count} avis publiés sur la boutique` : ' — note publiée par la boutique'}39 </p>40 )}41 {options.length > 0 && (42 <>43 <h3 className="fb-card-sub fb-subtitle">Options</h3>44 {options.map((o) => (45 <div key={optName(o)} style={{ marginBottom: 10 }}>46 <div className="fb-fine" style={{ margin: '0 0 6px', fontWeight: 600, color: 'var(--fb-text-2)' }}>{optName(o)}</div>47 <div className="fb-tags">48 {optValues(o).slice(0, 18).map((v) => <span className="fb-tag n" key={v}>{v}</span>)}49 {optValues(o).length > 18 && <span className="fb-tag n">+{optValues(o).length - 18}</span>}50 </div>51 </div>52 ))}53 </>54 )}55 {variants.length > 1 && (56 <>57 <h3 className="fb-card-sub fb-subtitle">Déclinaisons <small>· {variants.length}{det.variations && det.variations > variants.length ? ` sur ${det.variations}` : ''}</small></h3>58 <ul className="fb-list">59 {(all ? variants : variants.slice(0, LIMIT)).map((v, i) => {60 const vr = v.compare_at_price != null && v.price != null && v.compare_at_price > v.price && v.compare_at_price <= v.price * 1061 return (62 <li className="fb-item" key={`${v.title || v.sku}-${i}`}>63 <div className="fb-item-main">64 <div className="fb-item-t">{v.title || v.sku}</div>65 <div className="fb-item-s">{[v.sku && v.sku !== v.title ? `SKU ${v.sku}` : null, v.grams ? `${v.grams} g` : null, v.available === false ? 'épuisé' : null].filter(Boolean).join(' · ')}</div>66 </div>67 <div className="fb-item-r">68 <div className="fb-item-v">{v.price != null ? formatPrice(v.price, p.currency) : '—'}</div>69 {vr && <div className="fb-item-m" style={{ textDecoration: 'line-through' }}>{formatPrice(v.compare_at_price!, p.currency)}</div>}70 </div>71 </li>72 )73 })}74 </ul>75 {variants.length > LIMIT && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? 'Réduire' : `Voir les ${variants.length} déclinaisons`}</MoreButton>}76 </>77 )}78 <Accordion title="Méthodologie" small>79 <p>80 Prix, prix barré, options, déclinaisons et disponibilité sont ceux publiés par la boutique (Shopify, WooCommerce,81 Wix, Squarespace…) lors de la dernière synchronisation Fabri-Ka. Un prix barré supérieur à dix fois le prix est82 ignoré (saisie en cents chez certains marchands). Le prix final, les taxes et la livraison se confirment sur la83 boutique.84 </p>85 </Accordion>86 </SectionCard>87 )88}89