spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille5// Filtres : ville, quartier, taille, loyer min/max, dispo, animaux, meublé,6// superficie, gestionnaire, recherche — avec pastilles de filtres actifs.7// -----------------------------------------------------------------------------8import { Suspense, lazy, useEffect, useMemo, useState } from "react";9import { useSearchParams } from "react-router-dom";10import {11 Facets, Listing, ListingFilters, Stats,12 fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays,13 registerSourceNames, sourceName,14} from "../api";15import ListingCard from "../components/ListingCard";1617// La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre18const MapView = lazy(() => import("../components/MapView"));1920const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];21const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];22const AREA_STEPS = [400, 600, 800, 1000, 1200];2324/** Choix « Disponibilité » → paramètre API available_by (date ISO) */25const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [26 { key: "", label: "Peu importe", days: null },27 { key: "now", label: "Maintenant", days: 0 },28 { key: "30", label: "D'ici 1 mois", days: 30 },29 { key: "60", label: "D'ici 2 mois", days: 60 },30 { key: "90", label: "D'ici 3 mois", days: 90 },31];3233export default function Home() {34 const [listings, setListings] = useState<Listing[] | null>(null);35 const [total, setTotal] = useState(0);36 const [facets, setFacets] = useState<Facets | null>(null);37 const [sectors, setSectors] = useState<string[]>([]);38 const [stats, setStats] = useState<Stats | null>(null);39 const [error, setError] = useState<string | null>(null);4041 // filtres (pré-remplis depuis l'URL, ex. /?city=Montréal — liens de la page Stats)42 const [params] = useSearchParams();43 const [q, setQ] = useState(params.get("q") ?? "");44 const [city, setCity] = useState(params.get("city") ?? "");45 const [sector, setSector] = useState(params.get("sector") ?? "");46 const [source, setSource] = useState(params.get("source") ?? "");47 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");48 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");49 const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");50 const [dispo, setDispo] = useState(params.get("dispo") ?? "");51 const [pets, setPets] = useState(params.get("pets") ?? "");52 const [furnished, setFurnished] = useState(params.get("furnished") ?? "");53 const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");54 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop55 const [sheetOpen, setSheetOpen] = useState(false);56 const [advOpen, setAdvOpen] = useState(false);57 const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType,58 dispo, pets, furnished, areaMin].filter(Boolean).length;59 const advCount = [dispo, pets, furnished, areaMin, source].filter(Boolean).length;60 // vue liste ou carte (mémorisée dans l'URL : /?view=carte)61 const [view, setView] = useState<"liste" | "carte">(62 params.get("view") === "carte" ? "carte" : "liste");63 useEffect(() => {64 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte)65 setView(params.get("view") === "carte" ? "carte" : "liste");66 }, [params]);6768 const filters: ListingFilters = useMemo(() => {69 const d = DISPO_CHOICES.find((c) => c.key === dispo);70 return {71 q, city, sector, source, unit_type: unitType,72 price_min: priceMin, price_max: priceMax,73 pets, furnished, area_min: areaMin,74 available_by: d?.days != null ? isoInDays(d.days) : "",75 };76 }, [q, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo]);7778 useEffect(() => {79 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});80 fetchFacets().then(setFacets).catch(() => {});81 fetchStats().then(setStats).catch(() => {});82 }, []);8384 // quartiers dépendants de la ville choisie85 useEffect(() => {86 fetchFacets(city || undefined)87 .then((f) => setSectors(f.sectors))88 .catch(() => setSectors([]));89 }, [city]);9091 useEffect(() => {92 let cancelled = false;93 setListings(null);94 setError(null);95 fetchListings(filters)96 .then((r) => {97 if (!cancelled) {98 setListings(r.listings);99 setTotal(r.total);100 }101 })102 .catch((e) => !cancelled && setError(String(e)));103 return () => {104 cancelled = true;105 };106 }, [filters]);107108 const availableTypes = useMemo(() => {109 const set = new Set(facets?.unit_types ?? []);110 return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));111 }, [facets]);112113 const resetAll = () => {114 setQ(""); setCity(""); setSector(""); setSource("");115 setPriceMin(""); setPriceMax(""); setUnitType("");116 setDispo(""); setPets(""); setFurnished(""); setAreaMin("");117 };118119 // pastilles « filtres actifs » — libellé + action de retrait120 const pills: { label: string; clear: () => void }[] = [];121 if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });122 if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });123 if (sector) pills.push({ label: sector, clear: () => setSector("") });124 if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") });125 if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") });126 if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });127 if (dispo) pills.push({128 label: `Dispo : ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`,129 clear: () => setDispo(""),130 });131 if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") });132 if (furnished) pills.push({133 label: furnished === "1" ? "Meublé" : "Non meublé",134 clear: () => setFurnished(""),135 });136 if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });137 if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });138139 return (140 <div className="container">141 <section className="hero">142 <span className="kicker">Agrégateur — Québec · Lévis · Montréal</span>143 <h1>144 Tous les logements <span className="outline">à louer</span>,<br />145 <span className="hl">un seul</span> endroit.146 </h1>147 <p className="lede">148 Lou-Ka rassemble les appartements affichés par les gestionnaires immobiliers du149 Québec — Québec, Lévis et le Grand Montréal — mis à jour automatiquement, avec150 toutes les photos et un lien direct vers l'annonce originale.151 </p>152 <div className="stat-row">153 <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>154 {stats && (155 <>156 <span className="stat-chip"><b>{stats.total}</b> logements actifs</span>157 <span className="stat-chip"><b>{stats.quebec ?? 0}</b> à Québec</span>158 <span className="stat-chip"><b>{stats.levis ?? 0}</b> à Lévis</span>159 {(stats.montreal ?? 0) > 0 && (160 <span className="stat-chip"><b>{stats.montreal}</b> Grand Montréal</span>161 )}162 {(stats.autres ?? 0) > 0 && (163 <span className="stat-chip"><b>{stats.autres}</b> ailleurs au Québec</span>164 )}165 {stats.avg_price != null && (166 <span className="stat-chip">167 loyer moyen <b>{Math.round(stats.avg_price)} $</b>168 </span>169 )}170 </>171 )}172 </div>173 </section>174175 {sheetOpen && (176 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />177 )}178 <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">179 <div className="sheet-head">180 <span>Filtres</span>181 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">182 ✕183 </button>184 </div>185186 {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}187 <div className="f-primary">188 <div className="f-search">189 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">190 <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />191 </svg>192 <input193 id="f-q" placeholder="Adresse, quartier, rue…" value={q}194 onChange={(e) => setQ(e.target.value)}195 aria-label="Recherche"196 />197 {q && (198 <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>199 )}200 </div>201 <label className="f-ctl">202 <span>Ville</span>203 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>204 <option value="">Toutes</option>205 {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => (206 <option key={c} value={c}>{c}</option>207 ))}208 </select>209 </label>210 <label className="f-ctl">211 <span>Quartier</span>212 <select value={sector} onChange={(e) => setSector(e.target.value)}>213 <option value="">Tous</option>214 {sectors.map((s) => (215 <option key={s} value={s}>{s}</option>216 ))}217 </select>218 </label>219 <div className="f-ctl">220 <span>Loyer</span>221 <div className="range-pair">222 <select aria-label="Loyer minimum" value={priceMin}223 onChange={(e) => setPriceMin(e.target.value)}>224 <option value="">Min</option>225 {PRICE_STEPS.map((p) => (226 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>227 {p} $228 </option>229 ))}230 </select>231 <span className="range-sep">—</span>232 <select aria-label="Loyer maximum" value={priceMax}233 onChange={(e) => setPriceMax(e.target.value)}>234 <option value="">Max</option>235 {PRICE_STEPS.map((p) => (236 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>237 {p} $238 </option>239 ))}240 </select>241 </div>242 </div>243 <button244 className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}245 onClick={() => setAdvOpen(!advOpen)}246 aria-expanded={advOpen}247 >248 Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}249 </button>250 </div>251252 {/* — panneau avancé : segments & sélecteurs — */}253 {(advOpen || sheetOpen) && (254 <div className="f-adv">255 <div className="f-group">256 <label>Disponibilité</label>257 <div className="seg" role="group">258 {DISPO_CHOICES.map((c) => (259 <button key={c.key} className={dispo === c.key ? "on" : ""}260 onClick={() => setDispo(c.key)}>261 {c.label}262 </button>263 ))}264 </div>265 </div>266 <div className="f-group">267 <label>Meublé</label>268 <div className="seg" role="group">269 {[["", "Peu importe"], ["1", "Oui"], ["0", "Non"]].map(([v, l]) => (270 <button key={v} className={furnished === v ? "on" : ""}271 onClick={() => setFurnished(v)}>272 {l}273 </button>274 ))}275 </div>276 </div>277 <div className="f-group">278 <label>Animaux</label>279 <div className="seg" role="group">280 {[["", "Peu importe"], ["oui", "🐾 Acceptés"]].map(([v, l]) => (281 <button key={v} className={pets === v ? "on" : ""}282 onClick={() => setPets(v)}>283 {l}284 </button>285 ))}286 </div>287 </div>288 <div className="f-group">289 <label>Superficie minimale</label>290 <div className="seg" role="group">291 <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>292 Peu importe293 </button>294 {AREA_STEPS.map((a) => (295 <button key={a} className={areaMin === String(a) ? "on" : ""}296 onClick={() => setAreaMin(String(a))}>297 {a}+298 </button>299 ))}300 </div>301 </div>302 <div className="f-group">303 <label>Gestionnaire</label>304 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>305 <option value="">Tous</option>306 {(facets?.sources ?? []).map((s) => (307 <option key={s.source} value={s.source}>308 {sourceName(s.source)} ({s.n})309 </option>310 ))}311 </select>312 </div>313 <div className="f-group f-group-end">314 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>315 Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}316 </button>317 </div>318 </div>319 )}320321 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>322 Voir les résultats {listings ? `(${total})` : ""}323 </button>324 </section>325326 <div className="chips" role="group" aria-label="Filtres rapides">327 {availableTypes.map((t) => (328 <button329 key={t}330 className={`chip ${unitType === t ? "on" : ""}`}331 onClick={() => setUnitType(unitType === t ? "" : t)}332 >333 {t}334 </button>335 ))}336 <span className="chip-sep" aria-hidden="true" />337 <button338 className={`chip ${dispo === "now" ? "on" : ""}`}339 onClick={() => setDispo(dispo === "now" ? "" : "now")}340 >341 ⚡ Dispo maintenant342 </button>343 <button344 className={`chip ${pets === "oui" ? "on" : ""}`}345 onClick={() => setPets(pets === "oui" ? "" : "oui")}346 >347 🐾 Animaux ok348 </button>349 <button350 className={`chip ${furnished === "1" ? "on" : ""}`}351 onClick={() => setFurnished(furnished === "1" ? "" : "1")}352 >353 🛋 Meublé354 </button>355 </div>356357 {pills.length > 0 && (358 <div className="pills" aria-label="Filtres actifs">359 {pills.map((p) => (360 <button key={p.label} className="pill" onClick={p.clear}361 aria-label={`Retirer le filtre ${p.label}`}>362 {p.label} <span className="pill-x">✕</span>363 </button>364 ))}365 <button className="pill pill-clear" onClick={resetAll}>366 Tout effacer367 </button>368 </div>369 )}370371 <div className="results-head">372 <h2>Logements disponibles</h2>373 <div className="results-tools">374 {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}375 <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">376 <button377 role="tab" aria-selected={view === "liste"}378 className={view === "liste" ? "on" : ""}379 onClick={() => setView("liste")}380 >381 ☰ Liste382 </button>383 <button384 role="tab" aria-selected={view === "carte"}385 className={view === "carte" ? "on" : ""}386 onClick={() => setView("carte")}387 >388 ◈ Carte389 </button>390 </div>391 </div>392 </div>393394 {error && (395 <div className="notice">396 <div className="big">⚠️</div>397 <h2>Impossible de charger les annonces</h2>398 <p>{error}</p>399 <button className="btn btn-primary" onClick={() => window.location.reload()}>400 Réessayer401 </button>402 </div>403 )}404405 {!error && view === "liste" && listings === null && (406 <div className="grid" aria-busy="true">407 {Array.from({ length: 8 }).map((_, i) => (408 <div className="skel" key={i}>409 <div className="sk-img" />410 <div className="sk-line" />411 <div className="sk-line short" />412 </div>413 ))}414 </div>415 )}416417 {!error && view === "liste" && listings !== null && listings.length === 0 && (418 <div className="notice">419 <div className="big">🔍</div>420 <h2>Aucun logement ne correspond</h2>421 <p>422 Essayez d'élargir vos critères423 {activeFilters > 0 && (424 <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>425 )}.426 </p>427 </div>428 )}429430 {!error && view === "carte" && (431 <div className="map-split">432 <div className="map-list" aria-label="Résultats en liste">433 {(listings ?? []).map((l) => (434 <ListingCard key={l.uid} l={l} />435 ))}436 </div>437 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>438 <MapView filters={filters} />439 </Suspense>440 </div>441 )}442443 {!error && view === "liste" && listings !== null && listings.length > 0 && (444 <div className="grid">445 {listings.map((l) => (446 <ListingCard key={l.uid} l={l} />447 ))}448 </div>449 )}450451 {/* Bouton flottant mobile — ouvre la feuille de filtres */}452 <button453 className="fab"454 onClick={() => setSheetOpen(true)}455 aria-label="Ouvrir les filtres"456 >457 ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}458 </button>459 </div>460 );461}462