SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
6.0 KB · 101 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// fiche/VehicleHero.tsx : héro de la fiche — concessionnaire · ville, prix +5//   ancien prix barré + capsule marché, titre (H1) + résumé (année · km ·6//   transmission · carburant · carrosserie), galerie photo (balayage natif,7//   compteur, plein écran via Lightbox existante, vignettes ≥ 768 px), actions8//   (favoris · partager · Carfax · Voir chez le concessionnaire).9// -----------------------------------------------------------------------------10import { useRef, useState } from "react";11import { VehicleDetail, fmtPrice } from "../api";12import { Ico, IcoHeart } from "../components/Icons";13import Lightbox from "../components/Lightbox";14import { ComparaisonPrix, ligneResume } from "./synthese";1516export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {17  if (!cmp) return null;18  return (19    <span className={`ak-capsule ${cmp.tone}`}>20      {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}21    </span>22  );23}2425export function VehicleGallery({ images, titre }: { images: string[]; titre: string }) {26  const [idx, setIdx] = useState(0);27  const [zoom, setZoom] = useState(false);28  const [dead, setDead] = useState<Set<string>>(new Set());29  const track = useRef<HTMLDivElement>(null);30  const alive = images.filter((u) => !dead.has(u));31  const cur = Math.min(idx, Math.max(0, alive.length - 1));32  const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });33  if (alive.length === 0)34    return <div className="ak-gallery ak-gallery-empty" aria-label="Photos"><Ico name="car" size={44} /><span>Aucune photo fournie par le concessionnaire</span></div>;35  return (36    <>37      <div className="ak-gallery" aria-roledescription="carrousel" aria-label="Photos du véhicule">38        <div className="ak-gallery-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }}>39          {alive.map((u, i) => (40            <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async" alt={`${titre} — photo ${i + 1} de ${alive.length}`}41                 onError={() => setDead((d) => new Set(d).add(u))} onClick={() => setZoom(true)} />42          ))}43        </div>44        <span className="ak-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>45        <button type="button" className="ak-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}><Ico name="expand" size={17} /></button>46        {cur > 0 && <button type="button" className="ak-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>}47        {cur < alive.length - 1 && <button type="button" className="ak-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>}48      </div>49      {alive.length > 1 && (50        <div className="ak-thumbs" role="list">51          {alive.slice(0, 12).map((u, i) => (52            <button type="button" key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`} aria-current={i === cur} role="listitem">53              <img src={u} alt="" loading="lazy" decoding="async" />54            </button>55          ))}56        </div>57      )}58      {zoom && <Lightbox images={alive} index={cur} onIndex={(i) => { setIdx(i); goto(i); }} onClose={() => setZoom(false)} alt={titre} />}59    </>60  );61}6263export default function VehicleHero({ v, cmp, fav, onFav, onShare, actionsRef }: {64  v: VehicleDetail; cmp: ComparaisonPrix | null; fav: boolean;65  onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>;66}) {67  const resume = ligneResume(v);68  const hist = (v.price_history ?? []).filter((h) => h.price != null);69  const avant = hist.length >= 2 && hist[0].price! < hist[1].price! ? hist[1].price! : null;70  return (71    <header className="ak-hero" aria-label="Résumé du véhicule">72      <div className="ak-kicker">{v.dealer_name || v.source}{v.city ? ` — ${v.city}` : ""}{v.region ? ` · ${v.region}` : ""}</div>73      <div className="ak-price">74        {fmtPrice(v.price)}75        {avant != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(avant)}</small>}76        {v.details?.price_from ? <small>à partir de</small> : null}77      </div>78      <div className="ak-price-row" style={{ marginTop: 8 }}>79        <PriceCapsule cmp={cmp} />80        {v.year != null && <span className="ak-capsule neutral">{v.year}</span>}81        {v.ka_reco && <span className="ak-capsule brand">Recommandé pour vous</span>}82      </div>83      <h1 className="ak-h1">84        {v.title}85        {resume.length > 0 && <span className="ak-city">{resume.join(" · ")}</span>}86      </h1>87      <VehicleGallery images={v.images ?? []} titre={v.title} />88      <div className="ak-actions" ref={actionsRef}>89        <button type="button" className={`ak-btn ak-btn-ghost ak-btn-icon ${fav ? "on" : ""}`} aria-pressed={fav}90                aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>91        <button type="button" className="ak-btn ak-btn-ghost ak-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>92        {v.carfax_url && <a className="ak-btn ak-btn-ghost ak-btn-icon" aria-label="Rapport Carfax" title="Rapport Carfax" href={v.carfax_url} target="_blank" rel="noreferrer"><Ico name="doc" size={18} /></a>}93        <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer">94          Voir l'annonce <Ico name="external" size={16} />95          <span className="visually-hidden"> chez {v.dealer_name || "le concessionnaire"}</span>96        </a>97      </div>98    </header>99  );100}101