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/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