SPB Git

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%
10.3 KB · 445 lines typescript
Raw Blame History
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