spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57.9%
Python 18.6%
TypeScript 15.6%
CSS 7.8%
1// ---------------------------------------------------------------------------2// Fabri-Ka — typed API client3// ---------------------------------------------------------------------------45export interface Product {6 uid: string7 store_id: string8 url: string9 title: string10 description: string | null11 price: number | null12 price_max: number | null13 compare_at_price: number | null14 currency: string15 images: string[]16 category: string | null17 product_type: string | null18 tags: string[]19 vendor: string | null20 available: boolean21 store_name: string22 store_region: string | null23 store_city: string | null24 origin_class: OriginClass25}2627export interface ProductDetail extends Product {28 store_url: string29 related: Product[]30}3132export interface ProductsResponse {33 total: number34 page: number35 per_page: number36 items: Product[]37}3839export interface Store {40 id: string41 name: string42 url: string43 platform: string | null44 city: string | null45 region: string | null46 origin_class: OriginClass47 categories: string[]48 product_count: number49 last_sync: number | string | null50 last_status: string | null51 logo_url: string | null52 cover_url: string | null53 description_meta: string | null54}5556export interface StoreProductStats {57 n: number58 price_min: number | null59 price_max: number | null60 price_avg: number | null61}6263export interface StoreCategoryCount {64 key: string | null65 n: number66 label: string67}6869export interface SimilarStore {70 id: string71 name: string72 region: string | null73 origin_class: OriginClass74 logo_url: string | null75 product_count: number76}7778export interface StoreDetail extends Store {79 origin_evidence?: string | null80 discovery_sources?: string[]81 socials?: Record<string, string>82 product_stats: StoreProductStats83 category_breakdown: StoreCategoryCount[]84 similar: SimilarStore[]85}8687export interface StoresResponse {88 total: number89 items: Store[]90}9192export interface FacetCategory {93 key: string94 label: string95 n: number96}9798export interface FacetRegion {99 key: string100 n: number101}102103export interface FacetOrigin {104 key: string105 n: number106}107108export interface FacetStore {109 key: string110 name: string111 n: number112}113114export interface Facets {115 categories: FacetCategory[]116 regions: FacetRegion[]117 origins: FacetOrigin[]118 stores: FacetStore[]119 all_regions: string[]120}121122export interface StatsTotals {123 products: number124 stores_live: number125 stores_registry: number126 regions: number127}128129export interface StatsByRegion {130 region: string131 stores: number132 products: number133}134135export interface SyncLogEntry {136 ts: string137 store_id: string138 found: number139 added: number140 updated: number141 removed: number142 status: string143}144145export interface Stats {146 totals: StatsTotals147 by_region: StatsByRegion[]148 sync_log: SyncLogEntry[]149}150151// ---------------------------------------------------------------------------152// Extended stats (/api/stats/extended) — page /stats153// ---------------------------------------------------------------------------154155export interface ExtendedTotals {156 products: number157 products_priced: number158 stores_live: number159 stores_registry: number160 regions: number161 price_avg: number | null162 price_median: number | null163}164165export interface CategoryStat {166 key: string167 label: string168 products: number169 stores: number170 price_min: number | null171 price_avg: number | null172 price_max: number | null173 price_median: number | null174}175176export interface PriceBucket {177 bucket: string178 n: number179}180181export interface OriginStat {182 key: string183 stores: number184 products: number185}186187export interface PlatformStat {188 key: string189 stores: number190 products: number | null191}192193export interface RegionStat {194 key: string195 stores: number196 products: number197 price_avg: number | null198}199200export interface GrowthPoint {201 day: string202 n: number203}204205export interface TopStore {206 id: string207 name: string208 region: string | null209 origin_class: OriginClass210 logo_url: string | null211 products: number212}213214export interface ExpensiveProduct {215 uid: string216 title: string217 price: number218 store_name: string219}220221export interface NewestProduct {222 uid: string223 title: string224 price: number | null225 category: string | null226 category_label: string227 store_name: string228}229230export type AvailabilityKey = 'en stock' | 'rupture' | 'inconnu'231232export interface AvailabilityStat {233 key: AvailabilityKey234 n: number235}236237export interface Coverage {238 with_image: number239 with_desc: number240 with_logo: number241 with_region: number242}243244export interface ExtendedStats {245 totals: ExtendedTotals246 by_category: CategoryStat[]247 price_buckets: PriceBucket[]248 by_origin: OriginStat[]249 by_platform: PlatformStat[]250 by_region: RegionStat[]251 growth: GrowthPoint[]252 top_stores: TopStore[]253 most_expensive: ExpensiveProduct[]254 newest: NewestProduct[]255 availability: AvailabilityStat[]256 coverage: Coverage257}258259// ---------------------------------------------------------------------------260// Origin classes261// ---------------------------------------------------------------------------262263export type OriginClass = 'A' | 'B' | 'C' | 'D' | 'E'264265export const ORIGIN_LABELS: Record<OriginClass, string> = {266 A: 'Fabriqué au Québec',267 B: 'Conçu au Québec',268 C: 'Détaillant québécois',269 D: 'Mixte',270 E: 'À vérifier',271}272273export const ORIGIN_KEYS: OriginClass[] = ['A', 'B', 'C', 'D', 'E']274275// ---------------------------------------------------------------------------276// Query params for /api/products277// ---------------------------------------------------------------------------278279export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title'280281export interface ProductQuery {282 q?: string283 category?: string284 region?: string285 store?: string286 origin?: string287 price_min?: string | number288 price_max?: string | number289 sort?: SortKey | string290 page?: number291 per_page?: number292}293294// ---------------------------------------------------------------------------295// Fetch helpers296// ---------------------------------------------------------------------------297298async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {299 const res = await fetch(path, { signal })300 if (!res.ok) {301 throw new Error(`API ${res.status} — ${path}`)302 }303 return (await res.json()) as T304}305306function qs(params: Record<string, string | number | undefined>): string {307 const sp = new URLSearchParams()308 for (const [k, v] of Object.entries(params)) {309 if (v !== undefined && v !== null && `${v}`.length > 0) {310 sp.set(k, `${v}`)311 }312 }313 const s = sp.toString()314 return s ? `?${s}` : ''315}316317export function fetchProducts(318 query: ProductQuery = {},319 signal?: AbortSignal320): Promise<ProductsResponse> {321 return getJson<ProductsResponse>(322 `/api/products${qs({ ...query })}`,323 signal324 )325}326327export function fetchProduct(328 uid: string,329 signal?: AbortSignal330): Promise<ProductDetail> {331 return getJson<ProductDetail>(332 `/api/products/${encodeURIComponent(uid)}`,333 signal334 )335}336337export function fetchStores(338 params: { region?: string; q?: string; with_products?: boolean } = {},339 signal?: AbortSignal340): Promise<StoresResponse> {341 return getJson<StoresResponse>(342 `/api/stores${qs({343 region: params.region,344 q: params.q,345 with_products: params.with_products ? 'true' : undefined,346 })}`,347 signal348 )349}350351export function fetchStore(id: string, signal?: AbortSignal): Promise<StoreDetail> {352 return getJson<StoreDetail>(`/api/stores/${encodeURIComponent(id)}`, signal)353}354355export function fetchFacets(signal?: AbortSignal): Promise<Facets> {356 return getJson<Facets>('/api/facets', signal)357}358359export function fetchStats(signal?: AbortSignal): Promise<Stats> {360 return getJson<Stats>('/api/stats', signal)361}362363export function fetchExtendedStats(364 signal?: AbortSignal365): Promise<ExtendedStats> {366 return getJson<ExtendedStats>('/api/stats/extended', signal)367}368369// ---------------------------------------------------------------------------370// Formatting helpers (fr-CA)371// ---------------------------------------------------------------------------372373const cadFormatter = new Intl.NumberFormat('fr-CA', {374 style: 'currency',375 currency: 'CAD',376})377378export function formatPrice(value: number | null | undefined): string {379 if (value === null || value === undefined || Number.isNaN(value)) return ''380 return cadFormatter.format(value)381}382383const cadCompactFormatter = new Intl.NumberFormat('fr-CA', {384 style: 'currency',385 currency: 'CAD',386 maximumFractionDigits: 0,387})388389/** "1 250 $" — compact price without cents (ranges, chart labels). */390export function formatPriceCompact(value: number | null | undefined): string {391 if (value === null || value === undefined || Number.isNaN(value)) return ''392 return cadCompactFormatter.format(value)393}394395const intFormatter = new Intl.NumberFormat('fr-CA')396397export function formatInt(value: number | null | undefined): string {398 if (value === null || value === undefined || Number.isNaN(value)) return '0'399 return intFormatter.format(value)400}401402const relativeFormatter = new Intl.RelativeTimeFormat('fr-CA', {403 numeric: 'auto',404})405406/**407 * "il y a 3 jours" — accepts an epoch (seconds or ms) or an ISO string.408 * Returns null when the value is missing or unparseable.409 */410export function formatRelativeDate(411 value: number | string | null | undefined412): string | null {413 if (value === null || value === undefined || value === '') return null414 let ms: number415 if (typeof value === 'number') {416 ms = value > 1e12 ? value : value * 1000417 } else {418 const parsed = Date.parse(value)419 if (!Number.isNaN(parsed)) {420 ms = parsed421 } else {422 const n = Number(value)423 if (Number.isNaN(n)) return null424 ms = n > 1e12 ? n : n * 1000425 }426 }427 if (!Number.isFinite(ms) || ms <= 0) return null428 const diffSec = Math.round((ms - Date.now()) / 1000)429 const abs = Math.abs(diffSec)430 if (abs < 60) return relativeFormatter.format(diffSec, 'second')431 if (abs < 3600) return relativeFormatter.format(Math.round(diffSec / 60), 'minute')432 if (abs < 86400) return relativeFormatter.format(Math.round(diffSec / 3600), 'hour')433 if (abs < 86400 * 30) return relativeFormatter.format(Math.round(diffSec / 86400), 'day')434 if (abs < 86400 * 365) return relativeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month')435 return relativeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year')436}437438export function hostnameOf(url: string): string {439 try {440 return new URL(url).hostname.replace(/^www\./, '')441 } catch {442 return url443 }444}445