SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
23.7 KB · 520 lines tsx
Raw Blame History
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