// ----------------------------------------------------------------------------- // Rent-Ka — Rental listings aggregator (Canada, outside Québec) // Author: Simon-Pierre Boucher — contact@spboucher.ai // api.ts: types + robust API client (timeout, typed errors) // ----------------------------------------------------------------------------- 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; // grocery, pharmacy, school, park, 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 Listing { /** KA ID personalization: set by the server (rentka/kaid.py) when the personal score is clear — shows "Recommended for you" */ ka_reco?: { score: number; reasons: string[] } | null; uid: string; source: string; external_id: string; url: string; title: string; address: string; sector: string; city: string; province?: string | null; unit_type: string; price: number | null; price_label: string; availability: string; availability_date: string | null; // ISO "2026-07-01" or "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; bedrooms: number | null; poi?: Poi[]; // nearby amenities (detail page only) digest?: Digest | null; // structured description (detail page only) price_history?: { ts: number; price: number | null }[]; first_seen?: number; last_seen: number; updated_at: number; active: number; // fair rental value — rentka/fairvalue.py fv?: number | null; // estimated value ($/month) fv_low?: number | null; // low bound fv_high?: number | null; // high bound fv_deviation?: number | null; // (price - fv) / fv fv_verdict?: "sous" | "marche" | "sur" | null; fv_confidence?: "fort" | "moyen" | "faible" | null; // KA Scores (0-100, null = insufficient data / not served) ks_walk?: number | null; ks_transit?: number | null; ks_bike?: number | null; ks_calme?: number | null; ks_services?: number | null; ks_global?: number | null; kascores?: KaScores | null; // full detail (detail page only) // rental intelligence file (detail page only) building_key?: string | null; immeuble?: Immeuble | null; // building passport (precomputed) hiver?: Hiver | null; // winter daily-life score (0-100) } // --- KA Scores — in-house score family (rentka/kascores.py) ----------------- export interface KaScores { walk: number | null; transit: number | null; bike: number | null; calme: number | null; services: number | null; global: number | null; details: { walk?: { cats?: { cat: string; dist_m: number | null; pts: number }[]; bonus_choix?: number; raison?: string }; transit?: { arret_bus_m?: number | null; station_metro_m?: number | null; pmd_percentile?: number | null; raison?: string }; bike?: { km_cyclables_1km?: number; note?: string; raison?: string }; calme?: { sources_bruit?: { source: string; dist_m: number | null; pen: number }[]; bonus_parc?: number; note?: string }; services?: { familles?: Record; raison?: string }; labels?: Record; }; version: string; computed_at: number; } export interface KaScoresStats { annonces: number; avec_score: number; couverture_pct: number; version: string; moyennes: Record; } export const fetchKaScoresStats = () => get("/api/kascores/stats"); /** Label for a KA Score (same thresholds as rentka/kascores.py). */ export function kaLabel(score: number | null | undefined): string | null { if (score == null) return null; if (score >= 85) return "Exceptional"; if (score >= 70) return "Excellent"; if (score >= 55) return "Very good"; if (score >= 40) return "Average"; return "Low"; } /** Custom weights for the global KA Score (localStorage). */ export const KA_DEFAULT_WEIGHTS: Record = { walk: 0.30, transit: 0.20, bike: 0.15, calme: 0.20, services: 0.15, }; export function kaWeights(): Record { try { const raw = localStorage.getItem("rentka_ks_weights"); if (!raw) return KA_DEFAULT_WEIGHTS; const w = JSON.parse(raw) as Record; return Object.keys(KA_DEFAULT_WEIGHTS).every((k) => typeof w[k] === "number") ? w : KA_DEFAULT_WEIGHTS; } catch { return KA_DEFAULT_WEIGHTS; } } /** Global KA Score recomputed with the user's weights. */ export function kaGlobal( s: { walk?: number | null; transit?: number | null; bike?: number | null; calme?: number | null; services?: number | null }, weights: Record = kaWeights(), ): number | null { let poids = 0, acquis = 0; for (const k of Object.keys(KA_DEFAULT_WEIGHTS)) { const v = (s as Record)[k]; if (v != null) { poids += weights[k]!; acquis += weights[k]! * v; } } return poids < 0.5 ? null : Math.round((acquis / poids) * 10) / 10; } export interface FairValueDetail { uid: string; fv: number; fv_low: number; fv_high: number; deviation: number | null; verdict: "sous" | "marche" | "sur" | null; confidence: "fort" | "moyen" | "faible"; method: string; comps: number; segment: string; model_version: string; segment_n: number; histogram: { x0: number; x1: number; n: number }[]; } export const fetchFairValue = (uid: string) => get(`/api/fairvalue/${encodeURIComponent(uid)}`); export interface HistoriqueLouka { premiere_observation: number | null; derniere_observation: number | null; active: boolean; jours_en_ligne: number; prix_initial: number | null; prix_actuel: number | null; variation: number | null; modifications: number; timeline: { ts: number; type: string; statut: string; prix?: number | null; prix_avant?: number | null; avant?: unknown; apres?: unknown; }[]; methode: string; } export const fetchHistorique = (uid: string) => get(`/api/listings/${encodeURIComponent(uid)}/historique`); export interface Recyclees { matches: { uid: string; source: string; prix: number | null; unit_type: string | null; derniere_observation: number | null; premiere_observation: number | null; confiance: number; signaux: string[]; }[]; statut: string; methode: string; } export const fetchRecyclees = (uid: string) => get(`/api/listings/${encodeURIComponent(uid)}/recyclees`); export interface Immeuble { bkey: string; address: string | null; city: string | null; computed_at: number; annonces_total: number; annonces_actives: number; annonces_30j: number; annonces_90j: number; annonces_12m: number; unites_identifiees: number; unites_estimees: number; loyer_median: number | null; loyer_median_par_cc: Record | null; pi2_median: number | null; pression_loyers: { variation_12m: number; mediane_12m: number; mediane_12_24m: number; n_12m: number; n_12_24m: number; statut: string; } | null; rotation: { statut: string; classe?: string; ratio?: number; annonces_12m?: number; unites_estimees?: number; observation_jours?: number; methode?: string; }; gestionnaires: string[]; sources: Record; premiere_observation: number; derniere_observation: number; unites_actives: { uid: string; unit_type: string | null; price: number | null; bedrooms: number | null }[]; } export interface Hiver { score: number; classe: string; detail: { critere: string; score: number; distance_m: number | null; minutes?: number; nom?: string | null; note?: string }[]; statut: string; methode: string; } export interface Gestionnaire { source_id: string; nom: string | null; site_web: string | null; telephone: string | null; annonces_actives: number; google_maps?: { statut: string; nom?: string; adresse?: string; note?: number | null; nombre_avis?: number | null; confiance_association?: number; signaux?: Record; methode?: string; note_methode?: string; }; avis?: { n: number; moyenne?: number; distribution?: Record; pct_negatif?: number; pct_positif?: number; moyenne_12m?: number; n_12m?: number; tendance?: string; tendance_delta?: number; avec_reponse_proprietaire?: number; themes?: Record; plaintes_frequentes?: string[]; methode?: string; } | null; avis_recents?: { note: number | null; texte: string | null; date: string | null; auteur: string | null; reponse_proprietaire: boolean; analyse: { topics?: string[]; sentiment?: string; severite?: string }; }[]; derniere_synchro?: number | null; } export const fetchGestionnaire = (sourceId: string) => get(`/api/managers/${encodeURIComponent(sourceId)}`); /** 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)} km`; export interface Facets { cities: string[]; sectors: string[]; unit_types: string[]; provinces?: 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; } /** Province codes → display names (en-CA). */ export const PROVINCE_NAMES: Record = { ON: "Ontario", BC: "British Columbia", AB: "Alberta", SK: "Saskatchewan", MB: "Manitoba", NB: "New Brunswick", NS: "Nova Scotia", PE: "Prince Edward Island", NL: "Newfoundland and Labrador", YT: "Yukon", NT: "Northwest Territories", NU: "Nunavut", }; export interface Stats { total: number; provinces?: Record; 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; province?: string; unit_type?: string; source?: string; price_min?: string; price_max?: string; pets?: string; // "oui" -> accepted (yes OR conditions) furnished?: string; // "1" | "0" available_by?: string; // ISO: available now or before this date area_min?: string; // minimum area (sq ft) q?: string; deal?: string; // "sous" | "marche" | "sur" (fair value) kascore_min?: string; // minimum global KA Score ("60" | "70" | "80") sort?: string; // "deal" deals | "ka" KA Score } export function fetchListings(f: ListingFilters, limit?: number, offset?: number) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); if (limit != null) params.set("limit", String(limit)); if (offset) params.set("offset", String(offset)); return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`); } // --- Unified list + map search (/api/search) --------------------------------- // One response = the list page + ALL map points, same sort: the list counter // equals the number of markers by construction. /** Compact point: [uid, lng, lat, price, verdict ("s"|"m"|"o"|null)]. */ export type SearchPoint = [string, number, number, number | null, string | null]; export type SearchSort = "prix" | "prix_desc" | "recent" | "deal" | "ka"; export interface SearchQuery extends Omit { bbox?: string; // west,south,east,north — visible map area poly?: string; // lng,lat;lng,lat;… — drawn area sort?: SearchSort; page?: number; page_size?: number; include?: "tout" | "liste"; // "liste": points unchanged client-side } export interface SearchResponse { total: number; // shared counter list = map page: number; page_size: number; sort: SearchSort; listings: Listing[]; points: SearchPoint[] | null; // null if include="liste" unpositioned: number; // filtered listings without coordinates } export async function fetchSearch( qy: SearchQuery, signal?: AbortSignal, ): Promise { const params = new URLSearchParams(); for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v)); const res = await fetch(`/api/search?${params}`, { signal }); if (!res.ok) throw new Error(`API ${res.status} — /api/search`); return (await res.json()) as SearchResponse; } 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)}` : ""}`); /** ISO date at +n days (for "available within 1 month", 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"); // -- user account (Google sign-in) --------------------------------------------- export type Socials = Partial>; export interface Me { uid: number; ka_id: string; // member id "ka-0123456789" (Groupe KA ecosystem) email: string; name: string; // effective name (display_name else Google name) google_name: string; picture: string; // effective avatar (uploaded photo else Google) google_picture: string; avatar_url: string | null; // uploaded photo ("/uploads/avatars/…") display_name: string; bio: string; city: string; phone: string; website: string; socials: Socials; public: boolean; // public profile /u/{ka_id} enabled (opt-in) role: "locataire" | "gestionnaire" | null; org_source: string | null; // claimed source (manager) created_at: number | null; last_login: number | null; provider: string; // — profile managed at the Groupe KA HUB (source of truth: groupe-ka.com/compte) — profile_source?: "groupe-ka" | "local"; role_label?: string; // Groupe KA status (e.g. "ÉQUIPE · GROUPE KA") job_title?: string; company?: string; age?: number | null; public_url?: string; // hub public page (if public profile) } export const setRole = (role: "locataire" | "gestionnaire") => fetch(`/api/me/role?role=${role}`, { method: "POST" }); export const updateMyProfile = (fields: { display_name: string; bio: string; city: string; phone: string; website: string; socials: Socials; }) => fetch("/api/me/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fields), }); export async function uploadAvatar(file: File): Promise { const fd = new FormData(); fd.append("file", file); const res = await fetch("/api/me/avatar", { method: "POST", body: fd }); if (!res.ok) return null; return ((await res.json()) as { avatar_url: string }).avatar_url; } export const deleteAvatar = () => fetch("/api/me/avatar", { method: "DELETE" }); // -- saved rentals (tenant) ------------------------------------------------------ export const fetchFavorites = () => get<{ uids: string[]; listings: Listing[] }>("/api/favorites"); export const addFavorite = (uid: string) => fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "POST" }); export const removeFavorite = (uid: string) => fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "DELETE" }); // -- manager page ---------------------------------------------------------------- export interface OrgProfile { source: string; name: string; active_listings: number; claimed?: boolean; tagline?: string; description?: string; website?: string; phone?: string; email?: string; logo_url?: string; } export const fetchMyOrg = () => get<{ source: string | null; name?: string; active_listings?: number; profile?: Record }>("/api/org/mine"); export const claimOrg = (source: string) => fetch(`/api/org/claim?source=${encodeURIComponent(source)}`, { method: "POST" }); export const updateOrg = (fields: { tagline: string; description: string; website: string; phone: string; email: string; logo_url: string; }) => fetch("/api/org", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(fields), }); export const fetchOrg = (sourceId: string) => get(`/api/org/${encodeURIComponent(sourceId)}`); export interface PublicProfile { ka_id: string; name: string; picture: string; bio: string; city: string; website: string; socials: Socials; role: "locataire" | "gestionnaire" | null; created_at: number | null; // — Groupe KA HUB fields (profile_source === "groupe-ka") — profile_source?: "groupe-ka" | "local"; role_label?: string; job_title?: string; company?: string; age?: number | null; } export const fetchPublicProfile = (kaId: string) => get(`/api/users/${encodeURIComponent(kaId)}`); export const setPublicProfile = (enabled: boolean) => fetch(`/api/me/public?enabled=${enabled}`, { method: "POST" }); /** Signed-in profile, or null (401 = simply not signed in). */ export async function fetchMe(): Promise { try { const res = await fetch("/api/me"); return res.ok ? ((await res.json()) as Me) : null; } catch { return null; } } export const fetchAuthConfig = () => get<{ google: boolean; ka: boolean }>("/api/auth/config"); export const logout = () => fetch("/api/auth/logout", { method: "POST" }); export const fmtPrice = (p: number | null, label?: string) => p != null ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 }) : label || "Price on request"; /** "now" -> "Now", "2026-12-01" -> "December 1, 2026" */ export function fmtAvailability(iso: string | null): string | null { if (!iso) return null; if (iso === "now") return "Now"; const [y, m, d] = iso.split("-").map(Number); if (!y || !m || !d) return null; return new Date(y, m - 1, d).toLocaleDateString("en-CA", { day: "numeric", month: "long", year: "numeric", }); } // --- Optimized images + Rent-Ka fallback visual ------------------------------- /** WebP thumbnail served at screen size (see /api/img on the API side). * If the API does not know the URL yet (audit in progress), it answers 404 * and the component falls back to the original URL via onError. */ export const thumb = (u: string, w: 160 | 480 | 800 | 1280 = 480) => `/api/img?u=${encodeURIComponent(u)}&w=${w}`; /** Elegant fallback visual in Rent-Ka colours (never a broken-image icon) — * inline SVG with the unit type as a label. */ export function placeholderImage(label?: string): string { const txt = (label || "Rental").slice(0, 12); const svg = `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `${txt}` + `Photo coming soon · Rent-Ka` + ``; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }