SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

Filtres avancés — refonte du menu web + facettes par ville

- /api/facets accepte ?city= : quartiers restreints à la ville (sélecteur dépendant)
- Menu de filtres refait : rangée principale compacte (recherche, ville,
  quartier, loyer min–max, « Plus de filtres ») + panneau avancé repliable
  en segments (disponibilité, meublé, animaux, superficie, gestionnaire)
- Correction du débordement horizontal (colonnes 1fr → contrôles bornés)
- Chips rapides (dispo maintenant, animaux, meublé) + pastilles de filtres
  actifs retirables ; api.ts : price_min, pets, furnished, available_by, area_min
- ios/ ignoré : l'app iOS native vit dans son propre dépôt (lou-ka-ios)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 11, 2026) parent 802fb2f Browse files

5 changed files +388 −71

modified .gitignore +3 −0
@@ -9,3 +9,6 @@ frontend/dist/
9 reponse.txt 9 reponse.txt
10 data/staging-*.db 10 data/staging-*.db
11 data/quartier.db 11 data/quartier.db
12+
13+# app iOS native — dépôt séparé (git.spboucher.ai/lou-ka-ios)
14+ios/
modified frontend/src/api.ts +14 −1
@@ -162,7 +162,12 @@ export interface ListingFilters {
162 sector?: string; 162 sector?: string;
163 unit_type?: string; 163 unit_type?: string;
164 source?: string; 164 source?: string;
165+ price_min?: string;
165 price_max?: string; 166 price_max?: string;
167+ pets?: string; // "oui" -> acceptés (oui OU conditions)
168+ furnished?: string; // "1" | "0"
169+ available_by?: string; // ISO : dispo maintenant ou avant cette date
170+ area_min?: string; // superficie minimale (pi²)
166 q?: string; 171 q?: string;
167 } 172 }
168 173
@@ -226,7 +231,15 @@ export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed"
226 231
227 export const fetchListing = (uid: string) => 232 export const fetchListing = (uid: string) =>
228 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`); 233 get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
229−export const fetchFacets = () => get<Facets>("/api/facets"); 234+export const fetchFacets = (city?: string) =>
235+ get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);
236+
237+/** Date ISO à +n jours (pour « dispo d'ici 1 mois », etc.) */
238+export function isoInDays(n: number): string {
239+ const d = new Date();
240+ d.setDate(d.getDate() + n);
241+ return d.toISOString().slice(0, 10);
242+}
230 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); 243 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
231 export const fetchStats = () => get<Stats>("/api/stats"); 244 export const fetchStats = () => get<Stats>("/api/stats");
232 245
modified frontend/src/pages/Home.tsx +247 −61
@@ -1,13 +1,15 @@
1 // ----------------------------------------------------------------------------- 1 // -----------------------------------------------------------------------------
2 // Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 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 import { Suspense, lazy, useEffect, useMemo, useState } from "react"; 8 import { Suspense, lazy, useEffect, useMemo, useState } from "react";
7 import { useSearchParams } from "react-router-dom"; 9 import { useSearchParams } from "react-router-dom";
8 import { 10 import {
9 Facets, Listing, ListingFilters, Stats, 11 Facets, Listing, ListingFilters, Stats,
10− fetchFacets, fetchListings, fetchSources, fetchStats, 12+ fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays,
11 registerSourceNames, sourceName, 13 registerSourceNames, sourceName,
12 } from "../api"; 14 } from "../api";
13 import ListingCard from "../components/ListingCard"; 15 import ListingCard from "../components/ListingCard";
@@ -16,11 +18,23 @@ import ListingCard from "../components/ListingCard";
16 const MapView = lazy(() => import("../components/MapView")); 18 const MapView = lazy(() => import("../components/MapView"));
17 19
18 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; 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 export default function Home() { 33 export default function Home() {
21 const [listings, setListings] = useState<Listing[] | null>(null); 34 const [listings, setListings] = useState<Listing[] | null>(null);
22 const [total, setTotal] = useState(0); 35 const [total, setTotal] = useState(0);
23 const [facets, setFacets] = useState<Facets | null>(null); 36 const [facets, setFacets] = useState<Facets | null>(null);
37+ const [sectors, setSectors] = useState<string[]>([]);
24 const [stats, setStats] = useState<Stats | null>(null); 38 const [stats, setStats] = useState<Stats | null>(null);
25 const [error, setError] = useState<string | null>(null); 39 const [error, setError] = useState<string | null>(null);
26 40
@@ -28,12 +42,21 @@ export default function Home() {
28 const [params] = useSearchParams(); 42 const [params] = useSearchParams();
29 const [q, setQ] = useState(params.get("q") ?? ""); 43 const [q, setQ] = useState(params.get("q") ?? "");
30 const [city, setCity] = useState(params.get("city") ?? ""); 44 const [city, setCity] = useState(params.get("city") ?? "");
45+ const [sector, setSector] = useState(params.get("sector") ?? "");
31 const [source, setSource] = useState(params.get("source") ?? ""); 46 const [source, setSource] = useState(params.get("source") ?? "");
47+ const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
32 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); 48 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
33 const [unitType, setUnitType] = useState(params.get("unit_type") ?? ""); 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 const [sheetOpen, setSheetOpen] = useState(false); 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 // vue liste ou carte (mémorisée dans l'URL : /?view=carte) 60 // vue liste ou carte (mémorisée dans l'URL : /?view=carte)
38 const [view, setView] = useState<"liste" | "carte">( 61 const [view, setView] = useState<"liste" | "carte">(
39 params.get("view") === "carte" ? "carte" : "liste"); 62 params.get("view") === "carte" ? "carte" : "liste");
@@ -41,9 +64,16 @@ export default function Home() {
41 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte) 64 // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte)
42 setView(params.get("view") === "carte" ? "carte" : "liste"); 65 setView(params.get("view") === "carte" ? "carte" : "liste");
43 }, [params]); 66 }, [params]);
44− const filters: ListingFilters = useMemo( 67+
45− () => ({ q, city, source, price_max: priceMax, unit_type: unitType }), 68+ const filters: ListingFilters = useMemo(() => {
46− [q, city, source, priceMax, unitType]); 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 useEffect(() => { 78 useEffect(() => {
49 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); 79 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -51,11 +81,18 @@ export default function Home() {
51 fetchStats().then(setStats).catch(() => {}); 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 useEffect(() => { 91 useEffect(() => {
55 let cancelled = false; 92 let cancelled = false;
56 setListings(null); 93 setListings(null);
57 setError(null); 94 setError(null);
58− fetchListings({ q, city, source, price_max: priceMax, unit_type: unitType }) 95+ fetchListings(filters)
59 .then((r) => { 96 .then((r) => {
60 if (!cancelled) { 97 if (!cancelled) {
61 setListings(r.listings); 98 setListings(r.listings);
@@ -66,13 +103,39 @@ export default function Home() {
66 return () => { 103 return () => {
67 cancelled = true; 104 cancelled = true;
68 }; 105 };
69− }, [q, city, source, priceMax, unitType]); 106+ }, [filters]);
70 107
71 const availableTypes = useMemo(() => { 108 const availableTypes = useMemo(() => {
72 const set = new Set(facets?.unit_types ?? []); 109 const set = new Set(facets?.unit_types ?? []);
73 return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t)); 110 return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));
74 }, [facets]); 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 return ( 139 return (
77 <div className="container"> 140 <div className="container">
78 <section className="hero"> 141 <section className="hero">
@@ -119,63 +182,148 @@ export default function Home() {
119 ✕ 182 ✕
120 </button> 183 </button>
121 </div> 184 </div>
122− <div className="field"> 185+
123− <label htmlFor="f-q">Recherche</label> 186+ {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}
124− <input 187+ <div className="f-primary">
125− id="f-q" placeholder="Adresse, quartier, rue…" value={q} 188+ <div className="f-search">
126− onChange={(e) => setQ(e.target.value)} 189+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
127− /> 190+ <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
128− </div> 191+ </svg>
129− <div className="field"> 192+ <input
130− <label htmlFor="f-city">Ville</label> 193+ id="f-q" placeholder="Adresse, quartier, rue…" value={q}
131− <select id="f-city" value={city} onChange={(e) => setCity(e.target.value)}> 194+ onChange={(e) => setQ(e.target.value)}
132− <option value="">Toutes</option> 195+ aria-label="Recherche"
133− {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => ( 196+ />
134− <option key={c} value={c}>{c}</option> 197+ {q && (
135− ))} 198+ <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
136− </select> 199+ )}
137− </div> 200+ </div>
138− <div className="field"> 201+ <label className="f-ctl">
139− <label htmlFor="f-price">Loyer max</label> 202+ <span>Ville</span>
140− <select id="f-price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}> 203+ <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
141− <option value="">Aucun</option> 204+ <option value="">Toutes</option>
142− {[800, 1000, 1200, 1400, 1600, 1800, 2000, 2500].map((p) => ( 205+ {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => (
143− <option key={p} value={p}>{p} $</option> 206+ <option key={c} value={c}>{c}</option>
144− ))} 207+ ))}
145− </select> 208+ </select>
146− </div> 209+ </label>
147− <div className="field"> 210+ <label className="f-ctl">
148− <label htmlFor="f-source">Gestionnaire</label> 211+ <span>Quartier</span>
149− <select id="f-source" value={source} onChange={(e) => setSource(e.target.value)}> 212+ <select value={sector} onChange={(e) => setSector(e.target.value)}>
150− <option value="">Tous</option> 213+ <option value="">Tous</option>
151− {(facets?.sources ?? []).map((s) => ( 214+ {sectors.map((s) => (
152− <option key={s.source} value={s.source}> 215+ <option key={s} value={s}>{s}</option>
153− {sourceName(s.source)} ({s.n}) 216+ ))}
154− </option> 217+ </select>
155− ))} 218+ </label>
156− </select> 219+ <div className="f-ctl">
157− </div> 220+ <span>Loyer</span>
158− <div className="field"> 221+ <div className="range-pair">
159− <label htmlFor="f-type">Taille</label> 222+ <select aria-label="Loyer minimum" value={priceMin}
160− <select id="f-type" value={unitType} onChange={(e) => setUnitType(e.target.value)}> 223+ onChange={(e) => setPriceMin(e.target.value)}>
161− <option value="">Toutes</option> 224+ <option value="">Min</option>
162− {availableTypes.map((t) => ( 225+ {PRICE_STEPS.map((p) => (
163− <option key={t} value={t}>{t}</option> 226+ <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
164− ))} 227+ {p} $
165− </select> 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 </div> 250 </div>
167− <button 251+
168− className="btn btn-ghost" style={{ alignSelf: "end" }} 252+ {/* — panneau avancé : segments & sélecteurs — */}
169− onClick={() => { setQ(""); setCity(""); setSource(""); setPriceMax(""); setUnitType(""); }} 253+ {(advOpen || sheetOpen) && (
170− > 254+ <div className="f-adv">
171− Réinitialiser 255+ <div className="f-group">
172− </button> 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 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> 321 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
174 Voir les résultats {listings ? `(${total})` : ""} 322 Voir les résultats {listings ? `(${total})` : ""}
175 </button> 323 </button>
176 </section> 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 {availableTypes.map((t) => ( 327 {availableTypes.map((t) => (
180 <button 328 <button
181 key={t} 329 key={t}
@@ -185,8 +333,41 @@ export default function Home() {
185 {t} 333 {t}
186 </button> 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 </div> 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 <div className="results-head"> 371 <div className="results-head">
191 <h2>Logements disponibles</h2> 372 <h2>Logements disponibles</h2>
192 <div className="results-tools"> 373 <div className="results-tools">
@@ -237,7 +418,12 @@ export default function Home() {
237 <div className="notice"> 418 <div className="notice">
238 <div className="big">🔍</div> 419 <div className="big">🔍</div>
239 <h2>Aucun logement ne correspond</h2> 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 </div> 427 </div>
242 )} 428 )}
243 429
modified frontend/src/styles.css +112 −6
@@ -149,12 +149,114 @@ img { display: block; }
149 149
150 /* ================= Filter bar ================= */ 150 /* ================= Filter bar ================= */
151 .filterbar { 151 .filterbar {
152− background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); 152+ background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
153− box-shadow: var(--shadow-off-soft); padding: 16px; margin: 30px 0 6px; 153+ box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px;
154− display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr auto; gap: 12px; 154+ display: flex; flex-direction: column; gap: 0; min-width: 0;
155+}
156+
157+/* — rangée principale — */
158+.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
159+.f-search {
160+ flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px;
161+ border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2);
162+ padding: 0 13px; min-height: 52px; color: var(--ink-2);
163+ transition: box-shadow 0.15s ease;
164+}
165+.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
166+.f-search input {
167+ border: none; background: none; outline: none; flex: 1; min-width: 0;
168+ font-size: 15px; color: var(--ink); font-family: inherit;
169+}
170+.f-clear {
171+ border: none; background: var(--line); color: var(--ink-2); border-radius: 50%;
172+ width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none;
173+ display: grid; place-items: center;
174+}
175+.f-ctl {
176+ flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
177+ gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
178+ padding: 7px 12px 6px; min-height: 52px; cursor: pointer;
179+ transition: box-shadow 0.15s ease;
180+}
181+.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); }
182+.f-ctl > span {
183+ font-family: var(--font-mono); font-size: 9px; font-weight: 700;
184+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
185+}
186+.f-ctl select {
187+ border: none; background: transparent; outline: none; font-family: var(--font-display);
188+ font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;
189+ appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
190+ text-overflow: ellipsis;
191+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E");
192+ background-repeat: no-repeat; background-position: right center;
193+}
194+.range-pair { display: flex; align-items: center; gap: 4px; }
195+.range-pair select { max-width: 86px; }
196+.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
197+.f-more {
198+ flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px;
199+ background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer;
200+ font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px;
201+ transition: all 0.13s ease;
202+}
203+.f-more:hover { background: var(--lime-soft); }
204+.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); }
205+
206+/* — panneau avancé — */
207+.f-adv {
208+ display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px;
209+ border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0;
210+ animation: adv-in 0.18s ease;
211+}
212+@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
213+.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
214+.f-group > label {
215+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
216+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
217+}
218+.f-group-end { justify-content: flex-end; }
219+.f-group .btn:disabled { opacity: 0.4; cursor: default; }
220+.f-native {
221+ border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
222+ padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none;
223+ font-family: inherit; min-height: 42px; width: 100%; min-width: 0;
224+ appearance: none; -webkit-appearance: none;
225+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
226+ background-repeat: no-repeat; background-position: right 12px center;
227+}
228+.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
229+
230+/* segments (pilules soudées) */
231+.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
232+.seg button {
233+ border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2);
234+ padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px;
235+ cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px;
236+ transition: all 0.12s ease;
237+}
238+.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
239+.seg button:last-child { border-radius: 0 8px 8px 0; }
240+.seg button:hover { background: var(--lime-soft); color: var(--ink); }
241+.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; }
242+
243+/* pastilles de filtres actifs */
244+.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
245+.pill {
246+ display: inline-flex; align-items: center; gap: 7px;
247+ border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink);
248+ border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600;
249+ font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease;
250+}
251+.pill:hover { background: var(--lime); }
252+.pill-x { font-size: 10px; opacity: 0.65; }
253+.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
254+.pill-clear:hover { background: var(--danger); color: #fff; }
255+.chip-sep { width: 1.5px; align-self: stretch; background: var(--line); margin: 4px 4px; flex: none; }
256+.link-btn {
257+ background: none; border: none; padding: 0; color: var(--green); font: inherit;
258+ text-decoration: underline; cursor: pointer;
155 } 259 }
156−@media (max-width: 980px) { .filterbar { grid-template-columns: 1fr 1fr 1fr; } }
157−@media (max-width: 640px) { .filterbar { grid-template-columns: 1fr 1fr; padding: 14px; } }
158 .field { display: flex; flex-direction: column; gap: 5px; } 260 .field { display: flex; flex-direction: column; gap: 5px; }
159 .field label { 261 .field label {
160 font-family: var(--font-mono); font-size: 10px; font-weight: 700; 262 font-family: var(--font-mono); font-size: 10px; font-weight: 700;
@@ -443,8 +545,12 @@ img { display: block; }
443 545
444 /* La barre de filtres devient une feuille coulissante (bottom sheet) */ 546 /* La barre de filtres devient une feuille coulissante (bottom sheet) */
445 .filterbar { display: none; } 547 .filterbar { display: none; }
548+ .filterbar.open .f-primary { flex-direction: column; }
549+ .filterbar.open .f-ctl select { max-width: none; width: 100%; }
550+ .filterbar.open .f-more { display: none; }
551+ .filterbar.open .f-adv { margin-top: 4px; }
446 .filterbar.open { 552 .filterbar.open {
447− display: grid; grid-template-columns: 1fr; gap: 12px; 553+ display: flex; flex-direction: column; gap: 12px;
448 position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; 554 position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
449 margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; 555 margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
450 max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; 556 max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
modified louka/web.py +12 −3
@@ -223,14 +223,23 @@ def get_listing(uid: str):
223 223
224 224
225 @app.get("/api/facets") 225 @app.get("/api/facets")
226−def facets(): 226+def facets(city: str | None = None):
227− """Valeurs distinctes pour construire les filtres du frontend.""" 227+ """Valeurs distinctes pour construire les filtres du frontend.
228+
229+ `city` (optionnel) restreint la liste des quartiers à cette ville —
230+ utilisé par le sélecteur « Quartier » dépendant de « Ville ».
231+ """
228 con = db.connect() 232 con = db.connect()
233+ sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"
234+ sector_args: list = []
235+ if city:
236+ sector_sql += " AND city=?"
237+ sector_args.append(city)
229 out = { 238 out = {
230 "cities": [r["city"] for r in con.execute( 239 "cities": [r["city"] for r in con.execute(
231 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")], 240 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],
232 "sectors": [r["sector"] for r in con.execute( 241 "sectors": [r["sector"] for r in con.execute(
233− "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>'' ORDER BY sector")], 242+ sector_sql + " ORDER BY sector", sector_args)],
234 "unit_types": [r["unit_type"] for r in con.execute( 243 "unit_types": [r["unit_type"] for r in con.execute(
235 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")], 244 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],
236 "sources": [dict(r) for r in con.execute( 245 "sources": [dict(r) for r in con.execute(
237 246
Menu

Theme