SPB Git

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%
8.2 KB · 260 lines typescript
Raw Blame History
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