spb/toit-ka Public
Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com
Python 40.2%
TypeScript 39%
CSS 20.2%
HTML 0.7%
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// api.ts : types + client API — la colonne vertébrale bi-univers.6// `tx` ("louer" | "acheter") sélectionne l'univers ; les mêmes routes servent7// les deux (la BD unifiée toitka.db porte transaction_type).8// -----------------------------------------------------------------------------910export type Tx = "louer" | "acheter";1112export interface Listing {13 uid: string;14 origin: string; // 'louka' | 'immoka'15 transaction_type: Tx;16 source: string;17 external_id: string;18 url: string;19 title: string;20 address: string;21 sector: string;22 city: string; // ville canonique23 city_raw: string;24 type: string; // 3½, 4½, Condo, Maison, Terrain…25 price: number | null; // loyer mensuel (louer) ou prix demandé (acheter)26 price_label: string;27 bedrooms: number | null;28 bathrooms: number | null;29 area_sqft: number | null;30 lot_sqft: number | null;31 year_built: number | null;32 pets: string | null;33 furnished: number | null;34 availability_date: string | null;35 mls: string;36 broker_name: string;37 agency: string;38 description: string;39 images: string[];40 lat: number | null;41 lng: number | null;42 first_seen?: number;43 updated_at?: number;44 active?: number;45}4647export interface Facets {48 cities: { city: string; n: number }[];49 sectors: string[];50 types: { type: string; n: number }[];51 sources: { source: string; n: number }[];52}5354export interface TxStats {55 total: number;56 sources: number;57 cities: number;58 avg_price: number | null;59 min_price: number | null;60 max_price: number | null;61 top_cities: { city: string; n: number; avg_price: number | null }[];62 top_types: { type: string; n: number; avg_price: number | null }[];63}6465export interface Stats {66 louer: TxStats;67 acheter: TxStats;68 etl?: { ts: number; origin: string; found: number; ok: number; message: string }[];69}7071async function get<T>(path: string): Promise<T> {72 const ctrl = new AbortController();73 const timer = setTimeout(() => ctrl.abort(), 25000);74 try {75 const res = await fetch(path, { signal: ctrl.signal });76 if (!res.ok) throw new Error(`API ${res.status} — ${path}`);77 return (await res.json()) as T;78 } finally {79 clearTimeout(timer);80 }81}8283export interface ListingFilters {84 tx?: Tx | "";85 city?: string;86 sector?: string;87 type?: string;88 source?: string;89 price_min?: string;90 price_max?: string;91 bedrooms_min?: string;92 bathrooms_min?: string;93 area_min?: string;94 pets?: string;95 furnished?: string;96 q?: string;97 sort?: string; // recent | price_asc | price_desc98}99100export function listingParams(f: ListingFilters): URLSearchParams {101 const params = new URLSearchParams();102 for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);103 return params;104}105106export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {107 const params = listingParams(f);108 params.set("limit", String(limit));109 params.set("offset", String(offset));110 return get<{ total: number; count: number; listings: Listing[] }>(111 `/api/listings?${params}`);112}113114export const fetchListing = (uid: string) =>115 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);116export const fetchFacets = (tx?: Tx | "", city?: string) => {117 const p = new URLSearchParams();118 if (tx) p.set("tx", tx);119 if (city) p.set("city", city);120 return get<Facets>(`/api/facets?${p}`);121};122export const fetchStats = () => get<Stats>("/api/stats");123124// --- formatage -----------------------------------------------------------------125export const fmtPrice = (p: number | null, tx: Tx, label?: string) =>126 p != null127 ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"128 + (tx === "louer" ? " /mois" : "")129 : label || "Prix sur demande";130131export const fmtArea = (a: number | null): string | null =>132 a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null;133134export const fmtDate = (ts: number): string =>135 new Date(ts * 1000).toLocaleDateString("fr-CA", {136 day: "numeric", month: "long", year: "numeric",137 });138139/** Libellé lisible d'une source (« remax_quebec » -> « Remax Quebec »). */140export function sourceName(id: string): string {141 if (id.startsWith("remax_ag_"))142 return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());143 return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());144}145146// --- référencement ---------------------------------------------------------------147/** Slug URL — MÊME algorithme que slugify() de toitka/villes.py. */148export function slugify(s: string): string {149 return (s || "")150 .replace(/½/g, " 1 2 ")151 .replace(/\+/g, " plus ")152 .normalize("NFKD")153 .replace(/[̀-ͯ]/g, "")154 .replace(/[^\x00-\x7f]/g, "")155 .toLowerCase()156 .replace(/[^a-z0-9]+/g, "-")157 .replace(/^-+|-+$/g, "")158 .slice(0, 80)159 .replace(/-+$/g, "");160}161162/** Chemin canonique d'une fiche : /annonce/{uid}/{slug-adresse-ville}. */163export function fichePath(l: Pick<Listing, "uid" | "address" | "title" | "city">): string {164 let slug = slugify(l.address || l.title || "");165 const city = slugify(l.city || "");166 if (city && !slug.includes(city)) slug = slug ? slugify(`${slug} ${city}`) : city;167 return `/annonce/${encodeURIComponent(l.uid)}${slug ? `/${slug}` : ""}`;168}169170/** Titre d'onglet par page (le HTML initial est déjà titré côté serveur). */171export function setDocTitle(t?: string) {172 document.title = t ? `${t} | Toit-Ka` : "Toit-Ka — Louer ou acheter un toit au Québec";173}174175/** Bascule d'identité : l'accent du site suit l'univers affiché. */176export function setMode(mode: Tx | null) {177 if (mode) document.documentElement.dataset.mode = mode;178 else delete document.documentElement.dataset.mode;179}180181/** Slug de page programmatique -> valeurs exactes (ville, type). */182export const resolveSeo = (tx: Tx, ville?: string, type?: string) =>183 get<{ tx: Tx; city?: string; city_n?: number; type?: string; type_n?: number }>(184 `/api/seo/resolve?${new URLSearchParams({185 tx, ...(ville ? { ville } : {}), ...(type ? { type } : {}),186 })}`);187188// --- compte Groupe-Ka (KA ID — hub d'identité groupe-ka.com) ----------------------189export interface Socials {190 instagram?: string;191 facebook?: string;192 x?: string;193 linkedin?: string;194 tiktok?: string;195 youtube?: string;196}197198export interface User {199 sub: string;200 email: string;201 name: string;202 picture: string;203 ka_id?: string;204 provider?: string;205 created_at?: number | null;206 last_login?: number | null;207 profile_source?: "groupe-ka" | "local";208 bio?: string;209 city?: string;210 phone?: string;211 website?: string;212 socials?: Socials;213 job_title?: string;214 company?: string;215 age?: number | null;216 role_label?: string;217 public?: boolean;218 public_url?: string;219}220export const fetchMe = () => get<{ user: User | null; enabled?: boolean }>("/api/auth/me");221export async function logout(): Promise<void> {222 await fetch("/api/auth/logout", { method: "POST" });223}224225// --- favoris ♥ (magasin central au hub Groupe KA — « Mon univers Ka ») -------------226export interface FavItem {227 item_id: string;228 title: string;229 subtitle?: string;230 price_label?: string;231 image_url?: string;232 url?: string;233 app?: string;234}235236export const fetchFavorites = () =>237 get<{ ids: string[]; items: FavItem[] }>("/api/favorites");238239export async function toggleFavorite(on: boolean, item: FavItem): Promise<boolean> {240 const res = await fetch("/api/favorites/toggle", {241 method: "POST",242 headers: { "Content-Type": "application/json" },243 body: JSON.stringify({ on, item }),244 });245 if (res.status === 401) throw new Error("401");246 return res.ok;247}248249export function favItemFromListing(l: Listing): FavItem {250 return {251 item_id: l.uid,252 title: l.address || l.title || (l.transaction_type === "louer" ? "Logement" : "Propriété"),253 subtitle: [l.city, l.type, l.transaction_type === "louer" ? "à louer" : "à vendre"]254 .filter(Boolean).join(" · "),255 price_label: fmtPrice(l.price, l.transaction_type, l.price_label),256 image_url: (l.images && l.images[0]) || "",257 url: `https://www.toit-ka.com/annonce/${encodeURIComponent(l.uid)}`,258 };259}260