SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
18.1 KB · 382 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille + carte5// -----------------------------------------------------------------------------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// fenêtre de pagination : 1 … (p-1) p (p+1) … N26function pageNumbers(p: number, n: number): (number | "…")[] {27  if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);28  const out: (number | "…")[] = [1];29  const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);30  if (lo > 2) out.push("…");31  for (let i = lo; i <= hi; i++) out.push(i);32  if (hi < n - 1) out.push("…");33  out.push(n);34  return out;35}3637export default function Home() {38  const [listings, setListings] = useState<Listing[] | null>(null);39  const [total, setTotal] = useState(0);40  const [params, setParams] = useSearchParams();41  const [page, setPage] = useState(Number(params.get("page")) || 1);  // 12/page, dans l'URL42  const [facets, setFacets] = useState<Facets | null>(null);43  const [sectors, setSectors] = useState<string[]>([]);44  const [stats, setStats] = useState<Stats | null>(null);45  const [error, setError] = useState<string | null>(null);46  const [q, setQ] = useState(params.get("q") ?? "");47  const [city, setCity] = useState(params.get("city") ?? "");48  const [sector, setSector] = useState(params.get("sector") ?? "");49  const [ptype, setPtype] = useState(params.get("property_type") ?? "");50  const [source, setSource] = useState(params.get("source") ?? "");51  const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");52  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");53  const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");54  const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");55  const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");56  const [sort, setSort] = useState(params.get("sort") ?? "recent");5758  const [sheetOpen, setSheetOpen] = useState(false);59  const [advOpen, setAdvOpen] = useState(false);60  const activeFilters = [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin].filter(Boolean).length;61  const advCount = [bedsMin, bathsMin, areaMin, source, sector].filter(Boolean).length;6263  const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");64  useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);6566  const filters: ListingFilters = useMemo(() => ({67    q, city, sector, region: "", property_type: ptype, source,68    price_min: priceMin, price_max: priceMax,69    bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,70  }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);7172  useEffect(() => {73    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});74    fetchFacets().then(setFacets).catch(() => {});75    fetchStats().then(setStats).catch(() => {});76  }, []);7778  useEffect(() => {79    fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));80  }, [city]);8182  // revenir à la page 1 quand les filtres changent83  const firstRender = useRef(true);84  useEffect(() => {85    if (firstRender.current) { firstRender.current = false; return; }86    setPage(1);87  }, [filters]);8889  // synchroniser filtres + page + tri + vue dans l'URL → le retour arrière90  // depuis une fiche revient à la MÊME page/filtres.91  useEffect(() => {92    const p = new URLSearchParams();93    const set = (k: string, v: string) => { if (v) p.set(k, v); };94    set("q", q); set("city", city); set("sector", sector);95    set("property_type", ptype); set("source", source);96    set("price_min", priceMin); set("price_max", priceMax);97    set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);98    set("area_min", areaMin);99    if (sort && sort !== "recent") p.set("sort", sort);100    if (view === "carte") p.set("view", "carte");101    if (page > 1) p.set("page", String(page));102    setParams(p, { replace: true });103  }, [filters, page, view, q, city, sector, ptype, source, priceMin, priceMax,104      bedsMin, bathsMin, areaMin, sort, setParams]);105106  // charger la page courante (12 annonces) — remplace la grille107  useEffect(() => {108    let cancelled = false;109    setListings(null); setError(null);110    fetchListings(filters, PAGE, (page - 1) * PAGE)111      .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })112      .catch((e) => !cancelled && setError(String(e)));113    return () => { cancelled = true; };114  }, [filters, page]);115116  const totalPages = Math.max(1, Math.ceil(total / PAGE));117  const gotoPage = (p: number) => {118    setPage(Math.min(Math.max(1, p), totalPages));119    document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });120  };121122  const resetAll = () => {123    setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");124    setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");125  };126127  const pills: { label: string; clear: () => void }[] = [];128  if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });129  if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });130  if (sector) pills.push({ label: sector, clear: () => setSector("") });131  if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });132  if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });133  if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });134  if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });135  if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });136  if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });137  if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });138139  const topTypes = (facets?.property_types ?? []).slice(0, 7);140141  return (142    <div className="container">143      <section className="hero">144        <span className="kicker">Agrégateur — Toutes les agences du Québec</span>145        <h1>146          Toutes les propriétés <span className="outline">à vendre</span>,<br />147          <span className="hl">un seul</span> endroit.148        </h1>149        <p className="lede">150          Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage151          immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et plus —152          mises à jour automatiquement, avec toutes les photos, les détails complets et un lien153          direct vers l'annonce originale.154        </p>155        <div className="stat-row">156          <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>157          {stats && (158            <>159              <span className="stat-chip"><b>{stats.total.toLocaleString("fr-CA")}</b> propriétés</span>160              <span className="stat-chip"><b>{stats.sources}</b> agences</span>161              <span className="stat-chip"><b>{stats.cities?.toLocaleString("fr-CA")}</b> villes</span>162              {stats.avg_price != null && (163                <span className="stat-chip">prix moyen <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b></span>164              )}165            </>166          )}167        </div>168      </section>169170      {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}171      <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">172        <div className="sheet-head">173          <span>Filtres</span>174          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>175        </div>176177        <div className="f-primary">178          <div className="f-search">179            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">180              <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />181            </svg>182            <input id="f-q" placeholder="Adresse, ville, n° MLS…" value={q}183              onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />184            {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}185          </div>186          <label className="f-ctl">187            <span>Ville</span>188            <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>189              <option value="">Toutes</option>190              {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}191            </select>192          </label>193          <label className="f-ctl">194            <span>Type</span>195            <select value={ptype} onChange={(e) => setPtype(e.target.value)}>196              <option value="">Tous</option>197              {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}198            </select>199          </label>200          <div className="f-ctl">201            <span>Prix</span>202            <div className="range-pair">203              <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>204                <option value="">Min</option>205                {PRICE_STEPS.map((p) => (206                  <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>207                ))}208              </select>209              <span className="range-sep">—</span>210              <select aria-label="Prix maximum" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>211                <option value="">Max</option>212                {PRICE_STEPS.map((p) => (213                  <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>214                ))}215              </select>216            </div>217          </div>218          <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>219            Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}220          </button>221        </div>222223        {(advOpen || sheetOpen) && (224          <div className="f-adv">225            <div className="f-group">226              <label>Quartier / secteur</label>227              <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>228                <option value="">Tous</option>229                {sectors.map((s) => <option key={s} value={s}>{s}</option>)}230              </select>231            </div>232            <div className="f-group">233              <label>Chambres (min.)</label>234              <div className="seg" role="group">235                <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Toutes</button>236                {["1", "2", "3", "4", "5"].map((n) => (237                  <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>238                ))}239              </div>240            </div>241            <div className="f-group">242              <label>Salles de bain (min.)</label>243              <div className="seg" role="group">244                <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Toutes</button>245                {["1", "2", "3"].map((n) => (246                  <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>247                ))}248              </div>249            </div>250            <div className="f-group">251              <label>Superficie minimale</label>252              <div className="seg" role="group">253                <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Peu importe</button>254                {AREA_STEPS.map((a) => (255                  <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>256                ))}257              </div>258            </div>259            <div className="f-group">260              <label>Agence</label>261              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>262                <option value="">Toutes</option>263                {(facets?.sources ?? []).map((s) => (264                  <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>265                ))}266              </select>267            </div>268            <div className="f-group f-group-end">269              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>270                Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}271              </button>272            </div>273          </div>274        )}275276        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>277          Voir les résultats {listings ? `(${total})` : ""}278        </button>279      </section>280281      <div className="chips" role="group" aria-label="Filtres rapides">282        {topTypes.map((t) => (283          <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>284            {t}285          </button>286        ))}287      </div>288289      {pills.length > 0 && (290        <div className="pills" aria-label="Filtres actifs">291          {pills.map((p) => (292            <button key={p.label} className="pill" onClick={p.clear} aria-label={`Retirer le filtre ${p.label}`}>293              {p.label} <span className="pill-x">✕</span>294            </button>295          ))}296          <button className="pill pill-clear" onClick={resetAll}>Tout effacer</button>297        </div>298      )}299300      <div className="results-head" id="results-top">301        <h2>Propriétés à vendre</h2>302        <div className="results-tools">303          {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}304          <label className="sort-ctl">305            <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">306              <option value="recent">Plus récents</option>307              <option value="price_asc">Prix ↑</option>308              <option value="price_desc">Prix ↓</option>309            </select>310          </label>311          <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">312            <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>☰ Liste</button>313            <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>◈ Carte</button>314          </div>315        </div>316      </div>317318      {error && (319        <div className="notice">320          <div className="big"><Ico name="alert" size={40} /></div>321          <h2>Impossible de charger les annonces</h2>322          <p>{error}</p>323          <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>324        </div>325      )}326327      {!error && view === "liste" && listings === null && (328        <div className="grid" aria-busy="true">329          {Array.from({ length: 8 }).map((_, i) => (330            <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>331          ))}332        </div>333      )}334335      {!error && view === "liste" && listings !== null && listings.length === 0 && (336        <div className="notice">337          <div className="big"><Ico name="search" size={40} /></div>338          <h2>Aucune propriété ne correspond</h2>339          <p>Essayez d'élargir vos critères340            {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>341        </div>342      )}343344      {!error && view === "carte" && (345        <div className="map-split">346          <div className="map-list" aria-label="Résultats en liste">347            {(listings ?? []).map((l) => <ListingCard key={l.uid} l={l} />)}348          </div>349          <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>350            <MapView filters={filters} />351          </Suspense>352        </div>353      )}354355      {!error && view === "liste" && listings !== null && listings.length > 0 && (356        <>357          <div className="grid">358            {listings.map((l) => <ListingCard key={l.uid} l={l} />)}359          </div>360          {totalPages > 1 && (361            <nav className="pager" aria-label="Pagination">362              <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button>363              {pageNumbers(page, totalPages).map((p, i) =>364                p === "…"365                  ? <span key={`e${i}`} className="pager-gap">…</span>366                  : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}367                            onClick={() => gotoPage(p as number)}>{p}</button>368              )}369              <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button>370              <span className="pager-info">Page {page} / {totalPages}</span>371            </nav>372          )}373        </>374      )}375376      <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">377        ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}378      </button>379    </div>380  );381}382