// ----------------------------------------------------------------------------- // Forma-Ka — Agrégateur de formations (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + client API robuste (timeout, erreurs typées) // ----------------------------------------------------------------------------- export interface FormationDetails { uec?: number; credits?: number; level?: string; duration_hours?: number; price_from?: boolean; modes_offerts?: string[]; [k: string]: unknown; } export interface Formation { uid: string; source: string; external_id: string; url: string; title: string; training_type: string; // Cours universitaire, Séminaire, Atelier… category: string; // domaine : Informatique, Gestion, RH… mode: string; // en ligne | présentiel | hybride | asynchrone city: string; language: string; // fr | en | fr/en price: number | null; // null = non affiché (normal pour l'universitaire) price_label: string; is_free: boolean | null; duration: string; duration_hours: number | null; start_date: string | null; // ISO schedule_label: string; sessions: string[]; // toutes les dates offertes (ISO) level: string; credits: string; // « 3 crédits », « 1,4 UEC » credential: string; instructor: string; code: string; // sigle (ex. « GSF-1020 ») description: string; objectives: string[]; prerequisites: string; audience: string; program: string[]; // plan / contenu tags: string[]; details: FormationDetails; images: string[]; price_history?: { ts: number; price: number | null }[]; similar?: { uid: string; title: string; training_type: string; source: string; price: number | null; mode: string; duration: string; }[]; first_seen?: number; last_seen: number; updated_at: number; active: number; } export interface Facets { types: { t: string; n: number }[]; categories: { category: string; n: number }[]; modes: string[]; cities: string[]; languages: string[]; levels: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; listing_url: string; type_offre: string; connector: string | null; status: string; region: string; active_formations: number; last_sync: number | null; } export interface Stats { total: number; gratuites: number; en_ligne: number; universitaires: number; sources: number; avg_price: number | null; avg_hours: number | null; par_type: { t: string; n: number }[]; recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string; }[]; } const SOURCE_NAMES: Record = {}; export function registerSourceNames(sources: Source[]) { for (const s of sources) SOURCE_NAMES[s.id] = s.name; } export function sourceName(id: string): string { return SOURCE_NAMES[id] ?? id; } async function get(path: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 20000); try { const res = await fetch(path, { signal: ctrl.signal }); if (!res.ok) throw new Error(`API ${res.status} — ${path}`); return (await res.json()) as T; } finally { clearTimeout(timer); } } export interface FormationFilters { training_type?: string; category?: string; mode?: string; city?: string; language?: string; level?: string; source?: string; free?: string; // "1" = gratuites price_max?: string; credential?: string; starts_after?: string; // ISO q?: string; sort?: string; // recent | price | title | start limit?: string; // taille de page offset?: string; // décalage (pagination) } export function fetchFormations(f: FormationFilters) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return get<{ total: number; formations: Formation[] }>(`/api/formations?${params}`); } export const fetchFormation = (uid: string) => get(`/api/formations/${encodeURIComponent(uid)}`); export const fetchFacets = (trainingType?: string) => get(`/api/facets${trainingType ? `?training_type=${encodeURIComponent(trainingType)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); /** null -> « Prix non affiché » (fréquent : cours universitaires) */ export const fmtPrice = (p: number | null, label?: string) => { if (p === 0) return "Gratuit"; if (p != null) return p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"; return label || "Prix non affiché"; }; /** "2026-12-01" -> « 1ᵉʳ décembre 2026 » */ export function fmtDate(iso: string | null): string | null { if (!iso) return null; const [y, m, d] = iso.split("-").map(Number); if (!y || !m || !d) return null; const txt = new Date(y, m - 1, d).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric", }); return txt.replace(/^1 /, "1ᵉʳ "); } /** 14 -> « 14 h », 3.5 -> « 3,5 h » */ export const fmtHours = (h: number | null): string | null => h == null ? null : `${h.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} h`; export const MODE_ICONS: Record = { "en ligne": "💻", "présentiel": "🏛", "hybride": "🔀", "asynchrone": "🕓", };