// ----------------------------------------------------------------------------- // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: Toit-Ka // api.ts : types + client API — la colonne vertébrale bi-univers. // `tx` ("louer" | "acheter") sélectionne l'univers ; les mêmes routes servent // les deux (la BD unifiée toitka.db porte transaction_type). // ----------------------------------------------------------------------------- export type Tx = "louer" | "acheter"; export interface Listing { uid: string; origin: string; // 'louka' | 'immoka' transaction_type: Tx; source: string; external_id: string; url: string; title: string; address: string; sector: string; city: string; // ville canonique city_raw: string; type: string; // 3½, 4½, Condo, Maison, Terrain… price: number | null; // loyer mensuel (louer) ou prix demandé (acheter) price_label: string; bedrooms: number | null; bathrooms: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null; pets: string | null; furnished: number | null; availability_date: string | null; mls: string; broker_name: string; agency: string; description: string; images: string[]; lat: number | null; lng: number | null; first_seen?: number; updated_at?: number; active?: number; } export interface Facets { cities: { city: string; n: number }[]; sectors: string[]; types: { type: string; n: number }[]; sources: { source: string; n: number }[]; } export interface TxStats { total: number; sources: number; cities: number; avg_price: number | null; min_price: number | null; max_price: number | null; top_cities: { city: string; n: number; avg_price: number | null }[]; top_types: { type: string; n: number; avg_price: number | null }[]; } export interface Stats { louer: TxStats; acheter: TxStats; etl?: { ts: number; origin: string; found: number; ok: number; message: string }[]; } 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 { tx?: Tx | ""; city?: string; sector?: string; type?: string; source?: string; price_min?: string; price_max?: string; bedrooms_min?: string; bathrooms_min?: string; area_min?: string; pets?: string; furnished?: string; q?: string; sort?: string; // recent | price_asc | price_desc } 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 = (tx?: Tx | "", city?: string) => { const p = new URLSearchParams(); if (tx) p.set("tx", tx); if (city) p.set("city", city); return get(`/api/facets?${p}`); }; export const fetchStats = () => get("/api/stats"); // --- formatage ----------------------------------------------------------------- export const fmtPrice = (p: number | null, tx: Tx, label?: string) => p != null ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $" + (tx === "louer" ? " /mois" : "") : 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", }); /** Libellé lisible d'une source (« remax_quebec » -> « Remax Quebec »). */ export function sourceName(id: string): string { 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()); } // --- référencement --------------------------------------------------------------- /** Slug URL — MÊME algorithme que slugify() de toitka/villes.py. */ export function slugify(s: string): string { return (s || "") .replace(/½/g, " 1 2 ") .replace(/\+/g, " plus ") .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^\x00-\x7f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80) .replace(/-+$/g, ""); } /** Chemin canonique d'une fiche : /annonce/{uid}/{slug-adresse-ville}. */ export function fichePath(l: Pick): string { let slug = slugify(l.address || l.title || ""); const city = slugify(l.city || ""); if (city && !slug.includes(city)) slug = slug ? slugify(`${slug} ${city}`) : city; return `/annonce/${encodeURIComponent(l.uid)}${slug ? `/${slug}` : ""}`; } /** Titre d'onglet par page (le HTML initial est déjà titré côté serveur). */ export function setDocTitle(t?: string) { document.title = t ? `${t} | Toit-Ka` : "Toit-Ka — Louer ou acheter un toit au Québec"; } /** Bascule d'identité : l'accent du site suit l'univers affiché. */ export function setMode(mode: Tx | null) { if (mode) document.documentElement.dataset.mode = mode; else delete document.documentElement.dataset.mode; } /** Slug de page programmatique -> valeurs exactes (ville, type). */ export const resolveSeo = (tx: Tx, ville?: string, type?: string) => get<{ tx: Tx; city?: string; city_n?: number; type?: string; type_n?: number }>( `/api/seo/resolve?${new URLSearchParams({ tx, ...(ville ? { ville } : {}), ...(type ? { type } : {}), })}`); // --- compte Groupe-Ka (KA ID — hub d'identité groupe-ka.com) ---------------------- export interface Socials { instagram?: string; facebook?: string; x?: string; linkedin?: string; tiktok?: string; youtube?: string; } export interface User { sub: string; email: string; name: string; picture: string; ka_id?: string; provider?: string; created_at?: number | null; last_login?: number | null; profile_source?: "groupe-ka" | "local"; bio?: string; city?: string; phone?: string; website?: string; socials?: Socials; job_title?: string; company?: string; age?: number | null; role_label?: string; public?: boolean; public_url?: string; } export const fetchMe = () => get<{ user: User | null; enabled?: boolean }>("/api/auth/me"); export async function logout(): Promise { await fetch("/api/auth/logout", { method: "POST" }); } // --- favoris ♥ (magasin central au hub Groupe KA — « Mon univers Ka ») ------------- export interface FavItem { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; app?: string; } export const fetchFavorites = () => get<{ ids: string[]; items: FavItem[] }>("/api/favorites"); export async function toggleFavorite(on: boolean, item: FavItem): Promise { const res = await fetch("/api/favorites/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on, item }), }); if (res.status === 401) throw new Error("401"); return res.ok; } export function favItemFromListing(l: Listing): FavItem { return { item_id: l.uid, title: l.address || l.title || (l.transaction_type === "louer" ? "Logement" : "Propriété"), subtitle: [l.city, l.type, l.transaction_type === "louer" ? "à louer" : "à vendre"] .filter(Boolean).join(" · "), price_label: fmtPrice(l.price, l.transaction_type, l.price_label), image_url: (l.images && l.images[0]) || "", url: `https://www.toit-ka.com/annonce/${encodeURIComponent(l.uid)}`, }; }