// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + client API robuste (timeout, erreurs typées) // ----------------------------------------------------------------------------- export interface ListingDetails { inclusions?: Record; appliances?: Record; parking?: { available?: boolean; type?: string; included?: boolean; price?: number }; contact?: { phone?: string; email?: string }; ac?: boolean; elevator?: boolean; balcony?: boolean; pool?: boolean; gym?: boolean; laundry?: boolean; storage?: boolean; smoking?: boolean; floor?: number; price_from?: boolean; } export interface Poi { cat: string; // epicerie, pharmacie, ecole, parc, bus… name: string; dist_m: number; } export interface Digest { version: number; texte_nettoye: string; en_bref: string | null; sections: { titre: string; texte: string }[]; faits: { prix_mensuel: number | null; date_disponibilite: string | null; duree_bail_minimale_mois: number | null; nb_occupants_total: number | null; salle_de_bain: "commune" | "privee" | null; cuisine: "commune" | "privee" | null; electromenagers: string[]; inclusions: string[]; contraintes: string[]; depot_mentionne: string | null; quartier_mentionne: string | null; }; confiance: Record; incoherences: string[]; completude: number; } export interface Quartier { dauid?: string | null; demographie?: { population: number | null; densite: number | null; age_median: number | null; revenu_median: number | null; pct_locataires: number | null; loyer_moyen: number | null; pct_francais: number | null; pct_univ: number | null; }; proximite?: Record; // scores 0..1 (PMD StatCan) defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; chaleur?: { classe: number; ecart: number | null }; // 1 fraîcheur … 9 chaleur crime?: | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; } export interface Listing { uid: string; source: string; external_id: string; url: string; title: string; address: string; sector: string; city: string; unit_type: string; price: number | null; price_label: string; availability: string; availability_date: string | null; // ISO "2026-07-01" ou "now" area_sqft: number | null; pets: string | null; // "oui" | "non" | "conditions" furnished: boolean | null; description: string; amenities: string[]; details: ListingDetails; images: string[]; lat: number | null; lng: number | null; poi?: Poi[]; // commodités de proximité (fiche seulement) quartier?: Quartier | null; // stats de quartier (fiche seulement) digest?: Digest | null; // description structurée (fiche seulement) price_history?: { ts: number; price: number | null }[]; first_seen?: number; last_seen: number; updated_at: number; active: number; } /** 250 -> « 250 m », 1240 -> « 1,2 km » */ export const fmtDist = (m: number): string => m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; export interface Facets { cities: string[]; sectors: string[]; unit_types: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; listing_url: string; sectors: string; connector: string | null; status: string; active_listings: number; last_sync: number | null; } export interface Stats { total: number; quebec: number; levis: number; montreal: number; autres: number; // reste de la province (Outaouais, Estrie, Mauricie…) sources: number; avg_price: number | null; } 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 ListingFilters { city?: string; sector?: string; unit_type?: string; source?: string; price_min?: string; price_max?: string; pets?: string; // "oui" -> acceptés (oui OU conditions) furnished?: string; // "1" | "0" available_by?: string; // ISO : dispo maintenant ou avant cette date area_min?: string; // superficie minimale (pi²) q?: string; } export function fetchListings(f: ListingFilters) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`); } export interface GroupStat { key: string; count: number; sources?: number; avg_price: number | null; min_price: number | null; } export interface DetailedStats { totals: { total: number; with_price: number; avg: number | null; median: number | null; min: number | null; max: number | null; sources: number; cities: number; regions: number; gps_pct: number | null; superficie_moyenne: number | null; dispo_now: number; }; histogram: { lo: number; hi: number | null; count: number }[]; by_type: GroupStat[]; by_city: GroupStat[]; by_source: GroupStat[]; by_region: GroupStat[]; offre: { furnished_pct: number | null; pets_oui_pct: number | null; pets_connu: number; chauffage_pct: number | null; electricite_pct: number | null; eau_chaude_pct: number | null; internet_pct: number | null; clim_pct: number | null; stationnement_pct: number | null; balcon_pct: number | null; dispo_now: number; dispo_date: number; dispo_inconnue: number; superficie_moyenne: number | null; superficie_connue: number; prix_pi2: { key: string; count: number; val: number }[]; }; baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[]; sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] }; } export const fetchDetailedStats = () => get("/api/stats/detailed"); export const fetchListing = (uid: string) => get(`/api/listings/${encodeURIComponent(uid)}`); export const fetchFacets = (city?: string) => get(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`); /** Date ISO à +n jours (pour « dispo d'ici 1 mois », etc.) */ export function isoInDays(n: number): string { const d = new Date(); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); } export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); export const fmtPrice = (p: number | null, label?: string) => p != null ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $" : label || "Prix sur demande"; /** "now" -> « Maintenant », "2026-12-01" -> « 1ᵉʳ décembre 2026 » */ export function fmtAvailability(iso: string | null): string | null { if (!iso) return null; if (iso === "now") return "Maintenant"; 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ᵉʳ "); }