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>
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 | |