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%
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