Python 49.6%
TypeScript 25.5%
CSS 24.1%
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : home — hero, live stats, advanced filters, grid + map view5// -----------------------------------------------------------------------------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 SQFT_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). Respects reduced-motion. */26function useCountUp(target: number | null | undefined, ms = 900): string | null {27 const [v, setV] = useState<number | null>(null);28 useEffect(() => {29 if (target == null) return;30 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }31 let raf = 0;32 const t0 = performance.now();33 const step = (t: number) => {34 const p = Math.min(1, (t - t0) / ms);35 setV(Math.round(target * (1 - Math.pow(1 - p, 3))));36 if (p < 1) raf = requestAnimationFrame(step);37 };38 raf = requestAnimationFrame(step);39 return () => cancelAnimationFrame(raf);40 }, [target, ms]);41 return v == null ? null : v.toLocaleString("en-US");42}4344/** Live "X ago" — re-rendered every second while the ts exists. */45function useAgo(ts: number | null): string | null {46 const [, tick] = useState(0);47 useEffect(() => {48 if (ts == null) return;49 const id = setInterval(() => tick((x) => x + 1), 1000);50 return () => clearInterval(id);51 }, [ts]);52 if (ts == null) return null;53 const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));54 if (s < 90) return `${s} s ago`;55 if (s < 5400) return `${Math.round(s / 60)} min ago`;56 return `${Math.round(s / 3600)} h ago`;57}5859// pagination window: 1 … (p-1) p (p+1) … N60function pageNumbers(p: number, n: number): (number | "…")[] {61 if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);62 const out: (number | "…")[] = [1];63 const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);64 if (lo > 2) out.push("…");65 for (let i = lo; i <= hi; i++) out.push(i);66 if (hi < n - 1) out.push("…");67 out.push(n);68 return out;69}7071export default function Home() {72 const [listings, setListings] = useState<Listing[] | null>(null);73 const [total, setTotal] = useState(0);74 const [params, setParams] = useSearchParams();75 const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, in the URL76 const [facets, setFacets] = useState<Facets | null>(null);77 const [cities, setCities] = useState<{ city: string; n: number }[]>([]);78 const [stats, setStats] = useState<Stats | null>(null);79 const [error, setError] = useState<string | null>(null);80 const [q, setQ] = useState(params.get("q") ?? "");81 const [usState, setUsState] = useState(params.get("state") ?? "");82 const [city, setCity] = useState(params.get("city") ?? "");83 const [ptype, setPtype] = useState(params.get("property_type") ?? "");84 const [source, setSource] = useState(params.get("source") ?? "");85 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");86 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");87 const [bedsMin, setBedsMin] = useState(params.get("beds_min") ?? "");88 const [bathsMin, setBathsMin] = useState(params.get("baths_min") ?? "");89 const [sqftMin, setSqftMin] = useState(params.get("sqft_min") ?? "");90 const [sort, setSort] = useState(params.get("sort") ?? "recent");9192 const [sheetOpen, setSheetOpen] = useState(false);93 const [advOpen, setAdvOpen] = useState(false);9495 // Filters bottom-sheet: background scroll lock + Escape to close96 useEffect(() => {97 if (!sheetOpen) return;98 document.body.style.overflow = "hidden";99 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };100 window.addEventListener("keydown", onKey);101 return () => {102 document.body.style.overflow = "";103 window.removeEventListener("keydown", onKey);104 };105 }, [sheetOpen]);106 const activeFilters = [q, usState, city, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin].filter(Boolean).length;107 const advCount = [bedsMin, bathsMin, sqftMin, source].filter(Boolean).length;108109 const [view, setView] = useState<"list" | "map">(params.get("view") === "map" ? "map" : "list");110 useEffect(() => { setView(params.get("view") === "map" ? "map" : "list"); }, [params]);111112 // map mode (Ka Map System v2): body class — the page's filter sheet becomes113 // a modal ABOVE the full-viewport shell.114 useEffect(() => {115 document.body.classList.toggle("ka-map-mode", view === "map");116 return () => document.body.classList.remove("ka-map-mode");117 }, [view]);118119 const filters: ListingFilters = useMemo(() => ({120 q, city, state: usState, property_type: ptype, source,121 price_min: priceMin, price_max: priceMax,122 beds_min: bedsMin, baths_min: bathsMin, sqft_min: sqftMin, sort,123 }), [q, city, usState, ptype, source, priceMin, priceMax, bedsMin, bathsMin, sqftMin, sort]);124125 useEffect(() => {126 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});127 fetchFacets().then(setFacets).catch(() => {});128 fetchStats().then(setStats).catch(() => {});129 }, []);130131 // cities narrowed by the selected state132 useEffect(() => {133 fetchFacets(usState || undefined).then((f) => setCities(f.cities)).catch(() => setCities([]));134 }, [usState]);135136 // back to page 1 when filters change137 const firstRender = useRef(true);138 useEffect(() => {139 if (firstRender.current) { firstRender.current = false; return; }140 setPage(1);141 }, [filters]);142143 // sync filters + page + sort + view into the URL → going back from a144 // detail page returns to the SAME page/filters.145 useEffect(() => {146 const p = new URLSearchParams();147 const set = (k: string, v: string) => { if (v) p.set(k, v); };148 set("q", q); set("state", usState); set("city", city);149 set("property_type", ptype); set("source", source);150 set("price_min", priceMin); set("price_max", priceMax);151 set("beds_min", bedsMin); set("baths_min", bathsMin);152 set("sqft_min", sqftMin);153 if (sort && sort !== "recent") p.set("sort", sort);154 if (view === "map") p.set("view", "map");155 if (page > 1) p.set("page", String(page));156 setParams(p, { replace: true });157 }, [filters, page, view, q, usState, city, ptype, source, priceMin, priceMax,158 bedsMin, bathsMin, sqftMin, sort, setParams]);159160 // load the current page (12 listings) — replaces the grid161 useEffect(() => {162 let cancelled = false;163 setListings(null); setError(null);164 fetchListings(filters, PAGE, (page - 1) * PAGE)165 .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })166 .catch((e) => !cancelled && setError(String(e)));167 return () => { cancelled = true; };168 }, [filters, page]);169170 const totalPages = Math.max(1, Math.ceil(total / PAGE));171 const gotoPage = (p: number) => {172 setPage(Math.min(Math.max(1, p), totalPages));173 if (view === "map") return; // the map-mode panel manages its own scroll174 document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });175 };176177 const resetAll = () => {178 setQ(""); setUsState(""); setCity(""); setPtype(""); setSource("");179 setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setSqftMin("");180 };181182 // live hero data — real connector syncs (recent_syncs)183 const totalLive = useCountUp(stats?.total);184 const lastSync = useMemo(() => {185 const ss = stats?.recent_syncs ?? [];186 if (!ss.length) return null;187 const ts = Math.max(...ss.map((s) => s.ts));188 return ts > 1e12 ? Math.round(ts / 1000) : ts;189 }, [stats]);190 const syncAgo = useAgo(lastSync);191 const newToday = useMemo(() => {192 const ss = stats?.recent_syncs ?? [];193 const now = Date.now() / 1000;194 return ss195 .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400)196 .reduce((n, s) => n + (s.added || 0), 0);197 }, [stats]);198199 const pills: { label: string; clear: () => void }[] = [];200 if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });201 if (usState) pills.push({ label: usState, clear: () => { setUsState(""); setCity(""); } });202 if (city) pills.push({ label: city, clear: () => setCity("") });203 if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });204 if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });205 if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });206 if (bedsMin) pills.push({ label: `${bedsMin}+ bd`, clear: () => setBedsMin("") });207 if (bathsMin) pills.push({ label: `${bathsMin}+ ba`, clear: () => setBathsMin("") });208 if (sqftMin) pills.push({ label: `≥ ${sqftMin} sq ft`, clear: () => setSqftMin("") });209 if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });210211 const topTypes = (facets?.property_types ?? []).slice(0, 7);212213 return (214 <div className="container">215 <section className="hero">216 <div className="hero-wrap">217 <div className="hero-main">218 <span className="kicker">Aggregator — every listing source in the U.S.</span>219 <h1 className="hero-display" aria-label="Every home for sale in America. One place.">220 <span className="hd-l1" aria-hidden="true">Every home</span>221 <span className="hd-l2" aria-hidden="true">for sale</span>222 <span className="hd-l3" aria-hidden="true">in America.</span>223 <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>224 </h1>225 <p className="lede">226 Brokerages, MLS feeds and listing platforms across the United States —227 aggregated in one place, always current, with full photos and details228 and a direct link to the original listing.229 </p>230 <div className="live-line" aria-label="Live data">231 <span className="live-flag"><span className="live-dot" /> live</span>232 {syncAgo && <span className="live-item">synced {syncAgo}</span>}233 {newToday > 0 && (234 <span className="live-item"><b>+{newToday.toLocaleString("en-US")}</b> today</span>235 )}236 {stats && stats.sources > 0 && (237 <span className="live-item"><b>{stats.sources}</b> sources</span>238 )}239 </div>240 </div>241 <aside className="hero-data" aria-label="The market in numbers">242 <div className="hd-row">243 <b>{totalLive ?? "—"}</b><span>homes indexed</span>244 </div>245 {stats && stats.states != null && stats.states > 0 && (246 <div className="hd-row">247 <b>{stats.states.toLocaleString("en-US")}</b><span>states</span>248 </div>249 )}250 {stats?.avg_price != null && (251 <div className="hd-row">252 <b>${Math.round(stats.avg_price).toLocaleString("en-US")}</b><span>average price</span>253 </div>254 )}255 {stats?.max_price != null && (256 <div className="hd-row">257 <b>{fmtK(stats.max_price)}</b><span>highest price</span>258 </div>259 )}260 </aside>261 </div>262 </section>263264 {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}265 <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">266 <div className="sheet-handle" aria-hidden="true" />267 <div className="sheet-head">268 <span>Refine your search</span>269 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close filters">✕</button>270 </div>271272 {/* — search, the heart of the product: one large underlined field — */}273 <div className="q-big">274 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">275 <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />276 </svg>277 <input id="f-q" placeholder="Where do you want to live?" value={q}278 onChange={(e) => setQ(e.target.value)}279 aria-label="Search — address, city, ZIP or MLS number" />280 {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Clear search">✕</button>}281 </div>282283 {/* — inline criteria, separated by rules (no boxes) — */}284 <div className="crit-line">285 <label className="crit">286 <span>State</span>287 <select value={usState} onChange={(e) => { setUsState(e.target.value); setCity(""); }}>288 <option value="">All</option>289 {(facets?.states ?? []).map((s) => (290 <option key={s.state} value={s.state}>{s.state} ({s.n.toLocaleString("en-US")})</option>291 ))}292 </select>293 </label>294 <label className="crit">295 <span>City</span>296 <select value={city} onChange={(e) => setCity(e.target.value)}>297 <option value="">All</option>298 {cities.map((c) => <option key={c.city} value={c.city}>{c.city}</option>)}299 </select>300 </label>301 <label className="crit">302 <span>Type</span>303 <select value={ptype} onChange={(e) => setPtype(e.target.value)}>304 <option value="">All</option>305 {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}306 </select>307 </label>308 <div className="crit">309 <span>Price</span>310 <div className="range-pair">311 <select aria-label="Minimum price" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>312 <option value="">Min</option>313 {PRICE_STEPS.map((p) => (314 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>315 ))}316 </select>317 <span className="range-sep">—</span>318 <select aria-label="Maximum price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>319 <option value="">Max</option>320 {PRICE_STEPS.map((p) => (321 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>322 ))}323 </select>324 </div>325 </div>326 <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>327 All criteria328 {advCount > 0 && <span className="crit-badge">{advCount}</span>}329 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />330 </button>331 </div>332333 {(advOpen || sheetOpen) && (334 <div className="f-adv">335 <div className="f-group">336 <label>Bedrooms (min.)</label>337 <div className="seg" role="group">338 <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Any</button>339 {["1", "2", "3", "4", "5"].map((n) => (340 <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>341 ))}342 </div>343 </div>344 <div className="f-group">345 <label>Bathrooms (min.)</label>346 <div className="seg" role="group">347 <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Any</button>348 {["1", "2", "3"].map((n) => (349 <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>350 ))}351 </div>352 </div>353 <div className="f-group">354 <label>Minimum living area</label>355 <div className="seg" role="group">356 <button className={sqftMin === "" ? "on" : ""} onClick={() => setSqftMin("")}>Any</button>357 {SQFT_STEPS.map((a) => (358 <button key={a} className={sqftMin === String(a) ? "on" : ""} onClick={() => setSqftMin(String(a))}>{a}+</button>359 ))}360 </div>361 </div>362 <div className="f-group">363 <label>Source</label>364 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>365 <option value="">All</option>366 {(facets?.sources ?? []).map((s) => (367 <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>368 ))}369 </select>370 </div>371 <div className="f-group f-group-end">372 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>373 Reset all{activeFilters > 0 ? ` (${activeFilters})` : ""}374 </button>375 </div>376 </div>377 )}378379 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>380 Show {listings ? `${total.toLocaleString("en-US")} homes` : "results"}381 </button>382 </section>383384 {/* mobile: criteria summary — opens the sheet (search included, no FAB) */}385 <button className="crit-summary" onClick={() => setSheetOpen(true)}>386 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">387 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />388 </svg>389 <span className="cs-txt">390 {pills.length > 0391 ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")392 : "State, city, type, price, beds…"}393 </span>394 <span className="f-chev" aria-hidden="true" />395 </button>396397 <div className="chips" role="group" aria-label="Quick filters">398 {topTypes.map((t) => (399 <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>400 {t}401 </button>402 ))}403 </div>404405 {pills.length > 0 && (406 <div className="pills" aria-label="Active filters">407 {pills.map((p) => (408 <button key={p.label} className="pill" onClick={p.clear} aria-label={`Remove filter ${p.label}`}>409 {p.label} <span className="pill-x">✕</span>410 </button>411 ))}412 <button className="pill pill-clear" onClick={resetAll}>Clear all</button>413 </div>414 )}415416 <div className="results-bar" id="results-top">417 <h2 className="rb-count">418 {listings419 ? <><b>{total.toLocaleString("en-US")}</b> home{total !== 1 ? "s" : ""}</>420 : "Homes"}421 </h2>422 <div className="rb-tools">423 <label className="rb-sort">424 <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">425 <option value="recent">Newest</option>426 <option value="price_asc">Price: low to high</option>427 <option value="price_desc">Price: high to low</option>428 </select>429 </label>430 <div className="rb-tabs" role="tablist" aria-label="View mode">431 <button role="tab" aria-selected={view === "list"} className={`rb-tab ${view === "list" ? "on" : ""}`} onClick={() => setView("list")}>432 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">433 <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />434 </svg>435 List436 </button>437 <button role="tab" aria-selected={view === "map"} className={`rb-tab ${view === "map" ? "on" : ""}`} onClick={() => setView("map")}>438 <Ico name="map" size={13} /> Map439 </button>440 </div>441 <button className="rb-filters" onClick={() => setSheetOpen(true)}>442 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">443 <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />444 </svg>445 Filters446 {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}447 </button>448 </div>449 </div>450451 {error && (452 <div className="notice">453 <div className="big"><Ico name="alert" size={40} /></div>454 <h2>Could not load listings</h2>455 <p>{error}</p>456 <button className="btn btn-primary" onClick={() => window.location.reload()}>Retry</button>457 </div>458 )}459460 {!error && view === "list" && listings === null && (461 <div className="grid grid-edito" aria-busy="true">462 {Array.from({ length: 8 }).map((_, i) => (463 <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>464 ))}465 </div>466 )}467468 {!error && view === "list" && listings !== null && listings.length === 0 && (469 <div className="notice">470 <div className="big"><Ico name="search" size={40} /></div>471 <h2>No homes match your criteria</h2>472 <p>Try widening your search473 {activeFilters > 0 && <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>}.</p>474 </div>475 )}476477 {!error && view === "map" && (478 <div className="view-pane" key="map">479 <Suspense fallback={<div className="ka-shell-fallback">Loading the map…</div>}>480 <MapView481 filters={filters}482 listings={listings}483 total={total}484 page={page}485 totalPages={totalPages}486 onPage={gotoPage}487 sort={sort}488 onSort={setSort}489 onExit={() => setView("list")}490 onOpenFilters={() => setSheetOpen(true)}491 filtersCount={activeFilters}492 />493 </Suspense>494 </div>495 )}496497 {!error && view === "list" && listings !== null && listings.length > 0 && (498 <div className="view-pane">499 <div className="grid grid-edito">500 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}501 </div>502 {totalPages > 1 && (503 <nav className="pager" aria-label="Pagination">504 <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Prev</button>505 {pageNumbers(page, totalPages).map((p, i) =>506 p === "…"507 ? <span key={`e${i}`} className="pager-gap">…</span>508 : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}509 onClick={() => gotoPage(p as number)}>{p}</button>510 )}511 <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Next ›</button>512 <span className="pager-info">Page {page} / {totalPages}</span>513 </nav>514 )}515 </div>516 )}517 </div>518 );519}520