Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
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