spb/auto-ka Public
Python 82.8%
TypeScript 11.9%
CSS 5.1%
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