// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + client API robuste (timeout, erreurs typées) // ----------------------------------------------------------------------------- export interface Room { nom?: string; niveau?: string; dimensions?: string; revetement?: string; } /** details : dictionnaire libre de caractéristiques Centris (libellé → valeur), * avec la clé spéciale `pieces` (liste des pièces + dimensions). */ export interface ListingDetails { pieces?: Room[]; price_from?: boolean; [key: string]: unknown; } export interface Listing { uid: string; source: string; external_id: string; url: string; title: string; address: string; sector: string; city: string; region: string; property_type: string; price: number | null; price_label: string; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null; mls: string; status: string; broker_name: string; broker_phone: string; description: string; features: string[]; details: ListingDetails; images: string[]; lat: number | null; lng: number | null; price_history?: { ts: number; price: number | null }[]; poi?: Poi[]; // commodités de proximité (fiche seulement) quartier?: Quartier | null; // stats de quartier (fiche seulement) vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix) first_seen?: number; last_seen?: number; updated_at?: number; active?: number; } export interface Facets { cities: string[]; sectors: string[]; property_types: string[]; sources: { source: string; n: number }[]; } export interface Source { id: string; name: string; url: string; listing_url?: string; coverage?: string; type?: string; connector?: string | null; status: string; active_listings: number; last_sync: number | null; } export interface Stats { total: number; sources: number; cities: number; avg_price: number | null; min_price: number | null; max_price: number | null; recent_syncs?: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string; }[]; } // --- Noms d'agences (jolis libellés) ---------------------------------------- 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 { if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; // repli lisible pour les sous-agences RE/MAX générées (remax_ag_xxx) if (id.startsWith("remax_ag_")) return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } async function get(path: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 25000); 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; region?: string; property_type?: string; source?: string; price_min?: string; price_max?: string; bedrooms_min?: string; bathrooms_min?: string; area_min?: string; q?: string; sort?: string; // price_asc | price_desc | recent } export function listingParams(f: ListingFilters): URLSearchParams { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return params; } export function fetchListings(f: ListingFilters, limit = 60, offset = 0) { const params = listingParams(f); params.set("limit", String(limit)); params.set("offset", String(offset)); return get<{ total: number; count: number; listings: Listing[] }>( `/api/listings?${params}`); } export const fetchListing = (uid: string) => get(`/api/listings/${encodeURIComponent(uid)}`); export const fetchFacets = (city?: string) => get(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); export interface SubAgency { name: string; count: number; sources: string[] } export interface Franchise { franchise: string; total: number; sub_agencies: number; agencies: SubAgency[]; } export const fetchAgencies = () => get<{ franchises: Franchise[] }>("/api/agencies"); // --- Formatage -------------------------------------------------------------- export const fmtPrice = (p: number | null, label?: string) => p != null ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $" : label || "Prix sur demande"; export const fmtArea = (a: number | null): string | null => a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null; export const fmtDate = (ts: number): string => new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric", }); // --- Vrai-Prix : estimation de valeur marchande ------------------------------ export interface VraiPrix { id: string; value: number | null; low: number | null; high: number | null; confidence_pct: number | null; confidence: string | null; // A | B | C | D url: string; // page d'analyse détaillée } // --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ---- export interface Poi { cat: string; name: string; dist_m: 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; defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; chaleur?: { classe: number; ecart: number | null }; 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 }; }