Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : home — hero, live stats, advanced filters, grid + map5// -----------------------------------------------------------------------------6import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";7import { useSearchParams } from "react-router-dom";8import {9 Facets, Listing, ListingFilters, Stats,10 fetchFacets, fetchListings, fetchSources, fetchStats,11 registerSourceNames, sourceName,12} from "../api";13import ListingCard from "../components/ListingCard";14import { Ico } from "../components/Icons";1516const MapView = lazy(() => import("../components/MapView"));1718const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];19const AREA_STEPS = [800, 1000, 1500, 2000, 3000];20const PAGE = 12;2122const fmtK = (n: number) =>23 n >= 1_000_000 ? `$${n / 1_000_000}M` : `$${Math.round(n / 1000)}k`;2425/** Animated hero counter (~0.9 s, cubic easing) — the feel of a real engine26 indexing. Respects prefers-reduced-motion (direct value). */27function useCountUp(target: number | null | undefined, ms = 900): string | null {28 const [v, setV] = useState<number | null>(null);29 useEffect(() => {30 if (target == null) return;31 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }32 let raf = 0;33 const t0 = performance.now();34 const step = (t: number) => {35 const p = Math.min(1, (t - t0) / ms);36 setV(Math.round(target * (1 - Math.pow(1 - p, 3))));37 if (p < 1) raf = requestAnimationFrame(step);38 };39 raf = requestAnimationFrame(step);40 return () => cancelAnimationFrame(raf);41 }, [target, ms]);42 return v == null ? null : v.toLocaleString("en-CA");43}4445/** live "X s ago" — re-rendered every second while the ts exists. */46function useAgo(ts: number | null): string | null {47 const [, tick] = useState(0);48 useEffect(() => {49 if (ts == null) return;50 const id = setInterval(() => tick((x) => x + 1), 1000);51 return () => clearInterval(id);52 }, [ts]);53 if (ts == null) return null;54 const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));55 if (s < 90) return `${s} s ago`;56 if (s < 5400) return `${Math.round(s / 60)} min ago`;57 return `${Math.round(s / 3600)} h ago`;58}5960// pagination window: 1 … (p-1) p (p+1) … N61function pageNumbers(p: number, n: number): (number | "…")[] {62 if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);63 const out: (number | "…")[] = [1];64 const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);65 if (lo > 2) out.push("…");66 for (let i = lo; i <= hi; i++) out.push(i);67 if (hi < n - 1) out.push("…");68 out.push(n);69 return out;70}7172export default function Home() {73 const [listings, setListings] = useState<Listing[] | null>(null);74 const [total, setTotal] = useState(0);75 const [params, setParams] = useSearchParams();76 const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, in the URL77 const [facets, setFacets] = useState<Facets | null>(null);78 const [sectors, setSectors] = useState<string[]>([]);79 const [stats, setStats] = useState<Stats | null>(null);80 const [error, setError] = useState<string | null>(null);81 const [q, setQ] = useState(params.get("q") ?? "");82 const [city, setCity] = useState(params.get("city") ?? "");83 const [sector, setSector] = useState(params.get("sector") ?? "");84 const [ptype, setPtype] = useState(params.get("property_type") ?? "");85 const [source, setSource] = useState(params.get("source") ?? "");86 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");87 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");88 const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");89 const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");90 const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");91 const [sort, setSort] = useState(params.get("sort") ?? "recent");9293 const [sheetOpen, setSheetOpen] = useState(false);94 const [advOpen, setAdvOpen] = useState(false);9596 // Filter bottom-sheet: background scroll lock + Escape to close97 // (same rules as the header mobile menu — see App.tsx).98 useEffect(() => {99 if (!sheetOpen) return;100 document.body.style.overflow = "hidden";101 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };102 window.addEventListener("keydown", onKey);103 return () => {104 document.body.style.overflow = "";105 window.removeEventListener("keydown", onKey);106 };107 }, [sheetOpen]);108 const activeFilters = [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin].filter(Boolean).length;109 const advCount = [bedsMin, bathsMin, areaMin, source, sector].filter(Boolean).length;110111 const [view, setView] = useState<"list" | "map">(params.get("view") === "map" ? "map" : "list");112 useEffect(() => { setView(params.get("view") === "map" ? "map" : "list"); }, [params]);113114 // map mode (Ka Map System v2): body class — the page's filter sheet becomes115 // a modal ABOVE the full-viewport shell.116 useEffect(() => {117 document.body.classList.toggle("ka-map-mode", view === "map");118 return () => document.body.classList.remove("ka-map-mode");119 }, [view]);120121 const filters: ListingFilters = useMemo(() => ({122 q, city, sector, region: "", property_type: ptype, source,123 price_min: priceMin, price_max: priceMax,124 bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,125 }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);126127 useEffect(() => {128 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});129 fetchFacets().then(setFacets).catch(() => {});130 fetchStats().then(setStats).catch(() => {});131 }, []);132133 useEffect(() => {134 fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));135 }, [city]);136137 // back to page 1 whenever the filters change138 const firstRender = useRef(true);139 useEffect(() => {140 if (firstRender.current) { firstRender.current = false; return; }141 setPage(1);142 }, [filters]);143144 // sync filters + page + sort + view into the URL → going back from a145 // listing returns to the SAME page/filters.146 useEffect(() => {147 const p = new URLSearchParams();148 const set = (k: string, v: string) => { if (v) p.set(k, v); };149 set("q", q); set("city", city); set("sector", sector);150 set("property_type", ptype); set("source", source);151 set("price_min", priceMin); set("price_max", priceMax);152 set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);153 set("area_min", areaMin);154 if (sort && sort !== "recent") p.set("sort", sort);155 if (view === "map") p.set("view", "map");156 if (page > 1) p.set("page", String(page));157 setParams(p, { replace: true });158 }, [filters, page, view, q, city, sector, ptype, source, priceMin, priceMax,159 bedsMin, bathsMin, areaMin, sort, setParams]);160161 // load the current page (12 listings) — replaces the grid162 useEffect(() => {163 let cancelled = false;164 setListings(null); setError(null);165 fetchListings(filters, PAGE, (page - 1) * PAGE)166 .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })167 .catch((e) => !cancelled && setError(String(e)));168 return () => { cancelled = true; };169 }, [filters, page]);170171 const totalPages = Math.max(1, Math.ceil(total / PAGE));172 const gotoPage = (p: number) => {173 setPage(Math.min(Math.max(1, p), totalPages));174 if (view === "map") return; // the map-mode pane manages its own scrolling175 document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });176 };177178 const resetAll = () => {179 setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");180 setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");181 };182183 // live hero data — real connector syncs (recent_syncs)184 const totalLive = useCountUp(stats?.total);185 const lastSync = useMemo(() => {186 const ss = stats?.recent_syncs ?? [];187 if (!ss.length) return null;188 const ts = Math.max(...ss.map((s) => s.ts));189 return ts > 1e12 ? Math.round(ts / 1000) : ts;190 }, [stats]);191 const syncAgo = useAgo(lastSync);192 const newToday = useMemo(() => {193 const ss = stats?.recent_syncs ?? [];194 const now = Date.now() / 1000;195 return ss196 .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400)197 .reduce((n, s) => n + (s.added || 0), 0);198 }, [stats]);199200 const pills: { label: string; clear: () => void }[] = [];201 if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });202 if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });203 if (sector) pills.push({ label: sector, clear: () => setSector("") });204 if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });205 if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });206 if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });207 if (bedsMin) pills.push({ label: `${bedsMin}+ bed`, clear: () => setBedsMin("") });208 if (bathsMin) pills.push({ label: `${bathsMin}+ bath`, clear: () => setBathsMin("") });209 if (areaMin) pills.push({ label: `≥ ${areaMin} sq ft`, clear: () => setAreaMin("") });210 if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });211212 const topTypes = (facets?.property_types ?? []).slice(0, 7);213214 return (215 <div className="container">216 <section className="hero">217 <div className="hero-wrap">218 <div className="hero-main">219 <span className="kicker">Aggregator — Canadian brokerages, coast to coast</span>220 <h1 className="hero-display" aria-label="Every home for sale. One place.">221 <span className="hd-l1" aria-hidden="true">Every home</span>222 <span className="hd-l2" aria-hidden="true">for sale.</span>223 <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>224 </h1>225 <p className="lede">226 Homes listed by Canadian brokerages and national networks —227 every province except Québec (that's our sister site Immo-Ka),228 aggregated continuously with photos, details and a direct link229 to the original listing.230 </p>231 <div className="live-line" aria-label="Live data">232 <span className="live-flag"><span className="live-dot" /> live</span>233 {syncAgo && <span className="live-item">synced {syncAgo}</span>}234 {newToday > 0 && (235 <span className="live-item"><b>+{newToday.toLocaleString("en-CA")}</b> today</span>236 )}237 {stats && stats.sources > 0 && (238 <span className="live-item"><b>{stats.sources}</b> sources</span>239 )}240 </div>241 </div>242 <aside className="hero-data" aria-label="The market in numbers">243 <div className="hd-row">244 <b>{totalLive ?? "—"}</b><span>homes indexed</span>245 </div>246 {stats && stats.cities != null && (247 <div className="hd-row">248 <b>{stats.cities.toLocaleString("en-CA")}</b><span>cities & towns</span>249 </div>250 )}251 {stats?.avg_price != null && (252 <div className="hd-row">253 <b>${Math.round(stats.avg_price).toLocaleString("en-CA")}</b><span>average price</span>254 </div>255 )}256 {stats?.max_price != null && (257 <div className="hd-row">258 <b>{fmtK(stats.max_price)}</b><span>highest price</span>259 </div>260 )}261 </aside>262 </div>263 </section>264265 {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}266 <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">267 <div className="sheet-handle" aria-hidden="true" />268 <div className="sheet-head">269 <span>Refine your search</span>270 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close the filters">✕</button>271 </div>272273 {/* — search, the heart of the product: one large underlined field — */}274 <div className="q-big">275 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">276 <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />277 </svg>278 <input id="f-q" placeholder="Where do you want to live?" value={q}279 onChange={(e) => setQ(e.target.value)}280 aria-label="Search — address, city or MLS number" />281 {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Clear the search">✕</button>}282 </div>283284 {/* — inline criteria, separated by hairlines (no boxes) — */}285 <div className="crit-line">286 <label className="crit">287 <span>City</span>288 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>289 <option value="">All</option>290 {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}291 </select>292 </label>293 <label className="crit">294 <span>Type</span>295 <select value={ptype} onChange={(e) => setPtype(e.target.value)}>296 <option value="">All</option>297 {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}298 </select>299 </label>300 <div className="crit">301 <span>Price</span>302 <div className="range-pair">303 <select aria-label="Minimum price" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>304 <option value="">Min</option>305 {PRICE_STEPS.map((p) => (306 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>307 ))}308 </select>309 <span className="range-sep">—</span>310 <select aria-label="Maximum price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>311 <option value="">Max</option>312 {PRICE_STEPS.map((p) => (313 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>314 ))}315 </select>316 </div>317 </div>318 <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>319 All criteria320 {advCount > 0 && <span className="crit-badge">{advCount}</span>}321 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />322 </button>323 </div>324325 {(advOpen || sheetOpen) && (326 <div className="f-adv">327 <div className="f-group">328 <label>Neighbourhood / area</label>329 <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>330 <option value="">All</option>331 {sectors.map((s) => <option key={s} value={s}>{s}</option>)}332 </select>333 </div>334 <div className="f-group">335 <label>Bedrooms (min.)</label>336 <div className="seg" role="group">337 <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Any</button>338 {["1", "2", "3", "4", "5"].map((n) => (339 <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>340 ))}341 </div>342 </div>343 <div className="f-group">344 <label>Bathrooms (min.)</label>345 <div className="seg" role="group">346 <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Any</button>347 {["1", "2", "3"].map((n) => (348 <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>349 ))}350 </div>351 </div>352 <div className="f-group">353 <label>Minimum living area</label>354 <div className="seg" role="group">355 <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Any</button>356 {AREA_STEPS.map((a) => (357 <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>358 ))}359 </div>360 </div>361 <div className="f-group">362 <label>Source</label>363 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>364 <option value="">All</option>365 {(facets?.sources ?? []).map((s) => (366 <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>367 ))}368 </select>369 </div>370 <div className="f-group f-group-end">371 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>372 Reset everything{activeFilters > 0 ? ` (${activeFilters})` : ""}373 </button>374 </div>375 </div>376 )}377378 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>379 See {listings ? `the ${total.toLocaleString("en-CA")} homes` : "the results"}380 </button>381 </section>382383 {/* mobile: criteria summary — opens the sheet (search built in, no FAB) */}384 <button className="crit-summary" onClick={() => setSheetOpen(true)}>385 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">386 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />387 </svg>388 <span className="cs-txt">389 {pills.length > 0390 ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")391 : "City, type, price, bedrooms…"}392 </span>393 <span className="f-chev" aria-hidden="true" />394 </button>395396 <div className="chips" role="group" aria-label="Quick filters">397 {topTypes.map((t) => (398 <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>399 {t}400 </button>401 ))}402 </div>403404 {pills.length > 0 && (405 <div className="pills" aria-label="Active filters">406 {pills.map((p) => (407 <button key={p.label} className="pill" onClick={p.clear} aria-label={`Remove the filter ${p.label}`}>408 {p.label} <span className="pill-x">✕</span>409 </button>410 ))}411 <button className="pill pill-clear" onClick={resetAll}>Clear all</button>412 </div>413 )}414415 <div className="results-bar" id="results-top">416 <h2 className="rb-count">417 {listings418 ? <><b>{total.toLocaleString("en-CA")}</b> home{total > 1 ? "s" : ""}</>419 : "Homes"}420 </h2>421 <div className="rb-tools">422 <label className="rb-sort">423 <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">424 <option value="recent">Newest</option>425 <option value="price_asc">Price: low to high</option>426 <option value="price_desc">Price: high to low</option>427 </select>428 </label>429 <div className="rb-tabs" role="tablist" aria-label="Display mode">430 <button role="tab" aria-selected={view === "list"} className={`rb-tab ${view === "list" ? "on" : ""}`} onClick={() => setView("list")}>431 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">432 <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />433 </svg>434 List435 </button>436 <button role="tab" aria-selected={view === "map"} className={`rb-tab ${view === "map" ? "on" : ""}`} onClick={() => setView("map")}>437 <Ico name="map" size={13} /> Map438 </button>439 </div>440 <button className="rb-filters" onClick={() => setSheetOpen(true)}>441 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">442 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />443 </svg>444 Filters445 {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}446 </button>447 </div>448 </div>449450 {error && (451 <div className="notice">452 <div className="big"><Ico name="alert" size={40} /></div>453 <h2>Could not load the listings</h2>454 <p>{error}</p>455 <button className="btn btn-primary" onClick={() => window.location.reload()}>Try again</button>456 </div>457 )}458459 {!error && view === "list" && listings === null && (460 <div className="grid grid-edito" aria-busy="true">461 {Array.from({ length: 8 }).map((_, i) => (462 <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>463 ))}464 </div>465 )}466467 {!error && view === "list" && listings !== null && listings.length === 0 && (468 <div className="notice">469 <div className="big"><Ico name="search" size={40} /></div>470 <h2>No home matches</h2>471 <p>Try widening your criteria472 {activeFilters > 0 && <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>}.</p>473 </div>474 )}475476 {!error && view === "map" && (477 <div className="view-pane" key="map">478 <Suspense fallback={<div className="ka-shell-fallback">Loading the map…</div>}>479 <MapView480 filters={filters}481 listings={listings}482 total={total}483 page={page}484 totalPages={totalPages}485 onPage={gotoPage}486 sort={sort}487 onSort={setSort}488 onExit={() => setView("list")}489 onOpenFilters={() => setSheetOpen(true)}490 filtersCount={activeFilters}491 />492 </Suspense>493 </div>494 )}495496 {!error && view === "list" && listings !== null && listings.length > 0 && (497 <div className="view-pane">498 <div className="grid grid-edito">499 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}500 </div>501 {totalPages > 1 && (502 <nav className="pager" aria-label="Pagination">503 <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Prev</button>504 {pageNumbers(page, totalPages).map((p, i) =>505 p === "…"506 ? <span key={`e${i}`} className="pager-gap">…</span>507 : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}508 onClick={() => gotoPage(p as number)}>{p}</button>509 )}510 <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Next ›</button>511 <span className="pager-info">Page {page} / {totalPages}</span>512 </nav>513 )}514 </div>515 )}516 </div>517 );518}519