SPB Git

spb/auto-ka Public

Python 82.8% TypeScript 11.9% CSS 5.1%
11.1 KB · 280 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// Home.tsx : recherche — hero, filtres auto, grille de véhicules, pagination5// -----------------------------------------------------------------------------6import { useEffect, useMemo, useState } from "react";7import { useSearchParams } from "react-router-dom";8import {9  Facets, Vehicle, VehicleQuery, fetchFacets, fetchStats, fetchVehicles,10} from "../api";11import VehicleCard from "../components/VehicleCard";1213const PAGE_SIZE = 24;1415const SORTS = [16  { v: "price_asc", label: "Prix croissant" },17  { v: "price_desc", label: "Prix décroissant" },18  { v: "km_asc", label: "Kilométrage croissant" },19  { v: "year_desc", label: "Année récente" },20  { v: "recent", label: "Arrivages récents" },21];2223const PRICE_STEPS = [5000, 10000, 15000, 20000, 25000, 30000, 40000, 50000, 75000, 100000];24const KM_STEPS = [20000, 40000, 60000, 80000, 100000, 130000, 160000, 200000];2526const KIND_COPY: Record<string, { label: string; hero: string; sub: string }> = {27  auto: {28    label: "voitures usagées",29    hero: "voitures usagées",30    sub: "Auto-Ka visite les sites des concessionnaires et marchands d'occasion de toutes les régions, normalise chaque annonce et détecte les nouveautés, les ventes et les baisses de prix — automatiquement.",31  },32  moto: {33    label: "motos usagées",34    hero: "motos usagées",35    sub: "Les inventaires des concessionnaires moto du Québec — Harley-Davidson, Honda, Yamaha, Kawasaki, BMW et plus — agrégés à la source et tenus à jour automatiquement.",36  },37  scooter: {38    label: "scooters usagés",39    hero: "scooters usagés",40    sub: "Les scooters usagés des concessionnaires du Québec — Vespa, Honda, Yamaha, Kymco et plus — agrégés à la source et tenus à jour automatiquement.",41  },42};4344export default function Home({ kind = "auto" }: { kind?: string }) {45  const [params, setParams] = useSearchParams();46  const [facets, setFacets] = useState<Facets | null>(null);47  const [vehicles, setVehicles] = useState<Vehicle[]>([]);48  const [total, setTotal] = useState(0);49  const [loading, setLoading] = useState(true);50  const [heroStats, setHeroStats] = useState<{ total: number; sources: number; regions: number } | null>(null);5152  const query: VehicleQuery = useMemo(() => {53    const g = (k: string) => params.get(k) || undefined;54    const gn = (k: string) => (params.get(k) ? Number(params.get(k)) : undefined);55    return {56      kind,57      make: g("make"), model: g("model"), body_type: g("body"),58      fuel: g("fuel"), transmission: g("trans"), region: g("region"),59      source: g("source"), year_min: gn("ymin"), year_max: gn("ymax"),60      price_max: gn("pmax"), km_max: gn("kmax"), q: g("q"),61      sort: g("sort") || "price_asc",62      limit: PAGE_SIZE,63      offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE,64    };65  }, [params, kind]);6667  const page = Math.max(1, Number(params.get("page") || 1));68  const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));6970  useEffect(() => {71    fetchFacets(params.get("make") || undefined, kind).then(setFacets).catch(() => {});72  }, [params.get("make"), kind]);7374  useEffect(() => {75    fetchStats()76      .then((s) => setHeroStats({ total: s.total, sources: s.sources, regions: s.regions }))77      .catch(() => {});78  }, []);7980  useEffect(() => {81    setLoading(true);82    fetchVehicles(query)83      .then((r) => { setVehicles(r.vehicles); setTotal(r.total); })84      .catch(() => { setVehicles([]); setTotal(0); })85      .finally(() => setLoading(false));86  }, [query]);8788  const setFilter = (key: string, value: string) => {89    const next = new URLSearchParams(params);90    if (value) next.set(key, value);91    else next.delete(key);92    if (key !== "page") next.delete("page");93    if (key === "make") next.delete("model");   // le modèle dépend de la marque94    setParams(next, { replace: false });95  };9697  const clearAll = () => setParams(new URLSearchParams(), { replace: false });9899  const years: number[] = [];100  const yb = facets?.years?.[0];101  if (yb?.y_min && yb?.y_max) {102    for (let y = yb.y_max; y >= yb.y_min; y--) years.push(y);103  }104105  return (106    <div className="container">107      <section className="hero">108        <span className="kicker">Agrégateur indépendant — direct des concessionnaires</span>109        <h1>110          {kind === "scooter" ? "Tous les " : "Toutes les "}111          <em>{KIND_COPY[kind]?.hero ?? "véhicules"}</em> à vendre au Québec.112          Un seul endroit.113        </h1>114        <p className="sub">{KIND_COPY[kind]?.sub}</p>115        {kind === "auto" && heroStats && (116          <div className="hero-stats">117            <div className="hstat"><b>{heroStats.total.toLocaleString("fr-CA")}</b> véhicules en vente</div>118            <div className="hstat"><b>{heroStats.sources}</b> concessionnaires</div>119            <div className="hstat"><b>{heroStats.regions}</b> régions couvertes</div>120          </div>121        )}122      </section>123124      <section className="filters" aria-label="Filtres de recherche">125        <div className="f-field full">126          <label htmlFor="f-q">Recherche</label>127          <input128            id="f-q"129            type="search"130            placeholder="Marque, modèle, concessionnaire, ville…"131            defaultValue={params.get("q") || ""}132            onKeyDown={(e) => {133              if (e.key === "Enter") setFilter("q", (e.target as HTMLInputElement).value);134            }}135          />136        </div>137        <div className="f-field">138          <label htmlFor="f-make">Marque</label>139          <select id="f-make" value={params.get("make") || ""} onChange={(e) => setFilter("make", e.target.value)}>140            <option value="">Toutes</option>141            {facets?.makes.map((m) => (142              <option key={m.make} value={m.make}>{m.make} ({m.n})</option>143            ))}144          </select>145        </div>146        <div className="f-field">147          <label htmlFor="f-model">Modèle</label>148          <select id="f-model" value={params.get("model") || ""} onChange={(e) => setFilter("model", e.target.value)}>149            <option value="">Tous</option>150            {facets?.models.map((m) => (151              <option key={m.model} value={m.model}>{m.model} ({m.n})</option>152            ))}153          </select>154        </div>155        <div className="f-field">156          <label htmlFor="f-region">Région</label>157          <select id="f-region" value={params.get("region") || ""} onChange={(e) => setFilter("region", e.target.value)}>158            <option value="">Toutes</option>159            {facets?.regions.map((r) => (160              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>161            ))}162          </select>163        </div>164        <div className="f-field">165          <label htmlFor="f-body">Carrosserie</label>166          <select id="f-body" value={params.get("body") || ""} onChange={(e) => setFilter("body", e.target.value)}>167            <option value="">Toutes</option>168            {facets?.body_types.map((b) => (169              <option key={b} value={b}>{b}</option>170            ))}171          </select>172        </div>173        <div className="f-field">174          <label htmlFor="f-fuel">Carburant</label>175          <select id="f-fuel" value={params.get("fuel") || ""} onChange={(e) => setFilter("fuel", e.target.value)}>176            <option value="">Tous</option>177            {facets?.fuels.map((f) => (178              <option key={f} value={f}>{f}</option>179            ))}180          </select>181        </div>182        <div className="f-field">183          <label htmlFor="f-trans">Transmission</label>184          <select id="f-trans" value={params.get("trans") || ""} onChange={(e) => setFilter("trans", e.target.value)}>185            <option value="">Toutes</option>186            <option value="Automatique">Automatique</option>187            <option value="Manuelle">Manuelle</option>188          </select>189        </div>190        <div className="f-field">191          <label htmlFor="f-ymin">Année min</label>192          <select id="f-ymin" value={params.get("ymin") || ""} onChange={(e) => setFilter("ymin", e.target.value)}>193            <option value="">—</option>194            {years.map((y) => (195              <option key={y} value={y}>{y}</option>196            ))}197          </select>198        </div>199        <div className="f-field">200          <label htmlFor="f-pmax">Prix max</label>201          <select id="f-pmax" value={params.get("pmax") || ""} onChange={(e) => setFilter("pmax", e.target.value)}>202            <option value="">—</option>203            {PRICE_STEPS.map((p) => (204              <option key={p} value={p}>{p.toLocaleString("fr-CA")} $</option>205            ))}206          </select>207        </div>208        <div className="f-field">209          <label htmlFor="f-kmax">KM max</label>210          <select id="f-kmax" value={params.get("kmax") || ""} onChange={(e) => setFilter("kmax", e.target.value)}>211            <option value="">—</option>212            {KM_STEPS.map((k) => (213              <option key={k} value={k}>{k.toLocaleString("fr-CA")} km</option>214            ))}215          </select>216        </div>217        <div className="f-actions">218          <button className="btn ghost" onClick={clearAll}>Réinitialiser</button>219        </div>220      </section>221222      <div className="result-bar">223        <h2>224          <span className="count">{total.toLocaleString("fr-CA")}</span>{" "}225          véhicule{total !== 1 ? "s" : ""} trouvé{total !== 1 ? "s" : ""}226        </h2>227        <div className="sort-box">228          <label htmlFor="f-sort">Trier</label>229          <select id="f-sort" value={params.get("sort") || "price_asc"} onChange={(e) => setFilter("sort", e.target.value)}>230            {SORTS.map((s) => (231              <option key={s.v} value={s.v}>{s.label}</option>232            ))}233          </select>234        </div>235      </div>236237      {loading ? (238        <div className="vgrid">239          {Array.from({ length: 8 }).map((_, i) => (240            <div key={i} className="skeleton" style={{ height: 360 }} />241          ))}242        </div>243      ) : vehicles.length === 0 ? (244        <div className="notice">245          <div className="big">🔍</div>246          <h2>Aucun véhicule ne correspond</h2>247          <p>Essayez d'élargir vos filtres — l'inventaire évolue chaque jour.</p>248        </div>249      ) : (250        <>251          <div className="vgrid">252            {vehicles.map((v) => (253              <VehicleCard key={v.uid} v={v} />254            ))}255          </div>256          {pages > 1 && (257            <div className="pager">258              <button259                className="btn ghost"260                disabled={page <= 1}261                onClick={() => { setFilter("page", String(page - 1)); window.scrollTo(0, 0); }}262              >263                ← Précédent264              </button>265              <span className="mono">page {page} / {pages}</span>266              <button267                className="btn ghost"268                disabled={page >= pages}269                onClick={() => { setFilter("page", String(page + 1)); window.scrollTo(0, 0); }}270              >271                Suivant →272              </button>273            </div>274          )}275        </>276      )}277    </div>278  );279}280