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/synthese.ts : synthèse DÉTERMINISTE de la fiche produit (aucun LLM,5// aucune donnée inventée) : rabais, origine (classe A–E), disponibilité,6// note moyenne, déclinaisons, devise ; constats « En bref » ; résumé.7// ---------------------------------------------------------------------------8import { CATEGORY_LABELS, ORIGIN_LABELS, Product, ProductDetail, ProductVariant, formatPrice } from '../api'9import { NBSP, Tone } from './ui'1011export interface Rabais { pct: number; avant: number; apres: number }1213/** Rabais réel (prix barré plausible : ≤ 10× le prix, sinon saisie en cents). */14export function rabais(p: Product): Rabais | null {15 if (p.compare_at_price == null || p.price == null || p.compare_at_price <= p.price || p.compare_at_price > p.price * 10) return null16 const pct = Math.round((1 - p.price / p.compare_at_price) * 100)17 return pct >= 3 ? { pct, avant: p.compare_at_price, apres: p.price } : null18}1920export function variantsOf(p: Product): ProductVariant[] {21 return (p.details?.variants ?? []).filter((v) => v.title || v.sku)22}2324export interface Constat { tone: Tone; titre: string; detail: string; cle: string }2526export function enBref(p: ProductDetail): Constat[] {27 const out: Constat[] = []28 const det = p.details ?? {}2930 // 1. origine31 const o = p.origin_class32 if (o === 'A') out.push({ cle: 'origine', tone: 'good', titre: 'Fabriqué au Québec', detail: `Boutique ${p.store_name}${p.store_region ? ` · ${p.store_region}` : ''} — classe d'origine A (vérifiée par Fabri-Ka)` })33 else if (o === 'B') out.push({ cle: 'origine', tone: 'good', titre: 'Conçu au Québec', detail: 'Classe d\'origine B — conception québécoise, fabrication partielle ou externe' })34 else if (o === 'C') out.push({ cle: 'origine', tone: 'neutral', titre: 'Détaillant québécois', detail: 'Classe d\'origine C — boutique d\'ici, produit non nécessairement fabriqué au Québec' })35 else if (o === 'D') out.push({ cle: 'origine', tone: 'neutral', titre: 'Origine mixte', detail: 'Classe D — assortiment québécois et importé' })36 else if (o === 'E') out.push({ cle: 'origine', tone: 'warn', titre: 'Origine à vérifier', detail: 'Classe E — Fabri-Ka n\'a pas encore confirmé la provenance' })3738 // 2. prix / rabais39 const r = rabais(p)40 if (r) out.push({ cle: 'rabais', tone: 'good', titre: `En solde — ${r.pct}${NBSP}% de rabais`, detail: `${formatPrice(r.avant, p.currency)} → ${formatPrice(r.apres, p.currency)} chez ${p.store_name}` })41 else if (p.price == null) out.push({ cle: 'rabais', tone: 'neutral', titre: p.price_on_request ? 'Prix sur devis' : 'Prix affiché en boutique', detail: 'La boutique ne publie pas de prix en ligne pour ce produit' })42 else if (p.price_max != null && p.price_max > p.price) out.push({ cle: 'rabais', tone: 'info', titre: `De ${formatPrice(p.price, p.currency)} à ${formatPrice(p.price_max, p.currency)}`, detail: 'Le prix dépend de la déclinaison choisie' })4344 // 3. disponibilité45 if (!p.available) out.push({ cle: 'dispo', tone: 'bad', titre: 'Non disponible actuellement', detail: 'Selon la boutique lors de la dernière synchronisation' })46 else if (det.low_stock_remaining) out.push({ cle: 'dispo', tone: 'warn', titre: `Plus que ${det.low_stock_remaining} en stock`, detail: 'Inventaire publié par la boutique' })47 else if (det.is_on_backorder) out.push({ cle: 'dispo', tone: 'warn', titre: 'En précommande', detail: 'Délai de fabrication ou de réapprovisionnement à confirmer avec la boutique' })48 else if (det.inventory_quantity != null && det.inventory_quantity > 0) out.push({ cle: 'dispo', tone: 'good', titre: 'En stock', detail: `${det.inventory_quantity} unité${det.inventory_quantity > 1 ? 's' : ''} publiées par la boutique` })4950 // 4. avis51 if (det.average_rating && det.average_rating > 0)52 out.push({ cle: 'avis', tone: det.average_rating >= 4 ? 'good' : 'neutral', titre: `Noté ${det.average_rating.toFixed(1).replace('.', ',')} / 5`, detail: det.review_count ? `${det.review_count} avis publiés sur la boutique` : 'Note publiée par la boutique' })5354 // 5. déclinaisons55 const vars = variantsOf(p)56 if (vars.length > 1) {57 const prix = vars.map((v) => v.price).filter((x): x is number => x != null)58 out.push({ cle: 'var', tone: 'info', titre: `${vars.length} déclinaisons`, detail: prix.length > 1 ? `de ${formatPrice(Math.min(...prix), p.currency)} à ${formatPrice(Math.max(...prix), p.currency)}` : (det.options ?? []).map((o) => o.name || o.title).filter(Boolean).join(' · ') || 'Formats, tailles ou couleurs au choix' })59 }6061 // 6. devise62 if (p.currency && p.currency !== 'CAD') out.push({ cle: 'devise', tone: 'warn', titre: `Prix affiché en ${p.currency}`, detail: 'Boutique facturant dans une autre devise — conversion et frais possibles' })6364 return out.slice(0, 6)65}6667/** Ligne résumé : « Marque · Catégorie · Type » */68export function ligneResume(p: Product): string[] {69 const out: string[] = []70 if (p.vendor && p.vendor !== p.store_name) out.push(p.vendor)71 if (p.category) out.push(CATEGORY_LABELS[p.category] ?? p.category)72 if (p.product_type) out.push(p.product_type)73 return out74}7576export const origineLabel = (p: Product) => ORIGIN_LABELS[p.origin_class] ?? ''77