SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
23.7 KB · 519 lines tsx
Raw Blame History
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