SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%

Refonte UX des filtres : barre compacte (N vehicules · Trier · Filtres), bottom sheet mobile / tiroir desktop (FilterSheet), sliders min-max prix/annee/km, « Plus de filtres » repliable, chips actifs supprimables, CTA sticky « Voir N vehicules » avec compteur en direct, ajout pmin ; le gros panneau permanent disparait — resultats sous la ligne de flottaison mobile

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent babbf39

4 changed files +757 −221

added frontend/scripts/check-filters.mjs +84 −0
@@ -0,0 +1,84 @@
1 +// Vérification UI du système de filtres (barre compacte + FilterSheet) — mobile iPhone.
2 +import { chromium } from "playwright";
3 +const browser = await chromium.launch();
4 +const page = await browser.newPage({
5 + viewport: { width: 390, height: 844 },
6 + hasTouch: true,
7 + isMobile: true,
8 + deviceScaleFactor: 2,
9 +});
10 +const errors = [];
11 +page.on("pageerror", (e) => errors.push(String(e)));
12 +page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
13 +
14 +await page.goto("http://localhost:8095/", { waitUntil: "networkidle" });
15 +await page.waitForSelector(".vcard", { timeout: 15000 });
16 +
17 +const checks = {};
18 +checks.ancien_panneau_absent = (await page.locator(".filters").count()) === 0;
19 +checks.bouton_filtres = await page.locator(".filters-btn").textContent();
20 +checks.compteur = await page.locator(".result-bar .count").textContent();
21 +// la 1re carte véhicule doit être proche du haut de page (pas 2000px plus bas)
22 +checks.y_premiere_carte = Math.round(await page.locator(".vcard").first().evaluate((el) => el.getBoundingClientRect().top + window.scrollY));
23 +await page.screenshot({ path: "/tmp/autoka-filtres-1-accueil.png" });
24 +
25 +// — ouverture du bottom sheet —
26 +await page.locator(".filters-btn").tap();
27 +await page.waitForSelector(".fsheet", { timeout: 5000 });
28 +checks.sheet_ouvert = true;
29 +checks.scroll_verrouille = await page.evaluate(() => document.documentElement.classList.contains("ka-scroll-lock"));
30 +checks.sliders = await page.locator(".dr").count(); // prix + année + km = 3
31 +
32 +// marque via select natif (attendre le chargement des facettes)
33 +await page.waitForFunction(() => {
34 + const s = document.querySelector(".fsheet select");
35 + return s && s.options.length > 1;
36 +}, { timeout: 10000 });
37 +await page.locator(".fsheet select").first().evaluate((el) => {
38 + const opt = [...el.options].find((o) => o.value === "Honda");
39 + el.value = opt ? "Honda" : el.options[1].value;
40 + el.dispatchEvent(new Event("change", { bubbles: true }));
41 +});
42 +// prix max via clavier sur la poignée max du 1er slider (2 crans = -5 000 $)
43 +const pmax = page.locator(".fs-field:has(.fs-label:text('Prix')) .dr input").nth(1);
44 +await pmax.focus();
45 +await page.keyboard.press("ArrowLeft");
46 +await page.keyboard.press("ArrowLeft");
47 +checks.readout_prix = await page.locator(".fs-field:has(.fs-label:text('Prix')) .dr-readout").textContent();
48 +// « Plus de filtres »
49 +await page.locator(".fs-more").tap();
50 +checks.plus_de_filtres = (await page.locator(".fsheet select").count()) >= 6;
51 +// compteur en direct dans le CTA
52 +await page.waitForTimeout(900);
53 +checks.cta = (await page.locator(".fs-apply").textContent())?.trim();
54 +await page.screenshot({ path: "/tmp/autoka-filtres-2-sheet.png" });
55 +
56 +// — appliquer —
57 +await page.locator(".fs-apply").tap();
58 +await page.waitForTimeout(1200);
59 +checks.url_apres_apply = page.url();
60 +checks.chips = await page.locator(".fchip").allTextContents();
61 +checks.badge_nb_filtres = await page.locator(".fb-n").textContent().catch(() => "ABSENT");
62 +await page.screenshot({ path: "/tmp/autoka-filtres-3-applique.png" });
63 +
64 +// — retirer un chip —
65 +await page.locator(".fchip").first().tap();
66 +await page.waitForTimeout(800);
67 +checks.url_apres_retrait_chip = page.url();
68 +checks.chips_restants = await page.locator(".fchip").allTextContents();
69 +
70 +// — réouverture + réinitialiser —
71 +await page.locator(".filters-btn").tap();
72 +await page.waitForSelector(".fsheet");
73 +await page.locator(".fs-reset").tap();
74 +await page.waitForTimeout(700);
75 +checks.cta_apres_reset = (await page.locator(".fs-apply").textContent())?.trim();
76 +// fermeture par le voile
77 +await page.locator(".fsheet-veil").tap({ position: { x: 20, y: 60 } });
78 +await page.waitForTimeout(300);
79 +checks.sheet_ferme = (await page.locator(".fsheet").count()) === 0;
80 +checks.scroll_deverrouille = await page.evaluate(() => !document.documentElement.classList.contains("ka-scroll-lock"));
81 +
82 +console.log(JSON.stringify(checks, null, 2));
83 +console.log("JS errors:", errors.length ? errors : "aucune");
84 +await browser.close();
added frontend/src/components/FilterSheet.tsx +340 −0
@@ -0,0 +1,340 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// FilterSheet.tsx : panneau de filtres — bottom sheet (mobile) / tiroir (desktop)
5 +// · brouillon local appliqué au clic « Voir N véhicules » (compteur en direct)
6 +// · filtres principaux : marque, modèle, prix, année, kilométrage (sliders)
7 +// · « Plus de filtres » : carburant, transmission, carrosserie, région
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Facets, VehicleQuery, fetchFacets, fetchVehicles } from "../api";
11 +
12 +export interface FilterValues {
13 + make?: string;
14 + model?: string;
15 + region?: string;
16 + body?: string;
17 + fuel?: string;
18 + trans?: string;
19 + ymin?: number;
20 + ymax?: number;
21 + pmin?: number;
22 + pmax?: number;
23 + kmax?: number;
24 +}
25 +
26 +const PRICE_MAX = 100000;
27 +const PRICE_STEP = 2500;
28 +const KM_MAX = 200000;
29 +const KM_STEP = 10000;
30 +
31 +const fmt$ = (n: number) => `${n.toLocaleString("fr-CA")} $`;
32 +const fmtKm = (n: number) => `${n.toLocaleString("fr-CA")} km`;
33 +
34 +/* — slider à deux poignées (min/max) ; borne au bout = filtre libre — */
35 +function DualRange({ min, max, step, lo, hi, onChange, fmt, allLabel }: {
36 + min: number; max: number; step: number;
37 + lo?: number; hi?: number;
38 + onChange: (lo?: number, hi?: number) => void;
39 + fmt: (n: number) => string;
40 + allLabel: string;
41 +}) {
42 + const l = Math.max(min, Math.min(lo ?? min, max));
43 + const h = Math.max(min, Math.min(hi ?? max, max));
44 + const pct = (v: number) => ((v - min) / (max - min || 1)) * 100;
45 + const readout =
46 + lo == null && hi == null ? allLabel
47 + : lo != null && hi != null ? `${fmt(l)} – ${fmt(h)}`
48 + : lo != null ? `${fmt(l)} et plus`
49 + : `${fmt(h)} et moins`;
50 + return (
51 + <>
52 + <div className="dr-readout">{readout}</div>
53 + <div className="dr">
54 + <div className="dr-rail" />
55 + <div className="dr-fill" style={{ left: `${pct(l)}%`, width: `${pct(h) - pct(l)}%` }} />
56 + <input
57 + type="range" min={min} max={max} step={step} value={l}
58 + aria-label="Minimum"
59 + style={{ zIndex: l > (min + max) / 2 ? 4 : 3 }}
60 + onChange={(e) => {
61 + const v = Math.min(Number(e.target.value), h);
62 + onChange(v <= min ? undefined : v, hi);
63 + }}
64 + />
65 + <input
66 + type="range" min={min} max={max} step={step} value={h}
67 + aria-label="Maximum"
68 + onChange={(e) => {
69 + const v = Math.max(Number(e.target.value), l);
70 + onChange(lo, v >= max ? undefined : v);
71 + }}
72 + />
73 + </div>
74 + </>
75 + );
76 +}
77 +
78 +/* — slider à une poignée (plafond) ; au bout = illimité — */
79 +function MaxRange({ min, max, step, val, onChange, fmt, allLabel }: {
80 + min: number; max: number; step: number;
81 + val?: number;
82 + onChange: (v?: number) => void;
83 + fmt: (n: number) => string;
84 + allLabel: string;
85 +}) {
86 + const v = Math.max(min, Math.min(val ?? max, max));
87 + const pct = ((v - min) / (max - min || 1)) * 100;
88 + return (
89 + <>
90 + <div className="dr-readout">{val == null ? allLabel : `≤ ${fmt(v)}`}</div>
91 + <div className="dr">
92 + <div className="dr-rail" />
93 + <div className="dr-fill" style={{ left: 0, width: `${pct}%` }} />
94 + <input
95 + type="range" min={min} max={max} step={step} value={v}
96 + aria-label="Maximum"
97 + onChange={(e) => {
98 + const n = Number(e.target.value);
99 + onChange(n >= max ? undefined : n);
100 + }}
101 + />
102 + </div>
103 + </>
104 + );
105 +}
106 +
107 +export interface FilterSheetProps {
108 + open: boolean;
109 + onClose: () => void;
110 + kind: string;
111 + /** Filtres imposés par une page SEO — contrôles verrouillés. */
112 + fixed?: Partial<VehicleQuery>;
113 + facets: Facets | null;
114 + initial: FilterValues;
115 + /** Recherche libre courante (q) — hors panneau mais comptée. */
116 + baseQ?: string;
117 + fallbackCount: number;
118 + onApply: (v: FilterValues) => void;
119 +}
120 +
121 +export default function FilterSheet({
122 + open, onClose, kind, fixed, facets: facets0, initial, baseQ, fallbackCount, onApply,
123 +}: FilterSheetProps) {
124 + const [draft, setDraft] = useState<FilterValues>(initial);
125 + const [facets, setFacets] = useState<Facets | null>(facets0);
126 + const [count, setCount] = useState<number | null>(null);
127 + const [more, setMore] = useState(false);
128 +
129 + // à l'ouverture : repartir de l'état appliqué
130 + useEffect(() => {
131 + if (!open) return;
132 + setDraft(initial);
133 + setCount(null);
134 + setMore(Boolean(initial.fuel || initial.trans || initial.body || initial.region));
135 + // eslint-disable-next-line react-hooks/exhaustive-deps
136 + }, [open]);
137 +
138 + // verrou de scroll d'arrière-plan + fermeture Échap
139 + useEffect(() => {
140 + if (!open) return;
141 + document.documentElement.classList.add("ka-scroll-lock");
142 + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
143 + window.addEventListener("keydown", esc);
144 + return () => {
145 + document.documentElement.classList.remove("ka-scroll-lock");
146 + window.removeEventListener("keydown", esc);
147 + };
148 + }, [open, onClose]);
149 +
150 + // modèles dépendants de la marque du brouillon
151 + const draftMake = fixed?.make ?? draft.make;
152 + useEffect(() => {
153 + if (!open) return;
154 + fetchFacets(draftMake || undefined, kind).then(setFacets).catch(() => {});
155 + }, [open, draftMake, kind]);
156 +
157 + // compteur en direct (débouncé)
158 + useEffect(() => {
159 + if (!open) return;
160 + const t = setTimeout(() => {
161 + fetchVehicles({
162 + kind, q: baseQ,
163 + make: draft.make, model: draft.model, region: draft.region,
164 + body_type: draft.body, fuel: draft.fuel, transmission: draft.trans,
165 + year_min: draft.ymin, year_max: draft.ymax,
166 + price_min: draft.pmin, price_max: draft.pmax, km_max: draft.kmax,
167 + ...fixed, limit: 1, offset: 0,
168 + }).then((r) => setCount(r.total)).catch(() => {});
169 + }, 250);
170 + return () => clearTimeout(t);
171 + }, [open, draft, kind, baseQ, fixed]);
172 +
173 + const setSel = (key: keyof FilterValues, value: string) => {
174 + setDraft((d) => {
175 + const next: FilterValues = { ...d, [key]: value || undefined };
176 + if (key === "make") next.model = undefined; // le modèle dépend de la marque
177 + return next;
178 + });
179 + };
180 +
181 + const yb = facets?.years?.[0];
182 + const yMin = yb?.y_min ?? 1990;
183 + const yMax = yb?.y_max ?? new Date().getFullYear() + 1;
184 +
185 + if (!open) return null;
186 +
187 + const n = count ?? fallbackCount;
188 +
189 + return (
190 + <div className="fsheet-root" role="dialog" aria-modal="true" aria-label="Filtres de recherche">
191 + <div className="fsheet-veil" onClick={onClose} />
192 + <div className="fsheet">
193 + <header className="fsheet-head">
194 + <b>Filtres</b>
195 + <button className="fsheet-close" onClick={onClose} aria-label="Fermer les filtres">✕</button>
196 + </header>
197 +
198 + <div className="fsheet-body">
199 + <div className="fs-grid2">
200 + <div className="fs-field">
201 + <span className="fs-label">Marque</span>
202 + <select
203 + className="fs-select"
204 + value={fixed?.make ?? draft.make ?? ""}
205 + disabled={!!fixed?.make}
206 + onChange={(e) => setSel("make", e.target.value)}
207 + >
208 + <option value="">Toutes</option>
209 + {fixed?.make && !facets?.makes.some((m) => m.make === fixed.make) && (
210 + <option value={fixed.make}>{fixed.make}</option>
211 + )}
212 + {facets?.makes.map((m) => (
213 + <option key={m.make} value={m.make}>{m.make} ({m.n})</option>
214 + ))}
215 + </select>
216 + </div>
217 + <div className="fs-field">
218 + <span className="fs-label">Modèle</span>
219 + <select
220 + className="fs-select"
221 + value={fixed?.model ?? draft.model ?? ""}
222 + disabled={!!fixed?.model}
223 + onChange={(e) => setSel("model", e.target.value)}
224 + >
225 + <option value="">Tous</option>
226 + {fixed?.model && !facets?.models.some((m) => m.model === fixed.model) && (
227 + <option value={fixed.model}>{fixed.model}</option>
228 + )}
229 + {facets?.models.map((m) => (
230 + <option key={m.model} value={m.model}>{m.model} ({m.n})</option>
231 + ))}
232 + </select>
233 + </div>
234 + </div>
235 +
236 + <div className="fs-field">
237 + <span className="fs-label">Prix</span>
238 + <DualRange
239 + min={0} max={PRICE_MAX} step={PRICE_STEP}
240 + lo={draft.pmin} hi={draft.pmax}
241 + onChange={(lo, hi) => setDraft((d) => ({ ...d, pmin: lo, pmax: hi }))}
242 + fmt={fmt$} allLabel="Tous les prix"
243 + />
244 + </div>
245 +
246 + <div className="fs-field">
247 + <span className="fs-label">Année</span>
248 + <DualRange
249 + min={yMin} max={yMax} step={1}
250 + lo={draft.ymin} hi={draft.ymax}
251 + onChange={(lo, hi) => setDraft((d) => ({ ...d, ymin: lo, ymax: hi }))}
252 + fmt={(y) => String(y)} allLabel="Toutes les années"
253 + />
254 + </div>
255 +
256 + <div className="fs-field">
257 + <span className="fs-label">Kilométrage</span>
258 + <MaxRange
259 + min={0} max={KM_MAX} step={KM_STEP}
260 + val={draft.kmax}
261 + onChange={(v) => setDraft((d) => ({ ...d, kmax: v }))}
262 + fmt={fmtKm} allLabel="Tous les kilométrages"
263 + />
264 + </div>
265 +
266 + <button
267 + className={`fs-more${more ? " open" : ""}`}
268 + onClick={() => setMore(!more)}
269 + aria-expanded={more}
270 + >
271 + Plus de filtres
272 + </button>
273 +
274 + {more && (
275 + <div className="fs-grid2">
276 + <div className="fs-field">
277 + <span className="fs-label">Carburant</span>
278 + <select className="fs-select" value={draft.fuel ?? ""} onChange={(e) => setSel("fuel", e.target.value)}>
279 + <option value="">Tous</option>
280 + {facets?.fuels.map((f) => (
281 + <option key={f} value={f}>{f}</option>
282 + ))}
283 + </select>
284 + </div>
285 + <div className="fs-field">
286 + <span className="fs-label">Transmission</span>
287 + <select className="fs-select" value={draft.trans ?? ""} onChange={(e) => setSel("trans", e.target.value)}>
288 + <option value="">Toutes</option>
289 + <option value="Automatique">Automatique</option>
290 + <option value="Manuelle">Manuelle</option>
291 + </select>
292 + </div>
293 + <div className="fs-field">
294 + <span className="fs-label">Carrosserie</span>
295 + <select
296 + className="fs-select"
297 + value={fixed?.body_type ?? draft.body ?? ""}
298 + disabled={!!fixed?.body_type}
299 + onChange={(e) => setSel("body", e.target.value)}
300 + >
301 + <option value="">Toutes</option>
302 + {fixed?.body_type && !facets?.body_types.includes(fixed.body_type) && (
303 + <option value={fixed.body_type}>{fixed.body_type}</option>
304 + )}
305 + {facets?.body_types.map((b) => (
306 + <option key={b} value={b}>{b}</option>
307 + ))}
308 + </select>
309 + </div>
310 + <div className="fs-field">
311 + <span className="fs-label">Région</span>
312 + <select
313 + className="fs-select"
314 + value={fixed?.region ?? draft.region ?? ""}
315 + disabled={!!fixed?.region}
316 + onChange={(e) => setSel("region", e.target.value)}
317 + >
318 + <option value="">Toutes</option>
319 + {fixed?.region && !facets?.regions.some((r) => r.region === fixed.region) && (
320 + <option value={fixed.region}>{fixed.region}</option>
321 + )}
322 + {facets?.regions.map((r) => (
323 + <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
324 + ))}
325 + </select>
326 + </div>
327 + </div>
328 + )}
329 + </div>
330 +
331 + <footer className="fsheet-foot">
332 + <button className="fs-reset" onClick={() => setDraft({})}>Réinitialiser</button>
333 + <button className="fs-apply" onClick={() => onApply(draft)}>
334 + Voir {n.toLocaleString("fr-CA")} véhicule{n !== 1 ? "s" : ""}
335 + </button>
336 + </footer>
337 + </div>
338 + </div>
339 + );
340 +}
modified frontend/src/pages/Home.tsx +121 −146
@@ -1,14 +1,16 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// Home.tsx : recherche — hero, filtres auto, grille de véhicules, pagination
4 +// Home.tsx : recherche — hero, barre compacte (compteur · trier · filtres),
5 +// chips de filtres actifs, panneau FilterSheet, grille, pagination
5 6 // -----------------------------------------------------------------------------
6 7 import { useEffect, useMemo, useState } from "react";
7 8 import { useSearchParams } from "react-router-dom";
8 9 import {
9 − Facets, Vehicle, VehicleQuery, fetchFacets, fetchStats, fetchVehicles,
10 + Facets, Vehicle, VehicleQuery, fetchFacets, fetchStats, fetchVehicles, sourceName,
10 11 } from "../api";
11 12 import VehicleCard from "../components/VehicleCard";
13 +import FilterSheet, { FilterValues } from "../components/FilterSheet";
12 14
13 15 const PAGE_SIZE = 24;
14 16
@@ -20,9 +22,6 @@ const SORTS = [
20 22 { v: "year_desc", label: "Année récente" },
21 23 ];
22 24
23 −const PRICE_STEPS = [5000, 10000, 15000, 20000, 25000, 30000, 40000, 50000, 75000, 100000];
24 −const KM_STEPS = [20000, 40000, 60000, 80000, 100000, 130000, 160000, 200000];
25 −
26 25 const KIND_COPY: Record<string, { label: string; hero: string; sub: string }> = {
27 26 auto: {
28 27 label: "voitures usagées",
@@ -56,6 +55,7 @@ export default function Home({ kind = "auto", fixed, seoH1, seoIntro }: HomeProp
56 55 const [total, setTotal] = useState(0);
57 56 const [loading, setLoading] = useState(true);
58 57 const [heroStats, setHeroStats] = useState<{ total: number; sources: number; regions: number } | null>(null);
58 + const [sheetOpen, setSheetOpen] = useState(false);
59 59
60 60 const query: VehicleQuery = useMemo(() => {
61 61 const g = (k: string) => params.get(k) || undefined;
@@ -65,7 +65,7 @@ export default function Home({ kind = "auto", fixed, seoH1, seoIntro }: HomeProp
65 65 make: g("make"), model: g("model"), body_type: g("body"),
66 66 fuel: g("fuel"), transmission: g("trans"), region: g("region"),
67 67 source: g("source"), year_min: gn("ymin"), year_max: gn("ymax"),
68 − price_max: gn("pmax"), km_max: gn("kmax"), q: g("q"),
68 + price_min: gn("pmin"), price_max: gn("pmax"), km_max: gn("kmax"), q: g("q"),
69 69 sort: g("sort") || "recent",
70 70 limit: PAGE_SIZE,
71 71 offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE,
@@ -115,11 +115,63 @@ export default function Home({ kind = "auto", fixed, seoH1, seoIntro }: HomeProp
115 115
116 116 const clearAll = () => setParams(new URLSearchParams(), { replace: false });
117 117
118 − const years: number[] = [];
119 − const yb = facets?.years?.[0];
120 − if (yb?.y_min && yb?.y_max) {
121 − for (let y = yb.y_max; y >= yb.y_min; y--) years.push(y);
118 + const removeKeys = (keys: string[]) => {
119 + const next = new URLSearchParams(params);
120 + keys.forEach((k) => next.delete(k));
121 + next.delete("page");
122 + setParams(next, { replace: false });
123 + };
124 +
125 + // état des filtres pour le panneau (lu de l'URL)
126 + const gp = (k: string) => params.get(k) || undefined;
127 + const gpn = (k: string) => (params.get(k) ? Number(params.get(k)) : undefined);
128 + const filterValues: FilterValues = {
129 + make: gp("make"), model: gp("model"), region: gp("region"),
130 + body: gp("body"), fuel: gp("fuel"), trans: gp("trans"),
131 + ymin: gpn("ymin"), ymax: gpn("ymax"),
132 + pmin: gpn("pmin"), pmax: gpn("pmax"), kmax: gpn("kmax"),
133 + };
134 +
135 + const applyFilters = (v: FilterValues) => {
136 + const next = new URLSearchParams(params);
137 + const put = (k: string, val?: string | number) => {
138 + if (val !== undefined && val !== "") next.set(k, String(val));
139 + else next.delete(k);
140 + };
141 + put("make", v.make); put("model", v.model); put("region", v.region);
142 + put("body", v.body); put("fuel", v.fuel); put("trans", v.trans);
143 + put("ymin", v.ymin); put("ymax", v.ymax);
144 + put("pmin", v.pmin); put("pmax", v.pmax); put("kmax", v.kmax);
145 + next.delete("page");
146 + setParams(next, { replace: false });
147 + setSheetOpen(false);
148 + };
149 +
150 + // chips des filtres actifs (hors filtres imposés par la page SEO)
151 + const f$ = (n: number) => `${n.toLocaleString("fr-CA")} $`;
152 + const chips: { label: string; keys: string[] }[] = [];
153 + if (gp("q")) chips.push({ label: `« ${gp("q")} »`, keys: ["q"] });
154 + if (!fixed?.make && gp("make")) chips.push({ label: gp("make")!, keys: ["make", "model"] });
155 + if (!fixed?.model && gp("model")) chips.push({ label: gp("model")!, keys: ["model"] });
156 + {
157 + const a = gpn("ymin"), b = gpn("ymax");
158 + if (a || b) chips.push({
159 + label: a && b ? `${a}–${b}` : a ? `${a} +` : `≤ ${b}`,
160 + keys: ["ymin", "ymax"],
161 + });
162 + const p = gpn("pmin"), pm = gpn("pmax");
163 + if (p || pm) chips.push({
164 + label: p && pm ? `${f$(p)}–${f$(pm)}` : p ? `${f$(p)} +` : `≤ ${f$(pm!)}`,
165 + keys: ["pmin", "pmax"],
166 + });
167 + const k = gpn("kmax");
168 + if (k) chips.push({ label: `≤ ${k.toLocaleString("fr-CA")} km`, keys: ["kmax"] });
122 169 }
170 + if (gp("fuel")) chips.push({ label: gp("fuel")!, keys: ["fuel"] });
171 + if (gp("trans")) chips.push({ label: gp("trans")!, keys: ["trans"] });
172 + if (!fixed?.body_type && gp("body")) chips.push({ label: gp("body")!, keys: ["body"] });
173 + if (!fixed?.region && gp("region")) chips.push({ label: gp("region")!, keys: ["region"] });
174 + if (gp("source")) chips.push({ label: sourceName(gp("source")!), keys: ["source"] });
123 175
124 176 return (
125 177 <div className="container">
@@ -144,151 +196,74 @@ export default function Home({ kind = "auto", fixed, seoH1, seoIntro }: HomeProp
144 196 )}
145 197 </section>
146 198
147 − <section className="filters" aria-label="Filtres de recherche">
148 − <div className="f-field full">
149 − <label htmlFor="f-q">Recherche</label>
150 − <input
151 − id="f-q"
152 − type="search"
153 − placeholder="Marque, modèle, concessionnaire, ville…"
154 − defaultValue={params.get("q") || ""}
155 − onKeyDown={(e) => {
156 − if (e.key === "Enter") setFilter("q", (e.target as HTMLInputElement).value);
157 − }}
158 − />
159 − </div>
160 − <div className="f-field">
161 − <label htmlFor="f-make">Marque</label>
162 − <select
163 − id="f-make"
164 − value={fixed?.make ?? params.get("make") ?? ""}
165 − disabled={!!fixed?.make}
166 − onChange={(e) => setFilter("make", e.target.value)}
167 − >
168 − <option value="">Toutes</option>
169 − {fixed?.make && !facets?.makes.some((m) => m.make === fixed.make) && (
170 − <option value={fixed.make}>{fixed.make}</option>
171 − )}
172 − {facets?.makes.map((m) => (
173 − <option key={m.make} value={m.make}>{m.make} ({m.n})</option>
174 − ))}
175 − </select>
176 − </div>
177 − <div className="f-field">
178 − <label htmlFor="f-model">Modèle</label>
179 − <select
180 − id="f-model"
181 − value={fixed?.model ?? params.get("model") ?? ""}
182 − disabled={!!fixed?.model}
183 − onChange={(e) => setFilter("model", e.target.value)}
184 − >
185 − <option value="">Tous</option>
186 − {fixed?.model && !facets?.models.some((m) => m.model === fixed.model) && (
187 − <option value={fixed.model}>{fixed.model}</option>
188 − )}
189 − {facets?.models.map((m) => (
190 − <option key={m.model} value={m.model}>{m.model} ({m.n})</option>
191 − ))}
192 − </select>
193 − </div>
194 − <div className="f-field">
195 − <label htmlFor="f-region">Région</label>
196 − <select
197 − id="f-region"
198 − value={fixed?.region ?? params.get("region") ?? ""}
199 − disabled={!!fixed?.region}
200 − onChange={(e) => setFilter("region", e.target.value)}
201 − >
202 − <option value="">Toutes</option>
203 − {fixed?.region && !facets?.regions.some((r) => r.region === fixed.region) && (
204 − <option value={fixed.region}>{fixed.region}</option>
205 − )}
206 − {facets?.regions.map((r) => (
207 − <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
208 − ))}
209 − </select>
210 − </div>
211 − <div className="f-field">
212 − <label htmlFor="f-body">Carrosserie</label>
213 − <select
214 − id="f-body"
215 − value={fixed?.body_type ?? params.get("body") ?? ""}
216 − disabled={!!fixed?.body_type}
217 − onChange={(e) => setFilter("body", e.target.value)}
218 − >
219 − <option value="">Toutes</option>
220 − {fixed?.body_type && !facets?.body_types.includes(fixed.body_type) && (
221 − <option value={fixed.body_type}>{fixed.body_type}</option>
222 − )}
223 − {facets?.body_types.map((b) => (
224 − <option key={b} value={b}>{b}</option>
225 − ))}
226 − </select>
227 − </div>
228 − <div className="f-field">
229 − <label htmlFor="f-fuel">Carburant</label>
230 − <select id="f-fuel" value={params.get("fuel") || ""} onChange={(e) => setFilter("fuel", e.target.value)}>
231 − <option value="">Tous</option>
232 − {facets?.fuels.map((f) => (
233 − <option key={f} value={f}>{f}</option>
234 − ))}
235 − </select>
236 − </div>
237 − <div className="f-field">
238 − <label htmlFor="f-trans">Transmission</label>
239 − <select id="f-trans" value={params.get("trans") || ""} onChange={(e) => setFilter("trans", e.target.value)}>
240 − <option value="">Toutes</option>
241 − <option value="Automatique">Automatique</option>
242 − <option value="Manuelle">Manuelle</option>
243 − </select>
244 − </div>
245 − <div className="f-field">
246 − <label htmlFor="f-ymin">Année min</label>
247 − <select id="f-ymin" value={params.get("ymin") || ""} onChange={(e) => setFilter("ymin", e.target.value)}>
248 − <option value="">—</option>
249 − {years.map((y) => (
250 − <option key={y} value={y}>{y}</option>
251 − ))}
252 − </select>
253 − </div>
254 − <div className="f-field">
255 − <label htmlFor="f-pmax">Prix max</label>
256 − <select id="f-pmax" value={params.get("pmax") || ""} onChange={(e) => setFilter("pmax", e.target.value)}>
257 − <option value="">—</option>
258 − {PRICE_STEPS.map((p) => (
259 − <option key={p} value={p}>{p.toLocaleString("fr-CA")} $</option>
260 − ))}
261 − </select>
262 − </div>
263 − <div className="f-field">
264 − <label htmlFor="f-kmax">KM max</label>
265 − <select id="f-kmax" value={params.get("kmax") || ""} onChange={(e) => setFilter("kmax", e.target.value)}>
266 − <option value="">—</option>
267 − {KM_STEPS.map((k) => (
268 − <option key={k} value={k}>{k.toLocaleString("fr-CA")} km</option>
269 − ))}
270 − </select>
271 − </div>
272 − <div className="f-actions">
273 − <button className="btn ghost" onClick={clearAll}>Réinitialiser</button>
274 − </div>
199 + <section className="search-desk" aria-label="Recherche">
200 + <input
201 + id="f-q"
202 + key={params.get("q") || ""}
203 + type="search"
204 + placeholder="Marque, modèle, concessionnaire, ville…"
205 + defaultValue={params.get("q") || ""}
206 + aria-label="Recherche libre"
207 + onKeyDown={(e) => {
208 + if (e.key === "Enter") setFilter("q", (e.target as HTMLInputElement).value);
209 + }}
210 + />
275 211 </section>
276 212
277 213 <div className="result-bar">
278 214 <h2>
279 215 <span className="count">{total.toLocaleString("fr-CA")}</span>{" "}
280 − véhicule{total !== 1 ? "s" : ""} trouvé{total !== 1 ? "s" : ""}
216 + véhicule{total !== 1 ? "s" : ""}
281 217 </h2>
282 − <div className="sort-box">
283 − <label htmlFor="f-sort">Trier</label>
284 − <select id="f-sort" value={params.get("sort") || "recent"} onChange={(e) => setFilter("sort", e.target.value)}>
285 − {SORTS.map((s) => (
286 − <option key={s.v} value={s.v}>{s.label}</option>
287 − ))}
288 − </select>
218 + <div className="rb-tools">
219 + <div className="sort-box">
220 + <label htmlFor="f-sort">Trier</label>
221 + <select id="f-sort" value={params.get("sort") || "recent"} onChange={(e) => setFilter("sort", e.target.value)}>
222 + {SORTS.map((s) => (
223 + <option key={s.v} value={s.v}>{s.label}</option>
224 + ))}
225 + </select>
226 + </div>
227 + <button className="filters-btn" onClick={() => setSheetOpen(true)}>
228 + <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
229 + <path d="M1 3.5h6.5M11.5 3.5H13M1 10.5h1.5M6.5 10.5H13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
230 + <circle cx="9.5" cy="3.5" r="2" fill="none" stroke="currentColor" strokeWidth="1.6" />
231 + <circle cx="4.5" cy="10.5" r="2" fill="none" stroke="currentColor" strokeWidth="1.6" />
232 + </svg>
233 + Filtres
234 + {chips.length > 0 && <b className="fb-n">{chips.length}</b>}
235 + </button>
289 236 </div>
290 237 </div>
291 238
239 + {chips.length > 0 && (
240 + <div className="fchips" aria-label="Filtres actifs">
241 + {chips.map((c) => (
242 + <button
243 + key={c.keys.join(",")}
244 + className="fchip"
245 + onClick={() => removeKeys(c.keys)}
246 + aria-label={`Retirer le filtre ${c.label}`}
247 + >
248 + {c.label} <span aria-hidden="true">✕</span>
249 + </button>
250 + ))}
251 + <button className="fchips-clear" onClick={clearAll}>Réinitialiser</button>
252 + </div>
253 + )}
254 +
255 + <FilterSheet
256 + open={sheetOpen}
257 + onClose={() => setSheetOpen(false)}
258 + kind={kind}
259 + fixed={fixed}
260 + facets={facets}
261 + initial={filterValues}
262 + baseQ={params.get("q") || undefined}
263 + fallbackCount={total}
264 + onApply={applyFilters}
265 + />
266 +
292 267 {loading ? (
293 268 <div className="vgrid">
294 269 {Array.from({ length: 8 }).map((_, i) => (
modified frontend/src/styles.css +212 −75
@@ -163,56 +163,23 @@ button { font-family: inherit; }
163 163 letter-spacing: -0.03em; margin-bottom: 4px;
164 164 }
165 165
166 −/* ================= Pupitre de recherche (filtres sans boîte) =============== */
167 −.filters {
168 − background: transparent; border: 0; box-shadow: none; border-radius: 0;
169 − padding: 0; margin: 36px 0 0;
170 − display: grid; gap: 0;
171 − grid-template-columns: repeat(5, minmax(0, 1fr));
172 − border-top: 2.5px solid var(--ink);
173 −}
174 −.filters .full { grid-column: 1 / -1; }
175 −.f-field {
176 − display: flex; flex-direction: column; gap: 2px;
177 − padding: 10px 16px 10px 0; margin-right: 16px;
178 − border-bottom: 1px solid var(--line);
179 − position: relative;
180 −}
181 −/* hairline verticale entre les cellules d'une même rangée */
182 −.f-field:not(.full)::before {
183 − content: ""; position: absolute; right: 0; top: 12px; bottom: 12px;
184 − width: 1px; background: var(--line);
185 −}
186 −.f-field:nth-child(5n + 1):not(.full) { /* fin de rangée (5 col.) : pas de hairline */ }
187 −.f-field label {
188 − font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
189 − letter-spacing: 0.12em; color: var(--ink-3); font-weight: 600;
190 −}
191 −.f-field select, .f-field input {
192 − border: 0; border-radius: 0; background: transparent;
193 − font: inherit; font-size: 14.5px; font-weight: 600;
194 − padding: 4px 0; min-height: 36px; color: var(--ink);
195 − width: 100%; appearance: none; -webkit-appearance: none;
196 − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23141814' stroke-width='1.6'/%3E%3C/svg%3E");
197 − background-repeat: no-repeat; background-position: right 2px center;
198 −}
199 −.f-field input { background-image: none; }
200 −.f-field select:focus, .f-field input:focus { outline: none; }
201 −.f-field:focus-within { box-shadow: inset 0 -2.5px 0 var(--accent); }
202 −.f-field:focus-within label { color: var(--accent-deep); }
203 −/* champ de recherche : grand, typographique */
204 −.filters .full { margin-right: 0; }
205 −.filters .full::before { display: none; }
206 −.filters .full input {
166 +/* ================= Pupitre de recherche (barre typographique) ==============
167 + Le gros panneau de filtres est remplacé par : champ de recherche éditorial,
168 + barre compacte « N véhicules · Trier · Filtres », chips actifs, et le
169 + panneau FilterSheet (bottom sheet mobile / tiroir desktop) plus bas. */
170 +.search-desk {
171 + margin: 34px 0 0;
172 + border-top: 2.5px solid var(--ink); border-bottom: 1px solid var(--line);
173 +}
174 +.search-desk input {
175 + border: 0; border-radius: 0; background: transparent; width: 100%;
207 176 font-family: var(--font-display); font-weight: 600;
208 177 font-size: clamp(18px, 2.4vw, 26px); letter-spacing: -0.02em;
209 − min-height: 52px; padding: 6px 0;
210 −}
211 −.filters .full input::placeholder { color: var(--ink-3); font-weight: 500; }
212 −.f-actions {
213 − display: flex; align-items: center; justify-content: flex-end;
214 − border-bottom: 1px solid var(--line); padding: 10px 0;
178 + min-height: 56px; padding: 6px 0; color: var(--ink);
215 179 }
180 +.search-desk input::placeholder { color: var(--ink-3); font-weight: 500; }
181 +.search-desk input:focus { outline: none; }
182 +.search-desk:focus-within { box-shadow: inset 0 -2.5px 0 var(--accent); }
216 183 .btn {
217 184 font-family: var(--font-display); font-weight: 600; font-size: 14.5px;
218 185 border: 2px solid var(--ink); border-radius: var(--r-ctl);
@@ -223,22 +190,10 @@ button { font-family: inherit; }
223 190 .btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--accent); }
224 191 .btn.ghost { background: transparent; color: var(--ink); }
225 192 .btn.ghost:hover { box-shadow: 4px 4px 0 var(--ink); }
226 −/* « Réinitialiser » : lien texte, pas un bouton-boîte */
227 −.f-actions .btn.ghost {
228 − border: 0; min-height: 44px; padding: 4px 2px;
229 − font-family: var(--font-mono); font-size: 11px; font-weight: 600;
230 − text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2);
231 − text-decoration: underline; text-underline-offset: 4px;
232 − text-decoration-color: var(--line-strong);
233 −}
234 −.f-actions .btn.ghost:hover {
235 − transform: none; box-shadow: none; color: var(--accent-deep);
236 − text-decoration-color: var(--accent);
237 −}
238 193
239 −/* ================= Barre de résultats (compteur display) ================== */
194 +/* ================= Barre de résultats (compteur · trier · filtres) ======== */
240 195 .result-bar {
241 − display: flex; align-items: baseline; gap: 14px; margin: 30px 0 22px; flex-wrap: wrap;
196 + display: flex; align-items: baseline; gap: 14px; margin: 22px 0 18px; flex-wrap: wrap;
242 197 }
243 198 .result-bar h2 {
244 199 font-family: var(--font-mono); font-size: 11px; font-weight: 600;
@@ -256,7 +211,8 @@ button { font-family: inherit; }
256 211 animation: livepulse 2.2s ease-in-out infinite;
257 212 }
258 213 @keyframes livepulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
259 −.sort-box { margin-left: auto; display: flex; align-items: center; gap: 8px; }
214 +.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 16px; }
215 +.sort-box { display: flex; align-items: center; gap: 8px; }
260 216 .sort-box label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; color: var(--ink-3); }
261 217 .sort-box select {
262 218 border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
@@ -265,6 +221,185 @@ button { font-family: inherit; }
265 221 }
266 222 .sort-box select:focus { outline: none; border-bottom-color: var(--accent); }
267 223
224 +/* — bouton « Filtres » (ouvre le panneau) — */
225 +.filters-btn {
226 + display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
227 + min-height: 44px; padding: 8px 17px;
228 + border: 1.5px solid var(--ink); border-radius: 999px;
229 + background: var(--surface); color: var(--ink);
230 + font-family: var(--font-display); font-weight: 600; font-size: 14px;
231 + transition: transform 0.12s ease, box-shadow 0.12s ease;
232 +}
233 +.filters-btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--accent); }
234 +.filters-btn .fb-n {
235 + display: grid; place-items: center; min-width: 20px; height: 20px; padding: 0 5px;
236 + background: var(--accent); color: #101310; border-radius: 999px;
237 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
238 +}
239 +
240 +/* — chips des filtres actifs (supprimables un à un) — */
241 +.fchips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: -6px 0 18px; }
242 +.fchip {
243 + display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
244 + min-height: 32px; padding: 4px 12px;
245 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;
246 + background: var(--surface); color: var(--ink);
247 + border: 1.5px solid var(--ink); border-radius: 999px;
248 + transition: background 0.12s ease;
249 +}
250 +.fchip span { color: var(--ink-3); font-size: 10px; }
251 +.fchip:hover { background: var(--accent-soft); }
252 +.fchip:hover span { color: var(--accent-deep); }
253 +.fchips-clear {
254 + border: 0; background: none; cursor: pointer; min-height: 32px; padding: 4px 4px;
255 + font-family: var(--font-mono); font-size: 11px; font-weight: 600;
256 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2);
257 + text-decoration: underline; text-underline-offset: 4px;
258 + text-decoration-color: var(--line-strong);
259 +}
260 +.fchips-clear:hover { color: var(--accent-deep); text-decoration-color: var(--accent); }
261 +
262 +/* ================= FilterSheet — bottom sheet mobile / tiroir desktop ====== */
263 +.fsheet-veil {
264 + position: fixed; inset: 0; z-index: var(--z-overlay);
265 + background: rgba(16, 19, 16, 0.5); backdrop-filter: blur(2px);
266 +}
267 +.fsheet {
268 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal);
269 + max-height: calc(var(--vh100) - 40px);
270 + display: flex; flex-direction: column;
271 + background: var(--paper);
272 + border-top: 2.5px solid var(--ink); border-radius: 18px 18px 0 0;
273 + box-shadow: 0 -14px 44px rgba(16, 19, 16, 0.28);
274 + animation: fs-up 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
275 +}
276 +@keyframes fs-up { from { transform: translateY(28px); opacity: 0; } to { transform: none; opacity: 1; } }
277 +.fsheet-head {
278 + flex: none; display: flex; align-items: center; justify-content: space-between;
279 + padding: 14px 20px 12px; border-bottom: 1.5px solid var(--ink);
280 +}
281 +.fsheet-head b { font-family: var(--font-display); font-size: 20px; letter-spacing: -0.03em; }
282 +.fsheet-close {
283 + width: 40px; height: 40px; display: grid; place-items: center; cursor: pointer;
284 + border: 1.5px solid var(--ink); border-radius: 999px;
285 + background: var(--surface); color: var(--ink); font-size: 15px; padding: 0;
286 +}
287 +.fsheet-close:hover { background: var(--accent); }
288 +.fsheet-body {
289 + flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
290 + overscroll-behavior: contain; padding: 20px 20px 26px;
291 +}
292 +.fs-grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
293 +.fs-field { margin-bottom: 20px; min-width: 0; }
294 +.fs-label {
295 + display: block; margin-bottom: 7px;
296 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
297 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
298 +}
299 +.fs-select {
300 + width: 100%; min-height: 48px; padding: 10px 38px 10px 14px;
301 + font: inherit; font-size: 15px; font-weight: 600; color: var(--ink);
302 + border: 1.5px solid var(--ink); border-radius: 10px;
303 + appearance: none; -webkit-appearance: none;
304 + background: var(--surface)
305 + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23141814' stroke-width='1.6'/%3E%3C/svg%3E")
306 + no-repeat right 14px center;
307 + text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
308 +}
309 +.fs-select:focus { outline: none; box-shadow: 3px 3px 0 var(--accent); }
310 +.fs-select:disabled { opacity: 0.55; background-color: var(--surface-2); }
311 +/* — « Plus de filtres » : rangée à filet, discret — */
312 +.fs-more {
313 + display: flex; align-items: center; width: 100%; min-height: 50px;
314 + margin: 2px 0 16px; padding: 10px 2px;
315 + border: 0; border-top: 1.5px solid var(--ink); border-bottom: 1px solid var(--line);
316 + background: none; cursor: pointer;
317 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
318 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink);
319 +}
320 +.fs-more::after {
321 + content: "+"; margin-left: auto; font-size: 17px; line-height: 1;
322 + color: var(--accent-deep); font-weight: 600;
323 +}
324 +.fs-more.open::after { content: "–"; }
325 +/* — pied sticky : CTA + réinitialiser discret — */
326 +.fsheet-foot {
327 + flex: none; display: flex; align-items: center; gap: 16px;
328 + padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
329 + border-top: 1.5px solid var(--ink); background: var(--surface);
330 +}
331 +.fs-reset {
332 + flex: none; border: 0; background: none; cursor: pointer;
333 + min-height: 44px; padding: 8px 2px;
334 + font-family: var(--font-mono); font-size: 11px; font-weight: 600;
335 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2);
336 + text-decoration: underline; text-underline-offset: 4px;
337 + text-decoration-color: var(--line-strong);
338 +}
339 +.fs-reset:hover { color: var(--accent-deep); text-decoration-color: var(--accent); }
340 +.fs-apply {
341 + flex: 1; min-height: 52px; cursor: pointer;
342 + background: var(--accent); color: var(--on-accent);
343 + border: 2px solid var(--ink); border-radius: 12px;
344 + font-family: var(--font-display); font-weight: 700; font-size: 16px;
345 + letter-spacing: -0.01em; font-variant-numeric: tabular-nums;
346 + transition: transform 0.12s ease, box-shadow 0.12s ease;
347 +}
348 +.fs-apply:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); }
349 +.fs-apply:active { transform: none; box-shadow: none; }
350 +/* desktop : tiroir à droite plutôt que bottom sheet */
351 +@media (min-width: 768px) {
352 + .fsheet {
353 + left: auto; top: 0; right: 0; bottom: 0;
354 + width: min(460px, 94vw); max-height: none;
355 + border-radius: 0; border-top: 0; border-left: 2.5px solid var(--ink);
356 + box-shadow: -14px 0 44px rgba(16, 19, 16, 0.25);
357 + animation: fs-in 0.22s cubic-bezier(0.2, 0.7, 0.2, 1);
358 + }
359 + @keyframes fs-in { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } }
360 +}
361 +
362 +/* panneau/menu ouvert (ka-scroll-lock) : la bulle du widget ka-agent, qui vit
363 + à z-index maximal, masquerait le CTA sticky — on la retire le temps du modal */
364 +html.ka-scroll-lock .kaa-btn { display: none !important; }
365 +
366 +/* — sliders min/max (prix, année, kilométrage) — */
367 +.dr-readout {
368 + font-family: var(--font-display); font-weight: 700; font-size: 16px;
369 + letter-spacing: -0.01em; font-variant-numeric: tabular-nums; margin-bottom: 2px;
370 +}
371 +.dr { position: relative; height: 44px; margin: 0 13px; }
372 +.dr-rail {
373 + position: absolute; left: -2px; right: -2px; top: 50%; height: 3px;
374 + transform: translateY(-50%); background: var(--line); border-radius: 2px;
375 +}
376 +.dr-fill {
377 + position: absolute; top: 50%; height: 3px; transform: translateY(-50%);
378 + background: var(--accent); border-radius: 2px;
379 +}
380 +.dr input[type="range"] {
381 + position: absolute; inset: 0; width: 100%; height: 44px; margin: 0;
382 + -webkit-appearance: none; appearance: none; background: none;
383 + pointer-events: none; z-index: 3;
384 +}
385 +.dr input[type="range"]:focus { outline: none; }
386 +.dr input[type="range"]::-webkit-slider-runnable-track { height: 44px; background: transparent; border: 0; }
387 +.dr input[type="range"]::-webkit-slider-thumb {
388 + -webkit-appearance: none; pointer-events: auto; cursor: grab;
389 + width: 26px; height: 26px; margin-top: 9px; border-radius: 999px;
390 + background: var(--surface); border: 2.5px solid var(--ink);
391 + box-shadow: 0 1px 5px rgba(16, 19, 16, 0.25);
392 +}
393 +.dr input[type="range"]:active::-webkit-slider-thumb { background: var(--accent); cursor: grabbing; }
394 +.dr input[type="range"]::-moz-range-track { background: transparent; border: 0; }
395 +.dr input[type="range"]::-moz-range-thumb {
396 + pointer-events: auto; cursor: grab;
397 + width: 21px; height: 21px; border-radius: 999px;
398 + background: var(--surface); border: 2.5px solid var(--ink);
399 + box-shadow: 0 1px 5px rgba(16, 19, 16, 0.25);
400 +}
401 +.dr input[type="range"]:active::-moz-range-thumb { background: var(--accent); }
402 +
268 403 /* ================= Grille véhicules — cartes sans cadre ==================== */
269 404 .vgrid {
270 405 display: grid; gap: 36px 26px;
@@ -518,19 +653,21 @@ button { font-family: inherit; }
518 653 .hstat + .hstat { padding-left: 12px; }
519 654 .hstat b { font-size: 22px; }
520 655
521 − /* pupitre : 2 colonnes, recherche pleine largeur */
522 − .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 26px; }
523 − .f-field { margin-right: 12px; padding-right: 12px; }
524 − .f-field:nth-child(even):not(.full) { margin-right: 0; padding-right: 0; }
525 − .f-field:nth-child(even):not(.full)::before { display: none; }
526 − .filters .full input { font-size: 18px; min-height: 48px; }
527 − .f-actions { grid-column: 1 / -1; justify-content: flex-start; padding: 8px 0; }
528 − .f-field select, .f-field input { font-size: 16px; } /* évite le zoom iOS */
656 + /* recherche compacte + barre d'outils : résultats presque immédiats */
657 + .search-desk { margin-top: 24px; }
658 + .search-desk input { font-size: 17px; min-height: 50px; }
659 +
660 + .result-bar { gap: 6px 10px; margin: 16px 0 12px; align-items: center; }
661 + .result-bar .count { font-size: 26px; }
662 + .rb-tools { margin-left: 0; width: 100%; gap: 10px; }
663 + .sort-box { flex: 1; min-width: 0; }
664 + .sort-box label { display: none; }
665 + .sort-box select { width: 100%; min-height: 44px; }
666 + .filters-btn { flex: none; }
529 667
530 − .result-bar { gap: 8px; margin: 22px 0 16px; }
531 − .result-bar .count { font-size: 30px; }
532 − .sort-box { margin-left: 0; width: 100%; }
533 − .sort-box select { flex: 1; }
668 + .fchips { margin: 0 0 14px; }
669 + .fsheet-body { padding: 16px 16px 22px; }
670 + .fsheet-foot { padding-left: 16px; padding-right: 16px; }
534 671
535 672 .vgrid { gap: 26px; grid-template-columns: 1fr; }
536 673 .vcard:hover .photo img { transform: none; }
@@ -849,7 +986,7 @@ html:has(.mobile-menu.open) { overflow: hidden; }
849 986 input[type="text"],
850 987 input[type="search"],
851 988 input[type="number"],
852 − .f-field select, .f-field input, .sort-box select {
989 + .search-desk input, .fs-select, .sort-box select {
853 990 font-size: max(16px, 1em) !important;
854 991 }
855 992 }
856 993