Repository Public
1// ---------------------------------------------------------------------------2// Fabri-Ka — typed API client3// ---------------------------------------------------------------------------45/** Variante d'un produit (extraite par les connecteurs, structure libre). */6export interface ProductVariant {7 title?: string8 price?: number | null9 compare_at_price?: number | null10 sku?: string11 grams?: number | null12 image?: string13 available?: boolean | null14}1516/** Option de sélection (deux formes selon la plateforme source). */17export interface ProductOption {18 name?: string19 title?: string20 values?: string[]21 selections?: string[]22}2324/** Fiche détaillée additionnelle (Wix : matériaux, entretien…). */25export interface AdditionalInfo {26 title: string27 description: string28}2930/** Champ `details` JSON libre alimenté par les connecteurs. */31export interface ProductDetails {32 variants?: ProductVariant[]33 options?: ProductOption[]34 sku?: string35 gtin?: string36 brand?: string37 weight?: number | string38 weight_grams?: number39 formatted_weight?: string40 dimensions?: { length?: string; width?: string; height?: string }41 formatted_dimensions?: string42 attributes?: { name: string; terms: string[] }[]43 average_rating?: number44 review_count?: number45 inventory_quantity?: number46 low_stock_remaining?: number47 is_on_backorder?: boolean48 on_sale?: boolean49 variations?: number50 additional_info?: AdditionalInfo[]51 categories?: string[]52 model?: string53 availability_text?: string54 published_at?: string55 created_at?: string56 updated_at?: string57}5859export interface Product {60 /** personnalisation KA ID (fabrika/kaid.py) : « Recommandé pour vous » */61 ka_reco?: { score: number; reasons: string[] } | null;62 uid: string63 store_id: string64 url: string65 title: string66 description: string | null67 price: number | null68 price_max: number | null69 compare_at_price: number | null70 currency: string71 images: string[]72 category: string | null73 product_type: string | null74 tags: string[]75 vendor: string | null76 available: boolean77 price_on_request?: number78 listing_status?: string79 details?: ProductDetails | null80 store_name: string81 store_region: string | null82 store_city: string | null83 origin_class: OriginClass84 store_kind?: string | null85}8687export interface ProductDetail extends Product {88 store_url: string89 related: Product[]90}9192export interface ProductsResponse {93 total: number94 page: number95 per_page: number96 items: Product[]97}9899export interface Certification {100 label: string101 detail?: string102 certifier?: string | null103 since?: string | null104 operations?: string[]105 source?: string106}107108export interface Store {109 id: string110 name: string111 url: string112 platform: string | null113 city: string | null114 region: string | null115 origin_class: OriginClass116 categories: string[]117 product_count: number118 last_sync: number | string | null119 last_status: string | null120 logo_url: string | null121 cover_url: string | null122 description_meta: string | null123 store_kind?: string | null124 email?: string | null125 phone?: string | null126 lat?: number | null127 lng?: number | null128 shipping_info?: string | null129 certifications?: Certification[]130 presumed_closed?: number131}132133export const STORE_KIND_LABELS: Record<string, string> = {134 revendeur: 'Revendeur',135 collectif: "Collectif d'artisans",136 hors_mission: 'Hors mission',137}138139export interface StoreProductStats {140 n: number141 price_min: number | null142 price_max: number | null143 price_avg: number | null144}145146export interface StoreCategoryCount {147 key: string | null148 n: number149 label: string150}151152export interface SimilarStore {153 id: string154 name: string155 region: string | null156 origin_class: OriginClass157 logo_url: string | null158 product_count: number159}160161export interface StoreDetail extends Store {162 origin_evidence?: string | null163 discovery_sources?: string[]164 socials?: Record<string, string> | string[]165 product_stats: StoreProductStats166 category_breakdown: StoreCategoryCount[]167 similar: SimilarStore[]168}169170export interface StoresResponse {171 total: number172 items: Store[]173}174175export interface FacetCategory {176 key: string177 label: string178 n: number179}180181export interface FacetRegion {182 key: string183 n: number184}185186export interface FacetOrigin {187 key: string188 n: number189}190191export interface FacetStore {192 key: string193 name: string194 n: number195}196197export interface Facets {198 categories: FacetCategory[]199 regions: FacetRegion[]200 origins: FacetOrigin[]201 stores: FacetStore[]202 all_regions: string[]203 /** taxonomie complète (formulaire « Inscrire mon atelier ») */204 all_categories?: { key: string; label: string }[]205}206207export interface StatsTotals {208 products: number209 stores_live: number210 stores_registry: number211 regions: number212}213214export interface StatsByRegion {215 region: string216 stores: number217 products: number218}219220export interface SyncLogEntry {221 ts: string222 store_id: string223 found: number224 added: number225 updated: number226 removed: number227 status: string228}229230export interface Stats {231 totals: StatsTotals232 by_region: StatsByRegion[]233 sync_log: SyncLogEntry[]234}235236// ---------------------------------------------------------------------------237// Extended stats (/api/stats/extended) — page /stats238// ---------------------------------------------------------------------------239240export interface ExtendedTotals {241 products: number242 products_priced: number243 stores_live: number244 stores_registry: number245 regions: number246 price_avg: number | null247 price_median: number | null248}249250export interface CategoryStat {251 key: string252 label: string253 products: number254 stores: number255 price_min: number | null256 price_avg: number | null257 price_max: number | null258 price_median: number | null259}260261export interface PriceBucket {262 bucket: string263 n: number264}265266export interface OriginStat {267 key: string268 stores: number269 products: number270}271272export interface PlatformStat {273 key: string274 stores: number275 products: number | null276}277278export interface RegionStat {279 key: string280 stores: number281 products: number282 price_avg: number | null283}284285export interface GrowthPoint {286 day: string287 n: number288}289290export interface TopStore {291 id: string292 name: string293 region: string | null294 origin_class: OriginClass295 logo_url: string | null296 products: number297}298299export interface ExpensiveProduct {300 uid: string301 title: string302 price: number303 store_name: string304}305306export interface NewestProduct {307 uid: string308 title: string309 price: number | null310 category: string | null311 category_label: string312 store_name: string313}314315export type AvailabilityKey = 'en stock' | 'rupture' | 'inconnu'316317export interface AvailabilityStat {318 key: AvailabilityKey319 n: number320}321322export interface Coverage {323 with_image: number324 with_desc: number325 with_logo: number326 with_region: number327}328329export interface ExtendedStats {330 totals: ExtendedTotals331 by_category: CategoryStat[]332 price_buckets: PriceBucket[]333 by_origin: OriginStat[]334 by_platform: PlatformStat[]335 by_region: RegionStat[]336 growth: GrowthPoint[]337 top_stores: TopStore[]338 most_expensive: ExpensiveProduct[]339 newest: NewestProduct[]340 availability: AvailabilityStat[]341 coverage: Coverage342}343344// ---------------------------------------------------------------------------345// Compte — connexion « KA ID » (SSO Groupe KA)346// ---------------------------------------------------------------------------347348/** Réseaux sociaux du profil Groupe KA (pseudos ou URL, tels que saisis au hub) */349export interface Socials {350 instagram?: string351 facebook?: string352 x?: string353 linkedin?: string354 tiktok?: string355 youtube?: string356}357358export interface Me {359 ka_id: string360 email: string361 name: string362 picture: string363 provider: string364 created?: number | null365 last_login?: number | null366 // ——— profil Groupe KA (source de vérité : groupe-ka.com) ———367 profile_source?: 'groupe-ka' | 'local'368 bio?: string369 city?: string370 job_title?: string371 company?: string372 age?: number | null373 website?: string374 socials?: Socials375 role_label?: string376 public?: boolean377 public_url?: string378}379380export interface MeResponse {381 user: Me | null382 enabled: boolean383}384385export function fetchMe(signal?: AbortSignal): Promise<MeResponse> {386 return getJson<MeResponse>('/api/auth/me', signal)387}388389export async function logout(): Promise<void> {390 await fetch('/api/auth/logout', { method: 'POST' })391}392393// ---------------------------------------------------------------------------394// Favoris — « Mon univers Ka » (magasin central du hub Groupe KA)395// Aucun stockage local : GET /api/favorites lit le hub, POST …/toggle y pousse.396// ---------------------------------------------------------------------------397398export interface FavoriteItem {399 item_id: string400 title: string401 subtitle?: string402 price_label?: string403 image_url?: string404 url?: string405}406407export interface FavoritesResponse {408 ids: string[]409 items: FavoriteItem[]410}411412/** Favoris du membre connecté — `null` si non connecté (401). */413export async function fetchFavorites(414 signal?: AbortSignal415): Promise<FavoritesResponse | null> {416 const res = await fetch('/api/favorites', { signal })417 if (res.status === 401) return null418 if (!res.ok) throw new Error(`API ${res.status} — /api/favorites`)419 return (await res.json()) as FavoritesResponse420}421422/** ♥ on/off poussé au hub Groupe KA. Résout `true` si le hub a enregistré. */423export async function toggleFavorite(424 on: boolean,425 item: FavoriteItem426): Promise<boolean> {427 const res = await fetch('/api/favorites/toggle', {428 method: 'POST',429 headers: { 'Content-Type': 'application/json' },430 body: JSON.stringify({ on, item }),431 })432 if (!res.ok) return false433 const data = (await res.json()) as { ok?: boolean }434 return data.ok === true435}436437/** Produit -> item de favori pour le hub (URL canonique de la fiche produit). */438export function productFavItem(p: Product): FavoriteItem {439 return {440 item_id: p.uid,441 title: p.title,442 subtitle: [p.store_name, p.store_region || p.category]443 .filter(Boolean)444 .join(' · '),445 price_label:446 p.price !== null447 ? formatPrice(p.price, p.currency)448 : p.price_on_request449 ? 'Sur devis'450 : '',451 image_url: p.images.length > 0 ? p.images[0] : '',452 url: `https://www.fabri-ka.com${productPath(p)}`,453 }454}455456// ---------------------------------------------------------------------------457// SEO — slugs et URLs canoniques (doit rester aligné avec fabrika/seo.py)458// ---------------------------------------------------------------------------459460export const REGIONS_QC = [461 'Bas-Saint-Laurent',462 'Saguenay–Lac-Saint-Jean',463 'Capitale-Nationale',464 'Mauricie',465 'Estrie',466 'Montréal',467 'Outaouais',468 'Abitibi-Témiscamingue',469 'Côte-Nord',470 'Nord-du-Québec',471 'Gaspésie–Îles-de-la-Madeleine',472 'Chaudière-Appalaches',473 'Laval',474 'Lanaudière',475 'Laurentides',476 'Montérégie',477 'Centre-du-Québec',478]479480export const CATEGORY_LABELS: Record<string, string> = {481 erable: 'Érable & miel',482 epicerie: 'Épicerie fine',483 cafe_the: 'Café & thé',484 chocolat: 'Chocolat & confiseries',485 boulangerie: 'Boulangerie & pâtisserie',486 viande_poisson: 'Viandes & poissons',487 fromage_laitier: 'Fromages & produits laitiers',488 boissons: 'Boissons',489 alcool: 'Bières, vins & spiritueux',490 sante_beaute: 'Beauté & soins',491 maison: 'Maison & déco',492 mode: 'Mode & accessoires',493 bijoux: 'Bijoux',494 art: 'Art & artisanat',495 enfants: 'Enfants & bébés',496 animaux: 'Animaux',497 plein_air: 'Plein air & sport',498 jardin: 'Jardin & agriculture',499 industriel: 'Industriel & pro',500 autre: 'Autres',501}502503export function slugify(text: string): string {504 return text505 .normalize('NFKD')506 .replace(/[\u0300-\u036f]/g, '')507 .toLowerCase()508 .replace(/[^a-z0-9]+/g, '-')509 .replace(/^-+|-+$/g, '')510}511512/** URL canonique d'une fiche produit : /produits/<slug>-<uid>. */513export function productPath(p: { uid: string; title: string }): string {514 const slug = slugify(p.title).slice(0, 70).replace(/-+$/, '')515 return slug ? `/produits/${slug}-${p.uid}` : `/produits/${p.uid}`516}517518export function categorySlug(key: string): string {519 return slugify(key)520}521522export function categoryFromSlug(slug: string): string {523 return slug.replace(/-/g, '_')524}525526export function regionSlug(region: string): string {527 return slugify(region)528}529530export function regionFromSlug(slug: string): string | null {531 return REGIONS_QC.find((r) => slugify(r) === slug) ?? null532}533534/** Met à jour title / meta description / canonical lors des navigations SPA. */535export function setPageMeta(title: string, description?: string): void {536 document.title = title537 if (description) {538 document539 .querySelector('meta[name="description"]')540 ?.setAttribute('content', description)541 }542 document543 .querySelector('link[rel="canonical"]')544 ?.setAttribute('href', window.location.origin + window.location.pathname)545}546547// ---------------------------------------------------------------------------548// Origin classes549// ---------------------------------------------------------------------------550551export type OriginClass = 'A' | 'B' | 'C' | 'D' | 'E'552553export const ORIGIN_LABELS: Record<OriginClass, string> = {554 A: 'Fabriqué au Québec',555 B: 'Conçu au Québec',556 C: 'Détaillant québécois',557 D: 'Mixte',558 E: 'À vérifier',559}560561export const ORIGIN_KEYS: OriginClass[] = ['A', 'B', 'C', 'D', 'E']562563// ---------------------------------------------------------------------------564// Query params for /api/products565// ---------------------------------------------------------------------------566567export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title' | 'discount'568569export interface ProductQuery {570 q?: string571 category?: string572 region?: string573 store?: string574 origin?: string575 price_min?: string | number576 price_max?: string | number577 kind?: string578 on_sale?: string | number579 sort?: SortKey | string580 page?: number581 per_page?: number582}583584// ---------------------------------------------------------------------------585// Fetch helpers586// ---------------------------------------------------------------------------587588async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {589 const res = await fetch(path, { signal })590 if (!res.ok) {591 throw new Error(`API ${res.status} — ${path}`)592 }593 return (await res.json()) as T594}595596function qs(params: Record<string, string | number | undefined>): string {597 const sp = new URLSearchParams()598 for (const [k, v] of Object.entries(params)) {599 if (v !== undefined && v !== null && `${v}`.length > 0) {600 sp.set(k, `${v}`)601 }602 }603 const s = sp.toString()604 return s ? `?${s}` : ''605}606607export function fetchProducts(608 query: ProductQuery = {},609 signal?: AbortSignal610): Promise<ProductsResponse> {611 return getJson<ProductsResponse>(612 `/api/products${qs({ ...query })}`,613 signal614 )615}616617export function fetchProduct(618 uid: string,619 signal?: AbortSignal620): Promise<ProductDetail> {621 return getJson<ProductDetail>(622 `/api/products/${encodeURIComponent(uid)}`,623 signal624 )625}626627export function fetchStores(628 params: { region?: string; q?: string; with_products?: boolean } = {},629 signal?: AbortSignal630): Promise<StoresResponse> {631 return getJson<StoresResponse>(632 `/api/stores${qs({633 region: params.region,634 q: params.q,635 with_products: params.with_products ? 'true' : undefined,636 })}`,637 signal638 )639}640641export function fetchStore(id: string, signal?: AbortSignal): Promise<StoreDetail> {642 return getJson<StoreDetail>(`/api/stores/${encodeURIComponent(id)}`, signal)643}644645export function fetchFacets(signal?: AbortSignal): Promise<Facets> {646 return getJson<Facets>('/api/facets', signal)647}648649export function fetchStats(signal?: AbortSignal): Promise<Stats> {650 return getJson<Stats>('/api/stats', signal)651}652653export function fetchExtendedStats(654 signal?: AbortSignal655): Promise<ExtendedStats> {656 return getJson<ExtendedStats>('/api/stats/extended', signal)657}658659// ---------------------------------------------------------------------------660// Auto-inscription — « Inscrire mon atelier » (quarantaine de validation)661// ---------------------------------------------------------------------------662663export interface InscriptionPayload {664 name: string665 metier: string666 description: string667 address?: string668 website?: string669 email?: string670 phone?: string671 socials?: string[]672 photos?: string[]673 /** honeypot anti-robot — TOUJOURS laisser vide */674 entreprise_url?: string675}676677export interface InscriptionResponse {678 status: string679 id: number680 message?: string681}682683export async function submitInscription(684 payload: InscriptionPayload685): Promise<InscriptionResponse> {686 const res = await fetch('/api/inscriptions', {687 method: 'POST',688 headers: { 'Content-Type': 'application/json' },689 body: JSON.stringify(payload),690 })691 if (!res.ok) {692 let detail = `Erreur ${res.status}`693 try {694 const data = (await res.json()) as { detail?: string }695 if (data.detail) detail = data.detail696 } catch {697 /* réponse non-JSON */698 }699 throw new Error(detail)700 }701 return (await res.json()) as InscriptionResponse702}703704// ---------------------------------------------------------------------------705// Formatting helpers (fr-CA)706// ---------------------------------------------------------------------------707708const priceFormatters = new Map<string, Intl.NumberFormat>()709710function formatterFor(currency: string): Intl.NumberFormat {711 let f = priceFormatters.get(currency)712 if (!f) {713 f = new Intl.NumberFormat('fr-CA', { style: 'currency', currency })714 priceFormatters.set(currency, f)715 }716 return f717}718719/** Prix formaté dans la devise SOURCE ("24,95 $", "27,50 $ US", "12,00 €"). */720export function formatPrice(721 value: number | null | undefined,722 currency: string = 'CAD'723): string {724 if (value === null || value === undefined || Number.isNaN(value)) return ''725 const cur = currency && currency.length === 3 ? currency.toUpperCase() : 'CAD'726 try {727 return formatterFor(cur).format(value)728 } catch {729 return formatterFor('CAD').format(value)730 }731}732733const cadCompactFormatter = new Intl.NumberFormat('fr-CA', {734 style: 'currency',735 currency: 'CAD',736 maximumFractionDigits: 0,737})738739/** "1 250 $" — compact price without cents (ranges, chart labels). */740export function formatPriceCompact(value: number | null | undefined): string {741 if (value === null || value === undefined || Number.isNaN(value)) return ''742 return cadCompactFormatter.format(value)743}744745const intFormatter = new Intl.NumberFormat('fr-CA')746747export function formatInt(value: number | null | undefined): string {748 if (value === null || value === undefined || Number.isNaN(value)) return '0'749 return intFormatter.format(value)750}751752const relativeFormatter = new Intl.RelativeTimeFormat('fr-CA', {753 numeric: 'auto',754})755756/**757 * "il y a 3 jours" — accepts an epoch (seconds or ms) or an ISO string.758 * Returns null when the value is missing or unparseable.759 */760export function formatRelativeDate(761 value: number | string | null | undefined762): string | null {763 if (value === null || value === undefined || value === '') return null764 let ms: number765 if (typeof value === 'number') {766 ms = value > 1e12 ? value : value * 1000767 } else {768 const parsed = Date.parse(value)769 if (!Number.isNaN(parsed)) {770 ms = parsed771 } else {772 const n = Number(value)773 if (Number.isNaN(n)) return null774 ms = n > 1e12 ? n : n * 1000775 }776 }777 if (!Number.isFinite(ms) || ms <= 0) return null778 const diffSec = Math.round((ms - Date.now()) / 1000)779 const abs = Math.abs(diffSec)780 if (abs < 60) return relativeFormatter.format(diffSec, 'second')781 if (abs < 3600) return relativeFormatter.format(Math.round(diffSec / 60), 'minute')782 if (abs < 86400) return relativeFormatter.format(Math.round(diffSec / 3600), 'hour')783 if (abs < 86400 * 30) return relativeFormatter.format(Math.round(diffSec / 86400), 'day')784 if (abs < 86400 * 365) return relativeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month')785 return relativeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year')786}787788export function hostnameOf(url: string): string {789 try {790 return new URL(url).hostname.replace(/^www\./, '')791 } catch {792 return url793 }794}795