SPB Git

Repository Public

FilterSheet.tsx 12.5 KB 341 lines tsx

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
Files main
Menu

Theme