// ----------------------------------------------------------------------------- // Rent-Ka — Rental listings aggregator (Canada, outside Québec) // Author: Simon-Pierre Boucher — contact@spboucher.ai // pages/Home.tsx: home — hero, browse by province, advanced filters, grid // Filters: province, city, neighbourhood, size, rent min/max, availability, // pets, furnished, area, manager, search — with active-filter pills. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Facets, Listing, ListingFilters, PROVINCE_NAMES, Stats, fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays, registerSourceNames, sourceName, } from "../api"; import ListingCard from "../components/ListingCard"; import Pager from "../components/Pager"; import { useAgo, useCountUp } from "../live"; import { IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders, IcoSofa, } from "../components/Icons"; // The synchronized list+map search (Mapbox, ~220 kB) is only loaded when the // user opens the Map view — see search/MapSearch.tsx const MapSearch = lazy(() => import("../search/MapSearch")); const PAGE_SIZE = 12; // listings per page ("list" grid) /** Debounced value: avoids one API request per keystroke in the search box. */ 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; } // useCountUp / useAgo: shared "live data" hooks — see ../live.ts const UNIT_TYPES = ["Studio", "1 bedroom", "2 bedrooms", "3 bedrooms", "4 bedrooms", "Loft", "Condo", "House", "Room"]; const PRICE_STEPS = [800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000, 3500, 4000]; const AREA_STEPS = [400, 600, 800, 1000, 1200]; /** "Availability" choice → API parameter available_by (ISO date) */ const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [ { key: "", label: "Any", days: null }, { key: "now", label: "Now", days: 0 }, { key: "30", label: "Within 1 month", days: 30 }, { key: "60", label: "Within 2 months", days: 60 }, { key: "90", label: "Within 3 months", days: 90 }, ]; export default function Home() { const [listings, setListings] = useState(null); const [total, setTotal] = useState(0); const [facets, setFacets] = useState(null); const [sectors, setSectors] = useState([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); // filters (pre-filled from the URL, e.g. /?city=Toronto — Stats page links) const [params] = useSearchParams(); const [q, setQ] = useState(params.get("q") ?? ""); const [province, setProvince] = useState(params.get("province") ?? ""); const [city, setCity] = useState(params.get("city") ?? ""); const [sector, setSector] = useState(params.get("sector") ?? ""); const [source, setSource] = useState(params.get("source") ?? ""); const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); const [unitType, setUnitType] = useState(params.get("unit_type") ?? ""); const [dispo, setDispo] = useState(params.get("dispo") ?? ""); const [pets, setPets] = useState(params.get("pets") ?? ""); const [furnished, setFurnished] = useState(params.get("furnished") ?? ""); const [areaMin, setAreaMin] = useState(params.get("area_min") ?? ""); const [deal, setDeal] = useState(params.get("deal") ?? ""); // fair value const [kaMin, setKaMin] = useState(params.get("kascore_min") ?? ""); // KA Score // mobile filter sheet (bottom sheet) + desktop advanced panel const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); // sheet open: freeze background scroll + close on Escape // (same pattern as the App.tsx hamburger menu) useEffect(() => { document.body.style.overflow = sheetOpen ? "hidden" : ""; if (!sheetOpen) return () => { document.body.style.overflow = ""; }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", onKey); }; }, [sheetOpen]); const activeFilters = [q, province, city, sector, source, priceMin, priceMax, unitType, dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length; const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length; // list or map view (remembered in the URL: /?view=map) const [view, setView] = useState<"liste" | "carte">( params.get("view") === "map" ? "carte" : "liste"); useEffect(() => { // follow the URL when navigating via the menu ("Map" -> /?view=map) setView(params.get("view") === "map" ? "carte" : "liste"); }, [params]); // map mode (Ka Map System v2): body class — the filter sheet becomes a modal // ABOVE the shell — and view remembered in the URL (reload, back from a // listing: we land back on the map, camera included). useEffect(() => { document.body.classList.toggle("ka-map-mode", view === "carte"); const url = new URL(window.location.href); if (view === "carte") url.searchParams.set("view", "map"); else url.searchParams.delete("view"); window.history.replaceState(null, "", url); return () => document.body.classList.remove("ka-map-mode"); }, [view]); const qDebounced = useDebounced(q); const filters: ListingFilters = useMemo(() => { const d = DISPO_CHOICES.find((c) => c.key === dispo); return { q: qDebounced, province, city, sector, source, unit_type: unitType, price_min: priceMin, price_max: priceMax, pets, furnished, area_min: areaMin, available_by: d?.days != null ? isoInDays(d.days) : "", deal, kascore_min: kaMin, sort: deal === "sous" ? "deal" : "", // best deals first }; }, [qDebounced, province, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal, kaMin]); // shared counter of the map view (lifted by MapSearch: list = map) const [mapTotal, setMapTotal] = useState(null); // hero live data: last real sync (max across connectors) const [lastSync, setLastSync] = useState(null); useEffect(() => { fetchSources().then((r) => { registerSourceNames(r.sources); const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0)); if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts); }).catch(() => {}); fetchFacets().then(setFacets).catch(() => {}); fetchStats().then(setStats).catch(() => {}); }, []); const totalLive = useCountUp(stats?.total); const syncAgo = useAgo(lastSync); // Home geolocation: on arrival (bare URL, once per session), ask for the // position to directly show rentals around the user — Map view centred on // them. Shared link (filters/camera in the URL): respect the link, no // prompt. Denied: silent, the classic home stays as is. useEffect(() => { const url = new URL(window.location.href); const hasState = [...url.searchParams.keys()].length > 0; if (hasState) return; if (!("geolocation" in navigator)) return; if (sessionStorage.getItem("rentka_geo_session")) return; sessionStorage.setItem("rentka_geo_session", "1"); navigator.geolocation.getCurrentPosition( (pos) => { const { latitude, longitude } = pos.coords; // outside Canada (travel, VPN): don't centre on the ocean if (latitude < 41.6 || latitude > 83.2 || longitude < -141 || longitude > -52.5) return; const u = new URL(window.location.href); u.searchParams.set("lat", latitude.toFixed(5)); u.searchParams.set("lng", longitude.toFixed(5)); u.searchParams.set("zoom", "13.5"); u.searchParams.set("view", "map"); window.history.replaceState(null, "", u); // MapSearch already mounted (map view): recentre + list via event window.dispatchEvent(new CustomEvent("rentka:geolocate", { detail: { lat: latitude, lng: longitude }, })); setView("carte"); }, () => { /* denied or unavailable: home unchanged */ }, { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }, ); }, []); // neighbourhoods depend on the chosen city useEffect(() => { fetchFacets(city || undefined) .then((f) => setSectors(f.sectors)) .catch(() => setSectors([])); }, [city]); // pagination — 12 listings/page, page remembered in the URL (?page=N) const [page, setPage] = useState(() => { const p = Number(params.get("page")); return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1; }); const resultsRef = useRef(null); const filtersKey = useMemo(() => JSON.stringify(filters), [filters]); const firstRun = useRef(true); useEffect(() => { // any filter change goes back to page 1 (except on initial mount, // to honour a shared link /?city=…&page=3) if (firstRun.current) { firstRun.current = false; return; } setPage(1); }, [filtersKey]); useEffect(() => { // reflect the page in the URL without a router re-render // (in map view, pagination belongs to MapSearch) if (view === "carte") return; const url = new URL(window.location.href); if (page > 1) url.searchParams.set("page", String(page)); else url.searchParams.delete("page"); window.history.replaceState(null, "", url); }, [page, view]); const goToPage = (p: number) => { setPage(p); resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }; useEffect(() => { if (view === "carte") return; // the map view has its own unified request let cancelled = false; setListings(null); setError(null); fetchListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE) .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); // page now out of bounds (narrowed filter): go to the last one const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE)); if (page > last) setPage(last); } }) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; }, [filters, page, view]); const availableTypes = useMemo(() => { const set = new Set(facets?.unit_types ?? []); return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t)); }, [facets]); // "Browse by province" cards — provinces with at least one listing const provinceCards = useMemo(() => { const provs = stats?.provinces ?? {}; return Object.entries(provs) .filter(([, n]) => (n as number) > 0) .sort((a, b) => (b[1] as number) - (a[1] as number)) .map(([code, n]) => ({ code, name: PROVINCE_NAMES[code] || code, n: n as number })); }, [stats]); const resetAll = () => { setQ(""); setProvince(""); setCity(""); setSector(""); setSource(""); setPriceMin(""); setPriceMax(""); setUnitType(""); setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); setDeal(""); setKaMin(""); }; // "active filters" pills — label + removal action const pills: { label: string; clear: () => void }[] = []; if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") }); if (province) pills.push({ label: PROVINCE_NAMES[province] || province, clear: () => setProvince(""), }); if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } }); if (sector) pills.push({ label: sector, clear: () => setSector("") }); if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") }); if (priceMin) pills.push({ label: `≥ $${priceMin}`, clear: () => setPriceMin("") }); if (priceMax) pills.push({ label: `≤ $${priceMax}`, clear: () => setPriceMax("") }); if (dispo) pills.push({ label: `Available: ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`, clear: () => setDispo(""), }); if (pets) pills.push({ label: "Pets allowed", clear: () => setPets("") }); if (furnished) pills.push({ label: furnished === "1" ? "Furnished" : "Unfurnished", clear: () => setFurnished(""), }); if (areaMin) pills.push({ label: `≥ ${areaMin} sq ft`, clear: () => setAreaMin("") }); if (deal) pills.push({ label: "Below market", clear: () => setDeal("") }); if (kaMin) pills.push({ label: `KA Score ${kaMin}+`, clear: () => setKaMin("") }); if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); return (
Rentals — all of Canada, one place

