1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/App.tsx7 * Rôle : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,8 * navigation, routes, pied de page commun Groupe KA)9 * Créé : 2026-08-17 Modifié : 2026-08-2510 * =============================================================================11 */12import { lazy, Suspense, useEffect, useState } from "react";13import { NavLink, Route, Routes, useLocation } from "react-router-dom";14import Home from "./pages/Home";15import JobPage from "./pages/Job";16import Sources from "./pages/Sources";17import Stats from "./pages/Stats";18import Contact from "./pages/Contact";19import Employeurs from "./pages/Employeurs";20import VillePage, { VillesPage } from "./pages/Ville";21import FavorisPage from "./pages/Favoris";22import { FavProvider } from "./favorites";23import GroupeKaBadge from "./ka/GroupeKaBadge";24import KaFooter from "./ka/KaFooter";2526// MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte27const MapPage = lazy(() => import("./pages/MapPage"));2829interface Me { name: string; email: string; picture: string; ka_id: string }3031/** Connexion KA ID (hub groupe-ka.com) — bouton ou profil connecté. */32function KaAuth() {33 const [enabled, setEnabled] = useState(false);34 const [me, setMe] = useState<Me | null>(null);3536 useEffect(() => {37 fetch("/api/auth/config")38 .then((r) => r.json())39 .then((c) => setEnabled(Boolean(c.ka)))40 .catch(() => {});41 fetch("/api/me")42 .then((r) => (r.ok ? r.json() : null))43 .then(setMe)44 .catch(() => {});45 }, []);4647 const logout = () => {48 fetch("/api/auth/logout", { method: "POST" })49 .then(() => setMe(null))50 .catch(() => {});51 };5253 if (!enabled) return null;54 if (me) {55 return (56 <span className="auth-user">57 {me.picture ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> : null}58 <span className="name">{me.name || me.email}</span>59 <button type="button" onClick={logout}>Déconnexion</button>60 </span>61 );62 }63 return (64 <a className="btn-ka" href="/api/auth/ka/login">65 Se connecter avec <b>KA</b>66 </a>67 );68}6970/** Liens de navigation principaux (header desktop + menu mobile). */71const NAV_LINKS: Array<{ to: string; label: string; end?: boolean }> = [72 { to: "/", label: "Offres", end: true },73 { to: "/carte", label: "Carte" },74 { to: "/favoris", label: "Favoris ♥" },75 { to: "/sources", label: "Employeurs" },76 { to: "/stats", label: "Stats" },77 { to: "/employeurs", label: "Publier" },78 { to: "/contact", label: "Contact" },79];8081export default function App() {82 const [menuOpen, setMenuOpen] = useState(false);83 const location = useLocation();8485 // Hauteur réelle du header → --header-h sur <html> (la carte s'y cale :86 // le 58 px codé en dur était faux quand le header wrappe sur 2 lignes).87 useEffect(() => {88 const el = document.querySelector<HTMLElement>("header.site");89 if (!el) return;90 const set = () =>91 document.documentElement.style.setProperty("--header-h", `${el.offsetHeight}px`);92 set();93 const ro = new ResizeObserver(set);94 ro.observe(el);95 return () => ro.disconnect();96 }, []);9798 // Menu mobile : fermeture au changement de route.99 useEffect(() => {100 setMenuOpen(false);101 }, [location.pathname]);102103 // Menu ouvert : verrou du scroll d'arrière-plan, bulle KA Agent masquée104 // (z-index maximal chez elle) + fermeture à Escape (avec cleanup).105 useEffect(() => {106 if (!menuOpen) return;107 const prev = document.body.style.overflow;108 document.body.style.overflow = "hidden";109 document.body.classList.add("ka-hide-agent");110 const onKey = (e: KeyboardEvent) => {111 if (e.key === "Escape") setMenuOpen(false);112 };113 window.addEventListener("keydown", onKey);114 return () => {115 document.body.style.overflow = prev;116 document.body.classList.remove("ka-hide-agent");117 window.removeEventListener("keydown", onKey);118 };119 }, [menuOpen]);120121 return (122 <FavProvider>123 <header className={menuOpen ? "site menu-open" : "site"}>124 <div className="container">125 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>126 <GroupeKaBadge dark />127 <nav className="main">128 {NAV_LINKS.map((l) => (129 <NavLink130 key={l.to}131 to={l.to}132 end={l.end}133 className={({ isActive }) => (isActive ? "active" : "")}134 >135 {l.label}136 </NavLink>137 ))}138 </nav>139 <div className="auth-box">140 <KaAuth />141 </div>142 <button143 type="button"144 className="menu-btn"145 aria-label={menuOpen ? "Fermer le menu" : "Ouvrir le menu"}146 aria-expanded={menuOpen}147 onClick={() => setMenuOpen((o) => !o)}148 >149 <span />150 <span />151 <span />152 </button>153 </div>154 <div className={menuOpen ? "mobile-menu open" : "mobile-menu"}>155 {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du156 header est masqué à l'ouverture */}157 <button158 type="button"159 className="mm-close"160 aria-label="Fermer le menu"161 onClick={() => setMenuOpen(false)}162 tabIndex={menuOpen ? undefined : -1}163 >164 ✕165 </button>166 {NAV_LINKS.map((l) => (167 <NavLink168 key={l.to}169 to={l.to}170 end={l.end}171 className={({ isActive }) => (isActive ? "active" : "")}172 onClick={() => setMenuOpen(false)}173 tabIndex={menuOpen ? undefined : -1}174 >175 {l.label}176 </NavLink>177 ))}178 </div>179 </header>180 {menuOpen && <div className="mm-backdrop" onClick={() => setMenuOpen(false)} />}181 <Routes>182 <Route path="/" element={<Home />} />183 <Route path="/carte" element={184 <Suspense fallback={<div className="container empty">Chargement de la carte…</div>}>185 <MapPage />186 </Suspense>187 } />188 <Route path="/emploi/:uid" element={<JobPage />} />189 <Route path="/favoris" element={<FavorisPage />} />190 <Route path="/villes" element={<VillesPage />} />191 <Route path="/ville/:ville" element={<VillePage />} />192 <Route path="/sources" element={<Sources />} />193 <Route path="/stats" element={<Stats />} />194 <Route path="/contact" element={<Contact />} />195 <Route path="/employeurs" element={<Employeurs />} />196 <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} />197 </Routes>198 <div className="prefooter">199 <div className="container">200 <span className="baseline">201 <b>Job-Ka</b> — Tous les emplois des employeurs québécois, directement202 à la source. Chaque offre est traçable à sa source originale, zéro203 boîte noire.204 </span>205 <NavLink to="/">Offres</NavLink>206 <NavLink to="/favoris">Mes favoris</NavLink>207 <NavLink to="/villes">Villes</NavLink>208 <NavLink to="/carte">Carte</NavLink>209 <NavLink to="/sources">Employeurs</NavLink>210 <NavLink to="/stats">Stats</NavLink>211 <NavLink to="/employeurs">Publier une offre</NavLink>212 <NavLink to="/contact">Contact</NavLink>213 </div>214 </div>215 <KaFooter siteId="job-ka" localLegal={[{ label: "Documentation", href: "/doc/" }]} />216 </FavProvider>217 );218}219