| 1 |
1 |
// ----------------------------------------------------------------------------- |
| 2 |
2 |
// Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 |
3 |
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
|
−// pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces |
|
4 |
+// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille |
|
5 |
+// Filtres : ville, quartier, taille, loyer min/max, dispo, animaux, meublé, |
|
6 |
+// superficie, gestionnaire, recherche — avec pastilles de filtres actifs. |
| 5 |
7 |
// ----------------------------------------------------------------------------- |
| 6 |
8 |
import { Suspense, lazy, useEffect, useMemo, useState } from "react"; |
| 7 |
9 |
import { useSearchParams } from "react-router-dom"; |
| 8 |
10 |
import { |
| 9 |
11 |
Facets, Listing, ListingFilters, Stats, |
| 10 |
|
− fetchFacets, fetchListings, fetchSources, fetchStats, |
|
12 |
+ fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays, |
| 11 |
13 |
registerSourceNames, sourceName, |
| 12 |
14 |
} from "../api"; |
| 13 |
15 |
import ListingCard from "../components/ListingCard"; |
| 16 |
18 |
const MapView = lazy(() => import("../components/MapView")); |
| 17 |
19 |
|
| 18 |
20 |
const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; |
|
21 |
+const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]; |
|
22 |
+const AREA_STEPS = [400, 600, 800, 1000, 1200]; |
|
23 |
+ |
|
24 |
+/** Choix « Disponibilité » → paramètre API available_by (date ISO) */ |
|
25 |
+const 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 |
+]; |
| 19 |
32 |
|
| 20 |
33 |
export default function Home() { |
| 21 |
34 |
const [listings, setListings] = useState<Listing[] | null>(null); |
| 22 |
35 |
const [total, setTotal] = useState(0); |
| 23 |
36 |
const [facets, setFacets] = useState<Facets | null>(null); |
|
37 |
+ const [sectors, setSectors] = useState<string[]>([]); |
| 24 |
38 |
const [stats, setStats] = useState<Stats | null>(null); |
| 25 |
39 |
const [error, setError] = useState<string | null>(null); |
| 26 |
40 |
|
| 28 |
42 |
const [params] = useSearchParams(); |
| 29 |
43 |
const [q, setQ] = useState(params.get("q") ?? ""); |
| 30 |
44 |
const [city, setCity] = useState(params.get("city") ?? ""); |
|
45 |
+ const [sector, setSector] = useState(params.get("sector") ?? ""); |
| 31 |
46 |
const [source, setSource] = useState(params.get("source") ?? ""); |
|
47 |
+ const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); |
| 32 |
48 |
const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); |
| 33 |
49 |
const [unitType, setUnitType] = useState(params.get("unit_type") ?? ""); |
| 34 |
|
− // feuille de filtres mobile (bottom sheet) |
|
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é desktop |
| 35 |
55 |
const [sheetOpen, setSheetOpen] = useState(false); |
| 36 |
|
− const activeFilters = [q, city, source, priceMax, unitType].filter(Boolean).length; |
|
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; |
| 37 |
60 |
// vue liste ou carte (mémorisée dans l'URL : /?view=carte) |
| 38 |
61 |
const [view, setView] = useState<"liste" | "carte">( |
| 39 |
62 |
params.get("view") === "carte" ? "carte" : "liste"); |
| 41 |
64 |
// suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte) |
| 42 |
65 |
setView(params.get("view") === "carte" ? "carte" : "liste"); |
| 43 |
66 |
}, [params]); |
| 44 |
|
− const filters: ListingFilters = useMemo( |
| 45 |
|
− () => ({ q, city, source, price_max: priceMax, unit_type: unitType }), |
| 46 |
|
− [q, city, source, priceMax, unitType]); |
|
67 |
+ |
|
68 |
+ 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]); |
| 47 |
77 |
|
| 48 |
78 |
useEffect(() => { |
| 49 |
79 |
fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
| 51 |
81 |
fetchStats().then(setStats).catch(() => {}); |
| 52 |
82 |
}, []); |
| 53 |
83 |
|
|
84 |
+ // quartiers dépendants de la ville choisie |
|
85 |
+ useEffect(() => { |
|
86 |
+ fetchFacets(city || undefined) |
|
87 |
+ .then((f) => setSectors(f.sectors)) |
|
88 |
+ .catch(() => setSectors([])); |
|
89 |
+ }, [city]); |
|
90 |
+ |
| 54 |
91 |
useEffect(() => { |
| 55 |
92 |
let cancelled = false; |
| 56 |
93 |
setListings(null); |
| 57 |
94 |
setError(null); |
| 58 |
|
− fetchListings({ q, city, source, price_max: priceMax, unit_type: unitType }) |
|
95 |
+ fetchListings(filters) |
| 59 |
96 |
.then((r) => { |
| 60 |
97 |
if (!cancelled) { |
| 61 |
98 |
setListings(r.listings); |
| 66 |
103 |
return () => { |
| 67 |
104 |
cancelled = true; |
| 68 |
105 |
}; |
| 69 |
|
− }, [q, city, source, priceMax, unitType]); |
|
106 |
+ }, [filters]); |
| 70 |
107 |
|
| 71 |
108 |
const availableTypes = useMemo(() => { |
| 72 |
109 |
const set = new Set(facets?.unit_types ?? []); |
| 73 |
110 |
return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t)); |
| 74 |
111 |
}, [facets]); |
| 75 |
112 |
|
|
113 |
+ const resetAll = () => { |
|
114 |
+ setQ(""); setCity(""); setSector(""); setSource(""); |
|
115 |
+ setPriceMin(""); setPriceMax(""); setUnitType(""); |
|
116 |
+ setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); |
|
117 |
+ }; |
|
118 |
+ |
|
119 |
+ // pastilles « filtres actifs » — libellé + action de retrait |
|
120 |
+ 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("") }); |
|
138 |
+ |
| 76 |
139 |
return ( |
| 77 |
140 |
<div className="container"> |
| 78 |
141 |
<section className="hero"> |
| 119 |
182 |
✕ |
| 120 |
183 |
</button> |
| 121 |
184 |
</div> |
| 122 |
|
− <div className="field"> |
| 123 |
|
− <label htmlFor="f-q">Recherche</label> |
| 124 |
|
− <input |
| 125 |
|
− id="f-q" placeholder="Adresse, quartier, rue…" value={q} |
| 126 |
|
− onChange={(e) => setQ(e.target.value)} |
| 127 |
|
− /> |
| 128 |
|
− </div> |
| 129 |
|
− <div className="field"> |
| 130 |
|
− <label htmlFor="f-city">Ville</label> |
| 131 |
|
− <select id="f-city" value={city} onChange={(e) => setCity(e.target.value)}> |
| 132 |
|
− <option value="">Toutes</option> |
| 133 |
|
− {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => ( |
| 134 |
|
− <option key={c} value={c}>{c}</option> |
| 135 |
|
− ))} |
| 136 |
|
− </select> |
| 137 |
|
− </div> |
| 138 |
|
− <div className="field"> |
| 139 |
|
− <label htmlFor="f-price">Loyer max</label> |
| 140 |
|
− <select id="f-price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}> |
| 141 |
|
− <option value="">Aucun</option> |
| 142 |
|
− {[800, 1000, 1200, 1400, 1600, 1800, 2000, 2500].map((p) => ( |
| 143 |
|
− <option key={p} value={p}>{p} $</option> |
| 144 |
|
− ))} |
| 145 |
|
− </select> |
| 146 |
|
− </div> |
| 147 |
|
− <div className="field"> |
| 148 |
|
− <label htmlFor="f-source">Gestionnaire</label> |
| 149 |
|
− <select id="f-source" value={source} onChange={(e) => setSource(e.target.value)}> |
| 150 |
|
− <option value="">Tous</option> |
| 151 |
|
− {(facets?.sources ?? []).map((s) => ( |
| 152 |
|
− <option key={s.source} value={s.source}> |
| 153 |
|
− {sourceName(s.source)} ({s.n}) |
| 154 |
|
− </option> |
| 155 |
|
− ))} |
| 156 |
|
− </select> |
| 157 |
|
− </div> |
| 158 |
|
− <div className="field"> |
| 159 |
|
− <label htmlFor="f-type">Taille</label> |
| 160 |
|
− <select id="f-type" value={unitType} onChange={(e) => setUnitType(e.target.value)}> |
| 161 |
|
− <option value="">Toutes</option> |
| 162 |
|
− {availableTypes.map((t) => ( |
| 163 |
|
− <option key={t} value={t}>{t}</option> |
| 164 |
|
− ))} |
| 165 |
|
− </select> |
|
185 |
+ |
|
186 |
+ {/* — 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 |
+ <input |
|
193 |
+ 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 |
+ <button |
|
244 |
+ 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> |
| 166 |
250 |
</div> |
| 167 |
|
− <button |
| 168 |
|
− className="btn btn-ghost" style={{ alignSelf: "end" }} |
| 169 |
|
− onClick={() => { setQ(""); setCity(""); setSource(""); setPriceMax(""); setUnitType(""); }} |
| 170 |
|
− > |
| 171 |
|
− Réinitialiser |
| 172 |
|
− </button> |
|
251 |
+ |
|
252 |
+ {/* — 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 importe |
|
293 |
+ </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 |
+ )} |
|
320 |
+ |
| 173 |
321 |
<button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> |
| 174 |
322 |
Voir les résultats {listings ? `(${total})` : ""} |
| 175 |
323 |
</button> |
| 176 |
324 |
</section> |
| 177 |
325 |
|
| 178 |
|
− <div className="chips" role="group" aria-label="Filtrer par taille"> |
|
326 |
+ <div className="chips" role="group" aria-label="Filtres rapides"> |
| 179 |
327 |
{availableTypes.map((t) => ( |
| 180 |
328 |
<button |
| 181 |
329 |
key={t} |
| 185 |
333 |
{t} |
| 186 |
334 |
</button> |
| 187 |
335 |
))} |
|
336 |
+ <span className="chip-sep" aria-hidden="true" /> |
|
337 |
+ <button |
|
338 |
+ className={`chip ${dispo === "now" ? "on" : ""}`} |
|
339 |
+ onClick={() => setDispo(dispo === "now" ? "" : "now")} |
|
340 |
+ > |
|
341 |
+ ⚡ Dispo maintenant |
|
342 |
+ </button> |
|
343 |
+ <button |
|
344 |
+ className={`chip ${pets === "oui" ? "on" : ""}`} |
|
345 |
+ onClick={() => setPets(pets === "oui" ? "" : "oui")} |
|
346 |
+ > |
|
347 |
+ 🐾 Animaux ok |
|
348 |
+ </button> |
|
349 |
+ <button |
|
350 |
+ className={`chip ${furnished === "1" ? "on" : ""}`} |
|
351 |
+ onClick={() => setFurnished(furnished === "1" ? "" : "1")} |
|
352 |
+ > |
|
353 |
+ 🛋 Meublé |
|
354 |
+ </button> |
| 188 |
355 |
</div> |
| 189 |
356 |
|
|
357 |
+ {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 effacer |
|
367 |
+ </button> |
|
368 |
+ </div> |
|
369 |
+ )} |
|
370 |
+ |
| 190 |
371 |
<div className="results-head"> |
| 191 |
372 |
<h2>Logements disponibles</h2> |
| 192 |
373 |
<div className="results-tools"> |
| 237 |
418 |
<div className="notice"> |
| 238 |
419 |
<div className="big">🔍</div> |
| 239 |
420 |
<h2>Aucun logement ne correspond</h2> |
| 240 |
|
− <p>Essayez d'élargir vos critères, ou lancez une synchronisation (<code>python run.py sync</code>).</p> |
|
421 |
+ <p> |
|
422 |
+ Essayez d'élargir vos critères |
|
423 |
+ {activeFilters > 0 && ( |
|
424 |
+ <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></> |
|
425 |
+ )}. |
|
426 |
+ </p> |
| 241 |
427 |
</div> |
| 242 |
428 |
)} |
| 243 |
429 |
|