/**
* =============================================================================
* Job·Ka — Groupe KA
* Auteur : Simon-Pierre Boucher
* Contact : contact@spboucher.ai
* Fichier : frontend/src/fiche/Closing.tsx
* Rôle : « En bref », Où travailler (carte OSM), Dossier de l'offre,
* Sources et méthodologie, CTA sticky mobile, aside desktop et
* « Demander à Ka » (widget KA Agent partagé).
* Créé : 2026-09-07
* =============================================================================
*/
import { useEffect, useMemo, useState } from "react";
import { Job, displayTitle, formatDate, formatSalary } from "../api";
import { Ico, IcoHeart } from "../components/Icons";
import BottomSheet from "./BottomSheet";
import { EmployerLogo, SalaryCapsule } from "./JobHero";
import { ComparaisonSalaire, Constat } from "./synthese";
import { Accordion, SectionCard, SkeletonLines, SourceLine, relTime } from "./ui";
export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {
const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");
return (
{items.map((c) => (
{glyph(c.tone)}
{c.titre}
{!compact && c.detail &&
{c.detail}
}
))}
);
}
export function Summary({ items, loading }: { items: Constat[]; loading: boolean }) {
return (
{items.length > 0 ? : loading ? : Pas encore de synthèse pour cette offre.
}
);
}
export function LocationCard({ job }: { job: Job }) {
const src = useMemo(() => {
if (job.lat == null || job.lng == null) return null;
const d = 0.02;
const bbox = [job.lng - d, job.lat - d, job.lng + d, job.lat + d].join(",");
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${job.lat},${job.lng}`;
}, [job.lat, job.lng]);
if (!src) return null;
return (
}
sub={`${[job.address, job.city, job.region].filter(Boolean).join(" · ")}${job.work_mode === "teletravail" ? " — poste en télétravail" : ""}`}>
);
}
export function Dossier({ job }: { job: Job }) {
const first = job.first_seen ? new Date(job.first_seen * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }) : null;
return (
} sub="Ce que Job·Ka observe réellement : employeur, suivi, identifiants">
Employeur>} meta={job.employer}>
{job.employer} {typeof job.employer_jobs === "number" && job.employer_jobs > 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
}
{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}` : ""}. Tous les employeurs suivis
Suivi Job·Ka>} meta={first ? `depuis le ${first}` : undefined}>
{first &&
Première collecte {first}
}
{job.date_posted &&
Date de publication (employeur) {formatDate(job.date_posted)}
}
Statut {job.active ? "En ligne chez l'employeur" : "Retirée"}
{job.dup_sources && job.dup_sources.length > 0 &&
Aussi publiée sur {job.dup_sources.join(", ")}
}
Identifiants>} meta={job.external_id}>
Identifiant source {job.external_id}
Identifiant Job·Ka {job.uid}
);
}
export function Sources({ job, onShare }: { job: Job; onShare: () => void }) {
const maj = relTime(job.first_seen ?? null);
const srcs = [
{ n: job.employer, r: "Offre, salaire, exigences et description (source originale)", d: maj ?? undefined, href: job.url },
{ 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" },
{ n: "OpenStreetMap", r: "Carte de localisation" },
];
return (
} sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Job·Ka sont indicatifs et documentés">
{srcs.map((s) => (
{s.href ? {s.n} : s.n}
{s.d && {s.d} }
{s.r}
))}
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.
);
}
export function usePastElement(watch: React.RefObject): boolean {
const [past, setPast] = useState(false);
useEffect(() => {
const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0); };
check();
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };
}, [watch]);
return past;
}
export function StickyCTA({ job, cmp, show }: { job: Job; cmp: ComparaisonSalaire | null; show: boolean }) {
const salaire = formatSalary(job);
return (
{salaire ?? displayTitle(job)}
{cmp ?
{cmp.court}
:
{job.employer}
}
Postuler
);
}
export function DesktopAside({ job, cmp, brief, fav, onFav, onShare }: {
job: Job; cmp: ComparaisonSalaire | null; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void;
}) {
const salaire = formatSalary(job);
return (
{salaire ?? "Salaire non affiché"}
{displayTitle(job)} {[job.employer, job.location_label || job.city].filter(Boolean).join(" · ")}
Postuler chez {job.employer}
{fav ? "Favori" : "Favoris"}
Partager
window.dispatchEvent(new Event("jk:askka"))}> Demander à Ka
{brief.length > 0 && <>
>}
{job.date_posted &&
Publiée le {formatDate(job.date_posted)} · {job.ats || job.source}
}
);
}
const 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"];
function envoyerAKa(question: string): boolean {
const btn = document.querySelector(".kaa-btn");
const ta = document.querySelector(".kaa-panel textarea");
const send = document.querySelector(".kaa-in button");
if (!btn || !ta || !send) return false;
btn.click(); ta.value = question; setTimeout(() => send.click(), 60);
return true;
}
export function KaAssistant({ job, hidden }: { job: Job; hidden?: boolean }) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [err, setErr] = useState(false);
useEffect(() => { const on = () => setOpen(true); window.addEventListener("jk:askka", on); return () => window.removeEventListener("jk:askka", on); }, []);
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)})`;
const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true); };
return (
<>
setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"> Demander à Ka
setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"
footer={}>
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.
{job.company_logo &&
}
{displayTitle(job)} {[job.employer, job.city, formatSalary(job)].filter(Boolean).join(" · ")}
{SUGGESTIONS.map((s) => poser(s)}>{s} )}
{err && L'assistant n'est pas encore chargé — réessayez dans un instant.
}
>
);
}