Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts : types + robust API client (timeout, typed errors)5// -----------------------------------------------------------------------------67export interface Room {8 nom?: string;9 niveau?: string;10 dimensions?: string;11 revetement?: string;12}1314/** details: free-form dictionary of DDF fields (label → value), with the15 * special keys `pieces` (rooms) and `photo_captions`. */16export interface ListingDetails {17 pieces?: Room[];18 price_from?: boolean;19 [key: string]: unknown;20}2122export interface Listing {23 uid: string;24 source: string;25 external_id: string;26 url: string;27 title: string;28 address: string;29 sector: string;30 city: string;31 region: string;32 property_type: string;33 price: number | null;34 price_label: string;35 bedrooms: number | null;36 bathrooms: number | null;37 powder_rooms: number | null;38 area_sqft: number | null;39 lot_sqft: number | null;40 year_built: number | null;41 mls: string;42 status: string;43 broker_name: string;44 broker_phone: string;45 description: string;46 features: string[];47 details: ListingDetails;48 images: string[];49 lat: number | null;50 lng: number | null;51 price_history?: { ts: number; price: number | null }[];52 duplicates?: DuplicateListing[]; // other publications of the same property53 poi?: Poi[]; // nearby amenities (listing page only)54 first_seen?: number;55 last_seen?: number;56 updated_at?: number;57 active?: number;58 days_on_market?: number;59}6061export interface DuplicateListing {62 uid: string;63 source: string;64 url: string;65 broker_name: string;66 agency: string;67 price_label: string;68}6970export interface Poi { cat: string; name: string; dist_m: number }7172export interface Facets {73 cities: string[];74 sectors: string[];75 property_types: string[];76 sources: { source: string; n: number }[];77}7879export interface Source {80 id: string;81 name: string;82 url: string;83 listing_url?: string;84 coverage?: string;85 type?: string;86 connector?: string | null;87 status: string;88 active_listings: number;89 last_sync: number | null;90}9192export interface Stats {93 total: number;94 sources: number;95 cities: number;96 avg_price: number | null;97 min_price: number | null;98 max_price: number | null;99 recent_syncs?: {100 source: string; ts: number; found: number; added: number;101 updated: number; removed: number; ok: number; message: string;102 }[];103 qualite?: Quality;104}105106/** Data quality (completeness, quarantine, anomalies) — immoka/quality.py */107export interface Quality {108 actives: number;109 publiees: number;110 quarantaine: number;111 completude_moyenne: number | null;112 anomalies: Record<string, number>;113 par_source: {114 source: string; n: number; publiees: number;115 completude: number | null; anomalies: number;116 }[];117}118119// --- Source names (pretty labels) --------------------------------------------120const SOURCE_NAMES: Record<string, string> = {};121export function registerSourceNames(sources: Source[]) {122 for (const s of sources) SOURCE_NAMES[s.id] = s.name;123}124export function sourceName(id: string): string {125 if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];126 // readable fallback for generated RealtyPress sources (rp_ag_xxx)127 const base = id.replace(/^rp_ag_/, "").replace(/^rp_/, "");128 return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());129}130131async function get<T>(path: string): Promise<T> {132 const ctrl = new AbortController();133 const timer = setTimeout(() => ctrl.abort(), 25000);134 try {135 const res = await fetch(path, { signal: ctrl.signal });136 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);137 return (await res.json()) as T;138 } finally {139 clearTimeout(timer);140 }141}142143export interface ListingFilters {144 city?: string;145 sector?: string;146 region?: string;147 property_type?: string;148 source?: string;149 price_min?: string;150 price_max?: string;151 bedrooms_min?: string;152 bathrooms_min?: string;153 area_min?: string;154 q?: string;155 sort?: string; // price_asc | price_desc | recent156}157158export function listingParams(f: ListingFilters): URLSearchParams {159 const params = new URLSearchParams();160 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);161 return params;162}163164export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {165 const params = listingParams(f);166 params.set("limit", String(limit));167 params.set("offset", String(offset));168 return get<{ total: number; count: number; listings: Listing[] }>(169 `/api/listings?${params}`);170}171172export const fetchListing = (uid: string) =>173 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);174export const fetchFacets = (city?: string) =>175 get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);176export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");177export const fetchStats = () => get<Stats>("/api/stats");178179export interface SubAgency { name: string; count: number; sources: string[] }180export interface Franchise {181 franchise: string;182 total: number;183 sub_agencies: number;184 agencies: SubAgency[];185}186export const fetchAgencies = () =>187 get<{ franchises: Franchise[] }>("/api/agencies");188189// --- Formatting ---------------------------------------------------------------190export const fmtPrice = (p: number | null, label?: string) =>191 p != null192 ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })193 : label || "Price on request";194195export const fmtArea = (a: number | null): string | null =>196 a != null ? `${Math.round(a).toLocaleString("en-CA")} sq ft` : null;197198export const fmtDate = (ts: number): string =>199 new Date(ts * 1000).toLocaleDateString("en-CA", {200 day: "numeric", month: "long", year: "numeric",201 });202203/** 250 -> "250 m", 1240 -> "1.2 km" */204export const fmtDist = (m: number): string =>205 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;206207// -----------------------------------------------------------------------------208// Mortgage rates (immoka/mortgage) — real rates observed at the banks209// -----------------------------------------------------------------------------210export interface MortgageRate {211 provider: string;212 institution: string;213 product_key: string;214 product_name: string | null;215 rate_type: "fixed" | "variable" | "adjustable" | "other";216 term_months: number;217 kind: "posted" | "special";218 rate: number;219 apr: number | null;220 insured_status: "insured" | "insurable" | "uninsured" | "unknown";221 purpose: string;222 conditions: string | null;223 source_url: string | null;224 last_checked: number;225 age_minutes: number;226 stale: boolean;227}228229export interface MortgageBest extends MortgageRate {230 median_rate: number | null;231 institutions_count: number;232 per_institution: MortgageRate[];233}234235export interface MortgageMarket {236 rate_type: string;237 term_months: number;238 best: number;239 best_provider: string;240 best_institution: string;241 best_kind: string;242 median: number | null;243 spread: number | null;244 institutions_count: number;245 var_7d: number | null;246 var_30d: number | null;247 var_90d: number | null;248 lowest_6m: number | null;249}250251export interface MortgageIntelligence {252 products: MortgageMarket[];253 prime_rates: { institution: string; rate: number; product_name: string;254 age_minutes: number }[];255}256257export interface MortgageHistoryRow {258 provider: string; institution: string; product_name: string | null;259 kind: string; rate: number; insured_status: string;260 valid_from: number; valid_to: number | null; last_checked: number;261 source_url: string | null;262}263264export interface MortgageProviderHealth {265 provider: string; institution: string; source_url: string | null;266 level: "OK" | "WARNING" | "ERROR"; status: string | null;267 age_minutes: number; current_products: number; last_data_at: number | null;268}269270export interface MortgageRateSource {271 provider: string; institution: string; product_name: string | null;272 kind: string; rate: number; apr?: number | null; insured_status?: string;273 source_url: string | null; last_checked?: number;274 age_minutes: number; stale: boolean;275}276277export interface MortgageInsurance {278 required: boolean; eligible: boolean; premium: number; premium_rate: number;279 loan_before: number; total_mortgage: number; qc_tax: number;280 ltv: number | null; issues: string[];281}282283export interface MortgageCalc {284 inputs: {285 price: number; down_payment: number; down_payment_pct: number;286 rate: number; rate_type: string; term_months: number;287 amortization_years: number; frequency: string; compounding: string;288 };289 insurance: MortgageInsurance;290 principal: number;291 payment: number;292 payment_monthly_equivalent: number;293 qualifying: { rate: number; payment: number; note: string };294 term: {295 payment: number; frequency: string; payments_per_year: number;296 payments_in_term: number; annual_cost: number; principal_paid: number;297 interest_paid: number; balance_end_of_term: number; paid_off: boolean;298 };299 stress: { bump: number; rate: number; payment: number }[];300 renewal: {301 balance_at_renewal: number; remaining_amortization_years: number;302 scenarios: { bump: number; rate: number; payment: number }[];303 };304 payoff_years: number;305 rate_source: MortgageRateSource | null;306 annual: { year: number; payment: number; interest: number;307 principal: number; balance: number }[];308 ratios?: { gds: number | null; tds: number | null;309 gds_ok: boolean | null; tds_ok: boolean | null };310}311312export interface MortgageCalcInput {313 price: number;314 down_payment?: number;315 down_payment_pct?: number;316 amortization_years?: number;317 term_months?: number;318 frequency?: string;319 rate_type?: "fixed" | "variable";320 rate?: number;321 income?: number;322 property_tax_monthly?: number;323 heating_monthly?: number;324 condo_fees_monthly?: number;325 other_debts_monthly?: number;326}327328async function post<T>(path: string, body: unknown): Promise<T> {329 const ctrl = new AbortController();330 const timer = setTimeout(() => ctrl.abort(), 25000);331 try {332 const res = await fetch(path, {333 method: "POST",334 headers: { "Content-Type": "application/json" },335 body: JSON.stringify(body),336 signal: ctrl.signal,337 });338 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);339 return (await res.json()) as T;340 } finally {341 clearTimeout(timer);342 }343}344345export const fetchMortgageIntelligence = () =>346 get<MortgageIntelligence>("/api/mortgage/intelligence");347348export const fetchMortgageBest = (rateType: string, termMonths: number) =>349 get<MortgageBest>(350 `/api/mortgage/rates/best?rate_type=${rateType}&term_months=${termMonths}`);351352export const fetchMortgageHistory = (353 rateType: string, termMonths: number, days = 365, kind?: string,354) =>355 get<{ count: number; days: number; history: MortgageHistoryRow[] }>(356 `/api/mortgage/rates/history?rate_type=${rateType}` +357 `&term_months=${termMonths}&days=${days}${kind ? `&kind=${kind}` : ""}`);358359export const fetchMortgageProviders = () =>360 get<{ providers: MortgageProviderHealth[]; registered: string[] }>(361 "/api/mortgage/providers");362363export const calculateMortgage = (input: MortgageCalcInput) =>364 post<MortgageCalc>("/api/mortgage/calculate", input);365366/** 4.19 -> "4.19%" */367export const fmtRate = (r: number | null | undefined): string =>368 r == null ? "—" : `${r.toFixed(2)}%`;369370// -----------------------------------------------------------------------------371// Nearby places (Mapbox Search Box + OSM) — listing page block372// -----------------------------------------------------------------------------373export interface CommerceItem {374 id: string; commerce: string; nom: string; adresse: string;375 dist_m: number; lat: number; lng: number;376}377378export interface CommercesNearby {379 n: number; commerces: CommerceItem[]; transit?: CommerceItem[];380}381382export const fetchCommerces = (lat: number, lng: number, region?: string) =>383 get<CommercesNearby>(384 `/api/commerces?lat=${lat}&lng=${lng}` +385 (region ? `®ion=${encodeURIComponent(region)}` : ""));386387// -----------------------------------------------------------------------------388// KA ID account (SSO hub groupe-ka.com) + "My Ka universe" favourites389// -----------------------------------------------------------------------------390export type Socials = Partial<Record<391 "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string392>>;393394export interface User {395 sub: string;396 email: string;397 name: string;398 picture?: string;399 ka_id?: string;400 provider?: string; // "ka-id" | "google"401 created_at?: number | null; // epoch (s)402 last_login?: number | null; // epoch (s)403 // profile enriched by the Groupe KA HUB (source of truth — groupe-ka.com/compte)404 bio?: string;405 city?: string;406 phone?: string;407 website?: string;408 socials?: Socials;409 public?: boolean;410 role_label?: string;411 job_title?: string;412 company?: string;413 age?: number | null;414 public_url?: string;415 profile_source?: string; // "groupe-ka" | "local"416}417418/** Session profile ({user: null} if signed out; `enabled` = SSO configured). */419export const fetchMe = () => get<{ user: User | null; enabled: boolean }>("/api/auth/me");420421export const logout = () => fetch("/api/auth/logout", { method: "POST" });422423/** Favourite item as expected by the hub (see immoka/favorites.py). */424export interface FavItem {425 item_id: string;426 title: string;427 subtitle: string;428 price_label: string;429 image_url: string;430 url: string;431}432433export function favItemFromListing(l: Listing): FavItem {434 return {435 item_id: l.uid,436 title: l.title || l.address || l.property_type || "Property",437 subtitle: [l.city, l.region].filter(Boolean).join(", "),438 price_label: l.price_label || fmtPrice(l.price),439 image_url: l.images?.[0] ?? "",440 url: `/property/${l.uid}`,441 };442}443444export const fetchFavorites = () =>445 get<{ ids: string[]; items: FavItem[] }>("/api/favorites");446447export async function toggleFavorite(on: boolean, item: FavItem) {448 const res = await fetch("/api/favorites/toggle", {449 method: "POST",450 headers: { "Content-Type": "application/json" },451 body: JSON.stringify({ on, item }),452 });453 if (!res.ok) throw new Error(`favourites ${res.status}`);454 return (await res.json()) as { ok: boolean; on: boolean };455}456