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%
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