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%
8.3 KB · 128 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/Closing.tsx : « En bref », CTA sticky mobile, aside desktop et5//   « Demander à Ka » (widget KA Agent partagé) de la fiche véhicule.6// -----------------------------------------------------------------------------7import { useEffect, useState } from "react";8import { VehicleDetail, fmtPrice } from "../api";9import { Ico, IcoHeart } from "../components/Icons";10import BottomSheet from "./BottomSheet";11import { ScoreRing, kaLabel } from "./ScoreCard";12import { ComparaisonPrix, Constat } from "./synthese";13import { PriceCapsule } from "./VehicleHero";14import { SectionCard, SkeletonLines, relTime } from "./ui";1516export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {17  const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");18  return (19    <ul className="ak-brief">20      {items.map((c) => (21        <li key={c.cle}>22          <span className={`ak-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>23          <div><div className="ak-brief-t">{c.titre}</div>{!compact && c.detail && <div className="ak-brief-d">{c.detail}</div>}</div>24        </li>25      ))}26    </ul>27  );28}2930export function Summary({ items, loading }: { items: Constat[]; loading: boolean }) {31  return (32    <SectionCard id="resume" title="En bref">33      {items.length > 0 ? <BriefList items={items} /> : loading ? <SkeletonLines n={3} /> : <p className="ak-fine">Pas encore de synthèse pour ce véhicule.</p>}34    </SectionCard>35  );36}3738export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {39  const [past, setPast] = useState(false);40  useEffect(() => {41    const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0); };42    check();43    window.addEventListener("scroll", check, { passive: true });44    window.addEventListener("resize", check);45    return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };46  }, [watch]);47  return past;48}4950export function StickyCTA({ v, cmp, show }: { v: VehicleDetail; cmp: ComparaisonPrix | null; show: boolean }) {51  return (52    <div className={`ak-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>53      <div className="ak-cta-txt">54        <div className="ak-cta-price">{fmtPrice(v.price)}</div>55        {cmp && <div className={`ak-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div>}56      </div>57      <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer" tabIndex={show ? 0 : -1}>Voir l'annonce <Ico name="external" size={15} /></a>58    </div>59  );60}6162export function DesktopAside({ v, cmp, brief, fav, onFav, onShare }: {63  v: VehicleDetail; cmp: ComparaisonPrix | null; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void;64}) {65  const maj = relTime(v.updated_at);66  const s = v.ka_score;67  return (68    <aside className="ak-aside" aria-label="Résumé et actions">69      <div className="ak-card ak-aside-card">70        <div>71          <div className="ak-price">{fmtPrice(v.price)}</div>72          <div className="ak-price-row" style={{ marginTop: 8 }}><PriceCapsule cmp={cmp} /></div>73        </div>74        <div className="ak-aside-addr">{v.title}<br />{[v.dealer_name || v.source, v.city].filter(Boolean).join(" · ")}</div>75        <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer">Voir chez {v.dealer_name || "le concessionnaire"} <Ico name="external" size={16} /></a>76        <div className="ak-actions">77          <button type="button" className={`ak-btn ak-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}</button>78          <button type="button" className="ak-btn ak-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>79          {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={17} /></a>}80        </div>81        <button type="button" className="ak-btn ak-btn-ghost ak-ka-inline" onClick={() => window.dispatchEvent(new Event("ak:askka"))}><Ico name="sparkles" size={16} /> Demander à Ka</button>82        {(s || brief.length > 0) && <hr className="ak-aside-sep" />}83        {s && (84          <div className="ak-aside-score">85            <ScoreRing value={s.overall} size={56} />86            <div className="ak-aside-score-txt"><b>KA Score · {kaLabel(s.overall)}</b>{s.parts.map((p) => `${p.label.split(" ")[0]} ${p.score}`).join(" · ")}</div>87          </div>88        )}89        {brief.length > 0 && <BriefList items={brief.slice(0, 4)} compact />}90        {maj && <div className="ak-aside-meta">Vérifié {maj} · {v.dealer_name || v.source}</div>}91      </div>92    </aside>93  );94}9596const SUGGESTIONS = ["Est-ce un bon prix ?", "Compare-le aux véhicules similaires", "Quels sont les points de vigilance ?", "Quel budget mensuel prévoir ?", "Y a-t-il des rappels connus ?"];97function envoyerAKa(question: string): boolean {98  const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");99  const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");100  const send = document.querySelector<HTMLButtonElement>(".kaa-in button");101  if (!btn || !ta || !send) return false;102  btn.click(); ta.value = question; setTimeout(() => send.click(), 60);103  return true;104}105export function KaAssistant({ v, hidden }: { v: VehicleDetail; hidden?: boolean }) {106  const [open, setOpen] = useState(false);107  const [q, setQ] = useState("");108  const [err, setErr] = useState(false);109  useEffect(() => { const on = () => setOpen(true); window.addEventListener("ak:askka", on); return () => window.removeEventListener("ak:askka", on); }, []);110  const contexte = () => `(Véhicule consulté sur Auto-Ka : ${v.title}${v.mileage_km != null ? ` — ${Math.round(v.mileage_km).toLocaleString("fr-CA")} km` : ""}${v.price != null ? ` — ${fmtPrice(v.price)}` : ""} — ${v.dealer_name || v.source}${v.city ? `, ${v.city}` : ""} — https://www.auto-ka.com/vehicule/${encodeURIComponent(v.uid)})`;111  const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true); };112  return (113    <>114      <button type="button" className={`ak-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>115      <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"116                   footer={<form className="ak-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()); }}>117                     <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce véhicule…" aria-label="Votre question" />118                     <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>119        <div className="ak-ka-intro"><span className="ak-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>120          <p>Je peux situer ce prix dans le marché, expliquer le KA Score, résumer les rappels et chercher des alternatives chez d'autres concessionnaires.</p></div>121        <div className="ak-ka-ctx">{v.images?.[0] && <img src={v.images[0]} alt="" />}<span style={{ minWidth: 0 }}><b>{v.title}</b>{[v.dealer_name || v.source, v.price != null ? fmtPrice(v.price) : null, v.mileage_km != null ? `${Math.round(v.mileage_km).toLocaleString("fr-CA")} km` : null].filter(Boolean).join(" · ")}</span></div>122        <div className="ak-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="ak-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>123        {err && <p className="ak-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}124      </BottomSheet>125    </>126  );127}128