HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/fiche/Closing.tsx7 * Rôle : « En bref », Où travailler (carte OSM), Dossier de l'offre,8 * Sources et méthodologie, CTA sticky mobile, aside desktop et9 * « Demander à Ka » (widget KA Agent partagé).10 * Créé : 2026-09-0711 * =============================================================================12 */13import { useEffect, useMemo, useState } from "react";14import { Job, displayTitle, formatDate, formatSalary } from "../api";15import { Ico, IcoHeart } from "../components/Icons";16import BottomSheet from "./BottomSheet";17import { EmployerLogo, SalaryCapsule } from "./JobHero";18import { ComparaisonSalaire, Constat } from "./synthese";19import { Accordion, SectionCard, SkeletonLines, SourceLine, relTime } from "./ui";2021export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {22 const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");23 return (24 <ul className="jk-brief">25 {items.map((c) => (26 <li key={c.cle}>27 <span className={`jk-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>28 <div><div className="jk-brief-t">{c.titre}</div>{!compact && c.detail && <div className="jk-brief-d">{c.detail}</div>}</div>29 </li>30 ))}31 </ul>32 );33}3435export function Summary({ items, loading }: { items: Constat[]; loading: boolean }) {36 return (37 <SectionCard id="resume" title="En bref">38 {items.length > 0 ? <BriefList items={items} /> : loading ? <SkeletonLines n={3} /> : <p className="jk-fine">Pas encore de synthèse pour cette offre.</p>}39 </SectionCard>40 );41}4243export function LocationCard({ job }: { job: Job }) {44 const src = useMemo(() => {45 if (job.lat == null || job.lng == null) return null;46 const d = 0.02;47 const bbox = [job.lng - d, job.lat - d, job.lng + d, job.lat + d].join(",");48 return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${job.lat},${job.lng}`;49 }, [job.lat, job.lng]);50 if (!src) return null;51 return (52 <SectionCard id="carte" title="Où travailler" icon={<Ico name="pin" size={18} />}53 sub={`${[job.address, job.city, job.region].filter(Boolean).join(" · ")}${job.work_mode === "teletravail" ? " — poste en télétravail" : ""}`}>54 <div className="jk-map"><iframe className="jk-map-embed" src={src} title={`Carte — ${job.employer}`} loading="lazy" /></div>55 <SourceLine name="OpenStreetMap" href="https://www.openstreetmap.org" date="position géocodée de l'adresse publiée" />56 </SectionCard>57 );58}5960export function Dossier({ job }: { job: Job }) {61 const first = job.first_seen ? new Date(job.first_seen * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }) : null;62 return (63 <SectionCard id="dossier" title="Dossier de l'offre" icon={<Ico name="folder" size={18} />} sub="Ce que Job·Ka observe réellement : employeur, suivi, identifiants">64 <Accordion title={<><Ico name="store" size={15} className="jk-acc-ico" />Employeur</>} meta={job.employer}>65 <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 8 }}>66 <EmployerLogo job={job} size={44} />67 <div><b style={{ fontSize: 15 }}>{job.employer}</b>{typeof job.employer_jobs === "number" && job.employer_jobs > 0 && <div className="jk-fine" style={{ margin: 0 }}>{job.employer_jobs} offre{job.employer_jobs > 1 ? "s" : ""} active{job.employer_jobs > 1 ? "s" : ""} suivie{job.employer_jobs > 1 ? "s" : ""} par Job·Ka</div>}</div>68 </div>69 <p style={{ margin: 0 }}>{job.is_direct !== false ? "Offre collectée directement sur la page carrière de l'employeur" : `Offre relayée par ${job.ats || job.source}`}{job.ats ? ` · plateforme ${job.ats}` : ""}. <a href="/sources">Tous les employeurs suivis</a></p>70 </Accordion>71 <Accordion title={<><Ico name="history" size={15} className="jk-acc-ico" />Suivi Job·Ka</>} meta={first ? `depuis le ${first}` : undefined}>72 <div className="jk-facts-rows" style={{ marginTop: 0 }}>73 {first && <div className="jk-kv"><span className="k">Première collecte</span><span className="v">{first}</span></div>}74 {job.date_posted && <div className="jk-kv"><span className="k">Date de publication (employeur)</span><span className="v">{formatDate(job.date_posted)}</span></div>}75 <div className="jk-kv"><span className="k">Statut</span><span className="v">{job.active ? "En ligne chez l'employeur" : "Retirée"}</span></div>76 {job.dup_sources && job.dup_sources.length > 0 && <div className="jk-kv"><span className="k">Aussi publiée sur</span><span className="v">{job.dup_sources.join(", ")}</span></div>}77 </div>78 </Accordion>79 <Accordion title={<><Ico name="tag" size={15} className="jk-acc-ico" />Identifiants</>} meta={job.external_id}>80 <div className="jk-facts-rows" style={{ marginTop: 0 }}>81 <div className="jk-kv"><span className="k">Identifiant source</span><span className="v">{job.external_id}</span></div>82 <div className="jk-kv"><span className="k">Identifiant Job·Ka</span><span className="v">{job.uid}</span></div>83 <div className="jk-kv"><span className="k">Offre originale</span><span className="v"><a href={job.url} target="_blank" rel="noopener noreferrer">{job.source} ↗</a></span></div>84 </div>85 </Accordion>86 </SectionCard>87 );88}8990export function Sources({ job, onShare }: { job: Job; onShare: () => void }) {91 const maj = relTime(job.first_seen ?? null);92 const srcs = [93 { n: job.employer, r: "Offre, salaire, exigences et description (source originale)", d: maj ?? undefined, href: job.url },94 { n: "Job·Ka", r: "Référence de marché par catégorie, normalisation du titre et des conditions, détection des doublons — calculs maison, indicatifs" },95 { n: "OpenStreetMap", r: "Carte de localisation" },96 ];97 return (98 <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 Job·Ka sont indicatifs et documentés">99 <ul className="jk-sources">100 {srcs.map((s) => (101 <li key={s.n}>102 <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noopener noreferrer">{s.n}</a> : s.n}</span>103 {s.d && <span className="d">{s.d}</span>}104 <span className="r">{s.r}</span>105 </li>106 ))}107 </ul>108 <div className="jk-end" style={{ marginTop: 14 }}>109 <a href="/sources"><Ico name="folder" size={18} />Employeurs suivis<small>Toutes les sources Job·Ka</small></a>110 <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur l'offre ${job.uid}`)}`}><Ico name="alert" size={18} />Signaler une erreur<small>Salaire, lieu, lien…</small></a>111 <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager cette offre<small>Lien de la fiche</small></button>112 <a href="/stats"><Ico name="chart" size={18} />Statistiques du marché<small>Salaires, régions, catégories</small></a>113 </div>114 <p className="jk-fine" style={{ marginTop: 12 }}>Les offres sont celles publiées par les employeurs — chaque fiche renvoie à l'offre originale. Job·Ka est un agrégateur indépendant, sans intermédiation.</p>115 </SectionCard>116 );117}118119export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {120 const [past, setPast] = useState(false);121 useEffect(() => {122 const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0); };123 check();124 window.addEventListener("scroll", check, { passive: true });125 window.addEventListener("resize", check);126 return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };127 }, [watch]);128 return past;129}130131export function StickyCTA({ job, cmp, show }: { job: Job; cmp: ComparaisonSalaire | null; show: boolean }) {132 const salaire = formatSalary(job);133 return (134 <div className={`jk-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>135 <div className="jk-cta-txt">136 <div className="jk-cta-price" style={salaire ? undefined : { fontSize: 14 }}>{salaire ?? displayTitle(job)}</div>137 {cmp ? <div className={`jk-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div> : <div className="jk-cta-sub">{job.employer}</div>}138 </div>139 <a className="jk-btn jk-btn-primary" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>Postuler <Ico name="external" size={15} /></a>140 </div>141 );142}143144export function DesktopAside({ job, cmp, brief, fav, onFav, onShare }: {145 job: Job; cmp: ComparaisonSalaire | null; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void;146}) {147 const salaire = formatSalary(job);148 return (149 <aside className="jk-aside" aria-label="Résumé et actions">150 <div className="jk-card jk-aside-card">151 <div>152 <div className="jk-price" style={salaire ? undefined : { fontSize: 20 }}>{salaire ?? "Salaire non affiché"}</div>153 <div className="jk-price-row" style={{ marginTop: 8 }}><SalaryCapsule cmp={cmp} /></div>154 </div>155 <div className="jk-aside-addr"><b style={{ color: "var(--jk-text)" }}>{displayTitle(job)}</b><br />{[job.employer, job.location_label || job.city].filter(Boolean).join(" · ")}</div>156 <a className="jk-btn jk-btn-primary" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">Postuler chez {job.employer} <Ico name="external" size={16} /></a>157 <div className="jk-actions">158 <button type="button" className={`jk-btn jk-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}</button>159 <button type="button" className="jk-btn jk-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>160 </div>161 <button type="button" className="jk-btn jk-btn-ghost jk-ka-inline" onClick={() => window.dispatchEvent(new Event("jk:askka"))}><Ico name="sparkles" size={16} /> Demander à Ka</button>162 {brief.length > 0 && <><hr className="jk-aside-sep" /><BriefList items={brief.slice(0, 4)} compact /></>}163 {job.date_posted && <div className="jk-aside-meta">Publiée le {formatDate(job.date_posted)} · {job.ats || job.source}</div>}164 </div>165 </aside>166 );167}168169const SUGGESTIONS = ["Ce salaire est-il compétitif ?", "Résume les exigences du poste", "Quelles questions poser en entrevue ?", "Trouve des offres similaires", "Aide-moi à adapter mon CV"];170function envoyerAKa(question: string): boolean {171 const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");172 const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");173 const send = document.querySelector<HTMLButtonElement>(".kaa-in button");174 if (!btn || !ta || !send) return false;175 btn.click(); ta.value = question; setTimeout(() => send.click(), 60);176 return true;177}178export function KaAssistant({ job, hidden }: { job: Job; hidden?: boolean }) {179 const [open, setOpen] = useState(false);180 const [q, setQ] = useState("");181 const [err, setErr] = useState(false);182 useEffect(() => { const on = () => setOpen(true); window.addEventListener("jk:askka", on); return () => window.removeEventListener("jk:askka", on); }, []);183 const contexte = () => `(Offre consultée sur Job·Ka : ${displayTitle(job)} chez ${job.employer}${job.city ? `, ${job.city}` : ""}${formatSalary(job) ? ` — ${formatSalary(job)}` : ""} — https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)})`;184 const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true); };185 return (186 <>187 <button type="button" className={`jk-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>188 <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"189 footer={<form className="jk-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()); }}>190 <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur cette offre…" aria-label="Votre question" />191 <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>192 <div className="jk-ka-intro"><span className="jk-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>193 <p>Je peux situer ce salaire dans le marché, résumer les exigences, préparer l'entrevue et chercher des offres semblables chez d'autres employeurs.</p></div>194 <div className="jk-ka-ctx">{job.company_logo && <img src={job.company_logo} alt="" style={{ objectFit: "contain", background: "#fff" }} />}<span style={{ minWidth: 0 }}><b>{displayTitle(job)}</b>{[job.employer, job.city, formatSalary(job)].filter(Boolean).join(" · ")}</span></div>195 <div className="jk-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="jk-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>196 {err && <p className="jk-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}197 </BottomSheet>198 </>199 );200}201