Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts: types + robust API client (timeout, typed errors)5// -----------------------------------------------------------------------------67export interface ListingDetails {8 inclusions?: Record<string, boolean>;9 appliances?: Record<string, boolean>;10 parking?: { available?: boolean; type?: string; included?: boolean; price?: number };11 contact?: { phone?: string; email?: string };12 ac?: boolean;13 elevator?: boolean;14 balcony?: boolean;15 pool?: boolean;16 gym?: boolean;17 laundry?: boolean;18 storage?: boolean;19 smoking?: boolean;20 floor?: number;21 price_from?: boolean;22}2324export interface Poi {25 cat: string; // grocery, pharmacy, school, park, bus…26 name: string;27 dist_m: number;28}2930export interface Digest {31 version: number;32 texte_nettoye: string;33 en_bref: string | null;34 sections: { titre: string; texte: string }[];35 faits: {36 prix_mensuel: number | null;37 date_disponibilite: string | null;38 duree_bail_minimale_mois: number | null;39 nb_occupants_total: number | null;40 salle_de_bain: "commune" | "privee" | null;41 cuisine: "commune" | "privee" | null;42 electromenagers: string[];43 inclusions: string[];44 contraintes: string[];45 depot_mentionne: string | null;46 quartier_mentionne: string | null;47 };48 confiance: Record<string, "haute" | "faible">;49 incoherences: string[];50 completude: number;51}5253export interface Listing {54 /** KA ID personalization: set by the server (rentka/kaid.py) when the55 personal score is clear — shows "Recommended for you" */56 ka_reco?: { score: number; reasons: string[] } | null;57 uid: string;58 source: string;59 external_id: string;60 url: string;61 title: string;62 address: string;63 sector: string;64 city: string;65 province?: string | null;66 unit_type: string;67 price: number | null;68 price_label: string;69 availability: string;70 availability_date: string | null; // ISO "2026-07-01" or "now"71 area_sqft: number | null;72 pets: string | null; // "oui" | "non" | "conditions"73 furnished: boolean | null;74 description: string;75 amenities: string[];76 details: ListingDetails;77 images: string[];78 lat: number | null;79 lng: number | null;80 bedrooms: number | null;81 poi?: Poi[]; // nearby amenities (detail page only)82 digest?: Digest | null; // structured description (detail page only)83 price_history?: { ts: number; price: number | null }[];84 first_seen?: number;85 last_seen: number;86 updated_at: number;87 active: number;88 // fair rental value — rentka/fairvalue.py89 fv?: number | null; // estimated value ($/month)90 fv_low?: number | null; // low bound91 fv_high?: number | null; // high bound92 fv_deviation?: number | null; // (price - fv) / fv93 fv_verdict?: "sous" | "marche" | "sur" | null;94 fv_confidence?: "fort" | "moyen" | "faible" | null;95 // KA Scores (0-100, null = insufficient data / not served)96 ks_walk?: number | null;97 ks_transit?: number | null;98 ks_bike?: number | null;99 ks_calme?: number | null;100 ks_services?: number | null;101 ks_global?: number | null;102 kascores?: KaScores | null; // full detail (detail page only)103 // rental intelligence file (detail page only)104 building_key?: string | null;105 immeuble?: Immeuble | null; // building passport (precomputed)106 hiver?: Hiver | null; // winter daily-life score (0-100)107}108109// --- KA Scores — in-house score family (rentka/kascores.py) -----------------110export interface KaScores {111 walk: number | null;112 transit: number | null;113 bike: number | null;114 calme: number | null;115 services: number | null;116 global: number | null;117 details: {118 walk?: { cats?: { cat: string; dist_m: number | null; pts: number }[]; bonus_choix?: number; raison?: string };119 transit?: { arret_bus_m?: number | null; station_metro_m?: number | null; pmd_percentile?: number | null; raison?: string };120 bike?: { km_cyclables_1km?: number; note?: string; raison?: string };121 calme?: { sources_bruit?: { source: string; dist_m: number | null; pen: number }[]; bonus_parc?: number; note?: string };122 services?: { familles?: Record<string, number>; raison?: string };123 labels?: Record<string, string | null>;124 };125 version: string;126 computed_at: number;127}128129export interface KaScoresStats {130 annonces: number;131 avec_score: number;132 couverture_pct: number;133 version: string;134 moyennes: Record<string, { moyenne: number | null; n: number }>;135}136137export const fetchKaScoresStats = () => get<KaScoresStats>("/api/kascores/stats");138139/** Label for a KA Score (same thresholds as rentka/kascores.py). */140export function kaLabel(score: number | null | undefined): string | null {141 if (score == null) return null;142 if (score >= 85) return "Exceptional";143 if (score >= 70) return "Excellent";144 if (score >= 55) return "Very good";145 if (score >= 40) return "Average";146 return "Low";147}148149/** Custom weights for the global KA Score (localStorage). */150export const KA_DEFAULT_WEIGHTS: Record<string, number> = {151 walk: 0.30, transit: 0.20, bike: 0.15, calme: 0.20, services: 0.15,152};153154export function kaWeights(): Record<string, number> {155 try {156 const raw = localStorage.getItem("rentka_ks_weights");157 if (!raw) return KA_DEFAULT_WEIGHTS;158 const w = JSON.parse(raw) as Record<string, number>;159 return Object.keys(KA_DEFAULT_WEIGHTS).every((k) => typeof w[k] === "number")160 ? w : KA_DEFAULT_WEIGHTS;161 } catch {162 return KA_DEFAULT_WEIGHTS;163 }164}165166/** Global KA Score recomputed with the user's weights. */167export function kaGlobal(168 s: { walk?: number | null; transit?: number | null; bike?: number | null;169 calme?: number | null; services?: number | null },170 weights: Record<string, number> = kaWeights(),171): number | null {172 let poids = 0, acquis = 0;173 for (const k of Object.keys(KA_DEFAULT_WEIGHTS)) {174 const v = (s as Record<string, number | null | undefined>)[k];175 if (v != null) { poids += weights[k]!; acquis += weights[k]! * v; }176 }177 return poids < 0.5 ? null : Math.round((acquis / poids) * 10) / 10;178}179180export interface FairValueDetail {181 uid: string;182 fv: number; fv_low: number; fv_high: number;183 deviation: number | null;184 verdict: "sous" | "marche" | "sur" | null;185 confidence: "fort" | "moyen" | "faible";186 method: string; comps: number; segment: string;187 model_version: string;188 segment_n: number;189 histogram: { x0: number; x1: number; n: number }[];190}191192export const fetchFairValue = (uid: string) =>193 get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`);194195export interface HistoriqueLouka {196 premiere_observation: number | null;197 derniere_observation: number | null;198 active: boolean;199 jours_en_ligne: number;200 prix_initial: number | null;201 prix_actuel: number | null;202 variation: number | null;203 modifications: number;204 timeline: {205 ts: number; type: string; statut: string;206 prix?: number | null; prix_avant?: number | null;207 avant?: unknown; apres?: unknown;208 }[];209 methode: string;210}211212export const fetchHistorique = (uid: string) =>213 get<HistoriqueLouka>(`/api/listings/${encodeURIComponent(uid)}/historique`);214215export interface Recyclees {216 matches: {217 uid: string; source: string; prix: number | null;218 unit_type: string | null;219 derniere_observation: number | null; premiere_observation: number | null;220 confiance: number; signaux: string[];221 }[];222 statut: string;223 methode: string;224}225226export const fetchRecyclees = (uid: string) =>227 get<Recyclees>(`/api/listings/${encodeURIComponent(uid)}/recyclees`);228229export interface Immeuble {230 bkey: string;231 address: string | null;232 city: string | null;233 computed_at: number;234 annonces_total: number;235 annonces_actives: number;236 annonces_30j: number;237 annonces_90j: number;238 annonces_12m: number;239 unites_identifiees: number;240 unites_estimees: number;241 loyer_median: number | null;242 loyer_median_par_cc: Record<string, number> | null;243 pi2_median: number | null;244 pression_loyers: {245 variation_12m: number; mediane_12m: number; mediane_12_24m: number;246 n_12m: number; n_12_24m: number; statut: string;247 } | null;248 rotation: {249 statut: string; classe?: string; ratio?: number;250 annonces_12m?: number; unites_estimees?: number;251 observation_jours?: number; methode?: string;252 };253 gestionnaires: string[];254 sources: Record<string, number>;255 premiere_observation: number;256 derniere_observation: number;257 unites_actives: { uid: string; unit_type: string | null; price: number | null; bedrooms: number | null }[];258}259260export interface Hiver {261 score: number;262 classe: string;263 detail: { critere: string; score: number; distance_m: number | null; minutes?: number; nom?: string | null; note?: string }[];264 statut: string;265 methode: string;266}267268export interface Gestionnaire {269 source_id: string;270 nom: string | null;271 site_web: string | null;272 telephone: string | null;273 annonces_actives: number;274 google_maps?: {275 statut: string;276 nom?: string; adresse?: string; note?: number | null;277 nombre_avis?: number | null; confiance_association?: number;278 signaux?: Record<string, unknown>;279 methode?: string; note_methode?: string;280 };281 avis?: {282 n: number; moyenne?: number; distribution?: Record<string, number>;283 pct_negatif?: number; pct_positif?: number;284 moyenne_12m?: number; n_12m?: number;285 tendance?: string; tendance_delta?: number;286 avec_reponse_proprietaire?: number;287 themes?: Record<string, { mentions: number; negatif: number; positif: number }>;288 plaintes_frequentes?: string[];289 methode?: string;290 } | null;291 avis_recents?: {292 note: number | null; texte: string | null; date: string | null;293 auteur: string | null; reponse_proprietaire: boolean;294 analyse: { topics?: string[]; sentiment?: string; severite?: string };295 }[];296 derniere_synchro?: number | null;297}298299export const fetchGestionnaire = (sourceId: string) =>300 get<Gestionnaire>(`/api/managers/${encodeURIComponent(sourceId)}`);301302/** 250 -> "250 m", 1240 -> "1.2 km" */303export const fmtDist = (m: number): string =>304 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;305306export interface Facets {307 cities: string[];308 sectors: string[];309 unit_types: string[];310 provinces?: string[];311 sources: { source: string; n: number }[];312}313314export interface Source {315 id: string;316 name: string;317 url: string;318 listing_url: string;319 sectors: string;320 connector: string | null;321 status: string;322 active_listings: number;323 last_sync: number | null;324}325326/** Province codes → display names (en-CA). */327export const PROVINCE_NAMES: Record<string, string> = {328 ON: "Ontario",329 BC: "British Columbia",330 AB: "Alberta",331 SK: "Saskatchewan",332 MB: "Manitoba",333 NB: "New Brunswick",334 NS: "Nova Scotia",335 PE: "Prince Edward Island",336 NL: "Newfoundland and Labrador",337 YT: "Yukon",338 NT: "Northwest Territories",339 NU: "Nunavut",340};341342export interface Stats {343 total: number;344 provinces?: Record<string, number>;345 sources: number;346 avg_price: number | null;347}348349const SOURCE_NAMES: Record<string, string> = {};350351export function registerSourceNames(sources: Source[]) {352 for (const s of sources) SOURCE_NAMES[s.id] = s.name;353}354export function sourceName(id: string): string {355 return SOURCE_NAMES[id] ?? id;356}357358async function get<T>(path: string): Promise<T> {359 const ctrl = new AbortController();360 const timer = setTimeout(() => ctrl.abort(), 20000);361 try {362 const res = await fetch(path, { signal: ctrl.signal });363 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);364 return (await res.json()) as T;365 } finally {366 clearTimeout(timer);367 }368}369370export interface ListingFilters {371 city?: string;372 sector?: string;373 province?: string;374 unit_type?: string;375 source?: string;376 price_min?: string;377 price_max?: string;378 pets?: string; // "oui" -> accepted (yes OR conditions)379 furnished?: string; // "1" | "0"380 available_by?: string; // ISO: available now or before this date381 area_min?: string; // minimum area (sq ft)382 q?: string;383 deal?: string; // "sous" | "marche" | "sur" (fair value)384 kascore_min?: string; // minimum global KA Score ("60" | "70" | "80")385 sort?: string; // "deal" deals | "ka" KA Score386}387388export function fetchListings(f: ListingFilters, limit?: number, offset?: number) {389 const params = new URLSearchParams();390 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);391 if (limit != null) params.set("limit", String(limit));392 if (offset) params.set("offset", String(offset));393 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);394}395396// --- Unified list + map search (/api/search) ---------------------------------397// One response = the list page + ALL map points, same sort: the list counter398// equals the number of markers by construction.399400/** Compact point: [uid, lng, lat, price, verdict ("s"|"m"|"o"|null)]. */401export type SearchPoint = [string, number, number, number | null, string | null];402403export type SearchSort = "prix" | "prix_desc" | "recent" | "deal" | "ka";404405export interface SearchQuery extends Omit<ListingFilters, "sort"> {406 bbox?: string; // west,south,east,north — visible map area407 poly?: string; // lng,lat;lng,lat;… — drawn area408 sort?: SearchSort;409 page?: number;410 page_size?: number;411 include?: "tout" | "liste"; // "liste": points unchanged client-side412}413414export interface SearchResponse {415 total: number; // shared counter list = map416 page: number;417 page_size: number;418 sort: SearchSort;419 listings: Listing[];420 points: SearchPoint[] | null; // null if include="liste"421 unpositioned: number; // filtered listings without coordinates422}423424export async function fetchSearch(425 qy: SearchQuery,426 signal?: AbortSignal,427): Promise<SearchResponse> {428 const params = new URLSearchParams();429 for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v));430 const res = await fetch(`/api/search?${params}`, { signal });431 if (!res.ok) throw new Error(`API ${res.status} — /api/search`);432 return (await res.json()) as SearchResponse;433}434435export interface GroupStat {436 key: string;437 count: number;438 sources?: number;439 avg_price: number | null;440 min_price: number | null;441}442443export interface DetailedStats {444 totals: {445 total: number;446 with_price: number;447 avg: number | null;448 median: number | null;449 min: number | null;450 max: number | null;451 sources: number;452 cities: number;453 regions: number;454 gps_pct: number | null;455 superficie_moyenne: number | null;456 dispo_now: number;457 };458 histogram: { lo: number; hi: number | null; count: number }[];459 by_type: GroupStat[];460 by_city: GroupStat[];461 by_source: GroupStat[];462 by_region: GroupStat[];463 offre: {464 furnished_pct: number | null;465 pets_oui_pct: number | null;466 pets_connu: number;467 chauffage_pct: number | null;468 electricite_pct: number | null;469 eau_chaude_pct: number | null;470 internet_pct: number | null;471 clim_pct: number | null;472 stationnement_pct: number | null;473 balcon_pct: number | null;474 dispo_now: number;475 dispo_date: number;476 dispo_inconnue: number;477 superficie_moyenne: number | null;478 superficie_connue: number;479 prix_pi2: { key: string; count: number; val: number }[];480 };481 baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];482 sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };483}484485export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");486487export const fetchListing = (uid: string) =>488 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);489export const fetchFacets = (city?: string) =>490 get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);491492/** ISO date at +n days (for "available within 1 month", etc.) */493export function isoInDays(n: number): string {494 const d = new Date();495 d.setDate(d.getDate() + n);496 return d.toISOString().slice(0, 10);497}498export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");499export const fetchStats = () => get<Stats>("/api/stats");500501// -- user account (Google sign-in) ---------------------------------------------502export type Socials = Partial<Record<503 "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string>>;504505export interface Me {506 uid: number;507 ka_id: string; // member id "ka-0123456789" (Groupe KA ecosystem)508 email: string;509 name: string; // effective name (display_name else Google name)510 google_name: string;511 picture: string; // effective avatar (uploaded photo else Google)512 google_picture: string;513 avatar_url: string | null; // uploaded photo ("/uploads/avatars/…")514 display_name: string;515 bio: string;516 city: string;517 phone: string;518 website: string;519 socials: Socials;520 public: boolean; // public profile /u/{ka_id} enabled (opt-in)521 role: "locataire" | "gestionnaire" | null;522 org_source: string | null; // claimed source (manager)523 created_at: number | null;524 last_login: number | null;525 provider: string;526 // — profile managed at the Groupe KA HUB (source of truth: groupe-ka.com/compte) —527 profile_source?: "groupe-ka" | "local";528 role_label?: string; // Groupe KA status (e.g. "ÉQUIPE · GROUPE KA")529 job_title?: string;530 company?: string;531 age?: number | null;532 public_url?: string; // hub public page (if public profile)533}534535export const setRole = (role: "locataire" | "gestionnaire") =>536 fetch(`/api/me/role?role=${role}`, { method: "POST" });537538export const updateMyProfile = (fields: {539 display_name: string; bio: string; city: string;540 phone: string; website: string; socials: Socials;541}) => fetch("/api/me/profile", {542 method: "PUT",543 headers: { "Content-Type": "application/json" },544 body: JSON.stringify(fields),545});546547export async function uploadAvatar(file: File): Promise<string | null> {548 const fd = new FormData();549 fd.append("file", file);550 const res = await fetch("/api/me/avatar", { method: "POST", body: fd });551 if (!res.ok) return null;552 return ((await res.json()) as { avatar_url: string }).avatar_url;553}554export const deleteAvatar = () => fetch("/api/me/avatar", { method: "DELETE" });555556// -- saved rentals (tenant) ------------------------------------------------------557export const fetchFavorites = () =>558 get<{ uids: string[]; listings: Listing[] }>("/api/favorites");559export const addFavorite = (uid: string) =>560 fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "POST" });561export const removeFavorite = (uid: string) =>562 fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "DELETE" });563564// -- manager page ----------------------------------------------------------------565export interface OrgProfile {566 source: string;567 name: string;568 active_listings: number;569 claimed?: boolean;570 tagline?: string;571 description?: string;572 website?: string;573 phone?: string;574 email?: string;575 logo_url?: string;576}577export const fetchMyOrg = () =>578 get<{ source: string | null; name?: string; active_listings?: number;579 profile?: Record<string, string> }>("/api/org/mine");580export const claimOrg = (source: string) =>581 fetch(`/api/org/claim?source=${encodeURIComponent(source)}`, { method: "POST" });582export const updateOrg = (fields: {583 tagline: string; description: string; website: string;584 phone: string; email: string; logo_url: string;585}) => fetch("/api/org", {586 method: "PUT",587 headers: { "Content-Type": "application/json" },588 body: JSON.stringify(fields),589});590export const fetchOrg = (sourceId: string) =>591 get<OrgProfile>(`/api/org/${encodeURIComponent(sourceId)}`);592593export interface PublicProfile {594 ka_id: string;595 name: string;596 picture: string;597 bio: string;598 city: string;599 website: string;600 socials: Socials;601 role: "locataire" | "gestionnaire" | null;602 created_at: number | null;603 // — Groupe KA HUB fields (profile_source === "groupe-ka") —604 profile_source?: "groupe-ka" | "local";605 role_label?: string;606 job_title?: string;607 company?: string;608 age?: number | null;609}610export const fetchPublicProfile = (kaId: string) =>611 get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`);612export const setPublicProfile = (enabled: boolean) =>613 fetch(`/api/me/public?enabled=${enabled}`, { method: "POST" });614/** Signed-in profile, or null (401 = simply not signed in). */615export async function fetchMe(): Promise<Me | null> {616 try {617 const res = await fetch("/api/me");618 return res.ok ? ((await res.json()) as Me) : null;619 } catch {620 return null;621 }622}623export const fetchAuthConfig = () => get<{ google: boolean; ka: boolean }>("/api/auth/config");624export const logout = () => fetch("/api/auth/logout", { method: "POST" });625626export const fmtPrice = (p: number | null, label?: string) =>627 p != null628 ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })629 : label || "Price on request";630631/** "now" -> "Now", "2026-12-01" -> "December 1, 2026" */632export function fmtAvailability(iso: string | null): string | null {633 if (!iso) return null;634 if (iso === "now") return "Now";635 const [y, m, d] = iso.split("-").map(Number);636 if (!y || !m || !d) return null;637 return new Date(y, m - 1, d).toLocaleDateString("en-CA", {638 day: "numeric", month: "long", year: "numeric",639 });640}641642// --- Optimized images + Rent-Ka fallback visual -------------------------------643/** WebP thumbnail served at screen size (see /api/img on the API side).644 * If the API does not know the URL yet (audit in progress), it answers 404645 * and the component falls back to the original URL via onError. */646export const thumb = (u: string, w: 160 | 480 | 800 | 1280 = 480) =>647 `/api/img?u=${encodeURIComponent(u)}&w=${w}`;648649/** Elegant fallback visual in Rent-Ka colours (never a broken-image icon) —650 * inline SVG with the unit type as a label. */651export function placeholderImage(label?: string): string {652 const txt = (label || "Rental").slice(0, 12);653 const svg =654 `<svg xmlns='http://www.w3.org/2000/svg' width='800' height='525' viewBox='0 0 800 525'>` +655 `<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>` +656 `<stop offset='0' stop-color='#e9efff'/><stop offset='1' stop-color='#c8d7fb'/>` +657 `</linearGradient></defs>` +658 `<rect width='800' height='525' fill='url(#g)'/>` +659 `<g transform='translate(400 235)' stroke='#2456e6' stroke-width='14' fill='none' stroke-linecap='round' stroke-linejoin='round'>` +660 `<path d='M -95 -10 L 0 -85 L 95 -10'/>` +661 `<path d='M -70 -25 L -70 70 L 70 70 L 70 -25'/>` +662 `<rect x='-18' y='14' width='36' height='56'/>` +663 `</g>` +664 `<text x='400' y='390' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +665 `font-size='30' font-weight='700' fill='#1738a8'>${txt}</text>` +666 `<text x='400' y='426' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +667 `font-size='19' fill='#1738a8' opacity='0.75'>Photo coming soon · Rent-Ka</text>` +668 `</svg>`;669 return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;670}671