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/pages/Job.tsx7 * Rôle : Fiche d'une offre — refonte premium 2026-09-07 (même socle que8 * les fiches Lou-Ka v3 / Immo-Ka v3). Ordre DOM = ordre visuel,9 * identique mobile ET desktop (aucun `order`) : héro (employeur ·10 * salaire · capsules · titre · actions) → En bref → navigation11 * sticky → Salaire et marché → Le poste (+ avantages, description) →12 * Où travailler → Dossier → Sources. Desktop ≥ 1024 px : colonne13 * principale + aside sticky. Aucun scrollIntoView à l'ouverture.14 * Créé : 2026-08-17 Modifié : 2026-09-0715 * =============================================================================16 */17import { useCallback, useEffect, useMemo, useRef, useState } from "react";18import { Link, useParams } from "react-router-dom";19import { Job, displayTitle, fetchJob, formatSalary } from "../api";20import { Ico } from "../components/Icons";21import { jobFavItem, useFav } from "../favorites";22import "../fiche/fiche.css";23import { setCurrentListing } from "../fiche/current";24import { comparaisonSalaire, enBref } from "../fiche/synthese";25import JobHero from "../fiche/JobHero";26import SectionNav, { NavItem } from "../fiche/SectionNav";27import SalaryCard from "../fiche/SalaryCard";28import JobFacts from "../fiche/JobFacts";29import { DesktopAside, Dossier, KaAssistant, LocationCard, Sources, StickyCTA, Summary, usePastElement } from "../fiche/Closing";30import { Skeleton, useToast } from "../fiche/ui";3132function FicheSkeleton() {33 return (34 <div className="jk-fiche"><div className="jk-wrap" aria-busy="true" aria-label="Chargement de la fiche">35 <Skeleton h={14} w={180} /><div style={{ height: 10 }} />36 <Skeleton h={38} w={260} /><div style={{ height: 10 }} />37 <Skeleton h={22} w="80%" /><div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />38 <div className="jk-card"><Skeleton h={14} /><div style={{ height: 8 }} /><Skeleton h={14} w="80%" /><div style={{ height: 8 }} /><Skeleton h={14} w="60%" /></div>39 </div></div>40 );41}4243export default function JobPage() {44 const { uid = "" } = useParams();45 const [job, setJob] = useState<Job | null>(null);46 const [error, setError] = useState("");47 const { ids, toggle, connected } = useFav();48 const [toast, showToast] = useToast();49 const actionsRef = useRef<HTMLDivElement>(null);50 const pastHero = usePastElement(actionsRef);5152 useEffect(() => {53 setJob(null); setError(""); setCurrentListing(null);54 fetchJob(uid).then((j) => { setJob(j); setCurrentListing(j); }).catch((e) => setError(String(e)));55 window.scrollTo(0, 0);56 return () => setCurrentListing(null);57 }, [uid]);58 useEffect(() => { document.body.classList.add("jk-fiche-page"); return () => document.body.classList.remove("jk-fiche-page"); }, []);59 useEffect(() => { if (job) document.title = `${displayTitle(job)} — ${job.employer}${formatSalary(job) ? ` · ${formatSalary(job)}` : ""} | Job·Ka`; }, [job]);6061 const fav = !!(job && ids.has(job.uid));62 const onFav = useCallback(() => {63 if (!job) return;64 toggle(jobFavItem(job));65 if (connected) showToast(fav ? "Retiré des favoris" : "Ajouté à vos favoris");66 }, [job, toggle, connected, fav, showToast]);67 const onShare = useCallback(async () => {68 if (!job) return;69 const url = `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`;70 try {71 if (navigator.share) { await navigator.share({ title: `${displayTitle(job)} — ${job.employer} | Job·Ka`, url }); return; }72 await navigator.clipboard.writeText(url); showToast("Lien copié");73 } catch { /* annulé */ }74 }, [job, showToast]);7576 const cmp = useMemo(() => (job ? comparaisonSalaire(job) : null), [job]);77 const brief = useMemo(() => (job ? enBref(job) : []), [job]);7879 if (error)80 return (81 <div className="jk-fiche"><div className="jk-page-error">82 <Ico name="alert" size={40} />83 <h2>Offre introuvable</h2>84 <p>Cette offre n'existe pas ou a été retirée par l'employeur.</p>85 <Link className="jk-btn jk-btn-primary" to="/">Retour aux offres</Link>86 </div></div>87 );88 if (!job) return <FicheSkeleton />;8990 const geo = job.lat != null && job.lng != null;91 const nav: NavItem[] = [92 { id: "resume", label: "Résumé" },93 ...(formatSalary(job) || job.salary_context ? [{ id: "salaire", label: "Salaire" }] : []),94 { id: "poste", label: "Poste" },95 ...(job.benefits?.length ? [{ id: "avantages", label: "Avantages" }] : []),96 { id: "description", label: "Description" },97 ...(geo ? [{ id: "carte", label: "Carte" }] : []),98 { id: "dossier", label: "Dossier" }, { id: "sources", label: "Sources" },99 ];100101 return (102 <div className="jk-fiche">103 <div className="jk-wrap">104 <nav className="jk-crumbs" aria-label="Fil d'Ariane">105 <Link to="/">Offres</Link><span aria-hidden="true">›</span>106 {job.city && <><Link to={`/ville/${encodeURIComponent(job.city)}`}>{job.city}</Link><span aria-hidden="true">›</span></>}107 <span>{displayTitle(job)}</span>108 </nav>109 <div className="jk-grid">110 <div className="jk-main">111 <JobHero job={job} cmp={cmp} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />112 <Summary items={brief} loading={false} />113 <SectionNav items={nav} />114 <SalaryCard job={job} cmp={cmp} />115 <JobFacts job={job} />116 <LocationCard job={job} />117 <Dossier job={job} />118 <Sources job={job} onShare={onShare} />119 </div>120 <DesktopAside job={job} cmp={cmp} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />121 </div>122 </div>123 <StickyCTA job={job} cmp={cmp} show={pastHero} />124 <KaAssistant job={job} hidden={!pastHero} />125 {toast}126 </div>127 );128}129