// -----------------------------------------------------------------------------
// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille + carte
// -----------------------------------------------------------------------------
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Facets, Listing, ListingFilters, Stats,
fetchFacets, fetchListings, fetchSources, fetchStats,
registerSourceNames, sourceName,
} from "../api";
import ListingCard from "../components/ListingCard";
const MapView = lazy(() => import("../components/MapView"));
const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];
const AREA_STEPS = [800, 1000, 1500, 2000, 3000];
const PAGE = 12;
const fmtK = (n: number) =>
n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`;
// fenêtre de pagination : 1 … (p-1) p (p+1) … N
function pageNumbers(p: number, n: number): (number | "…")[] {
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
const out: (number | "…")[] = [1];
const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);
if (lo > 2) out.push("…");
for (let i = lo; i <= hi; i++) out.push(i);
if (hi < n - 1) out.push("…");
out.push(n);
return out;
}
export default function Home() {
const [listings, setListings] = useState
(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); // pagination 12/page
const [facets, setFacets] = useState(null);
const [sectors, setSectors] = useState([]);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const [params] = useSearchParams();
const [q, setQ] = useState(params.get("q") ?? "");
const [city, setCity] = useState(params.get("city") ?? "");
const [sector, setSector] = useState(params.get("sector") ?? "");
const [ptype, setPtype] = useState(params.get("property_type") ?? "");
const [source, setSource] = useState(params.get("source") ?? "");
const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");
const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");
const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
const [sort, setSort] = useState(params.get("sort") ?? "recent");
const [sheetOpen, setSheetOpen] = useState(false);
const [advOpen, setAdvOpen] = useState(false);
const activeFilters = [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin].filter(Boolean).length;
const advCount = [bedsMin, bathsMin, areaMin, source, sector].filter(Boolean).length;
const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");
useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);
const filters: ListingFilters = useMemo(() => ({
q, city, sector, region: "", property_type: ptype, source,
price_min: priceMin, price_max: priceMax,
bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,
}), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);
useEffect(() => {
fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
fetchFacets().then(setFacets).catch(() => {});
fetchStats().then(setStats).catch(() => {});
}, []);
useEffect(() => {
fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));
}, [city]);
// revenir à la page 1 quand les filtres changent
useEffect(() => { setPage(1); }, [filters]);
// charger la page courante (12 annonces) — remplace la grille
useEffect(() => {
let cancelled = false;
setListings(null); setError(null);
fetchListings(filters, PAGE, (page - 1) * PAGE)
.then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })
.catch((e) => !cancelled && setError(String(e)));
return () => { cancelled = true; };
}, [filters, page]);
const totalPages = Math.max(1, Math.ceil(total / PAGE));
const gotoPage = (p: number) => {
setPage(Math.min(Math.max(1, p), totalPages));
document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const resetAll = () => {
setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");
setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");
};
const pills: { label: string; clear: () => void }[] = [];
if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
if (sector) pills.push({ label: sector, clear: () => setSector("") });
if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });
if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });
if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });
if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });
if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });
if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });
if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
const topTypes = (facets?.property_types ?? []).slice(0, 7);
return (
Agrégateur — Toutes les agences du Québec
Toutes les propriétés à vendre ,
un seul endroit.
Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage
immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et plus —
mises à jour automatiquement, avec toutes les photos, les détails complets et un lien
direct vers l'annonce originale.
Données synchronisées en continu
{stats && (
<>
{stats.total.toLocaleString("fr-CA")} propriétés
{stats.sources} agences
{stats.cities?.toLocaleString("fr-CA")} villes
{stats.avg_price != null && (
prix moyen {Math.round(stats.avg_price).toLocaleString("fr-CA")} $
)}
>
)}
{sheetOpen &&
setSheetOpen(false)} aria-hidden="true" />}
Filtres
setSheetOpen(false)} aria-label="Fermer les filtres">✕
Ville
{ setCity(e.target.value); setSector(""); }}>
Toutes
{(facets?.cities ?? []).map((c) => {c} )}
Type
setPtype(e.target.value)}>
Tous
{(facets?.property_types ?? []).map((t) => {t} )}
Prix
setPriceMin(e.target.value)}>
Min
{PRICE_STEPS.map((p) => (
= Number(priceMax)}>{fmtK(p)}
))}
—
setPriceMax(e.target.value)}>
Max
{PRICE_STEPS.map((p) => (
{fmtK(p)}
))}
0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
{(advOpen || sheetOpen) && (
Quartier / secteur
setSector(e.target.value)}>
Tous
{sectors.map((s) => {s} )}
Chambres (min.)
setBedsMin("")}>Toutes
{["1", "2", "3", "4", "5"].map((n) => (
setBedsMin(n)}>{n}+
))}
Salles de bain (min.)
setBathsMin("")}>Toutes
{["1", "2", "3"].map((n) => (
setBathsMin(n)}>{n}+
))}
Superficie minimale
setAreaMin("")}>Peu importe
{AREA_STEPS.map((a) => (
setAreaMin(String(a))}>{a}+
))}
Agence
setSource(e.target.value)}>
Toutes
{(facets?.sources ?? []).map((s) => (
{sourceName(s.source)} ({s.n})
))}
Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
)}
setSheetOpen(false)}>
Voir les résultats {listings ? `(${total})` : ""}
{topTypes.map((t) => (
setPtype(ptype === t ? "" : t)}>
{t}
))}
{pills.length > 0 && (
{pills.map((p) => (
{p.label} ✕
))}
Tout effacer
)}
Propriétés à vendre
{listings &&
{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""} }
setSort(e.target.value)} aria-label="Trier">
Plus récents
Prix ↑
Prix ↓
setView("liste")}>☰ Liste
setView("carte")}>◈ Carte
{error && (
⚠️
Impossible de charger les annonces
{error}
window.location.reload()}>Réessayer
)}
{!error && view === "liste" && listings === null && (
{Array.from({ length: 8 }).map((_, i) => (
))}
)}
{!error && view === "liste" && listings !== null && listings.length === 0 && (
🔍
Aucune propriété ne correspond
Essayez d'élargir vos critères
{activeFilters > 0 && <> — ou retirez les {activeFilters} filtres actifs >}.
)}
{!error && view === "carte" && (
{(listings ?? []).map((l) => )}
Chargement de la carte… }>
)}
{!error && view === "liste" && listings !== null && listings.length > 0 && (
<>
{listings.map((l) => )}
{totalPages > 1 && (
gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.
{pageNumbers(page, totalPages).map((p, i) =>
p === "…"
? …
: gotoPage(p as number)}>{p}
)}
gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›
Page {page} / {totalPages}
)}
>
)}
setSheetOpen(true)} aria-label="Ouvrir les filtres">
⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
);
}