// ----------------------------------------------------------------------------- // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: Toit-Ka // pages/Home.tsx : accueil — LE commutateur Louer/Acheter, héros, filtres // adaptés à l'univers, grille + carte. L'accent du site suit le toggle. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Facets, Listing, ListingFilters, Stats, Tx, fetchFacets, fetchListings, fetchStats, setDocTitle, setMode, sourceName, } from "../api"; import ListingCard from "../components/ListingCard"; import { Ico } from "../components/Icons"; const MapView = lazy(() => import("../components/MapView")); const PRICE_STEPS: Record = { louer: [500, 700, 900, 1100, 1300, 1500, 1800, 2200, 2600, 3000, 4000], acheter: [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000], }; const AREA_STEPS = [500, 800, 1000, 1500, 2000, 3000]; const PAGE = 12; const fmtStep = (n: number, tx: Tx) => tx === "acheter" ? (n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`) : `${n.toLocaleString("fr-CA")} $`; 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; } /** Valeur retardée : évite une requête API à chaque frappe de recherche. */ function useDebounced(value: T, delayMs = 300): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(t); }, [value, delayMs]); return debounced; } export default function Home() { const [params, setParams] = useSearchParams(); const [tx, setTx] = useState(params.get("tx") === "acheter" ? "acheter" : "louer"); // le toggle est LE geste central : il bascule aussi l'identité du site useEffect(() => { setMode(tx); setDocTitle( tx === "louer" ? "Logements à louer au Québec" : "Propriétés à vendre au Québec"); }, [tx]); useEffect(() => { const urlTx = params.get("tx") === "acheter" ? "acheter" : "louer"; setTx(urlTx); }, [params]); const [listings, setListings] = useState(null); const [total, setTotal] = useState(0); const [page, setPage] = useState(Number(params.get("page")) || 1); const [facets, setFacets] = useState(null); const [sectors, setSectors] = useState([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [q, setQ] = useState(params.get("q") ?? ""); const [city, setCity] = useState(params.get("city") ?? ""); const [sector, setSector] = useState(params.get("sector") ?? ""); const [type, setType] = useState(params.get("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 [pets, setPets] = useState(params.get("pets") ?? ""); const [furnished, setFurnished] = useState(params.get("furnished") ?? ""); const [sort, setSort] = useState(params.get("sort") ?? "recent"); const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); const activeFilters = [q, city, sector, type, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, pets, furnished].filter(Boolean).length; const advCount = [bedsMin, bathsMin, areaMin, source, sector, pets, furnished].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 qDebounced = useDebounced(q); const filters: ListingFilters = useMemo(() => ({ tx, q: qDebounced, city, sector, type, source, price_min: priceMin, price_max: priceMax, bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, pets, furnished, sort, }), [tx, qDebounced, city, sector, type, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, pets, furnished, sort]); // sélection carte <-> liste const [mapSelectedUid, setMapSelectedUid] = useState(null); const [mapHoveredUid, setMapHoveredUid] = useState(null); const mapListRef = useRef(null); useEffect(() => { if (!mapSelectedUid) return; mapListRef.current ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`) ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [mapSelectedUid]); useEffect(() => { fetchStats().then(setStats).catch(() => {}); }, []); // les facettes (villes, types, sources) dépendent de l'univers useEffect(() => { fetchFacets(tx).then(setFacets).catch(() => {}); }, [tx]); useEffect(() => { fetchFacets(tx, city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([])); }, [tx, city]); // changer d'univers : purge les filtres propres à l'autre univers const switchTx = (next: Tx) => { if (next === tx) return; setTx(next); setType(""); setSource(""); setSector(""); setPriceMin(""); setPriceMax(""); setPets(""); setFurnished(""); setBedsMin(""); setBathsMin(""); setPage(1); }; const firstRender = useRef(true); useEffect(() => { if (firstRender.current) { firstRender.current = false; return; } setPage(1); }, [filters]); // synchroniser filtres + page + tri + vue dans l'URL useEffect(() => { const p = new URLSearchParams(); const set = (k: string, v: string) => { if (v) p.set(k, v); }; p.set("tx", tx); set("q", q); set("city", city); set("sector", sector); set("type", type); set("source", source); set("price_min", priceMin); set("price_max", priceMax); set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin); set("area_min", areaMin); set("pets", pets); set("furnished", furnished); if (sort && sort !== "recent") p.set("sort", sort); if (view === "carte") p.set("view", "carte"); if (page > 1) p.set("page", String(page)); setParams(p, { replace: true }); }, [filters, page, view, tx, q, city, sector, type, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, pets, furnished, sort, setParams]); 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(""); setType(""); setSource(""); setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin(""); setPets(""); setFurnished(""); }; const louer = tx === "louer"; const txStats = stats ? stats[tx] : null; 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 (type) pills.push({ label: type, clear: () => setType("") }); if (priceMin) pills.push({ label: `≥ ${fmtStep(Number(priceMin), tx)}`, clear: () => setPriceMin("") }); if (priceMax) pills.push({ label: `≤ ${fmtStep(Number(priceMax), tx)}`, 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 (pets) pills.push({ label: "animaux acceptés", clear: () => setPets("") }); if (furnished) pills.push({ label: furnished === "1" ? "meublé" : "non meublé", clear: () => setFurnished("") }); if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); const topTypes = (facets?.types ?? []).slice(0, 8).map((t) => t.type); const priceSteps = PRICE_STEPS[tx]; return (
Lou-Ka × Immo-Ka — la fusion

Un toit au Québec.
{louer ? "Louer" : "Acheter"}, un seul endroit.

{louer ? "Toit-Ka rassemble les logements à louer affichés par les gestionnaires immobiliers, courtiers et plateformes du Québec — mis à jour automatiquement, avec un lien direct vers l'annonce originale." : "Toit-Ka rassemble les propriétés à vendre affichées par les agences de courtage et plateformes du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct et plus — avec un lien direct vers l'annonce originale."}

Synchronisé en continu {txStats && ( <> {txStats.total.toLocaleString("fr-CA")} annonces {txStats.cities.toLocaleString("fr-CA")} villes {txStats.sources} sources {txStats.avg_price != null && ( {louer ? "loyer moyen" : "prix moyen"}{" "} {Math.round(txStats.avg_price).toLocaleString("fr-CA")} $ )} )}
{sheetOpen &&
setSheetOpen(false)} aria-hidden="true" />}
Filtres
setQ(e.target.value)} aria-label="Recherche" /> {q && }
{louer ? "Loyer" : "Prix"}
{(advOpen || sheetOpen) && (
{louer ? ( <>
) : ( <>
{["1", "2", "3", "4", "5"].map((n) => ( ))}
{["1", "2", "3"].map((n) => ( ))}
)}
{AREA_STEPS.map((a) => ( ))}
)}
{topTypes.map((t) => ( ))}
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

{louer ? "Logements à louer" : "Propriétés à vendre"}

{listings && {total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}}
{error && (

Impossible de charger les annonces

{error}

)} {!error && view === "liste" && listings === null && (
{Array.from({ length: 8 }).map((_, i) => (
))}
)} {!error && view === "liste" && listings !== null && listings.length === 0 && (

Aucune annonce ne correspond

Essayez d'élargir vos critères {activeFilters > 0 && <> — ou }.

)} {!error && view === "carte" && (
{(listings ?? []).map((l) => (
setMapHoveredUid(l.uid)} onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))} >
))}
Chargement de la carte…
}>
)} {!error && view === "liste" && listings !== null && listings.length > 0 && ( <>
{listings.map((l) => )}
{totalPages > 1 && ( )} )}
); }