Repository Public
1// -----------------------------------------------------------------------------2// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// 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égion8// -----------------------------------------------------------------------------9import { useEffect, useState } from "react";10import { Facets, VehicleQuery, fetchFacets, fetchVehicles } from "../api";1112export 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}2526const PRICE_MAX = 100000;27const PRICE_STEP = 2500;28const KM_MAX = 200000;29const KM_STEP = 10000;3031const fmt$ = (n: number) => `${n.toLocaleString("fr-CA")} $`;32const fmtKm = (n: number) => `${n.toLocaleString("fr-CA")} km`;3334/* — slider à deux poignées (min/max) ; borne au bout = filtre libre — */35function 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 ? allLabel47 : 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 <input57 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 <input66 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}7778/* — slider à une poignée (plafond) ; au bout = illimité — */79function 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 <input95 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}106107export 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}120121export 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);128129 // à 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-deps136 }, [open]);137138 // verrou de scroll d'arrière-plan + fermeture Échap139 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]);149150 // modèles dépendants de la marque du brouillon151 const draftMake = fixed?.make ?? draft.make;152 useEffect(() => {153 if (!open) return;154 fetchFacets(draftMake || undefined, kind).then(setFacets).catch(() => {});155 }, [open, draftMake, kind]);156157 // 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]);172173 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 marque177 return next;178 });179 };180181 const yb = facets?.years?.[0];182 const yMin = yb?.y_min ?? 1990;183 const yMax = yb?.y_max ?? new Date().getFullYear() + 1;184185 if (!open) return null;186187 const n = count ?? fallbackCount;188189 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>197198 <div className="fsheet-body">199 <div className="fs-grid2">200 <div className="fs-field">201 <span className="fs-label">Marque</span>202 <select203 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 <select220 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>235236 <div className="fs-field">237 <span className="fs-label">Prix</span>238 <DualRange239 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>245246 <div className="fs-field">247 <span className="fs-label">Année</span>248 <DualRange249 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>255256 <div className="fs-field">257 <span className="fs-label">Kilométrage</span>258 <MaxRange259 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>265266 <button267 className={`fs-more${more ? " open" : ""}`}268 onClick={() => setMore(!more)}269 aria-expanded={more}270 >271 Plus de filtres272 </button>273274 {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 <select296 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 <select313 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>330331 <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}341