SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
10.6 KB · 448 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// ---------------------------------------------------------------------------297298/** App base path without trailing slash (e.g. '/fabri') — prefix for all API calls. */299export const API_BASE = import.meta.env.BASE_URL.replace(/\/$/, '')300301async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {302  const res = await fetch(path, { signal })303  if (!res.ok) {304    throw new Error(`API ${res.status} — ${path}`)305  }306  return (await res.json()) as T307}308309function qs(params: Record<string, string | number | undefined>): string {310  const sp = new URLSearchParams()311  for (const [k, v] of Object.entries(params)) {312    if (v !== undefined && v !== null && `${v}`.length > 0) {313      sp.set(k, `${v}`)314    }315  }316  const s = sp.toString()317  return s ? `?${s}` : ''318}319320export function fetchProducts(321  query: ProductQuery = {},322  signal?: AbortSignal323): Promise<ProductsResponse> {324  return getJson<ProductsResponse>(325    `${API_BASE}/api/products${qs({ ...query })}`,326    signal327  )328}329330export function fetchProduct(331  uid: string,332  signal?: AbortSignal333): Promise<ProductDetail> {334  return getJson<ProductDetail>(335    `${API_BASE}/api/products/${encodeURIComponent(uid)}`,336    signal337  )338}339340export function fetchStores(341  params: { region?: string; q?: string; with_products?: boolean } = {},342  signal?: AbortSignal343): Promise<StoresResponse> {344  return getJson<StoresResponse>(345    `${API_BASE}/api/stores${qs({346      region: params.region,347      q: params.q,348      with_products: params.with_products ? 'true' : undefined,349    })}`,350    signal351  )352}353354export function fetchStore(id: string, signal?: AbortSignal): Promise<StoreDetail> {355  return getJson<StoreDetail>(`${API_BASE}/api/stores/${encodeURIComponent(id)}`, signal)356}357358export function fetchFacets(signal?: AbortSignal): Promise<Facets> {359  return getJson<Facets>(`${API_BASE}/api/facets`, signal)360}361362export function fetchStats(signal?: AbortSignal): Promise<Stats> {363  return getJson<Stats>(`${API_BASE}/api/stats`, signal)364}365366export function fetchExtendedStats(367  signal?: AbortSignal368): Promise<ExtendedStats> {369  return getJson<ExtendedStats>(`${API_BASE}/api/stats/extended`, signal)370}371372// ---------------------------------------------------------------------------373// Formatting helpers (fr-CA)374// ---------------------------------------------------------------------------375376const cadFormatter = new Intl.NumberFormat('fr-CA', {377  style: 'currency',378  currency: 'CAD',379})380381export function formatPrice(value: number | null | undefined): string {382  if (value === null || value === undefined || Number.isNaN(value)) return ''383  return cadFormatter.format(value)384}385386const cadCompactFormatter = new Intl.NumberFormat('fr-CA', {387  style: 'currency',388  currency: 'CAD',389  maximumFractionDigits: 0,390})391392/** "1 250 $" — compact price without cents (ranges, chart labels). */393export function formatPriceCompact(value: number | null | undefined): string {394  if (value === null || value === undefined || Number.isNaN(value)) return ''395  return cadCompactFormatter.format(value)396}397398const intFormatter = new Intl.NumberFormat('fr-CA')399400export function formatInt(value: number | null | undefined): string {401  if (value === null || value === undefined || Number.isNaN(value)) return '0'402  return intFormatter.format(value)403}404405const relativeFormatter = new Intl.RelativeTimeFormat('fr-CA', {406  numeric: 'auto',407})408409/**410 * "il y a 3 jours" — accepts an epoch (seconds or ms) or an ISO string.411 * Returns null when the value is missing or unparseable.412 */413export function formatRelativeDate(414  value: number | string | null | undefined415): string | null {416  if (value === null || value === undefined || value === '') return null417  let ms: number418  if (typeof value === 'number') {419    ms = value > 1e12 ? value : value * 1000420  } else {421    const parsed = Date.parse(value)422    if (!Number.isNaN(parsed)) {423      ms = parsed424    } else {425      const n = Number(value)426      if (Number.isNaN(n)) return null427      ms = n > 1e12 ? n : n * 1000428    }429  }430  if (!Number.isFinite(ms) || ms <= 0) return null431  const diffSec = Math.round((ms - Date.now()) / 1000)432  const abs = Math.abs(diffSec)433  if (abs < 60) return relativeFormatter.format(diffSec, 'second')434  if (abs < 3600) return relativeFormatter.format(Math.round(diffSec / 60), 'minute')435  if (abs < 86400) return relativeFormatter.format(Math.round(diffSec / 3600), 'hour')436  if (abs < 86400 * 30) return relativeFormatter.format(Math.round(diffSec / 86400), 'day')437  if (abs < 86400 * 365) return relativeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month')438  return relativeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year')439}440441export function hostnameOf(url: string): string {442  try {443    return new URL(url).hostname.replace(/^www\./, '')444  } catch {445    return url446  }447}448