SPB Git forge

spb/job-ka

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