// --------------------------------------------------------------------------- // Fabri-Ka — typed API client // --------------------------------------------------------------------------- export interface Product { uid: string store_id: string url: string title: string description: string | null price: number | null price_max: number | null compare_at_price: number | null currency: string images: string[] category: string | null product_type: string | null tags: string[] vendor: string | null available: boolean store_name: string store_region: string | null store_city: string | null origin_class: OriginClass } export interface ProductDetail extends Product { store_url: string related: Product[] } export interface ProductsResponse { total: number page: number per_page: number items: Product[] } export interface Store { id: string name: string url: string platform: string | null city: string | null region: string | null origin_class: OriginClass categories: string[] product_count: number last_sync: number | string | null last_status: string | null logo_url: string | null cover_url: string | null description_meta: string | null } export interface StoreProductStats { n: number price_min: number | null price_max: number | null price_avg: number | null } export interface StoreCategoryCount { key: string | null n: number label: string } export interface SimilarStore { id: string name: string region: string | null origin_class: OriginClass logo_url: string | null product_count: number } export interface StoreDetail extends Store { origin_evidence?: string | null discovery_sources?: string[] socials?: Record product_stats: StoreProductStats category_breakdown: StoreCategoryCount[] similar: SimilarStore[] } export interface StoresResponse { total: number items: Store[] } export interface FacetCategory { key: string label: string n: number } export interface FacetRegion { key: string n: number } export interface FacetOrigin { key: string n: number } export interface FacetStore { key: string name: string n: number } export interface Facets { categories: FacetCategory[] regions: FacetRegion[] origins: FacetOrigin[] stores: FacetStore[] all_regions: string[] } export interface StatsTotals { products: number stores_live: number stores_registry: number regions: number } export interface StatsByRegion { region: string stores: number products: number } export interface SyncLogEntry { ts: string store_id: string found: number added: number updated: number removed: number status: string } export interface Stats { totals: StatsTotals by_region: StatsByRegion[] sync_log: SyncLogEntry[] } // --------------------------------------------------------------------------- // Extended stats (/api/stats/extended) — page /stats // --------------------------------------------------------------------------- export interface ExtendedTotals { products: number products_priced: number stores_live: number stores_registry: number regions: number price_avg: number | null price_median: number | null } export interface CategoryStat { key: string label: string products: number stores: number price_min: number | null price_avg: number | null price_max: number | null price_median: number | null } export interface PriceBucket { bucket: string n: number } export interface OriginStat { key: string stores: number products: number } export interface PlatformStat { key: string stores: number products: number | null } export interface RegionStat { key: string stores: number products: number price_avg: number | null } export interface GrowthPoint { day: string n: number } export interface TopStore { id: string name: string region: string | null origin_class: OriginClass logo_url: string | null products: number } export interface ExpensiveProduct { uid: string title: string price: number store_name: string } export interface NewestProduct { uid: string title: string price: number | null category: string | null category_label: string store_name: string } export type AvailabilityKey = 'en stock' | 'rupture' | 'inconnu' export interface AvailabilityStat { key: AvailabilityKey n: number } export interface Coverage { with_image: number with_desc: number with_logo: number with_region: number } export interface ExtendedStats { totals: ExtendedTotals by_category: CategoryStat[] price_buckets: PriceBucket[] by_origin: OriginStat[] by_platform: PlatformStat[] by_region: RegionStat[] growth: GrowthPoint[] top_stores: TopStore[] most_expensive: ExpensiveProduct[] newest: NewestProduct[] availability: AvailabilityStat[] coverage: Coverage } // --------------------------------------------------------------------------- // Origin classes // --------------------------------------------------------------------------- export type OriginClass = 'A' | 'B' | 'C' | 'D' | 'E' export const ORIGIN_LABELS: Record = { A: 'Fabriqué au Québec', B: 'Conçu au Québec', C: 'Détaillant québécois', D: 'Mixte', E: 'À vérifier', } export const ORIGIN_KEYS: OriginClass[] = ['A', 'B', 'C', 'D', 'E'] // --------------------------------------------------------------------------- // Query params for /api/products // --------------------------------------------------------------------------- export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title' export interface ProductQuery { q?: string category?: string region?: string store?: string origin?: string price_min?: string | number price_max?: string | number sort?: SortKey | string page?: number per_page?: number } // --------------------------------------------------------------------------- // Fetch helpers // --------------------------------------------------------------------------- async function getJson(path: string, signal?: AbortSignal): Promise { const res = await fetch(path, { signal }) if (!res.ok) { throw new Error(`API ${res.status} — ${path}`) } return (await res.json()) as T } function qs(params: Record): string { const sp = new URLSearchParams() for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && `${v}`.length > 0) { sp.set(k, `${v}`) } } const s = sp.toString() return s ? `?${s}` : '' } export function fetchProducts( query: ProductQuery = {}, signal?: AbortSignal ): Promise { return getJson( `/api/products${qs({ ...query })}`, signal ) } export function fetchProduct( uid: string, signal?: AbortSignal ): Promise { return getJson( `/api/products/${encodeURIComponent(uid)}`, signal ) } export function fetchStores( params: { region?: string; q?: string; with_products?: boolean } = {}, signal?: AbortSignal ): Promise { return getJson( `/api/stores${qs({ region: params.region, q: params.q, with_products: params.with_products ? 'true' : undefined, })}`, signal ) } export function fetchStore(id: string, signal?: AbortSignal): Promise { return getJson(`/api/stores/${encodeURIComponent(id)}`, signal) } export function fetchFacets(signal?: AbortSignal): Promise { return getJson('/api/facets', signal) } export function fetchStats(signal?: AbortSignal): Promise { return getJson('/api/stats', signal) } export function fetchExtendedStats( signal?: AbortSignal ): Promise { return getJson('/api/stats/extended', signal) } // --------------------------------------------------------------------------- // Formatting helpers (fr-CA) // --------------------------------------------------------------------------- const cadFormatter = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD', }) export function formatPrice(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(value)) return '' return cadFormatter.format(value) } const cadCompactFormatter = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD', maximumFractionDigits: 0, }) /** "1 250 $" — compact price without cents (ranges, chart labels). */ export function formatPriceCompact(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(value)) return '' return cadCompactFormatter.format(value) } const intFormatter = new Intl.NumberFormat('fr-CA') export function formatInt(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(value)) return '0' return intFormatter.format(value) } const relativeFormatter = new Intl.RelativeTimeFormat('fr-CA', { numeric: 'auto', }) /** * "il y a 3 jours" — accepts an epoch (seconds or ms) or an ISO string. * Returns null when the value is missing or unparseable. */ export function formatRelativeDate( value: number | string | null | undefined ): string | null { if (value === null || value === undefined || value === '') return null let ms: number if (typeof value === 'number') { ms = value > 1e12 ? value : value * 1000 } else { const parsed = Date.parse(value) if (!Number.isNaN(parsed)) { ms = parsed } else { const n = Number(value) if (Number.isNaN(n)) return null ms = n > 1e12 ? n : n * 1000 } } if (!Number.isFinite(ms) || ms <= 0) return null const diffSec = Math.round((ms - Date.now()) / 1000) const abs = Math.abs(diffSec) if (abs < 60) return relativeFormatter.format(diffSec, 'second') if (abs < 3600) return relativeFormatter.format(Math.round(diffSec / 60), 'minute') if (abs < 86400) return relativeFormatter.format(Math.round(diffSec / 3600), 'hour') if (abs < 86400 * 30) return relativeFormatter.format(Math.round(diffSec / 86400), 'day') if (abs < 86400 * 365) return relativeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month') return relativeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year') } export function hostnameOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } }