| 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 | 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 | 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 | // 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 | 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 | 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 | ✕ |
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 | {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 | <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 | |