SPB Git

spb/lou-ka Public

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

HTML 99.7%

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 3 h ago (Aug 11, 2026) parent 802fb2f

Showing 5 changed files with +388 and −71

modified .gitignore +3 −0
@@ -9,3 +9,6 @@ frontend/dist/
9 9 reponse.txt
10 10 data/staging-*.db
11 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 162 sector?: string;
163 163 unit_type?: string;
164 164 source?: string;
165 + price_min?: string;
165 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 171 q?: string;
167 172 }
168 173
@@ -226,7 +231,15 @@ export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed"
226 231
227 232 export const fetchListing = (uid: string) =>
228 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 243 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
231 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 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,11 +18,23 @@ 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,12 +42,21 @@ export default function Home() {
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,9 +64,16 @@ export default function Home() {
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,11 +81,18 @@ export default function Home() {
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,13 +103,39 @@ export default function Home() {
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,63 +182,148 @@ export default function Home() {
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,8 +333,41 @@ export default function Home() {
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,7 +418,12 @@ export default function Home() {
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
modified frontend/src/styles.css +112 −6
@@ -149,12 +149,114 @@ img { display: block; }
149 149
150 150 /* ================= Filter bar ================= */
151 151 .filterbar {
152 background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);
153 box-shadow: var(--shadow-off-soft); padding: 16px; margin: 30px 0 6px;
154 display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr auto; gap: 12px;
152 + background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card);
153 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px;
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 260 .field { display: flex; flex-direction: column; gap: 5px; }
159 261 .field label {
160 262 font-family: var(--font-mono); font-size: 10px; font-weight: 700;
@@ -443,8 +545,12 @@ img { display: block; }
443 545
444 546 /* La barre de filtres devient une feuille coulissante (bottom sheet) */
445 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 552 .filterbar.open {
447 display: grid; grid-template-columns: 1fr; gap: 12px;
553 + display: flex; flex-direction: column; gap: 12px;
448 554 position: fixed; left: 0; right: 0; bottom: 0; z-index: 95;
449 555 margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
450 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 225 @app.get("/api/facets")
226 def facets():
227 """Valeurs distinctes pour construire les filtres du frontend."""
226 +def facets(city: str | None = None):
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 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 238 out = {
230 239 "cities": [r["city"] for r in con.execute(
231 240 "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],
232 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 243 "unit_types": [r["unit_type"] for r in con.execute(
235 244 "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>'' ORDER BY unit_type")],
236 245 "sources": [dict(r) for r in con.execute(
237 246