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%
11.2 KB · 163 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/ContextCards.tsx : cartes de contexte —5//   · RecallsCard : rappels Transports Canada (3 visibles + « Voir les N ») ;6//   · OffersCard : le même véhicule (même NIV) chez d'autres sources ;7//   · LocationCard : carte OpenStreetMap (position approximative du vendeur) ;8//   · SimilarCard : véhicules similaires (cartes de la grille) ;9//   · Dossier : suivi Auto-Ka, concessionnaire, identifiants ;10//   · Sources : sources et méthodologie + rangée d'actions.11// -----------------------------------------------------------------------------12import { useMemo, useState } from "react";13import { Recall, VehicleDetail, fmtDate, fmtPrice, sourceName } from "../api";14import { Ico } from "../components/Icons";15import VehicleCard from "../components/VehicleCard";16import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, StatusBadge, relTime } from "./ui";17import { Res } from "./useFicheData";1819export function RecallsCard({ v, r, onRetry }: { v: VehicleDetail; r: Res<Recall[]>; onRetry: () => void }) {20  const [all, setAll] = useState(false);21  if (r.status === "na" || r.status === "idle") return null;22  const items = r.status === "ok" ? r.data : null;23  return (24    <SectionCard id="rappels" title="Rappels Transports Canada" icon={<Ico name="shield" size={18} />}25                 aside={items && <StatusBadge tone={items.length ? "warn" : "good"} lg>{items.length ? `${items.length} rappel${items.length > 1 ? "s" : ""}` : "Aucun rappel"}</StatusBadge>}>26      {r.status === "loading" && <SkeletonLines n={2} />}27      {r.status === "error" && <ErrorState onRetry={onRetry}>Base des rappels temporairement indisponible.</ErrorState>}28      {items && items.length === 0 && <p className="ak-status-d" style={{ margin: 0 }}>Aucun rappel de sécurité répertorié pour {v.make} {v.model}{v.year ? ` ${v.year}` : ""}.</p>}29      {items && items.length > 0 && (30        <>31          <p className="ak-status-d" style={{ marginTop: 0 }}>Rappels visant {v.make} {v.model}{v.year ? ` ${v.year}` : ""} — un rappel concerne le modèle, pas forcément cet exemplaire ; vérifiez auprès du concessionnaire qu'ils ont été effectués.</p>32          {(all ? items : items.slice(0, 3)).map((x) => (33            <Accordion key={x.recall_number} small title={<><span style={{ fontFamily: "var(--font-mono)", color: "var(--ak-muted)", marginRight: 8 }}>{x.date}</span>{x.component}</>}>34              <p style={{ whiteSpace: "pre-line" }}>{x.description}</p>35              <p className="ak-fine">Rappel nº {x.recall_number}{x.units_affected ? ` · ${x.units_affected.toLocaleString("fr-CA")} unités visées` : ""}</p>36            </Accordion>37          ))}38          {items.length > 3 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : `Voir les ${items.length} rappels`}</MoreButton>}39        </>40      )}41      <SourceLine name="Transports Canada" href="https://tc.canada.ca/fr/transport-routier/rappels-securite-defauts" date="base des rappels de sécurité" />42    </SectionCard>43  );44}4546export function OffersCard({ v }: { v: VehicleDetail }) {47  const offers = v.dup_sources ?? [];48  if (offers.length === 0) return null;49  return (50    <SectionCard id="offres" title="Le même véhicule ailleurs" icon={<Ico name="layers" size={18} />}51                 sub={`Même NIV repéré sur ${offers.length} autre${offers.length > 1 ? "s" : ""} site${offers.length > 1 ? "s" : ""} (dédoublonnage Auto-Ka)`}>52      <ul className="ak-list">53        {offers.map((o) => {54          const delta = o.price != null && v.price != null ? o.price - v.price : null;55          return (56            <li className="ak-item" key={o.uid}>57              <span className="ak-item-ico" aria-hidden="true"><Ico name="store" size={17} /></span>58              <div className="ak-item-main">59                <div className="ak-item-t">{o.dealer_name || sourceName(o.source)}</div>60                <div className="ak-item-s">{[o.city, sourceName(o.source)].filter(Boolean).join(" · ")}</div>61              </div>62              <div className="ak-item-r">63                <div className="ak-item-v">{fmtPrice(o.price)}</div>64                <div className={`ak-item-m ${delta != null && delta < 0 ? "good" : ""}`} style={delta != null && delta < 0 ? { color: "var(--ak-success)", fontWeight: 600 } : undefined}>65                  {delta == null || delta === 0 ? <a className="ak-link" href={o.url} target="_blank" rel="noreferrer">Voir ↗</a> : `${delta < 0 ? "−" : "+"}${Math.abs(delta).toLocaleString("fr-CA")} $`}66                </div>67              </div>68            </li>69          );70        })}71      </ul>72    </SectionCard>73  );74}7576export function LocationCard({ v }: { v: VehicleDetail }) {77  const src = useMemo(() => {78    if (v.lat == null || v.lng == null) return null;79    const d = 0.02;80    const bbox = [v.lng - d, v.lat - d, v.lng + d, v.lat + d].join(",");81    return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${v.lat},${v.lng}`;82  }, [v.lat, v.lng]);83  if (!src) return null;84  return (85    <SectionCard id="carte" title="Où le voir" icon={<Ico name="pin" size={18} />} sub={`${[v.dealer_name, v.city, v.region].filter(Boolean).join(" · ")} — position approximative (ville du vendeur)`}>86      <div className="ak-map"><iframe className="ak-map-embed" src={src} title={`Carte — ${v.dealer_name || v.city}`} loading="lazy" /></div>87      <SourceLine name="OpenStreetMap" href="https://www.openstreetmap.org" />88    </SectionCard>89  );90}9192export function SimilarCard({ v }: { v: VehicleDetail }) {93  if (!v.similar || v.similar.length === 0) return null;94  return (95    <SectionCard id="similaires" title={`${v.make} ${v.model.split(" ")[0]} similaires`} icon={<Ico name="car" size={18} />} sub="En vente au Québec, du moins cher au plus cher">96      <div className="ak-similar vgrid">{v.similar.slice(0, 6).map((s) => <VehicleCard key={s.uid} v={s} />)}</div>97    </SectionCard>98  );99}100101export function Dossier({ v }: { v: VehicleDetail }) {102  const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;103  const hist = (v.price_history ?? []).filter((h) => h.price != null);104  const site = typeof v.details?.dealer_site === "string" ? (v.details.dealer_site as string) : null;105  return (106    <SectionCard id="dossier" title="Dossier de l'annonce" icon={<Ico name="folder" size={18} />} sub="Ce qu'Auto-Ka observe réellement : suivi, concessionnaire, identifiants">107      <Accordion title={<><Ico name="history" size={15} className="ak-acc-ico" />Suivi Auto-Ka</>} meta={jours != null ? (jours === 0 ? "arrivé aujourd'hui" : `${jours} j en vente`) : undefined}>108        <div className="ak-kpis cols-3" style={{ marginBottom: 10 }}>109          <StatTile value={fmtDate(v.first_seen)} label="Repéré le" anim={false} />110          <StatTile value={fmtDate(v.updated_at)} label="Vérifié le" anim={false} />111          <StatTile value={Math.max(0, hist.length - 1)} label="Changement(s) de prix" anim={false} />112        </div>113        <p className="ak-fine">Statut : {v.active ? "en vente chez le concessionnaire" : "retiré / vendu"}. Première observation et variations sont mesurées par les synchronisations Auto-Ka (plusieurs fois par jour).</p>114      </Accordion>115      <Accordion title={<><Ico name="store" size={15} className="ak-acc-ico" />Concessionnaire</>} meta={v.dealer_name || sourceName(v.source)}>116        <p style={{ margin: "0 0 6px" }}><b style={{ fontSize: 15 }}>{v.dealer_name || sourceName(v.source)}</b>{v.city ? ` — ${v.city}${v.region ? `, ${v.region}` : ""}` : ""}</p>117        <p style={{ margin: 0 }}>Source : {sourceName(v.source)}{site && <> · <a href={site} target="_blank" rel="noreferrer">site du concessionnaire ↗</a></>}. Auto-Ka est un agrégateur indépendant : prix, disponibilité et transaction relèvent du vendeur.</p>118      </Accordion>119      <Accordion title={<><Ico name="tag" size={15} className="ak-acc-ico" />Identifiants</>} meta={v.vin || v.external_id}>120        <div className="ak-facts-rows" style={{ marginTop: 0 }}>121          {v.vin && <div className="ak-kv"><span className="k">NIV (VIN)</span><span className="v" style={{ fontFamily: "var(--font-mono)", fontSize: 12.5 }}>{v.vin}</span></div>}122          {v.stock_number && <div className="ak-kv"><span className="k">Nº de stock</span><span className="v">{v.stock_number}</span></div>}123          <div className="ak-kv"><span className="k">Identifiant source</span><span className="v">{v.external_id}</span></div>124          <div className="ak-kv"><span className="k">Identifiant Auto-Ka</span><span className="v">{v.uid}</span></div>125          <div className="ak-kv"><span className="k">Annonce originale</span><span className="v"><a href={v.url} target="_blank" rel="noreferrer">{sourceName(v.source)} ↗</a></span></div>126        </div>127      </Accordion>128    </SectionCard>129  );130}131132export function Sources({ v, onShare }: { v: VehicleDetail; onShare: () => void }) {133  const maj = relTime(v.updated_at);134  const srcs = [135    { n: v.dealer_name || sourceName(v.source), r: "Annonce, prix, photos, équipements et description (source originale)", d: maj ?? undefined, href: v.url },136    { n: "Transports Canada", r: "Rappels de sécurité visant le modèle", href: "https://tc.canada.ca/fr/transport-routier/rappels-securite-defauts" },137    { n: "NHTSA (vPIC)", r: "Fiche constructeur décodée du NIV" },138    ...(v.carfax_url ? [{ n: "Carfax", r: "Rapport d'historique du véhicule (lien du concessionnaire)", href: v.carfax_url }] : []),139    { n: "OpenStreetMap", r: "Carte de localisation du vendeur" },140    { n: "Auto-Ka", r: "Analyse de marché, KA Score, dédoublonnage NIV, suivi des prix — calculs maison, indicatifs" },141  ];142  return (143    <SectionCard id="sources" title="Sources et méthodologie" icon={<Ico name="folder" size={18} />} sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Auto-Ka sont indicatifs et documentés">144      <ul className="ak-sources">145        {srcs.map((s) => (146          <li key={s.n}>147            <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noreferrer">{s.n}</a> : s.n}</span>148            {s.d && <span className="d">{s.d}</span>}149            <span className="r">{s.r}</span>150          </li>151        ))}152      </ul>153      <div className="ak-end" style={{ marginTop: 14 }}>154        <a href="/sources"><Ico name="folder" size={18} />Concessionnaires et sources<small>Toutes les sources Auto-Ka</small></a>155        <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur la fiche ${v.uid}`)}`}><Ico name="alert" size={18} />Signaler une erreur<small>Prix, photos, kilométrage…</small></a>156        <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager ce véhicule<small>Lien de la fiche</small></button>157        <a href="/stats"><Ico name="chart" size={18} />Statistiques du marché<small>Prix, régions, marques</small></a>158      </div>159      <p className="ak-fine" style={{ marginTop: 12 }}>Les prix et disponibilités sont ceux affichés par le concessionnaire — chaque fiche renvoie à l'annonce originale. Auto-Ka est un agrégateur indépendant.</p>160    </SectionCard>161  );162}163