SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

feat(fiche): refonte premium mobile-first de la fiche d'offre (2026-09-07)

Même socle que les fiches Lou-Ka v3 / Immo-Ka v3 (frontend/src/fiche/,
préfixe jk-, tokens --jk-*, fond papier, cartes blanches bord 1 px, cyan
réservé aux CTA/salaire ; header encre conservé en version compacte) :
héro (logo + employeur · lieu, salaire en exergue + équivalent annuel,
capsules marché / nouveau / offre directe, titre, actions favoris / partage /
Postuler) ; « En bref » déterministe (salaire vs médiane de la catégorie,
source directe ou portail, exigences, mode/type/niveau, fraîcheur et date
limite, volume de l'employeur, avantages) ; navigation sticky ; « Salaire et
marché » (KPI, jauge P25–P75 du point médian, historique du salaire affiché,
méthodologie — jamais d'estimation) ; « Le poste » (grille icône/valeur,
rangées, avantages, description tronquée, champs bruts en accordéon) ; « Où
travailler » (carte OSM) ; Dossier de l'offre ; Sources et méthodologie. CTA
sticky après le héro, aside desktop sticky, « Demander à Ka » via le widget
KA Agent. Header compact sur /emploi/ (favoris + partage via fiche/current.ts,
badge masqué). Rangées d'offres de la liste : surface blanche, bord 1 px,
ombre légère au survol. Icons.tsx (pictos partagés) ajouté. Aucun order CSS ;
pas de scrollIntoView à l'ouverture. Scripts QA : preview.mjs,
check-order.mjs, shots.mjs, interactions.mjs (Playwright via lien vers
vrai-prix/node_modules).

Validé sur M3U96a : tsc + vite build, check-order OK, interactions OK,
captures 6 largeurs ; dist basculé, aucun changement API.
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent 68cad94

18 changed files +2,150 −193

added frontend/scripts/check-order.mjs +55 −0
@@ -0,0 +1,55 @@
1 +// Validation ordre des sections — fiche Job·Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-07 : sections `.jk-*` / ids ; vérifie sur mobile que
3 +// l'ordre visuel est croissant, que la page s'ouvre en haut (scrollY 0), que
4 +// le héro (prix + galerie) est le premier contenu, qu'aucune section ne porte
5 +// un `order` CSS et qu'il n'y a pas de débordement horizontal.
6 +// Usage : node frontend/scripts/check-order.mjs <uid> [baseUrl]
7 +import { chromium, devices } from "playwright";
8 +
9 +const UID = process.argv[2];
10 +if (!UID) { console.error("usage: node check-order.mjs <uid> [baseUrl]"); process.exit(2); }
11 +const BASE = process.argv[3] || process.env.BASE || "http://localhost:8096";
12 +const URL = `${BASE}/emploi/${encodeURIComponent(UID)}`;
13 +
14 +async function check(name, ctxOpts) {
15 + const browser = await chromium.launch();
16 + const ctx = await browser.newContext(ctxOpts);
17 + const page = await ctx.newPage();
18 + const errors = [];
19 + page.on("pageerror", (e) => errors.push(String(e)));
20 + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
21 + await page.goto(URL, { waitUntil: "networkidle" });
22 + await page.waitForSelector(".jk-hero", { timeout: 20000 });
23 + await page.waitForTimeout(2500);
24 + const data = await page.evaluate(() => {
25 + const sel = [".jk-hero", "#resume", ".jk-nav", "#salaire", "#poste", "#avantages", "#description", "#carte", "#dossier", "#sources"];
26 + const out = [];
27 + for (const s of sel) {
28 + const el = document.querySelector(s);
29 + if (!el) { out.push({ s, missing: true }); continue; }
30 + const r = el.getBoundingClientRect();
31 + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY),
32 + left: Math.round(r.left), order: getComputedStyle(el).order });
33 + }
34 + return { out, scrollY: window.scrollY, overflow: document.documentElement.scrollWidth - window.innerWidth };
35 + });
36 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY} · débordement horizontal: ${data.overflow}px`);
37 + for (const b of data.out)
38 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
39 + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
40 + if (errors.length) console.log("console errors:", errors.slice(0, 5));
41 + await browser.close();
42 + return { ...data, errors };
43 +}
44 +
45 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
46 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
47 +
48 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
49 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
50 +const noOrder = vis.every((b) => b.order === "0");
51 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".jk-hero" && vis[0].top < 200 && noOrder
52 + && mob.overflow <= 0 && desk.overflow <= 0;
53 +console.log(`\nMOBILE: ordre visuel ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re section=${vis[0].s} top=${vis[0].top} · sans order CSS=${noOrder} · overflow mobile=${mob.overflow} desktop=${desk.overflow}`);
54 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
55 +process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +34 −0
@@ -0,0 +1,34 @@
1 +// Parcours d'interactions Playwright sur la fiche (mobile) : galerie plein
2 +// écran, panneau « Voir les N lieux », « Demander à Ka », accordéons — vérifie
3 +// l'absence d'erreur JS et que chaque panneau s'ouvre puis se ferme.
4 +// Usage : node frontend/scripts/interactions.mjs <uid> [baseUrl]
5 +import { chromium, devices } from "playwright";
6 +const UID = process.argv[2]; const BASE = process.argv[3] || "http://127.0.0.1:8096";
7 +if (!UID) { console.error("usage: node interactions.mjs <uid> [baseUrl]"); process.exit(2); }
8 +const browser = await chromium.launch();
9 +const ctx = await browser.newContext({ ...devices["iPhone 14"] }); const page = await ctx.newPage();
10 +const errs = [];
11 +page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
12 +page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 160)); });
13 +await page.goto(`${BASE}/emploi/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
14 +await page.waitForSelector(".jk-hero");
15 +const step = async (name, fn) => { try { await fn(); console.log("OK ", name); } catch (e) { console.log("FAIL", name, String(e).split("\n")[0]); } };
16 +await step("accordéon méthodologie salaire", async () => {
17 + const b = page.locator("#salaire .jk-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
18 + await page.waitForSelector("#salaire .jk-acc-body.open", { timeout: 2000 });
19 +});
20 +await step("accordéon dossier", async () => {
21 + const b = page.locator("#dossier .jk-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
22 + await page.waitForSelector("#dossier .jk-acc-body.open", { timeout: 2000 });
23 +});
24 +await step("Demander à Ka : sheet", async () => {
25 + await page.evaluate(() => window.scrollTo(0, 1200)); await page.waitForTimeout(500);
26 + await page.click(".jk-ka-btn"); await page.waitForSelector(".jk-sheet", { timeout: 3000 });
27 + await page.click(".jk-sheet-x"); await page.waitForSelector(".jk-sheet", { state: "detached", timeout: 3000 });
28 +});
29 +await step("404", async () => {
30 + await page.goto(`${BASE}/emploi/inexistant:0`, { waitUntil: "networkidle" }); await page.waitForSelector(".jk-page-error", { timeout: 8000 });
31 +});
32 +console.log("erreurs console:", errs.length, errs.slice(0, 5));
33 +await browser.close();
34 +process.exit(errs.filter((e) => !/Failed to fetch|api-ka|kaa/.test(e)).length ? 1 : 0);
added frontend/scripts/preview.mjs +28 −0
@@ -0,0 +1,28 @@
1 +// Serveur de prévisualisation QA (zéro dépendance) : sert un build Vite
2 +// (dist-next par défaut) et relaie /api vers le backend local — permet de
3 +// valider une fiche avec Playwright SANS toucher au dist/ servi en production.
4 +// Usage : node frontend/scripts/preview.mjs [dir=dist-next] [port=18096] [api=http://127.0.0.1:8096]
5 +import http from "node:http";
6 +import { createReadStream, existsSync, statSync } from "node:fs";
7 +import { extname, join, resolve } from "node:path";
8 +
9 +const DIR = resolve(process.argv[2] || "dist-next");
10 +const PORT = Number(process.argv[3] || 18096);
11 +const API = new URL(process.argv[4] || "http://127.0.0.1:8096");
12 +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml",
13 + ".png": "image/png", ".json": "application/json", ".geojson": "application/geo+json", ".woff2": "font/woff2", ".ico": "image/x-icon" };
14 +
15 +http.createServer((req, res) => {
16 + const url = new URL(req.url, "http://x");
17 + if (url.pathname.startsWith("/api/")) {
18 + const p = http.request({ host: API.hostname, port: API.port, path: req.url, method: req.method, headers: { ...req.headers, host: API.host } },
19 + (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
20 + p.on("error", () => { res.writeHead(502); res.end("api down"); });
21 + req.pipe(p);
22 + return;
23 + }
24 + let f = join(DIR, decodeURIComponent(url.pathname));
25 + if (!existsSync(f) || statSync(f).isDirectory()) f = join(DIR, "index.html");
26 + res.writeHead(200, { "content-type": MIME[extname(f)] || "application/octet-stream" });
27 + createReadStream(f).pipe(res);
28 +}).listen(PORT, "127.0.0.1", () => console.log(`preview ${DIR} → http://127.0.0.1:${PORT} (api → ${API.origin})`));
added frontend/scripts/shots.mjs +40 −0
@@ -0,0 +1,40 @@
1 +// Captures Playwright de la fiche propriété à 6 largeurs (QA visuelle).
2 +// Usage : node frontend/scripts/shots.mjs <uid> [baseUrl] [outDir]
3 +import { chromium, devices } from "playwright";
4 +const UID = process.argv[2];
5 +if (!UID) { console.error("usage: node shots.mjs <uid> [baseUrl] [outDir]"); process.exit(2); }
6 +const BASE = process.argv[3] || "http://127.0.0.1:8096";
7 +const OUT = process.argv[4] || "/tmp";
8 +const shots = [
9 + ["iphone14", { ...devices["iPhone 14"] }],
10 + ["w375", { viewport: { width: 375, height: 812 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
11 + ["w430", { viewport: { width: 430, height: 932 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
12 + ["w768", { viewport: { width: 768, height: 1024 }, deviceScaleFactor: 1 }],
13 + ["w1024", { viewport: { width: 1024, height: 800 }, deviceScaleFactor: 1 }],
14 + ["w1440", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }],
15 +];
16 +const browser = await chromium.launch();
17 +for (const [name, opts] of shots) {
18 + const ctx = await browser.newContext(opts); const page = await ctx.newPage();
19 + const errs = [];
20 + page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
21 + page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
22 + await page.goto(`${BASE}/emploi/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
23 + await page.waitForSelector(".jk-hero", { timeout: 20000 });
24 + // html{scroll-behavior:smooth} du site fausse le retour en haut scripté → défilement instantané pour les captures
25 + await page.evaluate(() => { document.documentElement.style.scrollBehavior = "auto"; });
26 + // défilement complet (déclenche les chargements différés) puis retour en haut
27 + await page.evaluate(async () => {
28 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
29 + window.scrollTo({ top: 0, behavior: "instant" });
30 + });
31 + await page.waitForTimeout(3500);
32 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
33 + await page.screenshot({ path: `${OUT}/jk-${name}-full.png`, fullPage: true });
34 + await page.screenshot({ path: `${OUT}/jk-${name}-top.png` });
35 + await page.evaluate(() => window.scrollTo({ top: 900, behavior: "instant" })); await page.waitForTimeout(600);
36 + await page.screenshot({ path: `${OUT}/jk-${name}-scrolled.png` });
37 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
38 + await ctx.close();
39 +}
40 +await browser.close();
modified frontend/src/App.tsx +35 −3
@@ -19,7 +19,9 @@ import Contact from "./pages/Contact";
19 19 import Employeurs from "./pages/Employeurs";
20 20 import VillePage, { VillesPage } from "./pages/Ville";
21 21 import FavorisPage from "./pages/Favoris";
22 −import { FavProvider } from "./favorites";
22 +import { FavProvider, jobFavItem, useFav } from "./favorites";
23 +import { Ico, IcoHeart } from "./components/Icons";
24 +import { useCurrentListing } from "./fiche/current";
23 25 import GroupeKaBadge from "./ka/GroupeKaBadge";
24 26 import KaFooter from "./ka/KaFooter";
25 27
@@ -67,6 +69,33 @@ function KaAuth() {
67 69 );
68 70 }
69 71
72 +/** Actions du header sur une fiche d'offre : favoris + partage (compactes).
73 + L'offre affichée est publiée par la page fiche (fiche/current.ts). */
74 +function FicheHeaderActions() {
75 + const job = useCurrentListing();
76 + const { ids, toggle } = useFav();
77 + if (!job) return null;
78 + const fav = ids.has(job.uid);
79 + const share = async () => {
80 + const url = `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`;
81 + try {
82 + if (navigator.share) await navigator.share({ title: document.title, url });
83 + else await navigator.clipboard.writeText(url);
84 + } catch { /* annulé */ }
85 + };
86 + return (
87 + <div className="hdr-actions">
88 + <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}
89 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={() => toggle(jobFavItem(job))}>
90 + <IcoHeart size={18} filled={fav} />
91 + </button>
92 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
93 + <Ico name="share" size={17} />
94 + </button>
95 + </div>
96 + );
97 +}
98 +
70 99 /** Liens de navigation principaux (header desktop + menu mobile). */
71 100 const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [
72 101 { to: "/", label: "Offres", end: true },
@@ -81,6 +110,8 @@ const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [
81 110 export default function App() {
82 111 const [menuOpen, setMenuOpen] = useState(false);
83 112 const location = useLocation();
113 + // fiche d'offre : header compact (56 px), sans badge ; favoris + partage
114 + const isFiche = location.pathname.startsWith("/emploi/");
84 115
85 116 // Hauteur réelle du header → --header-h sur <html> (la carte s'y cale :
86 117 // le 58 px codé en dur était faux quand le header wrappe sur 2 lignes).
@@ -120,10 +151,11 @@ export default function App() {
120 151
121 152 return (
122 153 <FavProvider>
123 − <header className={menuOpen ? "site menu-open" : "site"}>
154 + <header className={`site ${menuOpen ? "menu-open" : ""} ${isFiche ? "header--fiche" : ""}`}>
124 155 <div className="container">
125 156 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>
126 − <GroupeKaBadge dark />
157 + {!isFiche && <GroupeKaBadge dark />}
158 + {isFiche && <FicheHeaderActions />}
127 159 <nav className="main">
128 160 {NAV_LINKS.map((l) => (
129 161 <NavLink
added frontend/src/components/Icons.tsx +291 −0
@@ -0,0 +1,291 @@
1 +// -----------------------------------------------------------------------------
2 +// Groupe KA — iconographie partagée (portée depuis Immo-Ka)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
5 +// dessinée pour les plateformes Groupe KA (zéro emoji, zéro pack générique). Chaque icône est
6 +// un tracé « stroke » net qui hérite de la couleur du texte (currentColor).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const P: Record<string, ReactNode> = {
11 + // --- navigation -----------------------------------------------------------
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + map: (<>
18 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19 + <path d="M9 4.2v13.4M15 6.4v13.4" />
20 + </>),
21 + chart: (<>
22 + <path d="M4 20h16" />
23 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24 + </>),
25 + building: (<>
26 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28 + <path d="M3.2 20h17.6" />
29 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30 + </>),
31 +
32 + // --- specs propriété --------------------------------------------------------
33 + bed: (<>
34 + <path d="M3.4 6.8V18.6" />
35 + <path d="M3.4 15.4h17.2v3.2" />
36 + <path d="M3.4 12.2h6.2v3.2" />
37 + <circle cx="6.5" cy="9.9" r="1.35" />
38 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39 + </>),
40 + bath: (<>
41 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44 + </>),
45 + drop: (<>
46 + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47 + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48 + </>),
49 + area: (<>
50 + <path d="M4.4 19.6 19.6 4.4" />
51 + <path d="M4.4 14v5.6H10" />
52 + <path d="M19.6 10V4.4H14" />
53 + </>),
54 + land: (<>
55 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56 + <path d="M3 12.6h18M3 16.4h18" />
57 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58 + </>),
59 + calendar: (<>
60 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62 + </>),
63 + tag: (<>
64 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65 + <circle cx="8" cy="8" r="1.5" />
66 + </>),
67 + camera: (<>
68 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69 + <path d="M8.6 7 10 4.4h4L15.4 7" />
70 + <circle cx="12" cy="13.2" r="3.4" />
71 + </>),
72 +
73 + // --- actions / états ----------------------------------------------------------
74 + search: (<>
75 + <circle cx="10.6" cy="10.6" r="6.2" />
76 + <path d="m15.3 15.3 5.3 5.3" />
77 + </>),
78 + arrow: (<>
79 + <path d="M4 12h15.2" />
80 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81 + </>),
82 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83 + alert: (<>
84 + <path d="M12 4.2 2.9 19.4h18.2z" />
85 + <path d="M12 10.2v4.4" />
86 + <path d="M12 17.4v.05" />
87 + </>),
88 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89 + trendup: (<>
90 + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91 + <path d="M15.6 6.8h4.8v4.8" />
92 + </>),
93 + trenddown: (<>
94 + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95 + <path d="M15.6 17.4h4.8v-4.8" />
96 + </>),
97 + external: (<>
98 + <path d="M14.4 4h5.6v5.6" />
99 + <path d="M20 4 11.4 12.6" />
100 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101 + </>),
102 + pin: (<>
103 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104 + <circle cx="12" cy="10.6" r="2.3" />
105 + </>),
106 + download: (<>
107 + <path d="M12 3.6v11.2" />
108 + <path d="m7.2 10.4 4.8 4.8 4.8-4.8" />
109 + <path d="M4.4 19.2h15.2" />
110 + </>),
111 +
112 + // --- quartier ---------------------------------------------------------------------
113 + leaf: (<>
114 + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
115 + <path d="M5 19.2C7 14 11 10 15 8" />
116 + </>),
117 + thermo: (<>
118 + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
119 + <path d="M12.3 8.4v7" />
120 + </>),
121 + sun: (<>
122 + <circle cx="12" cy="12" r="4.2" />
123 + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
124 + </>),
125 + shield: (<>
126 + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
127 + <path d="m9.2 12 2 2 3.8-4" />
128 + </>),
129 + cart: (<>
130 + <circle cx="9.6" cy="19.6" r="1.35" />
131 + <circle cx="17" cy="19.6" r="1.35" />
132 + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
133 + </>),
134 + bus: (<>
135 + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
136 + <path d="M4.6 10.4h14.8" />
137 + <path d="M7.6 20.2v-3M16.4 20.2v-3" />
138 + <path d="M8.3 14h.05M15.7 14h.05" />
139 + </>),
140 + tree: (<>
141 + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
142 + <path d="M12 17.8v3.4" />
143 + </>),
144 + school: (<>
145 + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
146 + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
147 + <path d="M21.8 8.6v5.4" />
148 + </>),
149 + health: (<>
150 + <circle cx="12" cy="12" r="8.4" />
151 + <path d="M12 8.4v7.2M8.4 12h7.2" />
152 + </>),
153 + pill: (<>
154 + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
155 + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
156 + </>),
157 + people: (<>
158 + <circle cx="9" cy="8" r="3.2" />
159 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161 + </>),
162 +
163 + // --- fiche propriété (refonte premium 2026-09-07) — même trait 1,7, grille 24 ---
164 + chevdown: <path d="m6 9 6 6 6-6" />,
165 + chevleft: <path d="m14.5 5-7 7 7 7" />,
166 + chevright: <path d="m9.5 5 7 7-7 7" />,
167 + close: <path d="M18 6 6 18M6 6l12 12" />,
168 + expand: <path d="M15 3.5h5.5V9M9 20.5H3.5V15M20.5 3.5l-7 7M3.5 20.5l7-7" />,
169 + share: (<>
170 + <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" />
171 + <path d="M8.2 10.8 15.8 6.3M8.2 13.2l7.6 4.5" />
172 + </>),
173 + sparkles: (<>
174 + <path d="M12 3l1.9 5.6 5.6 1.9-5.6 1.9L12 18l-1.9-5.6-5.6-1.9 5.6-1.9L12 3z" />
175 + <path d="M19 16l.8 2.2 2.2.8-2.2.8L19 22l-.8-2.2-2.2-.8 2.2-.8L19 16z" />
176 + </>),
177 + scale: <path d="M12 3v18M5 21h14M12 6l7 2-3 7h-4M12 6 5 8l3 7h4" />,
178 + wallet: (<>
179 + <path d="M3 7a2 2 0 0 1 2-2h13v4" />
180 + <rect x="3" y="7" width="18" height="12" rx="2" />
181 + <path d="M16 13h.01" />
182 + </>),
183 + droplets: <path d="M12 3s-6 6.5-6 10.5a6 6 0 0 0 12 0C18 9.5 12 3 12 3z" />,
184 + wind: (<>
185 + <path d="M3 8h10a3 3 0 1 0-3-3" />
186 + <path d="M3 12h15a3 3 0 1 1-3 3" />
187 + <path d="M3 16h7" />
188 + </>),
189 + fuel: (<>
190 + <path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
191 + <path d="M3 21h12M6 8h6" />
192 + <path d="M14 10h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V9l-2.5-2.5" />
193 + </>),
194 + train: (<>
195 + <rect x="4" y="3" width="16" height="14" rx="3" />
196 + <path d="M4 11h16M8 21l2-4M16 21l-2-4M9 7h6" />
197 + <path d="M8.5 14h.01M15.5 14h.01" />
198 + </>),
199 + folder: <path d="M3 6h6l2 2.5h10V19H3z" />,
200 + doc: (<>
201 + <path d="M6 3h8l4 4v14H6z" />
202 + <path d="M14 3v4h4" />
203 + <path d="M9 12h6M9 15.5h6" />
204 + </>),
205 + layers: (<>
206 + <path d="m12 3 9 5-9 5-9-5 9-5z" /><path d="m3 13 9 5 9-5M3 17.5l9 5 9-5" />
207 + </>),
208 + info: (<>
209 + <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
210 + </>),
211 + ruler: (<>
212 + <path d="M3.5 16 16 3.5l4.5 4.5L8 20.5 3.5 16z" />
213 + <path d="M7.5 16l1.5 1.5M10.5 13l1.5 1.5M13.5 10l1.5 1.5M16.5 7 18 8.5" />
214 + </>),
215 + key: (<>
216 + <circle cx="8" cy="15" r="4.5" />
217 + <path d="m11.2 11.8 8.3-8.3M15.5 7.5l3 3M18 5l2 2" />
218 + </>),
219 + bank: (<>
220 + <path d="m3 9.5 9-5.5 9 5.5H3z" />
221 + <path d="M5 9.5v8M9.7 9.5v8M14.3 9.5v8M19 9.5v8M3.5 20.5h17" />
222 + </>),
223 + percent: (<>
224 + <path d="M19 5 5 19" /><circle cx="7" cy="7" r="2.5" /><circle cx="17" cy="17" r="2.5" />
225 + </>),
226 + car: (<>
227 + <path d="M4 16v-4.5l2-5A1.6 1.6 0 0 1 7.5 5.5h9a1.6 1.6 0 0 1 1.5 1l2 5V16" />
228 + <path d="M3 16h18M6.2 16v2.6M17.8 16v2.6M4.5 11.5h15" />
229 + <path d="M7.5 13.8h.01M16.5 13.8h.01" />
230 + </>),
231 + hammer: (<>
232 + <path d="m14.5 3.5 6 6-2.5 2.5-6-6z" />
233 + <path d="M13 8 4.5 16.5a1.8 1.8 0 0 0 2.5 2.5L15.5 10.5" />
234 + </>),
235 + store: (<>
236 + <path d="M3 9 5 4h14l2 5M3 9h18v3a2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-3 2.4V9z" />
237 + <path d="M5 14v6h14v-6M10 20v-4h4v4" />
238 + </>),
239 + coffee: <path d="M4 9h12v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9zM16 10h2a2.5 2.5 0 0 1 0 5h-2M7 3v3M11 3v3" />,
240 + book: (<>
241 + <path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5z" />
242 + <path d="M4 19a2 2 0 0 0 2 2h13" />
243 + </>),
244 + dumbbell: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
245 + baby: (<>
246 + <circle cx="12" cy="9" r="5" />
247 + <path d="M7 20a5 5 0 0 1 10 0M10 8.5h.01M14 8.5h.01M10.5 11.5c.8.7 2.2.7 3 0" />
248 + </>),
249 + history: (<>
250 + <path d="M3.5 12a8.5 8.5 0 1 0 2.5-6" />
251 + <path d="M3.5 4v4h4M12 7.5V12l3 2" />
252 + </>),
253 +};
254 +
255 +export type IconName = keyof typeof P;
256 +
257 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
258 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
259 + const paths = P[name as IconName];
260 + if (!paths) return null;
261 + return (
262 + <svg
263 + className={`ico ${className}`}
264 + width={size} height={size} viewBox="0 0 24 24"
265 + fill="none" stroke="currentColor" strokeWidth={stroke}
266 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
267 + >
268 + {paths}
269 + </svg>
270 + );
271 +}
272 +
273 +/** Cœur (favoris) — plein quand actif ; même grille que le reste. */
274 +export function IcoHeart({ size = 18, filled = false, className = "" }:
275 + { size?: number; filled?: boolean; className?: string }) {
276 + return (
277 + <svg className={`ico ${className}`} width={size} height={size} viewBox="0 0 24 24"
278 + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
279 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
280 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
281 + </svg>
282 + );
283 +}
284 +
285 +/** Version « chaîne HTML » pour les popups MapLibre (hors React). */
286 +export function icoHTML(name: IconName, size = 30): string {
287 + const d: Record<string, string> = {
288 + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
289 + };
290 + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
291 +}
added frontend/src/fiche/BottomSheet.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/BottomSheet.tsx : panneau générique — bottom sheet sur mobile (hauteur
5 +// initiale 68 % du viewport, glisser vers le haut = plein écran, glisser
6 +// vers le bas = fermeture), modale centrée à partir de 900 px (CSS).
7 +// Portail à la racine, verrou du défilement (.ka-scroll-lock), Escape,
8 +// focus initial, aria-modal. Utilisé pour lieux, stations, assistant Ka.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useEffect, useRef, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export default function BottomSheet({ open, onClose, title, sub, children, footer, tall = false }: {
15 + open: boolean; onClose: () => void; title: ReactNode; sub?: ReactNode;
16 + children: ReactNode; footer?: ReactNode; tall?: boolean;
17 +}) {
18 + const [full, setFull] = useState(tall);
19 + const panel = useRef<HTMLDivElement>(null);
20 + const drag = useRef<{ y0: number; t0: number } | null>(null);
21 +
22 + useEffect(() => {
23 + if (!open) return;
24 + setFull(tall);
25 + document.documentElement.classList.add("ka-scroll-lock");
26 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
27 + window.addEventListener("keydown", onKey);
28 + const t = setTimeout(() => panel.current?.focus(), 30);
29 + return () => {
30 + document.documentElement.classList.remove("ka-scroll-lock");
31 + window.removeEventListener("keydown", onKey);
32 + clearTimeout(t);
33 + };
34 + }, [open, onClose, tall]);
35 +
36 + if (!open) return null;
37 +
38 + const onDown = (e: React.PointerEvent) => { drag.current = { y0: e.clientY, t0: Date.now() }; };
39 + const onUp = (e: React.PointerEvent) => {
40 + if (!drag.current) return;
41 + const dy = e.clientY - drag.current.y0;
42 + drag.current = null;
43 + if (dy > 90) onClose();
44 + else if (dy < -60) setFull(true);
45 + };
46 +
47 + return createPortal(
48 + <>
49 + <div className="jk-sheet-backdrop" onClick={onClose} aria-hidden="true" />
50 + <div className={`jk-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
51 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
52 + <div className="jk-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
53 + <div className="jk-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
54 + <div style={{ minWidth: 0 }}>
55 + <h3 className="jk-sheet-title">{title}</h3>
56 + {sub && <p className="jk-sheet-sub">{sub}</p>}
57 + </div>
58 + <button type="button" className="jk-sheet-x" onClick={onClose} aria-label="Fermer">
59 + <Ico name="close" size={18} />
60 + </button>
61 + </div>
62 + <div className="jk-sheet-body">{children}</div>
63 + {footer && <div className="jk-sheet-foot">{footer}</div>}
64 + </div>
65 + </>,
66 + document.body,
67 + );
68 +}
added frontend/src/fiche/Closing.tsx +200 −0
@@ -0,0 +1,200 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/fiche/Closing.tsx
7 + * Rôle : « En bref », Où travailler (carte OSM), Dossier de l'offre,
8 + * Sources et méthodologie, CTA sticky mobile, aside desktop et
9 + * « Demander à Ka » (widget KA Agent partagé).
10 + * Créé : 2026-09-07
11 + * =============================================================================
12 + */
13 +import { useEffect, useMemo, useState } from "react";
14 +import { Job, displayTitle, formatDate, formatSalary } from "../api";
15 +import { Ico, IcoHeart } from "../components/Icons";
16 +import BottomSheet from "./BottomSheet";
17 +import { EmployerLogo, SalaryCapsule } from "./JobHero";
18 +import { ComparaisonSalaire, Constat } from "./synthese";
19 +import { Accordion, SectionCard, SkeletonLines, SourceLine, relTime } from "./ui";
20 +
21 +export 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 +}
34 +
35 +export 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 +}
42 +
43 +export 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 +}
59 +
60 +export 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 +}
89 +
90 +export 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 +}
118 +
119 +export 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 +}
130 +
131 +export 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 +}
143 +
144 +export 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 +}
168 +
169 +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"];
170 +function 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 +}
178 +export 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 +}
added frontend/src/fiche/JobFacts.tsx +90 −0
@@ -0,0 +1,90 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/fiche/JobFacts.tsx
7 + * Rôle : « Le poste » — grille icône/valeur (mode, type, séniorité, langue,
8 + * expérience, scolarité, catégorie, région), rangées (publiée, date
9 + * limite, plateforme, lieu), avantages, description (tronquée),
10 + * champs bruts des exigences en accordéon.
11 + * Créé : 2026-09-07
12 + * =============================================================================
13 + */
14 +import { ReactNode, useState } from "react";
15 +import { Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, agoLabel, daysSince, formatDate } from "../api";
16 +import { Ico } from "../components/Icons";
17 +import { Accordion, MoreButton, SectionCard } from "./ui";
18 +
19 +export default function JobFacts({ job }: { job: Job }) {
20 + const [open, setOpen] = useState(false);
21 + const r = job.requirements ?? {};
22 + const facts: { ico: ReactNode; v: string; l: string }[] = [];
23 + if (job.work_mode) facts.push({ ico: <Ico name="home" size={17} />, v: MODE_FR[job.work_mode] ?? job.work_mode, l: "Mode de travail" });
24 + if (job.employment_type) facts.push({ ico: <Ico name="calendar" size={17} />, v: TYPE_FR[job.employment_type] ?? job.employment_type, l: "Type d'emploi" });
25 + if (job.seniority) facts.push({ ico: <Ico name="layers" size={17} />, v: SENIORITY_FR[job.seniority] ?? job.seniority, l: "Niveau" });
26 + if (job.language) facts.push({ ico: <Ico name="book" size={17} />, v: LANG_FR[job.language] ?? job.language, l: "Langue de l'offre" });
27 + if (typeof r.experience_years === "number") facts.push({ ico: <Ico name="history" size={17} />, v: `${r.experience_years} an${r.experience_years > 1 ? "s" : ""} min.`, l: "Expérience exigée" });
28 + if (typeof r.education === "string" && r.education) facts.push({ ico: <Ico name="school" size={17} />, v: r.education, l: "Scolarité exigée" });
29 + if (Array.isArray(r.languages) && r.languages.length) facts.push({ ico: <Ico name="people" size={17} />, v: r.languages.length === 2 ? "Bilingue" : r.languages.join(", "), l: "Langues exigées" });
30 + if (job.category) facts.push({ ico: <Ico name="tag" size={17} />, v: job.category, l: "Catégorie" });
31 + if (job.region) facts.push({ ico: <Ico name="pin" size={17} />, v: job.region, l: "Région" });
32 +
33 + const days = daysSince(job.date_posted);
34 + const rows: [string, string][] = [];
35 + if (job.location_label || job.address) rows.push(["Lieu", job.location_label || [job.address, job.city].filter(Boolean).join(", ")]);
36 + if (job.date_posted) rows.push(["Publiée", `${formatDate(job.date_posted)}${days != null ? ` (${agoLabel(days)})` : ""}`]);
37 + if (job.date_deadline) rows.push(["Date limite", formatDate(job.date_deadline)]);
38 + if (job.ats) rows.push(["Plateforme de recrutement", job.ats]);
39 + const det = job.details ?? {};
40 + if (typeof det.industry === "string" && det.industry) rows.push(["Secteur", det.industry as string]);
41 + if (typeof det.employment_label === "string" && det.employment_label) rows.push(["Statut annoncé", det.employment_label as string]);
42 +
43 + const texte = (job.description || "").trim();
44 + const long = texte.length > 700;
45 + const bruts = Object.entries(r).filter(([k, v]) => !["experience_years", "education", "languages"].includes(k) && v != null && String(v).trim() && typeof v !== "object");
46 +
47 + return (
48 + <SectionCard id="poste" title="Le poste">
49 + {facts.length > 0 && (
50 + <div className="jk-facts" role="list">
51 + {facts.map((x) => (
52 + <div className="jk-fact" role="listitem" key={x.l}>
53 + <span className="jk-fact-ico" aria-hidden="true">{x.ico}</span>
54 + <span className="jk-fact-txt"><div className="jk-fact-v" title={x.v}>{x.v}</div><div className="jk-fact-l">{x.l}</div></span>
55 + </div>
56 + ))}
57 + </div>
58 + )}
59 + {rows.length > 0 && (
60 + <div className="jk-facts-rows">
61 + {rows.map(([k, v]) => <div className="jk-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>)}
62 + </div>
63 + )}
64 + {(job.benefits?.length ?? 0) > 0 && (
65 + <>
66 + <h3 className="jk-card-sub jk-subtitle" id="avantages">Avantages annoncés <small>· {job.benefits.length}</small></h3>
67 + <div className="jk-amen" role="list">
68 + {job.benefits.map((b) => (
69 + <div className="jk-amen-it" role="listitem" key={b}><span className="jk-amen-ico" aria-hidden="true"><Ico name="check" size={14} /></span><span className="jk-amen-txt">{b}</span></div>
70 + ))}
71 + </div>
72 + </>
73 + )}
74 + <h3 className="jk-card-sub jk-subtitle" id="description">Description du poste</h3>
75 + {texte ? (
76 + <>
77 + <div className={`jk-desc ${long && !open ? "clamped" : ""}`}><div className="jk-desc-body"><p>{texte}</p></div></div>
78 + {long && <MoreButton onClick={() => setOpen(!open)} expanded={open}>{open ? "Réduire" : "Lire la suite"}</MoreButton>}
79 + </>
80 + ) : <p className="jk-fine" style={{ marginTop: 0 }}>L'employeur ne fournit pas de description exploitable — consultez l'offre originale.</p>}
81 + {bruts.length > 0 && (
82 + <Accordion title="Autres champs publiés par l'employeur" meta={`${bruts.length}`}>
83 + <div className="jk-facts-rows" style={{ marginTop: 0 }}>
84 + {bruts.map(([k, v]) => <div className="jk-kv" key={k}><span className="k">{k}</span><span className="v">{String(v)}</span></div>)}
85 + </div>
86 + </Accordion>
87 + )}
88 + </SectionCard>
89 + );
90 +}
added frontend/src/fiche/JobHero.tsx +78 −0
@@ -0,0 +1,78 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/fiche/JobHero.tsx
7 + * Rôle : Héro de la fiche — logo + employeur · lieu, salaire en exergue
8 + * (+ équivalent annuel), capsules (marché, nouveau, offre directe),
9 + * titre du poste (H1) + résumé, actions (favoris · partager ·
10 + * Postuler). Ordre DOM = ordre visuel.
11 + * Créé : 2026-09-07
12 + * =============================================================================
13 + */
14 +import { useState } from "react";
15 +import { Job, daysSince, displayTitle, formatSalary, yearlyEquiv } from "../api";
16 +import { Ico, IcoHeart } from "../components/Icons";
17 +import { ComparaisonSalaire, ligneResume } from "./synthese";
18 +import { NBSP } from "./ui";
19 +
20 +export function SalaryCapsule({ cmp, short = false }: { cmp: ComparaisonSalaire | null; short?: boolean }) {
21 + if (!cmp) return null;
22 + return (
23 + <span className={`jk-capsule ${cmp.tone}`}>
24 + {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}
25 + </span>
26 + );
27 +}
28 +
29 +export function EmployerLogo({ job, size = 56 }: { job: Job; size?: number }) {
30 + const [ok, setOk] = useState(true);
31 + if (!job.company_logo || !ok)
32 + return <span className="jk-logo jk-logo-fallback" style={{ width: size, height: size }} aria-hidden="true">{(job.employer || "?").charAt(0).toUpperCase()}</span>;
33 + return <img className="jk-logo" src={job.company_logo} alt={`Logo ${job.employer}`} style={{ width: size, height: size }} onError={() => setOk(false)} />;
34 +}
35 +
36 +export default function JobHero({ job, cmp, fav, onFav, onShare, actionsRef }: {
37 + job: Job; cmp: ComparaisonSalaire | null; fav: boolean;
38 + onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>;
39 +}) {
40 + const salaire = formatSalary(job);
41 + const yearly = yearlyEquiv(job);
42 + const days = daysSince(job.date_posted);
43 + const resume = ligneResume(job);
44 + return (
45 + <header className="jk-hero" aria-label="Résumé de l'offre">
46 + <div className="jk-hero-emp">
47 + <EmployerLogo job={job} />
48 + <div style={{ minWidth: 0 }}>
49 + <div className="jk-kicker">{job.employer}{job.location_label ? ` — ${job.location_label}` : job.city ? ` — ${job.city}` : ""}</div>
50 + <div className="jk-price">
51 + {salaire ?? <span style={{ fontSize: "0.62em", fontWeight: 600, color: "var(--jk-text-2)" }}>Salaire non affiché</span>}
52 + {yearly && <small>{yearly}</small>}
53 + </div>
54 + </div>
55 + </div>
56 + <div className="jk-price-row" style={{ marginTop: 8 }}>
57 + <SalaryCapsule cmp={cmp} />
58 + {days != null && days <= 3 && <span className="jk-capsule brand">{days === 0 ? "Aujourd'hui" : "Nouveau"}</span>}
59 + {job.is_direct !== false && <span className="jk-capsule ok">Offre directe</span>}
60 + {job.active === 0 && <span className="jk-capsule high">Offre retirée</span>}
61 + {job.ka_reco && <span className="jk-capsule brand">Recommandé pour vous</span>}
62 + </div>
63 + <h1 className="jk-h1 jk-h1-big">
64 + {displayTitle(job)}
65 + {resume.length > 0 && <span className="jk-city">{resume.join(" · ")}</span>}
66 + </h1>
67 + <div className="jk-actions" ref={actionsRef}>
68 + <button type="button" className={`jk-btn jk-btn-ghost jk-btn-icon ${fav ? "on" : ""}`} aria-pressed={fav}
69 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>
70 + <button type="button" className="jk-btn jk-btn-ghost jk-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>
71 + <a className="jk-btn jk-btn-primary" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
72 + Postuler <Ico name="external" size={16} /><span className="visually-hidden"> chez {job.employer}</span>
73 + </a>
74 + </div>
75 + <p className="jk-fine" style={{ margin: "2px 0 0" }}>La candidature se fait chez l'employeur{job.ats ? ` (${job.ats})` : ""} — Job·Ka n'est pas un intermédiaire.{NBSP}</p>
76 + </header>
77 + );
78 +}
added frontend/src/fiche/SalaryCard.tsx +80 −0
@@ -0,0 +1,80 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/fiche/SalaryCard.tsx
7 + * Rôle : « Salaire et marché » — KPI (salaire affiché, médiane de la
8 + * catégorie, fourchette P25–P75), jauge de position du point médian
9 + * de l'offre dans la fenêtre interquartile, historique du salaire
10 + * affiché, méthodologie. Jamais d'estimation : sans salaire publié,
11 + * la carte le dit et montre seulement le marché.
12 + * Créé : 2026-09-07
13 + * =============================================================================
14 + */
15 +import { Job, compactMoney, formatSalary, yearlyEquiv } from "../api";
16 +import { Ico } from "../components/Icons";
17 +import { SalaryCapsule } from "./JobHero";
18 +import { ComparaisonSalaire } from "./synthese";
19 +import { Accordion, SectionCard, StatTile, NBSP } from "./ui";
20 +
21 +function Gauge({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {
22 + const lo = Math.min(p25 * 0.75, mid * 0.95), hi = Math.max(p75 * 1.25, mid * 1.05);
23 + const W = 320, H = 54;
24 + const x = (v: number) => 8 + ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * (W - 16);
25 + const anchor = (px: number) => (px < 60 ? "start" : px > W - 60 ? "end" : "middle");
26 + return (
27 + <div className="jk-gauge">
28 + <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`Fenêtre P25–P75 de ${compactMoney(p25)} à ${compactMoney(p75)}, cette offre à ${compactMoney(mid)} par an`}>
29 + <rect className="jk-gauge-track" x="8" y="24" width={W - 16} height="8" rx="4" />
30 + <rect className="jk-gauge-box" x={x(p25)} y="22" width={Math.max(4, x(p75) - x(p25))} height="12" rx="4" />
31 + <text className="jk-gauge-txt" x={x(p25)} y="12" textAnchor={anchor(x(p25))}>P25 {compactMoney(p25)}</text>
32 + <text className="jk-gauge-txt" x={x(p75)} y="12" textAnchor={anchor(x(p75))}>P75 {compactMoney(p75)}</text>
33 + <circle className="jk-gauge-me" cx={x(mid)} cy="28" r="7" />
34 + <text className="jk-gauge-txt me" x={x(mid)} y="52" textAnchor={anchor(x(mid))}>cette offre ≈ {compactMoney(mid)}/an</text>
35 + </svg>
36 + </div>
37 + );
38 +}
39 +
40 +export default function SalaryCard({ job, cmp }: { job: Job; cmp: ComparaisonSalaire | null }) {
41 + const ctx = job.salary_context;
42 + const salaire = formatSalary(job);
43 + const yearly = yearlyEquiv(job);
44 + const hist = (job.salary_history ?? []).filter((h) => h.s_min != null);
45 + if (!salaire && !ctx) return null;
46 + return (
47 + <SectionCard id="salaire" title="Salaire et marché" icon={<Ico name="scale" size={18} />} aside={cmp && <SalaryCapsule cmp={cmp} short />}>
48 + <div className="jk-kpis cols-3">
49 + <StatTile accent value={salaire ?? "Non affiché"} label={salaire ? (yearly ? `Salaire affiché · ${yearly}` : "Salaire affiché par l'employeur") : "L'employeur n'a pas publié de salaire"} />
50 + {ctx && <StatTile value={`${compactMoney(ctx.median)}/an`} label={`Médiane · ${ctx.category || "toutes catégories"}`} />}
51 + {ctx && <StatTile value={`${compactMoney(ctx.p25)} – ${compactMoney(ctx.p75)}`} label={`Fourchette P25–P75 · ${ctx.n.toLocaleString("fr-CA")} offres`} />}
52 + </div>
53 + {ctx && ctx.job_mid != null && <Gauge p25={ctx.p25} p75={ctx.p75} mid={ctx.job_mid} />}
54 + {ctx && ctx.job_mid == null && salaire == null && (
55 + <p className="jk-note info">Sans salaire publié, Job·Ka n'estime rien : seule la référence du marché de la catégorie est affichée.</p>
56 + )}
57 + {hist.length > 1 && (
58 + <>
59 + <h3 className="jk-card-sub jk-subtitle">Évolution du salaire affiché</h3>
60 + <ul className="jk-tl">
61 + {hist.map((h, i) => (
62 + <li key={i}>
63 + <span className="jk-tl-date">{new Date(h.ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}</span>
64 + <b>{formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}</b>
65 + </li>
66 + ))}
67 + </ul>
68 + </>
69 + )}
70 + <Accordion title="Méthodologie" small>
71 + <p>
72 + Le salaire est celui publié par l'employeur (jamais estimé). La référence de marché est calculée par Job·Ka
73 + sur les offres actives de la même catégorie qui affichent un salaire, converties en équivalent annuel :
74 + médiane et quartiles P25–P75 ; l'écart compare le point médian de la fourchette de l'offre à cette médiane.
75 + Les avantages, primes et horaires ne sont pas pris en compte.{NBSP}
76 + </p>
77 + </Accordion>
78 + </SectionCard>
79 + );
80 +}
added frontend/src/fiche/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/SectionNav.tsx : navigation sticky par sections — pastilles
5 +// défilables horizontalement, section active suivie au scroll
6 +// (IntersectionObserver), défilement doux au clic sans modifier l'URL
7 +// (pas de #hash : la page s'ouvre toujours en haut).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +
11 +export interface NavItem { id: string; label: string; }
12 +
13 +export default function SectionNav({ items }: { items: NavItem[] }) {
14 + const [active, setActive] = useState(items[0]?.id);
15 + const track = useRef<HTMLDivElement>(null);
16 +
17 + useEffect(() => {
18 + const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);
19 + if (els.length === 0) return;
20 + const visible = new Map<string, number>();
21 + const io = new IntersectionObserver((entries) => {
22 + for (const e of entries) visible.set((e.target as HTMLElement).id, e.isIntersecting ? e.intersectionRatio : 0);
23 + // section active = la première (ordre DOM) visible sous le header
24 + const first = items.find((i) => (visible.get(i.id) ?? 0) > 0);
25 + if (first) setActive(first.id);
26 + }, { rootMargin: "-120px 0px -55% 0px", threshold: [0, 0.1, 0.5] });
27 + els.forEach((e) => io.observe(e));
28 + return () => io.disconnect();
29 + }, [items]);
30 +
31 + // garder la pastille active visible dans la barre — défilement HORIZONTAL de
32 + // la piste seulement (jamais scrollIntoView : il ferait défiler la page
33 + // verticalement, y compris à l'ouverture)
34 + useEffect(() => {
35 + const t = track.current, a = t?.querySelector<HTMLElement>(".on");
36 + if (!t || !a) return;
37 + const left = a.offsetLeft, right = left + a.offsetWidth;
38 + if (left < t.scrollLeft) t.scrollTo({ left: Math.max(0, left - 12), behavior: "smooth" });
39 + else if (right > t.scrollLeft + t.clientWidth) t.scrollTo({ left: right - t.clientWidth + 12, behavior: "smooth" });
40 + }, [active]);
41 +
42 + const go = (id: string) => (e: React.MouseEvent) => {
43 + e.preventDefault();
44 + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
45 + setActive(id);
46 + };
47 +
48 + return (
49 + <nav className="jk-nav" aria-label="Sections de la fiche">
50 + <div className="jk-nav-track" ref={track}>
51 + {items.map((i) => (
52 + <a key={i.id} href={`#${i.id}`} className={active === i.id ? "on" : ""}
53 + aria-current={active === i.id ? "true" : undefined} onClick={go(i.id)}>
54 + {i.label}
55 + </a>
56 + ))}
57 + </div>
58 + </nav>
59 + );
60 +}
added frontend/src/fiche/current.ts +26 −0
@@ -0,0 +1,26 @@
1 +// -----------------------------------------------------------------------------
2 +// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/current.ts : mini-magasin « fiche affichée » partagé entre la page
5 +// fiche et le header compact (favoris ♥ + partage). Le toggle favoris du
6 +// compte KA exige l'objet Job complet (titre, image, prix pour le hub) :
7 +// la page le publie ici, le header le lit sans re-fetch.
8 +// -----------------------------------------------------------------------------
9 +import { useSyncExternalStore } from "react";
10 +import type { Job } from "../api";
11 +
12 +let current: Job | null = null;
13 +const subs = new Set<() => void>();
14 +
15 +export function setCurrentListing(l: Job | null) {
16 + current = l;
17 + subs.forEach((f) => f());
18 +}
19 +
20 +export function useCurrentListing(): Job | null {
21 + return useSyncExternalStore(
22 + (cb) => { subs.add(cb); return () => { subs.delete(cb); }; },
23 + () => current,
24 + () => null,
25 + );
26 +}
added frontend/src/fiche/fiche.css +674 −0
@@ -0,0 +1,674 @@
1 +/* -----------------------------------------------------------------------------
2 + Job·Ka — Agrégateur d'offres d'emploi (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + fiche/fiche.css : fiche d'offre premium (refonte 2026-09-07, même socle que
5 + les fiches Lou-Ka v3 / Immo-Ka v3)
6 + · Tokens `--jk-*` posés sur :root, styles SCOPÉS sous `.jk-fiche` / `.jk-*`.
7 + · Fond papier Groupe KA #F5F3EE, cartes blanches à bord 1 px, ombres
8 + légères, rayons 10/14/18/22 ; CYAN Job·Ka réservé aux CTA / salaire /
9 + états actifs. Le header encre du site est conservé, compact (56 px).
10 + · Mobile-first ; desktop ≥ 1024 px : colonne principale + aside sticky.
11 + · Aucune propriété `order` : ordre DOM = ordre visuel (standard Groupe Ka).
12 +----------------------------------------------------------------------------- */
13 +:root {
14 + --jk-bg: #f5f3ee; --jk-surface: #ffffff; --jk-surface-2: #faf9f5;
15 + --jk-border: #e6e3dc; --jk-border-2: #d3cfc6;
16 + --jk-text: #141814; --jk-text-2: #4d5551; --jk-muted: #7c837e;
17 + --jk-accent: #0c8599; --jk-accent-deep: #095c6b; --jk-accent-soft: #def0f4;
18 + --jk-success: #1e7b4a; --jk-success-soft: #e7f4ec;
19 + --jk-warning: #a8690a; --jk-warning-soft: #fcf3e1;
20 + --jk-danger: #b3423a; --jk-danger-soft: #fbe9e7;
21 + --jk-info: #3b5bdb; --jk-info-soft: #e9edfb;
22 + --jk-r-sm: 10px; --jk-r-md: 14px; --jk-r-lg: 18px; --jk-r-xl: 22px;
23 + --jk-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04); --jk-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
24 + --jk-header-h: 56px; --jk-nav-h: 52px;
25 +}
26 +
27 +/* ---- page : fond papier, header compact, chrome global effacé ---- */
28 +body.jk-fiche-page { background: var(--jk-bg); }
29 +body.jk-fiche-page .prefooter { margin-top: 8px; }
30 +body.jk-fiche-page .kaa-btn, body.jk-fiche-page .kaa-hello { display: none !important; } /* remplacé par « Demander à Ka » */
31 +header.site.header--fiche .container { min-height: var(--jk-header-h); padding-top: 4px; padding-bottom: 4px; gap: 4px 10px; flex-wrap: nowrap; }
32 +header.site.header--fiche .gk-badge { display: none; }
33 +header.site.header--fiche nav.main { display: none; }
34 +@media (min-width: 980px) { header.site.header--fiche nav.main { display: flex; margin-left: 0; } }
35 +header.site.header--fiche .auth-box { margin-left: 0; }
36 +header.site.header--fiche .menu-btn { display: flex; }
37 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
38 +.hdr-btn {
39 + display: inline-grid; place-items: center; width: 40px; height: 40px;
40 + border-radius: 999px; border: 1px solid rgba(245, 243, 238, 0.28); background: transparent;
41 + color: #f5f3ee; cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
42 +}
43 +.hdr-btn:active { transform: scale(0.95); }
44 +.hdr-btn.on { color: var(--jk-accent-soft); border-color: var(--jk-accent); background: rgba(12, 133, 153, 0.25); }
45 +@media (hover: hover) { .hdr-btn:hover { background: rgba(245, 243, 238, 0.1); } }
46 +@media (max-width: 760px) {
47 + header.site.header--fiche .btn-ka { padding: 0; width: 40px; height: 40px; min-height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
48 + header.site.header--fiche .btn-ka b { font-size: 12px; }
49 +}
50 +
51 +/* ---- gabarit ---- */
52 +.jk-fiche { padding: 10px 0 110px; color: var(--jk-text); }
53 +.jk-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
54 +.jk-grid { display: block; }
55 +.jk-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
56 +.jk-aside { display: none; }
57 +@media (min-width: 768px) {
58 + .jk-wrap { padding: 0 24px; }
59 + .jk-fiche { padding-top: 18px; }
60 + .jk-main { gap: 16px; }
61 +}
62 +@media (min-width: 1024px) {
63 + .jk-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
64 + .jk-aside { display: block; position: sticky; top: calc(var(--jk-header-h) + 16px); }
65 + .jk-fiche { padding-bottom: 80px; }
66 +}
67 +.jk-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--jk-muted); margin: 0 0 10px; }
68 +.jk-crumbs a { color: var(--jk-text-2); }
69 +.jk-crumbs span:last-child { color: var(--jk-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
70 +.jk-fiche section[id] { scroll-margin-top: calc(var(--jk-header-h) + var(--jk-nav-h) + 10px); }
71 +
72 +/* ---- carte de section ---- */
73 +.jk-card {
74 + background: var(--jk-surface); border: 1px solid var(--jk-border);
75 + border-radius: var(--jk-r-lg); box-shadow: var(--jk-shadow-sm);
76 + padding: 16px; min-width: 0;
77 +}
78 +@media (min-width: 768px) { .jk-card { padding: 20px 22px; } }
79 +.jk-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
80 +.jk-card-title { display: flex; align-items: center; gap: 9px; margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 17px; line-height: 1.2; letter-spacing: -0.02em; color: var(--jk-text); }
81 +.jk-card-title svg { color: var(--jk-accent-deep); flex: none; }
82 +.jk-card-sub { font-size: 12.5px; color: var(--jk-muted); margin: 3px 0 0; }
83 +.jk-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
84 +.jk-link { background: none; border: 0; padding: 0; color: var(--jk-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--jk-border-2); }
85 +.jk-link:hover { color: var(--jk-accent-deep); }
86 +.jk-more {
87 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
88 + width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 14px;
89 + border: 1px solid var(--jk-border); border-radius: var(--jk-r-sm); background: var(--jk-surface);
90 + color: var(--jk-text); font: 600 13.5px var(--font-body); cursor: pointer;
91 + transition: background 0.15s, border-color 0.15s;
92 +}
93 +.jk-more:hover { background: var(--jk-surface-2); border-color: var(--jk-border-2); }
94 +.jk-more svg { color: var(--jk-muted); }
95 +
96 +/* ---- boutons ---- */
97 +.jk-btn {
98 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
99 + min-height: 46px; padding: 10px 16px; border-radius: 12px; border: 1px solid transparent;
100 + font: 600 14.5px var(--font-body); cursor: pointer; text-decoration: none;
101 + transition: transform 0.12s, background 0.15s, box-shadow 0.15s; white-space: nowrap;
102 +}
103 +.jk-btn:active { transform: translateY(1px); }
104 +.jk-btn-primary { background: var(--jk-accent); color: #fff; box-shadow: 0 6px 18px rgba(226, 55, 68, 0.22); }
105 +.jk-btn-primary:hover { background: var(--jk-accent-deep); }
106 +.jk-btn-ghost { background: var(--jk-surface); border-color: var(--jk-border); color: var(--jk-text); }
107 +.jk-btn-ghost:hover { background: var(--jk-surface-2); border-color: var(--jk-border-2); }
108 +.jk-btn-icon { width: 46px; padding: 0; flex: none; }
109 +.jk-btn-icon.on { color: var(--jk-accent); border-color: var(--jk-accent); background: var(--jk-accent-soft); }
110 +
111 +/* ---- héro ---- */
112 +.jk-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
113 +.jk-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
114 +.jk-price { font-family: var(--font-display); font-weight: 700; font-size: clamp(32px, 8.4vw, 38px); line-height: 1; letter-spacing: -0.035em; color: var(--jk-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
115 +.jk-price small { font: 500 14px var(--font-body); color: var(--jk-muted); letter-spacing: 0; }
116 +.jk-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
117 +.jk-capsule {
118 + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
119 + border-radius: 999px; font: 600 12.5px var(--font-body); border: 1px solid transparent; white-space: nowrap;
120 +}
121 +.jk-capsule b { font-weight: 700; }
122 +.jk-capsule.good { background: var(--jk-success-soft); color: var(--jk-success); }
123 +.jk-capsule.ok { background: var(--jk-surface); color: var(--jk-text-2); border-color: var(--jk-border); }
124 +.jk-capsule.high { background: var(--jk-warning-soft); color: var(--jk-warning); }
125 +.jk-capsule.neutral { background: var(--jk-surface-2); color: var(--jk-muted); border-color: var(--jk-border); }
126 +.jk-capsule.brand { background: var(--jk-accent-soft); color: var(--jk-accent-deep); }
127 +.jk-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--jk-text); }
128 +.jk-h1 .jk-city { display: block; font-weight: 500; font-size: 14px; color: var(--jk-text-2); }
129 +.jk-summary { margin: 0; font-size: 14px; color: var(--jk-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
130 +.jk-summary .sep { color: var(--jk-border-2); }
131 +.jk-actions { display: flex; gap: 8px; align-items: center; }
132 +.jk-actions .jk-btn-primary { flex: 1; }
133 +.jk-hero .jk-actions { margin-top: 2px; }
134 +@media (min-width: 1024px) {
135 + .jk-hero .jk-actions { display: none; } /* actions dans l'aside sticky */
136 +}
137 +
138 +/* ---- galerie ---- */
139 +.jk-gallery { position: relative; border-radius: var(--jk-r-lg); overflow: hidden; background: #ecece8; }
140 +.jk-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
141 +.jk-gallery-track::-webkit-scrollbar { display: none; }
142 +.jk-gallery-track img, .jk-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
143 +.jk-gallery > img { aspect-ratio: 4 / 3; }
144 +@media (min-width: 768px) { .jk-gallery-track, .jk-gallery > img { aspect-ratio: 16 / 9; } }
145 +.jk-gallery-count {
146 + position: absolute; left: 12px; bottom: 12px; z-index: 2;
147 + background: rgba(20, 24, 20, 0.66); color: #fff; font: 600 12px var(--font-body);
148 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
149 + font-variant-numeric: tabular-nums;
150 +}
151 +.jk-gallery-full, .jk-gallery-nav {
152 + position: absolute; z-index: 2; display: grid; place-items: center;
153 + width: 40px; height: 40px; border-radius: 999px; border: 0; cursor: pointer;
154 + background: rgba(255, 255, 255, 0.92); color: var(--jk-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
155 +}
156 +.jk-gallery-full { right: 12px; bottom: 12px; }
157 +.jk-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
158 +.jk-gallery-nav.prev { left: 12px; } .jk-gallery-nav.next { right: 12px; }
159 +@media (hover: hover) and (min-width: 768px) { .jk-gallery-nav { display: grid; } }
160 +.jk-thumbs { display: none; }
161 +@media (min-width: 768px) {
162 + .jk-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
163 + .jk-thumbs button { border: 0; padding: 0; border-radius: 8px; overflow: hidden; aspect-ratio: 4 / 3; background: #ecece8; cursor: pointer; outline-offset: 2px; opacity: 0.75; transition: opacity 0.15s; }
164 + .jk-thumbs button.on, .jk-thumbs button:hover { opacity: 1; }
165 + .jk-thumbs button.on { outline: 2px solid var(--jk-accent); }
166 + .jk-thumbs img { width: 100%; height: 100%; object-fit: cover; }
167 +}
168 +/* lightbox (plein écran) */
169 +.jk-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
170 +.jk-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
171 +.jk-lightbox-track::-webkit-scrollbar { display: none; }
172 +.jk-lightbox-cell { flex: 0 0 100%; height: 100%; scroll-snap-align: center; display: flex; align-items: center; justify-content: center; padding: max(56px, env(safe-area-inset-top)) 10px max(28px, env(safe-area-inset-bottom)); overflow: hidden; }
173 +.jk-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
174 +.jk-lightbox-close { position: fixed; top: max(12px, env(safe-area-inset-top)); right: 12px; z-index: 3; width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(0, 0, 0, 0.5); color: #fff; display: grid; place-items: center; cursor: pointer; }
175 +.jk-lightbox-count { position: fixed; top: max(22px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 3; color: #fff; font: 600 13px var(--font-body); background: rgba(0, 0, 0, 0.45); padding: 4px 12px; border-radius: 999px; }
176 +.jk-lightbox .jk-gallery-nav { display: none; }
177 +@media (hover: hover) { .jk-lightbox .jk-gallery-nav { display: grid; position: fixed; } }
178 +
179 +/* ---- Score ---- */
180 +.jk-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
181 +.jk-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
182 +.jk-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
183 +.jk-score-ring .bg { fill: none; stroke: var(--jk-surface-2); stroke-width: 7; }
184 +.jk-score-ring .arc { fill: none; stroke: var(--jk-accent); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
185 +.jk-score-ring .arc.partial { stroke: var(--jk-text-2); }
186 +.jk-score-val { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-display); font-weight: 700; font-size: 27px; letter-spacing: -0.03em; line-height: 1; }
187 +.jk-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--jk-muted); margin-top: 2px; letter-spacing: 0.04em; }
188 +.jk-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
189 +.jk-score-sub { font-size: 13px; color: var(--jk-text-2); margin-top: 2px; line-height: 1.4; }
190 +.jk-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
191 +.jk-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--jk-surface-2); border: 1px solid var(--jk-border); color: var(--jk-text-2); }
192 +.jk-score-part b { color: var(--jk-text); font-variant-numeric: tabular-nums; }
193 +.jk-score-part.na { color: var(--jk-muted); border-style: dashed; }
194 +
195 +/* ---- En bref ---- */
196 +.jk-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
197 +.jk-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
198 +.jk-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
199 +.jk-brief-ico.good { background: var(--jk-success-soft); color: var(--jk-success); }
200 +.jk-brief-ico.warn { background: var(--jk-warning-soft); color: var(--jk-warning); }
201 +.jk-brief-ico.bad { background: var(--jk-danger-soft); color: var(--jk-danger); }
202 +.jk-brief-ico.neutral { background: var(--jk-surface-2); color: var(--jk-muted); border: 1px solid var(--jk-border); }
203 +.jk-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
204 +.jk-brief-d { font-size: 13px; color: var(--jk-text-2); line-height: 1.4; margin-top: 1px; }
205 +
206 +/* ---- navigation par sections (sticky) ---- */
207 +.jk-nav {
208 + position: sticky; top: var(--jk-header-h); z-index: var(--z-sticky, 300);
209 + margin: 0 -16px; padding: 6px 16px;
210 + background: rgba(245, 243, 238, 0.97); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
211 + border-bottom: 1px solid var(--jk-border);
212 +}
213 +@media (min-width: 768px) { .jk-nav { margin: 0 -24px; padding: 6px 24px; } }
214 +@media (min-width: 1024px) { .jk-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
215 +.jk-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
216 +.jk-nav-track::-webkit-scrollbar { display: none; }
217 +.jk-nav a {
218 + flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 40px; padding: 8px 13px;
219 + border-radius: 999px; font: 600 13px var(--font-body); color: var(--jk-text-2);
220 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
221 +}
222 +.jk-nav a:hover { background: var(--jk-surface); border-color: var(--jk-border); }
223 +.jk-nav a.on { background: var(--jk-text); color: #fff; border-color: var(--jk-text); }
224 +
225 +/* ---- caractéristiques ---- */
226 +.jk-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
227 +@media (min-width: 560px) { .jk-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
228 +@media (min-width: 900px) { .jk-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
229 +.jk-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--jk-border); border-radius: 12px; background: var(--jk-surface-2); min-width: 0; }
230 +.jk-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--jk-surface); border: 1px solid var(--jk-border); display: grid; place-items: center; color: var(--jk-accent-deep); flex: none; }
231 +.jk-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232 +.jk-fact-l { font-size: 11.5px; color: var(--jk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
233 +.jk-fact-txt { min-width: 0; }
234 +.jk-facts-rows { margin-top: 12px; }
235 +.jk-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--jk-border); font-size: 13.5px; }
236 +.jk-kv .k { color: var(--jk-muted); } .jk-kv .v { font-weight: 600; text-align: right; }
237 +.jk-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
238 +.jk-note.good { background: var(--jk-success-soft); color: var(--jk-success); }
239 +.jk-note.info { background: var(--jk-info-soft); color: var(--jk-info); }
240 +.jk-note.warn { background: var(--jk-warning-soft); color: var(--jk-warning); }
241 +
242 +/* ---- description ---- */
243 +.jk-desc { position: relative; }
244 +.jk-desc-body p { font-size: 15px; line-height: 1.6; color: var(--jk-text-2); margin: 0 0 10px; white-space: pre-line; }
245 +.jk-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--jk-text); }
246 +.jk-desc-lead { font-size: 15.5px !important; color: var(--jk-text) !important; font-weight: 500; }
247 +.jk-desc.clamped .jk-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
248 +.jk-desc-orig { margin-top: 8px; }
249 +.jk-desc-orig p { font-size: 13.5px; color: var(--jk-muted); }
250 +
251 +/* ---- inclusions ---- */
252 +.jk-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
253 +@media (min-width: 640px) { .jk-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
254 +.jk-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--jk-border); font-size: 13.5px; min-width: 0; }
255 +.jk-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--jk-accent-soft); color: var(--jk-accent-deep); display: grid; place-items: center; flex: none; }
256 +.jk-amen-it.unconfirmed { color: var(--jk-text-2); }
257 +.jk-amen-it.unconfirmed .jk-amen-ico { background: var(--jk-surface-2); color: var(--jk-muted); border: 1px dashed var(--jk-border-2); }
258 +.jk-amen-txt { min-width: 0; line-height: 1.25; }
259 +.jk-amen-conf { margin-left: auto; color: var(--jk-success); flex: none; }
260 +
261 +/* ---- KPI / tuiles ---- */
262 +.jk-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
263 +@media (min-width: 600px) { .jk-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .jk-kpi-v { font-size: 20px; } }
264 +.jk-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
265 +.jk-kpi { padding: 10px 12px; border: 1px solid var(--jk-border); border-radius: 12px; background: var(--jk-surface); min-width: 0; }
266 +.jk-kpi.accent { background: var(--jk-accent-soft); border-color: #f5c2c6; }
267 +.jk-kpi-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(16px, 4.8vw, 20px); letter-spacing: -0.025em; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--jk-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
268 +.jk-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
269 +.jk-kpi-v small { font: 600 11px var(--font-body); color: var(--jk-muted); margin-left: 3px; }
270 +.jk-kpi-l { font-size: 11.5px; color: var(--jk-muted); margin-top: 3px; line-height: 1.3; }
271 +.jk-kpi.anim .jk-kpi-v { animation: jk-rise 0.5s ease both; }
272 +@keyframes jk-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
273 +
274 +/* ---- rangées compactes (accessibilité, mesures) ---- */
275 +.jk-rows { display: flex; flex-direction: column; gap: 4px; }
276 +.jk-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
277 +.jk-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--jk-text); }
278 +.jk-row-name small { color: var(--jk-muted); font-size: 12px; margin-left: 4px; }
279 +.jk-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--jk-surface-2); border: 1px solid var(--jk-border); overflow: hidden; }
280 +.jk-row-bar i { display: block; height: 100%; background: var(--jk-text); border-radius: 3px; }
281 +.jk-row-bar i.good { background: var(--jk-success); }
282 +.jk-row-bar i.warn { background: var(--jk-warning); }
283 +.jk-row-bar i.bad { background: var(--jk-danger); }
284 +.jk-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
285 +.jk-row-lbl { font-size: 12px; color: var(--jk-muted); min-width: 68px; text-align: right; }
286 +.jk-row-lbl.good { color: var(--jk-success); } .jk-row-lbl.warn { color: var(--jk-warning); } .jk-row-lbl.bad { color: var(--jk-danger); }
287 +
288 +/* ---- pastilles d'état ---- */
289 +.jk-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font: 600 12px var(--font-body); border: 1px solid transparent; white-space: nowrap; }
290 +.jk-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
291 +.jk-badge.good { background: var(--jk-success-soft); color: var(--jk-success); }
292 +.jk-badge.warn { background: var(--jk-warning-soft); color: var(--jk-warning); }
293 +.jk-badge.bad { background: var(--jk-danger-soft); color: var(--jk-danger); }
294 +.jk-badge.neutral { background: var(--jk-surface-2); color: var(--jk-text-2); border-color: var(--jk-border); }
295 +.jk-badge.info { background: var(--jk-info-soft); color: var(--jk-info); }
296 +.jk-badge.lg { font-size: 13.5px; padding: 6px 12px; }
297 +.jk-pill { display: inline-block; font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 6px; margin-left: 6px; vertical-align: 1px; }
298 +.jk-pill.included { background: var(--jk-success-soft); color: var(--jk-success); }
299 +.jk-pill.observed { background: var(--jk-surface-2); color: var(--jk-text-2); border: 1px solid var(--jk-border); }
300 +.jk-pill.estimated { background: var(--jk-accent-soft); color: var(--jk-accent-deep); }
301 +.jk-pill.unknown { background: var(--jk-surface-2); color: var(--jk-muted); border: 1px dashed var(--jk-border-2); }
302 +
303 +/* ---- listes d'items (lieux, comparables, stations) ---- */
304 +.jk-list { list-style: none; margin: 0; padding: 0; }
305 +.jk-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--jk-border); min-width: 0; }
306 +.jk-list > .jk-item:first-child { border-top: 0; padding-top: 2px; }
307 +.jk-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--jk-surface-2); border: 1px solid var(--jk-border); color: var(--jk-text-2); }
308 +.jk-item-ico svg.cm-ico { width: 26px; height: 26px; }
309 +.jk-item-main { flex: 1; min-width: 0; }
310 +.jk-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
311 +.jk-item-s { font-size: 12.5px; color: var(--jk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
312 +.jk-item-r { text-align: right; flex: none; }
313 +.jk-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
314 +.jk-item-m { font-size: 12px; color: var(--jk-muted); }
315 +.jk-item-best { color: var(--jk-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
316 +
317 +/* ---- carrousel horizontal ---- */
318 +.jk-carousel { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; margin: 0 -16px; padding: 2px 16px 6px; }
319 +.jk-carousel::-webkit-scrollbar { display: none; }
320 +@media (min-width: 768px) { .jk-carousel { margin: 0; padding: 2px 0 6px; } }
321 +.jk-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--jk-border); border-radius: var(--jk-r-md); padding: 12px; background: var(--jk-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
322 +@media (min-width: 768px) { .jk-ccard { flex-basis: 176px; } }
323 +.jk-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
324 +.jk-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--jk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
325 +.jk-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
326 +.jk-ccard-n { font-size: 12.5px; color: var(--jk-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
327 +.jk-ccard-m { font-size: 11.5px; color: var(--jk-muted); }
328 +
329 +/* ---- filtres (chips) ---- */
330 +.jk-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
331 +.jk-chips::-webkit-scrollbar { display: none; }
332 +.jk-chip { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--jk-border); background: var(--jk-surface); color: var(--jk-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
333 +.jk-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--jk-text)); flex: none; }
334 +.jk-chip small { font-weight: 600; opacity: 0.65; }
335 +.jk-chip.on { background: var(--jk-text); color: #fff; border-color: var(--jk-text); }
336 +.jk-chip:disabled { opacity: 0.45; cursor: default; }
337 +
338 +/* ---- carte ---- */
339 +.jk-map { position: relative; height: 340px; border-radius: var(--jk-r-lg); overflow: hidden; border: 1px solid var(--jk-border); background: #ecece8; }
340 +@media (min-width: 768px) { .jk-map { height: 440px; } }
341 +@media (min-width: 1024px) { .jk-map { height: 520px; } }
342 +.jk-map .ka-map {
343 + position: absolute; inset: 0;
344 + --ka-accent: var(--jk-accent); --ka-on-accent: #fff; --ka-surface: var(--jk-surface);
345 + --ka-ink: var(--jk-text); --ka-line: var(--jk-border-2); --ka-radius: 10px;
346 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
347 +}
348 +.jk-map-legend { position: absolute; left: 10px; bottom: 10px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--jk-border); font: 500 11px var(--font-body); color: var(--jk-text-2); pointer-events: none; }
349 +.jk-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--jk-accent); }
350 +.jk-map-skel { position: absolute; inset: 0; }
351 +.jk-map-hint { font-size: 12px; color: var(--jk-muted); margin: 8px 0 0; }
352 +
353 +/* ---- accordéon ---- */
354 +.jk-acc { border-top: 1px solid var(--jk-border); }
355 +.jk-acc:first-child { border-top: 0; }
356 +.jk-acc-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 10px 0; background: none; border: 0; font: 600 14px var(--font-body); color: var(--jk-text); cursor: pointer; text-align: left; }
357 +.jk-acc-btn .jk-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--jk-muted); margin-left: auto; white-space: nowrap; }
358 +.jk-acc-btn .chev { color: var(--jk-muted); flex: none; transition: transform 0.2s ease; }
359 +.jk-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
360 +.jk-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
361 +.jk-acc-body.open { grid-template-rows: 1fr; }
362 +.jk-acc-body > div { min-height: 0; overflow: hidden; }
363 +.jk-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--jk-text-2); line-height: 1.5; }
364 +.jk-acc-inner p { margin: 0 0 8px; }
365 +.jk-acc-inner a { color: var(--jk-text-2); text-decoration: underline; text-underline-offset: 2px; }
366 +.jk-acc.sm .jk-acc-btn { min-height: 40px; font-size: 13px; color: var(--jk-text-2); }
367 +
368 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
369 +.jk-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: jk-fade 0.18s ease; }
370 +.jk-sheet {
371 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
372 + height: var(--jk-sheet-h, 68dvh); max-height: 94dvh;
373 + background: var(--jk-surface); border-radius: 20px 20px 0 0;
374 + display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.18);
375 + animation: jk-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
376 + padding-bottom: env(safe-area-inset-bottom);
377 + touch-action: pan-y;
378 +}
379 +.jk-sheet.full { height: 94dvh; }
380 +.jk-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--jk-border-2); margin: 8px auto 0; flex: none; }
381 +.jk-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--jk-border); flex: none; }
382 +.jk-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
383 +.jk-sheet-sub { font-size: 12.5px; color: var(--jk-muted); margin: 2px 0 0; }
384 +.jk-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--jk-border); background: var(--jk-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--jk-text); flex: none; }
385 +.jk-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
386 +.jk-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--jk-border); background: var(--jk-surface); }
387 +@keyframes jk-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
388 +@keyframes jk-fade { from { opacity: 0; } to { opacity: 1; } }
389 +@keyframes jk-pop { from { transform: translate(-50%, -50%) scale(0.97); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
390 +@media (min-width: 900px) {
391 + .jk-sheet, .jk-sheet.full {
392 + left: 50%; right: auto; top: 50%; bottom: auto; transform: translate(-50%, -50%);
393 + width: min(680px, calc(100vw - 48px)); height: auto; max-height: min(82vh, 780px);
394 + border-radius: var(--jk-r-lg); animation: jk-pop 0.18s ease; padding-bottom: 0;
395 + }
396 + .jk-sheet-handle { display: none; }
397 + .jk-sheet-head { padding: 16px 20px 12px; }
398 + .jk-sheet-body { padding: 14px 20px 20px; }
399 +}
400 +.jk-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
401 +.jk-seg { display: inline-flex; border: 1px solid var(--jk-border); border-radius: 999px; overflow: hidden; background: var(--jk-surface); }
402 +.jk-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--jk-text-2); cursor: pointer; }
403 +.jk-seg button.on { background: var(--jk-text); color: #fff; }
404 +.jk-seg button + button { border-left: 1px solid var(--jk-border); }
405 +
406 +/* ---- CTA sticky ---- */
407 +.jk-cta-bar {
408 + position: fixed; left: 0; right: 0; bottom: var(--consent-h, 0px); z-index: var(--z-bottombar, 600);
409 + display: flex; align-items: center; gap: 12px;
410 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
411 + background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
412 + border-top: 1px solid var(--jk-border);
413 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
414 +}
415 +.jk-cta-bar.show { transform: none; }
416 +.jk-cta-txt { min-width: 0; flex: 0 1 auto; }
417 +.jk-cta-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; }
418 +.jk-cta-price small { font: 500 11px var(--font-body); color: var(--jk-muted); letter-spacing: 0; }
419 +.jk-cta-sub { font-size: 12px; color: var(--jk-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
420 +.jk-cta-sub.good { color: var(--jk-success); font-weight: 600; }
421 +.jk-cta-bar .jk-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
422 +@media (min-width: 1024px) { .jk-cta-bar { display: none; } }
423 +
424 +/* ---- Demander à Ka ---- */
425 +.jk-ka-btn {
426 + position: fixed; right: 14px; bottom: calc(78px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: var(--z-dropdown, 700);
427 + display: inline-flex; align-items: center; gap: 7px; min-height: 42px; padding: 8px 14px 8px 12px;
428 + border-radius: 999px; border: 0; background: var(--jk-text); color: #fff;
429 + font: 600 13px var(--font-body); cursor: pointer; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
430 + transition: transform 0.15s, opacity 0.2s;
431 +}
432 +.jk-ka-btn svg { color: var(--jk-accent); }
433 +.jk-ka-btn:active { transform: scale(0.97); }
434 +.jk-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
435 +@media (min-width: 1024px) { .jk-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
436 +.jk-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
437 +.jk-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--jk-text); color: var(--jk-accent); display: grid; place-items: center; flex: none; }
438 +.jk-ka-intro p { margin: 0; font-size: 13.5px; color: var(--jk-text-2); line-height: 1.45; }
439 +.jk-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--jk-surface-2); border: 1px solid var(--jk-border); font-size: 12.5px; color: var(--jk-text-2); margin-bottom: 12px; }
440 +.jk-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
441 +.jk-ka-ctx b { color: var(--jk-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
442 +.jk-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
443 +.jk-ka-sug { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--jk-border); background: var(--jk-surface); font: 500 14px var(--font-body); color: var(--jk-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
444 +.jk-ka-sug:hover { background: var(--jk-accent-soft); border-color: #f5c2c6; }
445 +.jk-ka-sug svg { color: var(--jk-muted); flex: none; }
446 +.jk-ka-in { display: flex; gap: 8px; align-items: center; }
447 +.jk-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--jk-border); background: var(--jk-surface); font: 400 16px var(--font-body); color: var(--jk-text); }
448 +.jk-ka-in input:focus { outline: 2px solid var(--jk-accent); outline-offset: 1px; }
449 +.jk-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--jk-accent); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
450 +.jk-ka-in button:disabled { opacity: 0.4; cursor: default; }
451 +
452 +/* ---- aside desktop ---- */
453 +.jk-aside-card { display: flex; flex-direction: column; gap: 12px; }
454 +.jk-aside .jk-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
455 +.jk-aside .jk-ka-inline { justify-content: flex-start; }
456 +.jk-aside .jk-ka-inline svg { color: var(--jk-accent); }
457 +.jk-aside .jk-price { font-size: 32px; }
458 +.jk-aside-addr { font-size: 14px; color: var(--jk-text-2); line-height: 1.4; }
459 +.jk-aside-sep { border: 0; border-top: 1px solid var(--jk-border); margin: 4px 0; }
460 +.jk-aside-score { display: flex; align-items: center; gap: 12px; }
461 +.jk-aside-score .jk-score-ring { width: 56px; height: 56px; }
462 +.jk-aside-score .jk-score-ring svg { width: 56px; height: 56px; }
463 +.jk-aside-score .jk-score-val { font-size: 18px; }
464 +.jk-aside-score .jk-score-val small { display: none; }
465 +.jk-aside-score-txt { font-size: 13px; color: var(--jk-text-2); line-height: 1.4; }
466 +.jk-aside-score-txt b { display: block; color: var(--jk-text); font-size: 14px; }
467 +.jk-aside .jk-brief { gap: 8px; }
468 +.jk-aside .jk-brief-t { font-size: 13.5px; }
469 +.jk-aside .jk-brief-d { display: none; }
470 +.jk-aside-meta { font-size: 12px; color: var(--jk-muted); text-align: center; }
471 +
472 +/* ---- source & méthodologie (pied de section) ---- */
473 +.jk-source { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--jk-border); font-size: 12px; color: var(--jk-muted); }
474 +.jk-source b { font-weight: 600; color: var(--jk-text-2); }
475 +.jk-source a, .jk-source button { color: var(--jk-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--jk-border-2); cursor: pointer; }
476 +.jk-source a:hover, .jk-source button:hover { color: var(--jk-accent-deep); }
477 +.jk-fine { font-size: 12.5px; color: var(--jk-muted); line-height: 1.5; margin: 8px 0 0; }
478 +.jk-fine a { color: var(--jk-text-2); text-decoration: underline; text-underline-offset: 2px; }
479 +.jk-fine.meth { margin-top: 0; }
480 +
481 +/* ---- états : squelettes, vides, erreurs ---- */
482 +.jk-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: jk-shimmer 1.3s infinite linear; }
483 +@keyframes jk-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
484 +.jk-skel-lines { display: flex; flex-direction: column; gap: 8px; }
485 +.jk-skel-lines .jk-skel { height: 12px; }
486 +.jk-skel-lines .jk-skel.short { width: 55%; }
487 +.jk-empty { padding: 14px; border-radius: 12px; background: var(--jk-surface-2); border: 1px dashed var(--jk-border-2); color: var(--jk-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
488 +.jk-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--jk-warning-soft); color: var(--jk-warning); font-size: 13.5px; }
489 +.jk-error button { border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 6px 12px; font: 600 12.5px var(--font-body); cursor: pointer; min-height: 36px; }
490 +.jk-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
491 +.jk-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
492 +.jk-page-error p { color: var(--jk-text-2); }
493 +.jk-toast { position: fixed; left: 50%; bottom: calc(96px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); transform: translateX(-50%); z-index: var(--z-toast, 950); background: var(--jk-text); color: #fff; font: 600 13px var(--font-body); padding: 10px 16px; border-radius: 999px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); animation: jk-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
494 +
495 +/* ---- prix / marché ---- */
496 +.jk-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
497 +.jk-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
498 +.jk-market-big small { font: 500 13px var(--font-body); color: var(--jk-muted); margin-left: 4px; letter-spacing: 0; }
499 +.jk-market-sub { font-size: 13px; color: var(--jk-text-2); margin-top: 4px; }
500 +.jk-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
501 +.jk-histo-bar { fill: #dedfd9; }
502 +.jk-histo-bar.on { fill: var(--jk-accent); }
503 +.jk-histo-lbl { font: 700 10px var(--font-body); fill: var(--jk-text); }
504 +.jk-histo-lbl.fv { fill: var(--jk-text-2); }
505 +.jk-histo-axis { font: 500 9.5px var(--font-body); fill: var(--jk-muted); }
506 +.jk-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--jk-text-2); margin: 6px 0 0; }
507 +.jk-meta b { color: var(--jk-text); }
508 +
509 +/* jauge du registre des loyers */
510 +.jk-gauge { margin: 14px 0 6px; }
511 +.jk-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--jk-muted); margin-bottom: 6px; }
512 +.jk-gauge-lbls .good { color: var(--jk-success); } .jk-gauge-lbls .bad { color: var(--jk-danger); }
513 +.jk-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
514 +.jk-gauge, .jk-gauge-lbls { max-width: 520px; }
515 +.jk-gauge-track { fill: var(--jk-surface-2); stroke: var(--jk-border); }
516 +.jk-gauge-box { fill: #ecece8; }
517 +.jk-gauge-med { stroke: var(--jk-text-2); stroke-width: 1.5; }
518 +.jk-gauge-me { fill: var(--jk-accent); stroke: #fff; stroke-width: 2.5; }
519 +.jk-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--jk-muted); }
520 +.jk-gauge-txt.me { fill: var(--jk-text); font-weight: 700; }
521 +.jk-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
522 +.jk-bar { fill: #dedfd9; }
523 +.jk-bar.on { fill: var(--jk-accent); }
524 +.jk-bar-val { font: 600 10px var(--font-body); fill: var(--jk-text-2); }
525 +.jk-bar-cat { font: 600 11px var(--font-body); fill: var(--jk-text-2); }
526 +.jk-bar-cat.on { fill: var(--jk-text); font-weight: 700; }
527 +.jk-bar-n { font: 500 9.5px var(--font-body); fill: var(--jk-muted); }
528 +.jk-bars-axe { stroke: var(--jk-border); }
529 +
530 +/* ---- risques / environnement ---- */
531 +.jk-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
532 +.jk-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
533 +.jk-status-d { font-size: 13.5px; color: var(--jk-text-2); margin: 8px 0 0; line-height: 1.5; }
534 +.jk-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
535 +@media (max-width: 400px) { .jk-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
536 +.jk-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--jk-border); background: var(--jk-surface-2); min-width: 0; }
537 +.jk-air-p { font: 600 11.5px var(--font-body); color: var(--jk-muted); text-transform: uppercase; letter-spacing: 0.05em; }
538 +.jk-air-v { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; margin-top: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; }
539 +.jk-air-v small { font: 500 11px var(--font-body); color: var(--jk-muted); margin-left: 3px; }
540 +.jk-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
541 +.jk-air-s.good { color: var(--jk-success); } .jk-air-s.warn { color: var(--jk-warning); } .jk-air-s.bad { color: var(--jk-danger); } .jk-air-s.neutral { color: var(--jk-muted); }
542 +
543 +/* ---- coût réel ---- */
544 +.jk-cost { list-style: none; margin: 0; padding: 0; }
545 +.jk-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--jk-border); font-size: 13.5px; }
546 +.jk-cost li:first-child { border-top: 0; }
547 +.jk-cost .n { color: var(--jk-text-2); min-width: 0; }
548 +.jk-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
549 +.jk-cost .v.na { color: var(--jk-muted); font-weight: 500; }
550 +.jk-cost li.total { border-top: 1px solid var(--jk-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
551 +.jk-cost li.total .n, .jk-cost li.total .v { color: var(--jk-text); font-weight: 700; }
552 +.jk-cost li.annuel { font-size: 12.5px; color: var(--jk-muted); border-top: 0; padding-top: 0; }
553 +.jk-cost-note { font-size: 12.5px; color: var(--jk-text-2); margin: 8px 0 0; }
554 +
555 +/* ---- dossier de l'immeuble (accordéons) ---- */
556 +.jk-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--jk-border); display: flex; flex-direction: column; gap: 8px; }
557 +.jk-tl li { position: relative; font-size: 13px; color: var(--jk-text-2); }
558 +.jk-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--jk-surface); border: 2px solid var(--jk-border-2); }
559 +.jk-tl li.prix::before { border-color: var(--jk-accent); }
560 +.jk-tl li.disparition::before { border-color: var(--jk-danger); }
561 +.jk-tl li.reapparition::before { border-color: var(--jk-success); }
562 +.jk-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--jk-muted); }
563 +.jk-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--jk-text); display: inline-flex; align-items: center; gap: 5px; }
564 +.jk-star svg { color: #e0a800; }
565 +.jk-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--jk-text-2); border-left: 3px solid var(--jk-border-2); background: var(--jk-surface-2); border-radius: 8px; }
566 +.jk-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--jk-muted); }
567 +.jk-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
568 +.jk-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--jk-danger-soft); color: var(--jk-danger); }
569 +.jk-tag.n { background: var(--jk-surface-2); color: var(--jk-text-2); border: 1px solid var(--jk-border); }
570 +
571 +/* ---- KA Scores (cercles compacts) ---- */
572 +.jk-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
573 +@media (max-width: 400px) { .jk-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
574 +.jk-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--jk-border); background: var(--jk-surface-2); min-width: 0; }
575 +.jk-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
576 +.jk-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
577 +.jk-ks-c .arc { fill: none; stroke: var(--jk-text); stroke-width: 5; stroke-linecap: round; }
578 +.jk-ks-c.haut .arc { stroke: var(--jk-success); } .jk-ks-c.bon .arc { stroke: var(--jk-text); } .jk-ks-c.moyen .arc { stroke: var(--jk-warning); } .jk-ks-c.bas .arc { stroke: var(--jk-danger); }
579 +.jk-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
580 +.jk-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
581 +.jk-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--jk-text); }
582 +.jk-ks-l { font-size: 10.5px; color: var(--jk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
583 +.jk-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
584 +.jk-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--jk-text); }
585 +.jk-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--jk-text-2); }
586 +.jk-ks-detail li { margin: 2px 0; }
587 +
588 +/* ---- fin de fiche ---- */
589 +.jk-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
590 +@media (min-width: 640px) { .jk-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
591 +.jk-end a, .jk-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--jk-border); background: var(--jk-surface); color: var(--jk-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
592 +.jk-end a:hover, .jk-end button:hover { background: var(--jk-surface-2); }
593 +.jk-end svg { color: var(--jk-accent-deep); }
594 +.jk-end small { font-weight: 500; color: var(--jk-muted); font-size: 12px; }
595 +.jk-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
596 +.jk-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--jk-border); font-size: 13px; align-items: center; }
597 +.jk-sources li:first-child { border-top: 0; }
598 +.jk-sources .n { font-weight: 600; grid-column: 1; grid-row: 1; }
599 +.jk-sources .r { font-size: 12.5px; color: var(--jk-text-2); grid-column: 1; grid-row: 2; }
600 +.jk-sources .d { font-size: 12px; color: var(--jk-muted); grid-column: 2; grid-row: 1 / span 2; text-align: right; white-space: nowrap; align-self: start; }
601 +.jk-sources a { color: var(--jk-text-2); text-decoration: underline; text-underline-offset: 2px; }
602 +
603 +
604 +/* ---- ajouts Immo-Ka : héro, galerie, formulaire, tableaux, rôle ---- */
605 +.jk-kicker { font-family: var(--font-mono); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--jk-muted); margin-bottom: 4px; }
606 +.jk-gallery-empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
607 +@media (min-width: 768px) { .jk-gallery-empty { aspect-ratio: 16 / 9; } }
608 +.jk-gallery-empty .type-fallback { position: static; width: 100%; height: 100%; }
609 +.jk-gallery-caption {
610 + position: absolute; left: 12px; top: 12px; z-index: 2; max-width: calc(100% - 24px);
611 + background: rgba(20, 24, 20, 0.62); color: #fff; font: 500 12px var(--font-body);
612 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
613 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
614 +}
615 +.jk-subtitle { margin: 14px 0 8px !important; font-weight: 600 !important; color: var(--jk-text-2) !important; font-size: 13px !important; }
616 +.jk-subtitle small { font-weight: 500; color: var(--jk-muted); }
617 +.jk-more .flip { transform: rotate(180deg); }
618 +.jk-acc-ico { vertical-align: -2px; margin-right: 8px; color: var(--jk-muted); }
619 +.jk-role { margin-top: 14px; }
620 +.jk-tl li.baisse::before { border-color: var(--jk-success); }
621 +.jk-tl li.hausse::before { border-color: var(--jk-warning); }
622 +/* formulaire du calculateur */
623 +.jk-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
624 +@media (min-width: 640px) { .jk-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
625 +.jk-form label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
626 +.jk-form label span { font-size: 11.5px; color: var(--jk-muted); font-weight: 600; }
627 +.jk-form input, .jk-form select {
628 + min-height: 42px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--jk-border); background: var(--jk-surface);
629 + font: 500 15px var(--font-body); color: var(--jk-text); width: 100%; min-width: 0; appearance: none; -webkit-appearance: none;
630 +}
631 +.jk-form select { background-image: linear-gradient(45deg, transparent 50%, var(--jk-muted) 50%), linear-gradient(135deg, var(--jk-muted) 50%, transparent 50%); background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 28px; }
632 +.jk-form input:focus, .jk-form select:focus { outline: 2px solid var(--jk-accent); outline-offset: 1px; }
633 +/* tableaux (pièces, banques, amortissement) */
634 +.jk-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
635 +.jk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
636 +.jk-table th { text-align: left; font: 600 11.5px var(--font-body); color: var(--jk-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 8px; border-bottom: 1px solid var(--jk-border); white-space: nowrap; }
637 +.jk-table td { padding: 8px 8px; border-bottom: 1px solid var(--jk-border); vertical-align: top; color: var(--jk-text-2); }
638 +.jk-table td:first-child { color: var(--jk-text); font-weight: 500; }
639 +.jk-table tr:last-child td { border-bottom: 0; }
640 +.jk-table a { color: var(--jk-text-2); text-decoration: underline; text-underline-offset: 2px; }
641 +/* aside : courtier */
642 +.jk-aside-broker { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; color: var(--jk-text-2); }
643 +.jk-aside-broker-k { font: 600 11px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--jk-muted); }
644 +.jk-aside-broker b { color: var(--jk-text); }
645 +.jk-aside-broker a { display: inline-flex; align-items: center; gap: 6px; color: var(--jk-text-2); }
646 +/* jauge Vrai-Prix : libellés */
647 +.jk-gauge-lbls { margin-top: 4px; margin-bottom: 0; }
648 +.jk-kv .v a { color: var(--jk-text-2); text-decoration: underline; text-underline-offset: 2px; }
649 +.jk-kv .v { min-width: 0; overflow-wrap: anywhere; }
650 +
651 +.jk-fiche h3[id] { scroll-margin-top: calc(var(--jk-header-h) + var(--jk-nav-h) + 10px); }
652 +
653 +
654 +/* ---- ajouts Job·Ka : logo employeur, titre en exergue, carte ---- */
655 +.jk-hero-emp { display: flex; align-items: center; gap: 14px; }
656 +.jk-logo { width: 56px; height: 56px; flex: none; object-fit: contain; border-radius: 12px; background: #fff; border: 1px solid var(--jk-border); padding: 4px; }
657 +.jk-logo-fallback { display: grid; place-items: center; font: 700 22px var(--font-display); color: var(--jk-accent-deep); background: var(--jk-accent-soft); border-color: transparent; }
658 +.jk-hero .jk-price { font-size: clamp(24px, 6.4vw, 32px); }
659 +.jk-h1-big { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 5.6vw, 34px); letter-spacing: -0.03em; line-height: 1.1; }
660 +.jk-h1-big .jk-city { font-family: var(--font-body); font-weight: 500; font-size: 14px; letter-spacing: 0; margin-top: 6px; }
661 +.jk-map { height: 300px; }
662 +@media (min-width: 768px) { .jk-map { height: 360px; } }
663 +.jk-map-embed { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
664 +.jk-ka-ctx img { object-fit: contain; background: #fff; }
665 +.jk-gauge svg text.jk-gauge-txt { font-size: 10px; }
666 +
667 +/* ---- accessibilité mouvement réduit ---- */
668 +@media (prefers-reduced-motion: reduce) {
669 + .jk-sheet, .jk-sheet-backdrop, .jk-toast, .jk-kpi.anim .jk-kpi-v { animation: none; }
670 + .jk-score-ring .arc, .jk-acc-body, .jk-cta-bar { transition: none; }
671 +}
672 +
673 +/* ---- utilitaire a11y ---- */
674 +.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
added frontend/src/fiche/synthese.ts +111 −0
@@ -0,0 +1,111 @@
1 +/**
2 + * =============================================================================
3 + * Job·Ka — Groupe KA
4 + * Auteur : Simon-Pierre Boucher
5 + * Contact : contact@spboucher.ai
6 + * Fichier : frontend/src/fiche/synthese.ts
7 + * Rôle : Synthèse DÉTERMINISTE de la fiche d'offre (aucun LLM, aucune donnée
8 + * inventée) : position du salaire face au marché de sa catégorie
9 + * (salary_context calculé côté API), constats « En bref », résumé.
10 + * Créé : 2026-09-07
11 + * =============================================================================
12 + */
13 +import {
14 + Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, compactMoney, daysSince, formatDate, formatSalary,
15 +} from "../api";
16 +import { Tone, NBSP } from "./ui";
17 +
18 +export interface ComparaisonSalaire {
19 + tone: "good" | "ok" | "high"; // good = au-dessus du marché (avantage candidat)
20 + pct: number; // écart signé en % vs médiane de la catégorie
21 + label: string;
22 + court: string;
23 + ref: number; // médiane ($/an)
24 + n: number;
25 + categorie: string;
26 +}
27 +
28 +export function comparaisonSalaire(job: Job): ComparaisonSalaire | null {
29 + const ctx = job.salary_context;
30 + if (!ctx || ctx.job_mid == null || ctx.delta_pct == null || ctx.n < 20) return null;
31 + const pct = Math.round(ctx.delta_pct);
32 + const tone = pct >= 5 ? "good" : pct >= -5 ? "ok" : "high";
33 + return {
34 + tone, pct, ref: ctx.median, n: ctx.n, categorie: ctx.category || "toutes catégories",
35 + label: tone === "good" ? "Au-dessus du marché" : tone === "ok" ? "Dans le marché" : "Sous le marché",
36 + court: `${pct > 0 ? "↑" : pct < 0 ? "↓" : "≈"} ${Math.abs(pct)}${NBSP}% vs médiane`,
37 + };
38 +}
39 +
40 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }
41 +
42 +export function enBref(job: Job): Constat[] {
43 + const out: Constat[] = [];
44 + const salaire = formatSalary(job);
45 + const cmp = comparaisonSalaire(job);
46 +
47 + // 1. salaire
48 + if (salaire && cmp) {
49 + out.push({ cle: "salaire", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",
50 + titre: cmp.tone === "good" ? `Salaire ${Math.abs(cmp.pct)}${NBSP}% au-dessus du marché` : cmp.tone === "high" ? `Salaire ${Math.abs(cmp.pct)}${NBSP}% sous le marché` : "Salaire dans le marché",
51 + detail: `${salaire} · médiane ${compactMoney(cmp.ref)}/an sur ${cmp.n.toLocaleString("fr-CA")} offres (${cmp.categorie})` });
52 + } else if (salaire) {
53 + out.push({ cle: "salaire", tone: "good", titre: "Salaire affiché", detail: `${salaire} — publié par l'employeur, jamais estimé par Job·Ka` });
54 + } else {
55 + out.push({ cle: "salaire", tone: "neutral", titre: "Salaire non affiché", detail: "L'employeur n'a pas publié de rémunération" });
56 + }
57 +
58 + // 2. offre directe / source
59 + if (job.is_direct !== false)
60 + out.push({ cle: "direct", tone: "good", titre: "Offre publiée à la source", detail: `Page carrière de ${job.employer}${job.ats ? ` (${job.ats})` : ""} — pas un portail intermédiaire` });
61 + else
62 + out.push({ cle: "direct", tone: "neutral", titre: "Offre relayée par un portail", detail: `${job.ats || job.source} — la candidature se fait chez l'employeur` });
63 +
64 + // 3. exigences
65 + const r = job.requirements ?? {};
66 + const ex: string[] = [];
67 + if (typeof r.experience_years === "number") ex.push(`${r.experience_years} an${r.experience_years > 1 ? "s" : ""} d'expérience`);
68 + if (typeof r.education === "string" && r.education && r.education.toLowerCase() !== "aucun") ex.push(r.education);
69 + if (Array.isArray(r.languages) && r.languages.length) ex.push(r.languages.length === 2 ? "bilingue" : r.languages.join(", "));
70 + if (ex.length) out.push({ cle: "exig", tone: "neutral", titre: "Exigences explicites", detail: ex.join(" · ") });
71 + else if (typeof r.education === "string" && r.education.toLowerCase() === "aucun") out.push({ cle: "exig", tone: "good", titre: "Aucun diplôme exigé", detail: "Extrait du texte de l'offre" });
72 +
73 + // 4. mode et type
74 + const mode: string[] = [];
75 + if (job.work_mode) mode.push(MODE_FR[job.work_mode] ?? job.work_mode);
76 + if (job.employment_type) mode.push(TYPE_FR[job.employment_type] ?? job.employment_type);
77 + if (job.seniority) mode.push(SENIORITY_FR[job.seniority] ?? job.seniority);
78 + if (mode.length) out.push({ cle: "mode", tone: job.work_mode === "teletravail" ? "good" : "info", titre: mode.join(" · "), detail: job.language ? `Langue de travail : ${LANG_FR[job.language] ?? job.language}` : "Conditions publiées par l'employeur" });
79 +
80 + // 5. fraîcheur et date limite
81 + const days = daysSince(job.date_posted);
82 + const deadlineIn = job.date_deadline ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000) : null;
83 + if (deadlineIn != null && deadlineIn >= 0 && deadlineIn <= 7)
84 + out.push({ cle: "limite", tone: "warn", titre: deadlineIn === 0 ? "Dernier jour pour postuler" : `Plus que ${deadlineIn} jour${deadlineIn > 1 ? "s" : ""} pour postuler`, detail: `Date limite : ${formatDate(job.date_deadline)}` });
85 + else if (deadlineIn != null && deadlineIn < 0)
86 + out.push({ cle: "limite", tone: "bad", titre: "Date limite dépassée", detail: `Limite publiée : ${formatDate(job.date_deadline)} — l'offre peut rester affichée par l'employeur` });
87 + else if (days != null && days <= 3)
88 + out.push({ cle: "frais", tone: "good", titre: days === 0 ? "Publiée aujourd'hui" : `Publiée il y a ${days} jour${days > 1 ? "s" : ""}`, detail: "Offre récente — candidatures probablement encore ouvertes" });
89 + else if (days != null && days >= 45)
90 + out.push({ cle: "frais", tone: "neutral", titre: `En ligne depuis ${days} jours`, detail: "Offre ancienne — vérifiez qu'elle est toujours ouverte" });
91 +
92 + // 6. employeur
93 + if (typeof job.employer_jobs === "number" && job.employer_jobs > 1)
94 + out.push({ cle: "emp", tone: "info", titre: `${job.employer} recrute pour ${job.employer_jobs} postes`, detail: "Offres actives suivies par Job·Ka chez cet employeur" });
95 +
96 + // 7. avantages
97 + if (job.benefits && job.benefits.length >= 3)
98 + out.push({ cle: "benef", tone: "good", titre: `${job.benefits.length} avantages annoncés`, detail: job.benefits.slice(0, 3).join(" · ") });
99 +
100 + return out.slice(0, 6);
101 +}
102 +
103 +/** Ligne résumé : « Présentiel · Temps plein · Senior · Montréal » */
104 +export function ligneResume(job: Job): string[] {
105 + const p: string[] = [];
106 + if (job.work_mode) p.push(MODE_FR[job.work_mode] ?? job.work_mode);
107 + if (job.employment_type) p.push(TYPE_FR[job.employment_type] ?? job.employment_type);
108 + if (job.seniority) p.push(SENIORITY_FR[job.seniority] ?? job.seniority);
109 + if (job.city) p.push(job.city);
110 + return p;
111 +}
added frontend/src/fiche/ui.tsx +177 −0
@@ -0,0 +1,177 @@
1 +// -----------------------------------------------------------------------------
2 +// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ui.tsx : primitives d'interface de la fiche d'offre (refonte
5 +// premium 2026-09-07, même socle que la fiche Lou-Ka v3) :
6 +// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
7 +// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
8 +// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useCallback, useEffect, useId, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
15 +
16 +/* --- carte de section ------------------------------------------------------ */
17 +export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
18 + id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
19 + children: ReactNode; className?: string; label?: string;
20 +}) {
21 + return (
22 + <section id={id} className={`jk-card ${className}`} aria-label={label}>
23 + {(title || aside) && (
24 + <div className="jk-card-head">
25 + <div>
26 + {title && <h2 className="jk-card-title">{icon}{title}</h2>}
27 + {sub && <p className="jk-card-sub">{sub}</p>}
28 + </div>
29 + {aside && <div className="jk-card-aside">{aside}</div>}
30 + </div>
31 + )}
32 + {children}
33 + </section>
34 + );
35 +}
36 +
37 +/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
38 +export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
39 + title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
40 + small?: boolean; onToggle?: (open: boolean) => void;
41 +}) {
42 + const [open, setOpen] = useState(defaultOpen);
43 + const id = useId();
44 + return (
45 + <div className={`jk-acc ${small ? "sm" : ""}`}>
46 + <button type="button" className="jk-acc-btn" aria-expanded={open}
47 + aria-controls={`${id}-body`} id={`${id}-btn`}
48 + onClick={() => { setOpen(!open); onToggle?.(!open); }}>
49 + <span>{title}</span>
50 + {meta && <span className="jk-acc-meta">{meta}</span>}
51 + <Ico name="chevdown" size={18} className="chev" />
52 + </button>
53 + <div className={`jk-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
54 + role="region" aria-labelledby={`${id}-btn`}>
55 + <div><div className="jk-acc-inner">{children}</div></div>
56 + </div>
57 + </div>
58 + );
59 +}
60 +
61 +/* --- tuile KPI ------------------------------------------------------------- */
62 +export function StatTile({ value, unit, label, accent = false, anim = true }: {
63 + value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
64 +}) {
65 + return (
66 + <div className={`jk-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
67 + <div className={`jk-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
68 + <div className="jk-kpi-l">{label}</div>
69 + </div>
70 + );
71 +}
72 +
73 +/* --- pastille d'état ------------------------------------------------------- */
74 +export function StatusBadge({ tone = "neutral", children, lg = false }: {
75 + tone?: Tone; children: ReactNode; lg?: boolean;
76 +}) {
77 + return <span className={`jk-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;
78 +}
79 +
80 +/* --- squelettes ------------------------------------------------------------ */
81 +export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
82 + return <div className={`jk-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;
83 +}
84 +export function SkeletonLines({ n = 3 }: { n?: number }) {
85 + return (
86 + <div className="jk-skel-lines" aria-busy="true">
87 + {Array.from({ length: n }).map((_, i) => (
88 + <div key={i} className={`jk-skel ${i === n - 1 ? "short" : ""}`} />
89 + ))}
90 + </div>
91 + );
92 +}
93 +
94 +/* --- états vides / erreur -------------------------------------------------- */
95 +export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
96 + return <div className="jk-empty">{children}</div>;
97 +}
98 +export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
99 + onRetry?: () => void; children?: ReactNode;
100 +}) {
101 + return (
102 + <div className="jk-error" role="alert">
103 + <span>{children}</span>
104 + {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}
105 + </div>
106 + );
107 +}
108 +
109 +/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
110 +export function MoreButton({ children, onClick, expanded }: {
111 + children: ReactNode; onClick: () => void; expanded?: boolean;
112 +}) {
113 + return (
114 + <button type="button" className="jk-more" onClick={onClick} aria-expanded={expanded}>
115 + {children}
116 + <Ico name="chevdown" size={16} className={expanded ? "flip" : ""} />
117 + </button>
118 + );
119 +}
120 +
121 +/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
122 +export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
123 + name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
124 +}) {
125 + return (
126 + <div className="jk-source">
127 + <span>
128 + Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>
129 + {date && <> · {date}</>}
130 + </span>
131 + {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}
132 + </div>
133 + );
134 +}
135 +
136 +/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
137 +export function useToast(): [ReactNode, (msg: string) => void] {
138 + const [msg, setMsg] = useState<string | null>(null);
139 + useEffect(() => {
140 + if (!msg) return;
141 + const t = setTimeout(() => setMsg(null), 2200);
142 + return () => clearTimeout(t);
143 + }, [msg]);
144 + const show = useCallback((m: string) => setMsg(m), []);
145 + const node = msg
146 + ? createPortal(<div className="jk-toast" role="status" aria-live="polite">{msg}</div>, document.body)
147 + : null;
148 + return [node, show];
149 +}
150 +
151 +/* --- utilitaires de format -------------------------------------------------- */
152 +export const NBSP = " ";
153 +export const fmtN = (v: number, d = 0) =>
154 + v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
155 +export const fmtPct = (v: number, signed = true) =>
156 + `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
157 +/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
158 +export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
159 +export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
160 +export const relTime = (ts: number | null | undefined): string | null => {
161 + if (!ts) return null;
162 + const s = Date.now() / 1000 - ts;
163 + if (s < 3600) return "à l'instant";
164 + if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
165 + const j = Math.round(s / 86400);
166 + if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
167 + return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
168 +};
169 +/** « 19 989 $ (2026) » → 19989 ; « 1 640 pi² » → 1640 ; sinon null */
170 +export const parseMontant = (v: unknown): number | null => {
171 + if (v == null) return null;
172 + const s = String(v).replace(/\(.*?\)/g, "").replace(/[^\d.,]/g, "").replace(/\s/g, "");
173 + if (!s) return null;
174 + // « 19 989 » et « 19989,50 » : la virgule est décimale, le point aussi
175 + const n = parseFloat(s.replace(/,(\d{1,2})$/, ".$1").replace(/,/g, ""));
176 + return Number.isFinite(n) && n > 0 ? n : null;
177 +};
modified frontend/src/pages/Job.tsx +97 −185
@@ -4,44 +4,39 @@
4 4 * Auteur : Simon-Pierre Boucher
5 5 * Contact : contact@spboucher.ai
6 6 * Fichier : frontend/src/pages/Job.tsx
7 − * Rôle : Fiche d'une offre — panneau de métriques « En un coup d'œil »
8 − * (salaire vs marché, exigences, fraîcheur), description, lien vers
9 − * l'offre originale, cœur ♥ favoris « Mon univers Ka »
10 − * Créé : 2026-08-17 Modifié : 2026-08-25
7 + * Rôle : Fiche d'une offre — refonte premium 2026-09-07 (même socle que
8 + * 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 → navigation
11 + * sticky → Salaire et marché → Le poste (+ avantages, description) →
12 + * Où travailler → Dossier → Sources. Desktop ≥ 1024 px : colonne
13 + * principale + aside sticky. Aucun scrollIntoView à l'ouverture.
14 + * Créé : 2026-08-17 Modifié : 2026-09-07
11 15 * =============================================================================
12 16 */
13 −import { ReactNode, useEffect, useState } from "react";
17 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14 18 import { Link, useParams } from "react-router-dom";
15 −import {
16 − agoLabel, compactMoney, daysSince, displayTitle, fetchJob, formatDate,
17 − formatSalary, Job, LANG_FR, MODE_FR, SENIORITY_FR, TYPE_FR, yearlyEquiv,
18 −} from "../api";
19 −import { FavButton } from "../favorites";
19 +import { Job, displayTitle, fetchJob, formatSalary } from "../api";
20 +import { Ico } from "../components/Icons";
21 +import { jobFavItem, useFav } from "../favorites";
22 +import "../fiche/fiche.css";
23 +import { setCurrentListing } from "../fiche/current";
24 +import { comparaisonSalaire, enBref } from "../fiche/synthese";
25 +import JobHero from "../fiche/JobHero";
26 +import SectionNav, { NavItem } from "../fiche/SectionNav";
27 +import SalaryCard from "../fiche/SalaryCard";
28 +import JobFacts from "../fiche/JobFacts";
29 +import { DesktopAside, Dossier, KaAssistant, LocationCard, Sources, StickyCTA, Summary, usePastElement } from "../fiche/Closing";
30 +import { Skeleton, useToast } from "../fiche/ui";
20 31
21 −/** Tuile métrique : valeur mise en avant + libellé mono + précision optionnelle. */
22 −function Metric({ label, value, sub, title }: {
23 − label: string; value: ReactNode; sub?: ReactNode; title?: string;
24 −}) {
32 +function FicheSkeleton() {
25 33 return (
26 − <div className="metric" title={title}>
27 − <div className="m-value">{value}</div>
28 − <div className="m-label">{label}</div>
29 − {sub && <div className="m-sub">{sub}</div>}
30 − </div>
31 − );
32 −}
33 −
34 −/** Barre de position du salaire de l'offre dans la fourchette P25–P75 de sa
35 − * catégorie (fenêtre interquartile = zone « normale » du marché). */
36 −function MarketBar({ p25, p75, mid }: { p25: number; p75: number; mid: number }) {
37 − const lo = p25 * 0.75, hi = p75 * 1.25; // marges autour des quartiles
38 − const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));
39 − return (
40 − <div className="market-bar" aria-hidden="true">
41 − <div className="mb-track" />
42 − <div className="mb-window" style={{ left: `${pct(p25)}%`, width: `${pct(p75) - pct(p25)}%` }} />
43 − <div className="mb-marker" style={{ left: `${pct(mid)}%` }} />
44 − </div>
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>
45 40 );
46 41 }
47 42
@@ -49,168 +44,85 @@ export default function JobPage() {
49 44 const { uid = "" } = useParams();
50 45 const [job, setJob] = useState<Job | null>(null);
51 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);
52 51
53 52 useEffect(() => {
54 − setJob(null);
55 − fetchJob(uid).then(setJob).catch((e) => setError(String(e)));
53 + setJob(null); setError(""); setCurrentListing(null);
54 + fetchJob(uid).then((j) => { setJob(j); setCurrentListing(j); }).catch((e) => setError(String(e)));
56 55 window.scrollTo(0, 0);
56 + return () => setCurrentListing(null);
57 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]);
58 60
59 − if (error) return <div className="container empty"><h2>Offre introuvable</h2><Link to="/">← Retour aux offres</Link></div>;
60 − if (!job) return <div className="container empty">Chargement…</div>;
61 + 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]);
61 75
62 − const salary = formatSalary(job);
63 − const yearly = yearlyEquiv(job);
64 − const days = daysSince(job.date_posted);
65 − const deadlineDays = job.date_deadline ? daysSince(job.date_deadline) : null;
66 − const deadlineIn = job.date_deadline
67 − ? Math.round((new Date(`${job.date_deadline}T12:00:00`).getTime() - Date.now()) / 86400000)
68 − : null;
69 − const ctx = job.salary_context;
70 − const reqs = job.requirements ?? {};
71 − const langsReq = reqs.languages;
72 − const history = (job.salary_history ?? []).filter((h) => h.s_min != null);
76 + const cmp = useMemo(() => (job ? comparaisonSalaire(job) : null), [job]);
77 + const brief = useMemo(() => (job ? enBref(job) : []), [job]);
73 78
74 − return (
75 − <main className="container">
76 − <div className="sheet">
77 − <p className="muted"><Link to="/">← Toutes les offres</Link></p>
78 − <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
79 − {job.company_logo && (
80 − <img
81 − src={job.company_logo}
82 − alt={`Logo ${job.employer}`}
83 − style={{ width: 56, height: 56, objectFit: "contain", borderRadius: 8, background: "#fff", border: "1.5px solid var(--ink)", flexShrink: 0 }}
84 − onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
85 − />
86 − )}
87 − <h1 style={{ margin: 0, flex: 1 }}>{displayTitle(job)}</h1>
88 − <FavButton job={job} big />
89 − </div>
90 − <p style={{ margin: "6px 0 0" }}>
91 − <span className="employer" style={{ color: "var(--accent-deep)", fontWeight: 600 }}>{job.employer}</span>
92 − {typeof job.employer_jobs === "number" && job.employer_jobs > 1 && (
93 − <span className="muted"> · {job.employer_jobs} offres actives</span>
94 − )}
95 − {job.location_label && <span className="muted"> — {job.location_label}</span>}
96 − {job.region && job.region !== "Québec" && <span className="muted"> ({job.region})</span>}
97 − </p>
79 + 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 />;
98 89
99 − <div className="facts">
100 − {days !== null && days <= 3 && <span className="badge new">Nouveau</span>}
101 − {job.is_direct !== false && <span className="badge direct">Offre directe</span>}
102 − {job.seniority && <span className="badge seniority">{SENIORITY_FR[job.seniority] ?? job.seniority}</span>}
103 − {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>}
104 − {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>}
105 − {job.language && <span className="badge">{LANG_FR[job.language] ?? job.language}</span>}
106 − {job.category && <span className="badge">{job.category}</span>}
107 − {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>}
108 − </div>
90 + 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 + ];
109 100
110 − {/* --- En un coup d'œil : métriques claires, jamais inventées --------- */}
111 − <h2 className="section-title" style={{ marginTop: 18 }}>En un coup d'œil</h2>
112 − <div className="metrics">
113 − <Metric
114 − label="Salaire affiché"
115 − value={salary ?? "Non affiché"}
116 − sub={salary
117 − ? (yearly ?? (job.salary_hour_min != null && job.salary_unit !== "hour"
118 − ? `≈ ${job.salary_hour_min.toLocaleString("fr-CA", { maximumFractionDigits: 2 })} $/h`
119 − : null))
120 − : "L'employeur n'a pas publié de salaire"}
121 − title="Salaire tel que publié par l'employeur — jamais estimé par Job·Ka"
122 − />
123 − {ctx && ctx.job_mid != null && ctx.delta_pct != null && (
124 − <Metric
125 − label={`Vs marché ${ctx.category || "toutes catégories"}`}
126 − value={
127 − <span className={ctx.delta_pct >= 0 ? "delta-up" : "delta-down"}>
128 − {ctx.delta_pct > 0 ? "+" : ""}{ctx.delta_pct.toLocaleString("fr-CA")} %
129 − </span>
130 − }
131 − sub={
132 − <>
133 − <MarketBar p25={ctx.p25} p75={ctx.p75} mid={ctx.job_mid} />
134 − médiane {compactMoney(ctx.median)} · P25 {compactMoney(ctx.p25)} – P75 {compactMoney(ctx.p75)} · {ctx.n.toLocaleString("fr-CA")} offres comparées
135 − </>
136 − }
137 − title="Écart entre le point médian de la fourchette de l'offre et la médiane des salaires affichés de la même catégorie sur Job·Ka"
138 − />
139 − )}
140 − {typeof reqs.experience_years === "number" && (
141 − <Metric
142 − label="Expérience exigée"
143 − value={`${reqs.experience_years} an${reqs.experience_years > 1 ? "s" : ""} min.`}
144 − sub="Extrait du texte de l'offre"
145 − />
146 − )}
147 − {typeof reqs.education === "string" && (
148 − <Metric label="Scolarité exigée" value={reqs.education} sub="Plus bas diplôme accepté" />
149 − )}
150 − {Array.isArray(langsReq) && langsReq.length > 0 && (
151 − <Metric
152 − label="Langues exigées"
153 − value={langsReq.length === 2 ? "Bilingue" : langsReq.join(", ")}
154 − sub={langsReq.length === 2 ? "Français et anglais" : undefined}
155 − />
156 − )}
157 − {days !== null && (
158 − <Metric
159 − label="Fraîcheur"
160 − value={agoLabel(days)}
161 − sub={`Publiée le ${formatDate(job.date_posted)}`}
162 − />
163 − )}
164 − {deadlineIn !== null && deadlineDays !== null && (
165 − <Metric
166 − label="Date limite"
167 − value={deadlineIn >= 0 ? `dans ${deadlineIn} jour${deadlineIn > 1 ? "s" : ""}` : "dépassée"}
168 − sub={formatDate(job.date_deadline)}
169 − title="Date limite pour postuler, publiée par l'employeur"
170 − />
171 − )}
101 + 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} />
172 121 </div>
173 −
174 − <p>
175 − <a className="btn btn-apply" href={job.apply_url || job.url} target="_blank" rel="noopener noreferrer">
176 − Postuler chez {job.employer} ↗
177 − </a>
178 − </p>
179 − <p className="muted">
180 − Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka
181 − n'est pas un intermédiaire, la candidature se fait chez l'employeur.
182 − </p>
183 −
184 − {history.length > 1 && (
185 − <>
186 − <h2 className="section-title">Évolution du salaire affiché</h2>
187 − <ul className="salary-history">
188 − {history.map((h, i) => (
189 − <li key={i}>
190 − <span className="muted">{new Date(h.ts * 1000).toLocaleDateString("fr-CA")}</span>
191 − {" — "}
192 − {formatSalary({ salary_min: h.s_min, salary_max: h.s_max, salary_unit: h.unit })}
193 − </li>
194 − ))}
195 − </ul>
196 − </>
197 − )}
198 −
199 − {job.benefits && job.benefits.length > 0 && (
200 − <>
201 − <h2 className="section-title">Avantages</h2>
202 − <ul>
203 − {job.benefits.map((b, i) => <li key={i}>{b}</li>)}
204 − </ul>
205 − </>
206 − )}
207 − {job.description && (
208 − <>
209 − <h2 className="section-title">Description du poste</h2>
210 − <div className="description">{job.description}</div>
211 − </>
212 − )}
213 122 </div>
214 − </main>
123 + <StickyCTA job={job} cmp={cmp} show={pastHero} />
124 + <KaAssistant job={job} hidden={!pastHero} />
125 + {toast}
126 + </div>
215 127 );
216 128 }
modified frontend/src/styles.css +6 −5
@@ -409,16 +409,17 @@ header.site.menu-open .menu-btn { visibility: hidden; }
409 409 .sort-quiet select:focus { outline: none; color: var(--accent-deep); }
410 410
411 411 /* --- liste d'offres : rangées éditoriales (fini les cartes bordées) ------------- */
412 −.job-list { margin: 0 0 30px; border-top: 2px solid var(--ink); }
412 +.job-list { margin: 0 0 30px; display: flex; flex-direction: column; gap: 10px; }
413 413 .job-row {
414 414 position: relative;
415 415 display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
416 416 grid-template-areas: "main side fav";
417 417 gap: 2px 26px; align-items: start;
418 − padding: 19px 10px 19px 18px;
419 − border-bottom: 1px solid var(--line);
418 + padding: 18px 16px 18px 18px;
419 + background: var(--surface); border: 1px solid rgba(20, 24, 20, 0.1); border-radius: 14px;
420 + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); overflow: hidden;
420 421 color: inherit; text-decoration: none;
421 − transition: background 0.14s;
422 + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
422 423 }
423 424 .job-row .jr-main { grid-area: main; }
424 425 .job-row .jr-side { grid-area: side; }
@@ -428,7 +429,7 @@ header.site.menu-open .menu-btn { visibility: hidden; }
428 429 background: var(--accent); transform: scaleY(0); transform-origin: top;
429 430 transition: transform 0.16s ease;
430 431 }
431 −.job-row:hover { background: var(--surface); text-decoration: none; }
432 +.job-row:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); border-color: rgba(20, 24, 20, 0.18); text-decoration: none; }
432 433 .job-row:hover::before { transform: scaleY(1); }
433 434 .jr-main { min-width: 0; }
434 435 .jr-title {
435 436