spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 67.2%
TypeScript 19.4%
CSS 12.9%
HTML 0.5%
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, 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";1415const MapView = lazy(() => import("../components/MapView"));1617const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];18const AREA_STEPS = [800, 1000, 1500, 2000, 3000];19const PAGE = 12;2021const fmtK = (n: number) =>22 n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`;2324// fenêtre de pagination : 1 … (p-1) p (p+1) … N25function pageNumbers(p: number, n: number): (number | "…")[] {26 if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);27 const out: (number | "…")[] = [1];28 const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);29 if (lo > 2) out.push("…");30 for (let i = lo; i <= hi; i++) out.push(i);31 if (hi < n - 1) out.push("…");32 out.push(n);33 return out;34}3536export default function Home() {37 const [listings, setListings] = useState<Listing[] | null>(null);38 const [total, setTotal] = useState(0);39 const [page, setPage] = useState(1); // pagination 12/page40 const [facets, setFacets] = useState<Facets | null>(null);41 const [sectors, setSectors] = useState<string[]>([]);42 const [stats, setStats] = useState<Stats | null>(null);43 const [error, setError] = useState<string | null>(null);4445 const [params] = useSearchParams();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 useEffect(() => { setPage(1); }, [filters]);8485 // charger la page courante (12 annonces) — remplace la grille86 useEffect(() => {87 let cancelled = false;88 setListings(null); setError(null);89 fetchListings(filters, PAGE, (page - 1) * PAGE)90 .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })91 .catch((e) => !cancelled && setError(String(e)));92 return () => { cancelled = true; };93 }, [filters, page]);9495 const totalPages = Math.max(1, Math.ceil(total / PAGE));96 const gotoPage = (p: number) => {97 setPage(Math.min(Math.max(1, p), totalPages));98 document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });99 };100101 const resetAll = () => {102 setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");103 setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");104 };105106 const pills: { label: string; clear: () => void }[] = [];107 if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });108 if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });109 if (sector) pills.push({ label: sector, clear: () => setSector("") });110 if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });111 if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });112 if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });113 if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });114 if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });115 if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });116 if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });117118 const topTypes = (facets?.property_types ?? []).slice(0, 7);119120 return (121 <div className="container">122 <section className="hero">123 <span className="kicker">Agrégateur — Toutes les agences du Québec</span>124 <h1>125 Toutes les propriétés <span className="outline">à vendre</span>,<br />126 <span className="hl">un seul</span> endroit.127 </h1>128 <p className="lede">129 Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage130 immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et plus —131 mises à jour automatiquement, avec toutes les photos, les détails complets et un lien132 direct vers l'annonce originale.133 </p>134 <div className="stat-row">135 <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>136 {stats && (137 <>138 <span className="stat-chip"><b>{stats.total.toLocaleString("fr-CA")}</b> propriétés</span>139 <span className="stat-chip"><b>{stats.sources}</b> agences</span>140 <span className="stat-chip"><b>{stats.cities?.toLocaleString("fr-CA")}</b> villes</span>141 {stats.avg_price != null && (142 <span className="stat-chip">prix moyen <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b></span>143 )}144 </>145 )}146 </div>147 </section>148149 {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}150 <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">151 <div className="sheet-head">152 <span>Filtres</span>153 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>154 </div>155156 <div className="f-primary">157 <div className="f-search">158 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">159 <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />160 </svg>161 <input id="f-q" placeholder="Adresse, ville, n° MLS…" value={q}162 onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />163 {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}164 </div>165 <label className="f-ctl">166 <span>Ville</span>167 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>168 <option value="">Toutes</option>169 {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}170 </select>171 </label>172 <label className="f-ctl">173 <span>Type</span>174 <select value={ptype} onChange={(e) => setPtype(e.target.value)}>175 <option value="">Tous</option>176 {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}177 </select>178 </label>179 <div className="f-ctl">180 <span>Prix</span>181 <div className="range-pair">182 <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>183 <option value="">Min</option>184 {PRICE_STEPS.map((p) => (185 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>186 ))}187 </select>188 <span className="range-sep">—</span>189 <select aria-label="Prix maximum" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>190 <option value="">Max</option>191 {PRICE_STEPS.map((p) => (192 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>193 ))}194 </select>195 </div>196 </div>197 <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>198 Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}199 </button>200 </div>201202 {(advOpen || sheetOpen) && (203 <div className="f-adv">204 <div className="f-group">205 <label>Quartier / secteur</label>206 <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>207 <option value="">Tous</option>208 {sectors.map((s) => <option key={s} value={s}>{s}</option>)}209 </select>210 </div>211 <div className="f-group">212 <label>Chambres (min.)</label>213 <div className="seg" role="group">214 <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Toutes</button>215 {["1", "2", "3", "4", "5"].map((n) => (216 <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>217 ))}218 </div>219 </div>220 <div className="f-group">221 <label>Salles de bain (min.)</label>222 <div className="seg" role="group">223 <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Toutes</button>224 {["1", "2", "3"].map((n) => (225 <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>226 ))}227 </div>228 </div>229 <div className="f-group">230 <label>Superficie minimale</label>231 <div className="seg" role="group">232 <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Peu importe</button>233 {AREA_STEPS.map((a) => (234 <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>235 ))}236 </div>237 </div>238 <div className="f-group">239 <label>Agence</label>240 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>241 <option value="">Toutes</option>242 {(facets?.sources ?? []).map((s) => (243 <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>244 ))}245 </select>246 </div>247 <div className="f-group f-group-end">248 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>249 Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}250 </button>251 </div>252 </div>253 )}254255 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>256 Voir les résultats {listings ? `(${total})` : ""}257 </button>258 </section>259260 <div className="chips" role="group" aria-label="Filtres rapides">261 {topTypes.map((t) => (262 <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>263 {t}264 </button>265 ))}266 </div>267268 {pills.length > 0 && (269 <div className="pills" aria-label="Filtres actifs">270 {pills.map((p) => (271 <button key={p.label} className="pill" onClick={p.clear} aria-label={`Retirer le filtre ${p.label}`}>272 {p.label} <span className="pill-x">✕</span>273 </button>274 ))}275 <button className="pill pill-clear" onClick={resetAll}>Tout effacer</button>276 </div>277 )}278279 <div className="results-head" id="results-top">280 <h2>Propriétés à vendre</h2>281 <div className="results-tools">282 {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}283 <label className="sort-ctl">284 <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">285 <option value="recent">Plus récents</option>286 <option value="price_asc">Prix ↑</option>287 <option value="price_desc">Prix ↓</option>288 </select>289 </label>290 <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">291 <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>☰ Liste</button>292 <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>◈ Carte</button>293 </div>294 </div>295 </div>296297 {error && (298 <div className="notice">299 <div className="big">⚠️</div>300 <h2>Impossible de charger les annonces</h2>301 <p>{error}</p>302 <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>303 </div>304 )}305306 {!error && view === "liste" && listings === null && (307 <div className="grid" aria-busy="true">308 {Array.from({ length: 8 }).map((_, i) => (309 <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>310 ))}311 </div>312 )}313314 {!error && view === "liste" && listings !== null && listings.length === 0 && (315 <div className="notice">316 <div className="big">🔍</div>317 <h2>Aucune propriété ne correspond</h2>318 <p>Essayez d'élargir vos critères319 {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>320 </div>321 )}322323 {!error && view === "carte" && (324 <div className="map-split">325 <div className="map-list" aria-label="Résultats en liste">326 {(listings ?? []).map((l) => <ListingCard key={l.uid} l={l} />)}327 </div>328 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>329 <MapView filters={filters} />330 </Suspense>331 </div>332 )}333334 {!error && view === "liste" && listings !== null && listings.length > 0 && (335 <>336 <div className="grid">337 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}338 </div>339 {totalPages > 1 && (340 <nav className="pager" aria-label="Pagination">341 <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button>342 {pageNumbers(page, totalPages).map((p, i) =>343 p === "…"344 ? <span key={`e${i}`} className="pager-gap">…</span>345 : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}346 onClick={() => gotoPage(p as number)}>{p}</button>347 )}348 <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button>349 <span className="pager-info">Page {page} / {totalPages}</span>350 </nav>351 )}352 </>353 )}354355 <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">356 ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}357 </button>358 </div>359 );360}361