Find your next
home.

Apartments and homes for rent across Canada, aggregated continuously — full photos, estimated fair value, direct link to the original listing.

live {totalLive && ( {totalLive} rentals indexed )} {stats && stats.sources > 0 && ( {stats.sources} sources )} {syncAgo && synced {syncAgo}} {stats?.avg_price != null && ( average rent ${Math.round(stats.avg_price).toLocaleString("en-CA")} )}
{/* — Browse by province: signature Rent-Ka section — */} {provinceCards.length > 0 && activeFilters === 0 && (

Browse by province

Every listing links back to the original ad.
{provinceCards.map((p) => ( ))}
)} {sheetOpen && (
setSheetOpen(false)} aria-hidden="true" /> )}
{/* mobile: criteria summary — opens the sheet (search included, no FAB) */}
{availableTypes.map((t) => ( ))}
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

{view === "carte" ? (mapTotal != null ? <>{mapTotal.toLocaleString("en-CA")} rental{mapTotal > 1 ? "s" : ""} : "Rentals") : (listings ? <>{total.toLocaleString("en-CA")} rental{total > 1 ? "s" : ""} : "Rentals")}

{error && (

Could not load listings

{error}

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

No rentals match

Try widening your criteria {activeFilters > 0 && ( <> — or )}.

)} {!error && view === "carte" && (
Loading the map…
} > setView("liste")} onOpenFilters={() => setSheetOpen(true)} filtersCount={activeFilters} />
)} {!error && view === "liste" && listings !== null && listings.length > 0 && (
{listings.map((l) => ( ))}
)}
); }