SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
2 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
13.6 KB · 201 lines tsx
Raw Blame History
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