SPB Git

Repository Public

api.ts 20.0 KB 795 lines typescript

1// ---------------------------------------------------------------------------2// Fabri-Ka — typed API client3// ---------------------------------------------------------------------------45/** Variante d'un produit (extraite par les connecteurs, structure libre). */6export interface ProductVariant {7  title?: string8  price?: number | null9  compare_at_price?: number | null10  sku?: string11  grams?: number | null12  image?: string13  available?: boolean | null14}1516/** Option de sélection (deux formes selon la plateforme source). */17export interface ProductOption {18  name?: string19  title?: string20  values?: string[]21  selections?: string[]22}2324/** Fiche détaillée additionnelle (Wix : matériaux, entretien…). */25export interface AdditionalInfo {26  title: string27  description: string28}2930/** Champ `details` JSON libre alimenté par les connecteurs. */31export interface ProductDetails {32  variants?: ProductVariant[]33  options?: ProductOption[]34  sku?: string35  gtin?: string36  brand?: string37  weight?: number | string38  weight_grams?: number39  formatted_weight?: string40  dimensions?: { length?: string; width?: string; height?: string }41  formatted_dimensions?: string42  attributes?: { name: string; terms: string[] }[]43  average_rating?: number44  review_count?: number45  inventory_quantity?: number46  low_stock_remaining?: number47  is_on_backorder?: boolean48  on_sale?: boolean49  variations?: number50  additional_info?: AdditionalInfo[]51  categories?: string[]52  model?: string53  availability_text?: string54  published_at?: string55  created_at?: string56  updated_at?: string57}5859export interface Product {60  /** personnalisation KA ID (fabrika/kaid.py) : « Recommandé pour vous » */61  ka_reco?: { score: number; reasons: string[] } | null;62  uid: string63  store_id: string64  url: string65  title: string66  description: string | null67  price: number | null68  price_max: number | null69  compare_at_price: number | null70  currency: string71  images: string[]72  category: string | null73  product_type: string | null74  tags: string[]75  vendor: string | null76  available: boolean77  price_on_request?: number78  listing_status?: string79  details?: ProductDetails | null80  store_name: string81  store_region: string | null82  store_city: string | null83  origin_class: OriginClass84  store_kind?: string | null85}8687export interface ProductDetail extends Product {88  store_url: string89  related: Product[]90}9192export interface ProductsResponse {93  total: number94  page: number95  per_page: number96  items: Product[]97}9899export interface Certification {100  label: string101  detail?: string102  certifier?: string | null103  since?: string | null104  operations?: string[]105  source?: string106}107108export interface Store {109  id: string110  name: string111  url: string112  platform: string | null113  city: string | null114  region: string | null115  origin_class: OriginClass116  categories: string[]117  product_count: number118  last_sync: number | string | null119  last_status: string | null120  logo_url: string | null121  cover_url: string | null122  description_meta: string | null123  store_kind?: string | null124  email?: string | null125  phone?: string | null126  lat?: number | null127  lng?: number | null128  shipping_info?: string | null129  certifications?: Certification[]130  presumed_closed?: number131}132133export const STORE_KIND_LABELS: Record<string, string> = {134  revendeur: 'Revendeur',135  collectif: "Collectif d'artisans",136  hors_mission: 'Hors mission',137}138139export interface StoreProductStats {140  n: number141  price_min: number | null142  price_max: number | null143  price_avg: number | null144}145146export interface StoreCategoryCount {147  key: string | null148  n: number149  label: string150}151152export interface SimilarStore {153  id: string154  name: string155  region: string | null156  origin_class: OriginClass157  logo_url: string | null158  product_count: number159}160161export interface StoreDetail extends Store {162  origin_evidence?: string | null163  discovery_sources?: string[]164  socials?: Record<string, string> | string[]165  product_stats: StoreProductStats166  category_breakdown: StoreCategoryCount[]167  similar: SimilarStore[]168}169170export interface StoresResponse {171  total: number172  items: Store[]173}174175export interface FacetCategory {176  key: string177  label: string178  n: number179}180181export interface FacetRegion {182  key: string183  n: number184}185186export interface FacetOrigin {187  key: string188  n: number189}190191export interface FacetStore {192  key: string193  name: string194  n: number195}196197export interface Facets {198  categories: FacetCategory[]199  regions: FacetRegion[]200  origins: FacetOrigin[]201  stores: FacetStore[]202  all_regions: string[]203  /** taxonomie complète (formulaire « Inscrire mon atelier ») */204  all_categories?: { key: string; label: string }[]205}206207export interface StatsTotals {208  products: number209  stores_live: number210  stores_registry: number211  regions: number212}213214export interface StatsByRegion {215  region: string216  stores: number217  products: number218}219220export interface SyncLogEntry {221  ts: string222  store_id: string223  found: number224  added: number225  updated: number226  removed: number227  status: string228}229230export interface Stats {231  totals: StatsTotals232  by_region: StatsByRegion[]233  sync_log: SyncLogEntry[]234}235236// ---------------------------------------------------------------------------237// Extended stats (/api/stats/extended) — page /stats238// ---------------------------------------------------------------------------239240export interface ExtendedTotals {241  products: number242  products_priced: number243  stores_live: number244  stores_registry: number245  regions: number246  price_avg: number | null247  price_median: number | null248}249250export interface CategoryStat {251  key: string252  label: string253  products: number254  stores: number255  price_min: number | null256  price_avg: number | null257  price_max: number | null258  price_median: number | null259}260261export interface PriceBucket {262  bucket: string263  n: number264}265266export interface OriginStat {267  key: string268  stores: number269  products: number270}271272export interface PlatformStat {273  key: string274  stores: number275  products: number | null276}277278export interface RegionStat {279  key: string280  stores: number281  products: number282  price_avg: number | null283}284285export interface GrowthPoint {286  day: string287  n: number288}289290export interface TopStore {291  id: string292  name: string293  region: string | null294  origin_class: OriginClass295  logo_url: string | null296  products: number297}298299export interface ExpensiveProduct {300  uid: string301  title: string302  price: number303  store_name: string304}305306export interface NewestProduct {307  uid: string308  title: string309  price: number | null310  category: string | null311  category_label: string312  store_name: string313}314315export type AvailabilityKey = 'en stock' | 'rupture' | 'inconnu'316317export interface AvailabilityStat {318  key: AvailabilityKey319  n: number320}321322export interface Coverage {323  with_image: number324  with_desc: number325  with_logo: number326  with_region: number327}328329export interface ExtendedStats {330  totals: ExtendedTotals331  by_category: CategoryStat[]332  price_buckets: PriceBucket[]333  by_origin: OriginStat[]334  by_platform: PlatformStat[]335  by_region: RegionStat[]336  growth: GrowthPoint[]337  top_stores: TopStore[]338  most_expensive: ExpensiveProduct[]339  newest: NewestProduct[]340  availability: AvailabilityStat[]341  coverage: Coverage342}343344// ---------------------------------------------------------------------------345// Compte — connexion « KA ID » (SSO Groupe KA)346// ---------------------------------------------------------------------------347348/** Réseaux sociaux du profil Groupe KA (pseudos ou URL, tels que saisis au hub) */349export interface Socials {350  instagram?: string351  facebook?: string352  x?: string353  linkedin?: string354  tiktok?: string355  youtube?: string356}357358export interface Me {359  ka_id: string360  email: string361  name: string362  picture: string363  provider: string364  created?: number | null365  last_login?: number | null366  // ——— profil Groupe KA (source de vérité : groupe-ka.com) ———367  profile_source?: 'groupe-ka' | 'local'368  bio?: string369  city?: string370  job_title?: string371  company?: string372  age?: number | null373  website?: string374  socials?: Socials375  role_label?: string376  public?: boolean377  public_url?: string378}379380export interface MeResponse {381  user: Me | null382  enabled: boolean383}384385export function fetchMe(signal?: AbortSignal): Promise<MeResponse> {386  return getJson<MeResponse>('/api/auth/me', signal)387}388389export async function logout(): Promise<void> {390  await fetch('/api/auth/logout', { method: 'POST' })391}392393// ---------------------------------------------------------------------------394// Favoris — « Mon univers Ka » (magasin central du hub Groupe KA)395// Aucun stockage local : GET /api/favorites lit le hub, POST …/toggle y pousse.396// ---------------------------------------------------------------------------397398export interface FavoriteItem {399  item_id: string400  title: string401  subtitle?: string402  price_label?: string403  image_url?: string404  url?: string405}406407export interface FavoritesResponse {408  ids: string[]409  items: FavoriteItem[]410}411412/** Favoris du membre connecté — `null` si non connecté (401). */413export async function fetchFavorites(414  signal?: AbortSignal415): Promise<FavoritesResponse | null> {416  const res = await fetch('/api/favorites', { signal })417  if (res.status === 401) return null418  if (!res.ok) throw new Error(`API ${res.status} — /api/favorites`)419  return (await res.json()) as FavoritesResponse420}421422/** ♥ on/off poussé au hub Groupe KA. Résout `true` si le hub a enregistré. */423export async function toggleFavorite(424  on: boolean,425  item: FavoriteItem426): Promise<boolean> {427  const res = await fetch('/api/favorites/toggle', {428    method: 'POST',429    headers: { 'Content-Type': 'application/json' },430    body: JSON.stringify({ on, item }),431  })432  if (!res.ok) return false433  const data = (await res.json()) as { ok?: boolean }434  return data.ok === true435}436437/** Produit -> item de favori pour le hub (URL canonique de la fiche produit). */438export function productFavItem(p: Product): FavoriteItem {439  return {440    item_id: p.uid,441    title: p.title,442    subtitle: [p.store_name, p.store_region || p.category]443      .filter(Boolean)444      .join(' · '),445    price_label:446      p.price !== null447        ? formatPrice(p.price, p.currency)448        : p.price_on_request449          ? 'Sur devis'450          : '',451    image_url: p.images.length > 0 ? p.images[0] : '',452    url: `https://www.fabri-ka.com${productPath(p)}`,453  }454}455456// ---------------------------------------------------------------------------457// SEO — slugs et URLs canoniques (doit rester aligné avec fabrika/seo.py)458// ---------------------------------------------------------------------------459460export const REGIONS_QC = [461  'Bas-Saint-Laurent',462  'Saguenay–Lac-Saint-Jean',463  'Capitale-Nationale',464  'Mauricie',465  'Estrie',466  'Montréal',467  'Outaouais',468  'Abitibi-Témiscamingue',469  'Côte-Nord',470  'Nord-du-Québec',471  'Gaspésie–Îles-de-la-Madeleine',472  'Chaudière-Appalaches',473  'Laval',474  'Lanaudière',475  'Laurentides',476  'Montérégie',477  'Centre-du-Québec',478]479480export const CATEGORY_LABELS: Record<string, string> = {481  erable: 'Érable & miel',482  epicerie: 'Épicerie fine',483  cafe_the: 'Café & thé',484  chocolat: 'Chocolat & confiseries',485  boulangerie: 'Boulangerie & pâtisserie',486  viande_poisson: 'Viandes & poissons',487  fromage_laitier: 'Fromages & produits laitiers',488  boissons: 'Boissons',489  alcool: 'Bières, vins & spiritueux',490  sante_beaute: 'Beauté & soins',491  maison: 'Maison & déco',492  mode: 'Mode & accessoires',493  bijoux: 'Bijoux',494  art: 'Art & artisanat',495  enfants: 'Enfants & bébés',496  animaux: 'Animaux',497  plein_air: 'Plein air & sport',498  jardin: 'Jardin & agriculture',499  industriel: 'Industriel & pro',500  autre: 'Autres',501}502503export function slugify(text: string): string {504  return text505    .normalize('NFKD')506    .replace(/[\u0300-\u036f]/g, '')507    .toLowerCase()508    .replace(/[^a-z0-9]+/g, '-')509    .replace(/^-+|-+$/g, '')510}511512/** URL canonique d'une fiche produit : /produits/<slug>-<uid>. */513export function productPath(p: { uid: string; title: string }): string {514  const slug = slugify(p.title).slice(0, 70).replace(/-+$/, '')515  return slug ? `/produits/${slug}-${p.uid}` : `/produits/${p.uid}`516}517518export function categorySlug(key: string): string {519  return slugify(key)520}521522export function categoryFromSlug(slug: string): string {523  return slug.replace(/-/g, '_')524}525526export function regionSlug(region: string): string {527  return slugify(region)528}529530export function regionFromSlug(slug: string): string | null {531  return REGIONS_QC.find((r) => slugify(r) === slug) ?? null532}533534/** Met à jour title / meta description / canonical lors des navigations SPA. */535export function setPageMeta(title: string, description?: string): void {536  document.title = title537  if (description) {538    document539      .querySelector('meta[name="description"]')540      ?.setAttribute('content', description)541  }542  document543    .querySelector('link[rel="canonical"]')544    ?.setAttribute('href', window.location.origin + window.location.pathname)545}546547// ---------------------------------------------------------------------------548// Origin classes549// ---------------------------------------------------------------------------550551export type OriginClass = 'A' | 'B' | 'C' | 'D' | 'E'552553export const ORIGIN_LABELS: Record<OriginClass, string> = {554  A: 'Fabriqué au Québec',555  B: 'Conçu au Québec',556  C: 'Détaillant québécois',557  D: 'Mixte',558  E: 'À vérifier',559}560561export const ORIGIN_KEYS: OriginClass[] = ['A', 'B', 'C', 'D', 'E']562563// ---------------------------------------------------------------------------564// Query params for /api/products565// ---------------------------------------------------------------------------566567export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title' | 'discount'568569export interface ProductQuery {570  q?: string571  category?: string572  region?: string573  store?: string574  origin?: string575  price_min?: string | number576  price_max?: string | number577  kind?: string578  on_sale?: string | number579  sort?: SortKey | string580  page?: number581  per_page?: number582}583584// ---------------------------------------------------------------------------585// Fetch helpers586// ---------------------------------------------------------------------------587588async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {589  const res = await fetch(path, { signal })590  if (!res.ok) {591    throw new Error(`API ${res.status} — ${path}`)592  }593  return (await res.json()) as T594}595596function qs(params: Record<string, string | number | undefined>): string {597  const sp = new URLSearchParams()598  for (const [k, v] of Object.entries(params)) {599    if (v !== undefined && v !== null && `${v}`.length > 0) {600      sp.set(k, `${v}`)601    }602  }603  const s = sp.toString()604  return s ? `?${s}` : ''605}606607export function fetchProducts(608  query: ProductQuery = {},609  signal?: AbortSignal610): Promise<ProductsResponse> {611  return getJson<ProductsResponse>(612    `/api/products${qs({ ...query })}`,613    signal614  )615}616617export function fetchProduct(618  uid: string,619  signal?: AbortSignal620): Promise<ProductDetail> {621  return getJson<ProductDetail>(622    `/api/products/${encodeURIComponent(uid)}`,623    signal624  )625}626627export function fetchStores(628  params: { region?: string; q?: string; with_products?: boolean } = {},629  signal?: AbortSignal630): Promise<StoresResponse> {631  return getJson<StoresResponse>(632    `/api/stores${qs({633      region: params.region,634      q: params.q,635      with_products: params.with_products ? 'true' : undefined,636    })}`,637    signal638  )639}640641export function fetchStore(id: string, signal?: AbortSignal): Promise<StoreDetail> {642  return getJson<StoreDetail>(`/api/stores/${encodeURIComponent(id)}`, signal)643}644645export function fetchFacets(signal?: AbortSignal): Promise<Facets> {646  return getJson<Facets>('/api/facets', signal)647}648649export function fetchStats(signal?: AbortSignal): Promise<Stats> {650  return getJson<Stats>('/api/stats', signal)651}652653export function fetchExtendedStats(654  signal?: AbortSignal655): Promise<ExtendedStats> {656  return getJson<ExtendedStats>('/api/stats/extended', signal)657}658659// ---------------------------------------------------------------------------660// Auto-inscription — « Inscrire mon atelier » (quarantaine de validation)661// ---------------------------------------------------------------------------662663export interface InscriptionPayload {664  name: string665  metier: string666  description: string667  address?: string668  website?: string669  email?: string670  phone?: string671  socials?: string[]672  photos?: string[]673  /** honeypot anti-robot — TOUJOURS laisser vide */674  entreprise_url?: string675}676677export interface InscriptionResponse {678  status: string679  id: number680  message?: string681}682683export async function submitInscription(684  payload: InscriptionPayload685): Promise<InscriptionResponse> {686  const res = await fetch('/api/inscriptions', {687    method: 'POST',688    headers: { 'Content-Type': 'application/json' },689    body: JSON.stringify(payload),690  })691  if (!res.ok) {692    let detail = `Erreur ${res.status}`693    try {694      const data = (await res.json()) as { detail?: string }695      if (data.detail) detail = data.detail696    } catch {697      /* réponse non-JSON */698    }699    throw new Error(detail)700  }701  return (await res.json()) as InscriptionResponse702}703704// ---------------------------------------------------------------------------705// Formatting helpers (fr-CA)706// ---------------------------------------------------------------------------707708const priceFormatters = new Map<string, Intl.NumberFormat>()709710function formatterFor(currency: string): Intl.NumberFormat {711  let f = priceFormatters.get(currency)712  if (!f) {713    f = new Intl.NumberFormat('fr-CA', { style: 'currency', currency })714    priceFormatters.set(currency, f)715  }716  return f717}718719/** Prix formaté dans la devise SOURCE ("24,95 $", "27,50 $ US", "12,00 €"). */720export function formatPrice(721  value: number | null | undefined,722  currency: string = 'CAD'723): string {724  if (value === null || value === undefined || Number.isNaN(value)) return ''725  const cur = currency && currency.length === 3 ? currency.toUpperCase() : 'CAD'726  try {727    return formatterFor(cur).format(value)728  } catch {729    return formatterFor('CAD').format(value)730  }731}732733const cadCompactFormatter = new Intl.NumberFormat('fr-CA', {734  style: 'currency',735  currency: 'CAD',736  maximumFractionDigits: 0,737})738739/** "1 250 $" — compact price without cents (ranges, chart labels). */740export function formatPriceCompact(value: number | null | undefined): string {741  if (value === null || value === undefined || Number.isNaN(value)) return ''742  return cadCompactFormatter.format(value)743}744745const intFormatter = new Intl.NumberFormat('fr-CA')746747export function formatInt(value: number | null | undefined): string {748  if (value === null || value === undefined || Number.isNaN(value)) return '0'749  return intFormatter.format(value)750}751752const relativeFormatter = new Intl.RelativeTimeFormat('fr-CA', {753  numeric: 'auto',754})755756/**757 * "il y a 3 jours" — accepts an epoch (seconds or ms) or an ISO string.758 * Returns null when the value is missing or unparseable.759 */760export function formatRelativeDate(761  value: number | string | null | undefined762): string | null {763  if (value === null || value === undefined || value === '') return null764  let ms: number765  if (typeof value === 'number') {766    ms = value > 1e12 ? value : value * 1000767  } else {768    const parsed = Date.parse(value)769    if (!Number.isNaN(parsed)) {770      ms = parsed771    } else {772      const n = Number(value)773      if (Number.isNaN(n)) return null774      ms = n > 1e12 ? n : n * 1000775    }776  }777  if (!Number.isFinite(ms) || ms <= 0) return null778  const diffSec = Math.round((ms - Date.now()) / 1000)779  const abs = Math.abs(diffSec)780  if (abs < 60) return relativeFormatter.format(diffSec, 'second')781  if (abs < 3600) return relativeFormatter.format(Math.round(diffSec / 60), 'minute')782  if (abs < 86400) return relativeFormatter.format(Math.round(diffSec / 3600), 'hour')783  if (abs < 86400 * 30) return relativeFormatter.format(Math.round(diffSec / 86400), 'day')784  if (abs < 86400 * 365) return relativeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month')785  return relativeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year')786}787788export function hostnameOf(url: string): string {789  try {790    return new URL(url).hostname.replace(/^www\./, '')791  } catch {792    return url793  }794}795
Files f7bcd6b52df598930c0b8a2e6d54cd32c1426026
Menu

Theme