HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/pages/Home.tsx7 * Rôle : Accueil — hero éditorial avec recherche centrale et chiffres du8 * marché en direct, filtres en rail discret (desktop) + panneau bas9 * (mobile), puces de filtres actifs, liste d'offres paginée10 * Créé : 2026-08-17 Modifié : 2026-08-2511 * =============================================================================12 */13import { FormEvent, useEffect, useMemo, useState } from "react";14import { useSearchParams } from "react-router-dom";15import {16 Facets, fetchFacets, fetchJobs, fetchStats, Job, LANG_FR, MODE_FR,17 SENIORITY_FR, Stats, TYPE_FR,18} from "../api";19import JobCard from "../components/JobCard";2021const PAGE = 30;2223/** Paramètres d'URL des filtres (hors recherche q et tri). */24const FILTER_KEYS = ["ville", "region", "categorie", "mode", "type", "seniorite", "langue", "salaire"] as const;2526interface FilterValues {27 q: string;28 city: string;29 region: string;30 category: string;31 work_mode: string;32 employment_type: string;33 seniority: string;34 language: string;35 with_salary: 1 | undefined;36 sort: string;37}3839/** Les 7 sélecteurs de filtres — mêmes contrôles dans le rail (desktop)40 * et le panneau bas (mobile), seul le style CSS change selon le contexte. */41function FilterSelects({ facets, filters, setParam }: {42 facets: Facets | null;43 filters: FilterValues;44 setParam: (key: string, value: string) => void;45}) {46 return (47 <>48 <select49 className="f-select" data-on={filters.city ? "1" : "0"} aria-label="Ville"50 value={filters.city} onChange={(e) => setParam("ville", e.target.value)}51 >52 <option value="">Ville</option>53 {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)}54 </select>55 <select56 className="f-select" data-on={filters.region ? "1" : "0"} aria-label="Région"57 value={filters.region} onChange={(e) => setParam("region", e.target.value)}58 >59 <option value="">Région</option>60 {facets?.regions?.map((r) => (61 <option key={r.region} value={r.region}>{r.region} ({r.n})</option>62 ))}63 </select>64 <select65 className="f-select" data-on={filters.category ? "1" : "0"} aria-label="Catégorie"66 value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}67 >68 <option value="">Catégorie</option>69 {facets?.categories.map((c) => (70 <option key={c.category} value={c.category}>{c.category} ({c.n})</option>71 ))}72 </select>73 <select74 className="f-select" data-on={filters.work_mode ? "1" : "0"} aria-label="Mode de travail"75 value={filters.work_mode} onChange={(e) => setParam("mode", e.target.value)}76 >77 <option value="">Mode</option>78 {facets?.work_modes.map((m) => <option key={m} value={m}>{MODE_FR[m] ?? m}</option>)}79 </select>80 <select81 className="f-select" data-on={filters.employment_type ? "1" : "0"} aria-label="Type d'emploi"82 value={filters.employment_type} onChange={(e) => setParam("type", e.target.value)}83 >84 <option value="">Type</option>85 {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)}86 </select>87 <select88 className="f-select" data-on={filters.seniority ? "1" : "0"} aria-label="Séniorité"89 value={filters.seniority} onChange={(e) => setParam("seniorite", e.target.value)}90 >91 <option value="">Séniorité</option>92 {facets?.seniorities?.map((s) => (93 <option key={s.seniority} value={s.seniority}>94 {SENIORITY_FR[s.seniority] ?? s.seniority} ({s.n})95 </option>96 ))}97 </select>98 <select99 className="f-select" data-on={filters.language ? "1" : "0"} aria-label="Langue"100 value={filters.language} onChange={(e) => setParam("langue", e.target.value)}101 >102 <option value="">Langue</option>103 {facets?.languages?.map((l) => (104 <option key={l.language} value={l.language}>{LANG_FR[l.language] ?? l.language} ({l.n})</option>105 ))}106 </select>107 </>108 );109}110111export default function Home() {112 const [params, setParams] = useSearchParams();113 const [facets, setFacets] = useState<Facets | null>(null);114 const [stats, setStats] = useState<Stats | null>(null);115 const [jobs, setJobs] = useState<Job[]>([]);116 const [total, setTotal] = useState(0);117 const [loading, setLoading] = useState(true);118 const [error, setError] = useState("");119 const [sheetOpen, setSheetOpen] = useState(false);120121 const filters = useMemo<FilterValues>(() => ({122 q: params.get("q") ?? "",123 city: params.get("ville") ?? "",124 region: params.get("region") ?? "",125 category: params.get("categorie") ?? "",126 work_mode: params.get("mode") ?? "",127 employment_type: params.get("type") ?? "",128 seniority: params.get("seniorite") ?? "",129 language: params.get("langue") ?? "",130 with_salary: params.get("salaire") === "1" ? 1 : undefined,131 sort: params.get("tri") ?? "recent",132 }), [params]);133 const page = Math.max(1, parseInt(params.get("page") ?? "1", 10));134135 useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []);136 useEffect(() => { fetchStats().then(setStats).catch(() => {}); }, []);137138 useEffect(() => {139 setLoading(true);140 setError("");141 fetchJobs(filters, PAGE, (page - 1) * PAGE)142 .then((r) => { setJobs(r.jobs); setTotal(r.total); })143 .catch((e) => setError(String(e)))144 .finally(() => setLoading(false));145 }, [filters, page]);146147 // Panneau de filtres ouvert : verrou du scroll, bulle KA Agent masquée148 // (z-index maximal chez elle — on la cache plutôt que de la couvrir),149 // fermeture à Escape.150 useEffect(() => {151 if (!sheetOpen) return;152 const prev = document.body.style.overflow;153 document.body.style.overflow = "hidden";154 document.body.classList.add("ka-hide-agent");155 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };156 window.addEventListener("keydown", onKey);157 return () => {158 document.body.style.overflow = prev;159 document.body.classList.remove("ka-hide-agent");160 window.removeEventListener("keydown", onKey);161 };162 }, [sheetOpen]);163164 const setParam = (key: string, value: string) => {165 const next = new URLSearchParams(params);166 if (value) next.set(key, value); else next.delete(key);167 next.delete("page");168 setParams(next, { replace: true });169 };170171 const resetFilters = () => {172 const next = new URLSearchParams(params);173 for (const k of FILTER_KEYS) next.delete(k);174 next.delete("q");175 next.delete("page");176 setParams(next, { replace: true });177 };178179 const submitSearch = (e: FormEvent<HTMLFormElement>) => {180 e.preventDefault();181 const input = e.currentTarget.elements.namedItem("q") as HTMLInputElement | null;182 setParam("q", input?.value.trim() ?? "");183 };184185 const activeCount = FILTER_KEYS.filter((k) => params.get(k)).length;186187 // puces des filtres actifs (retrait en un geste)188 const chips: Array<{ key: string; label: string }> = [];189 if (filters.q) chips.push({ key: "q", label: `« ${filters.q} »` });190 if (filters.city) chips.push({ key: "ville", label: filters.city });191 if (filters.region) chips.push({ key: "region", label: filters.region });192 if (filters.category) chips.push({ key: "categorie", label: filters.category });193 if (filters.work_mode) chips.push({ key: "mode", label: MODE_FR[filters.work_mode] ?? filters.work_mode });194 if (filters.employment_type) chips.push({ key: "type", label: TYPE_FR[filters.employment_type] ?? filters.employment_type });195 if (filters.seniority) chips.push({ key: "seniorite", label: SENIORITY_FR[filters.seniority] ?? filters.seniority });196 if (filters.language) chips.push({ key: "langue", label: LANG_FR[filters.language] ?? filters.language });197 if (filters.with_salary === 1) chips.push({ key: "salaire", label: "Salaire affiché" });198199 const pages = Math.max(1, Math.ceil(total / PAGE));200 const pctSalary = stats && stats.total > 0201 ? Math.round((stats.with_salary / stats.total) * 100) : null;202203 return (204 <>205 <section className="hero">206 <div className="container">207 <span className="kicker"><i className="pulse" aria-hidden="true" />Marché de l'emploi en direct · zéro intermédiaire</span>208 <h1>L'emploi au Québec,<br /><em>à la source</em>.</h1>209 <p className="hero-lede">210 Chaque offre vient de la page carrière de l'employeur — traçable,211 datée, avec le salaire quand il est publié.212 </p>213 <form className="searchbar" onSubmit={submitSearch} role="search">214 <svg viewBox="0 0 24 24" aria-hidden="true">215 <circle cx="11" cy="11" r="7" />216 <line x1="16.5" y1="16.5" x2="21" y2="21" />217 </svg>218 <input219 key={filters.q}220 type="search"221 name="q"222 placeholder="Poste, employeur, mot-clé…"223 defaultValue={filters.q}224 autoComplete="off"225 />226 <button type="submit">Rechercher</button>227 </form>228 {stats && (229 <p className="hero-stats">230 <span><b>{stats.total.toLocaleString("fr-CA")}</b> offres en ligne</span>231 <span><b>{stats.employers.toLocaleString("fr-CA")}</b> employeurs</span>232 {pctSalary !== null && <span><b>{pctSalary} %</b> avec salaire affiché</span>}233 <span className="hs-sync">synchronisé jour et nuit</span>234 </p>235 )}236 </div>237 </section>238239 <div className="filter-zone">240 <div className="container">241 {/* rail desktop : sélecteurs discrets */}242 <div className="filter-rail" aria-label="Filtres">243 <span className="fr-label">Affiner</span>244 <FilterSelects facets={facets} filters={filters} setParam={setParam} />245 <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>246 <input247 type="checkbox"248 checked={filters.with_salary === 1}249 onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")}250 />251 Salaire affiché252 </label>253 </div>254255 {/* mobile : bouton panneau + bascule salaire */}256 <div className="filter-mobile">257 <button type="button" className="fm-btn" onClick={() => setSheetOpen(true)}>258 <svg viewBox="0 0 24 24" aria-hidden="true">259 <line x1="4" y1="7" x2="20" y2="7" /><circle cx="9" cy="7" r="2.4" />260 <line x1="4" y1="16" x2="20" y2="16" /><circle cx="15" cy="16" r="2.4" />261 </svg>262 Filtres{activeCount > 0 && <b>{activeCount}</b>}263 </button>264 <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>265 <input266 type="checkbox"267 checked={filters.with_salary === 1}268 onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")}269 />270 Salaire affiché271 </label>272 </div>273274 {chips.length > 0 && (275 <div className="filter-chips">276 {chips.map((c) => (277 <button278 key={c.key}279 type="button"280 className="fchip"281 onClick={() => setParam(c.key, "")}282 title="Retirer ce filtre"283 >284 {c.label}<span aria-hidden="true">✕</span>285 </button>286 ))}287 <button type="button" className="fchip-clear" onClick={resetFilters}>288 Tout effacer289 </button>290 </div>291 )}292 </div>293 </div>294295 <main className="container">296 <div className="results-head">297 <h2 className="results-count" aria-live="polite">298 {loading299 ? "Chargement…"300 : <>{total.toLocaleString("fr-CA")} offre{total > 1 ? "s" : ""}</>}301 </h2>302 <label className="sort-quiet">303 Trier :304 <select value={filters.sort} onChange={(e) => setParam("tri", e.target.value)}>305 <option value="recent">plus récentes</option>306 <option value="salary">meilleurs salaires</option>307 </select>308 </label>309 </div>310 {error && <div className="empty">Erreur de chargement : {error}</div>}311 <div className="job-list">312 {jobs.map((j) => <JobCard key={j.uid} job={j} />)}313 </div>314 {!loading && jobs.length === 0 && !error && (315 <div className="empty">316 <p>Aucune offre ne correspond à ces filtres.</p>317 {chips.length > 0 && (318 <button type="button" className="btn" onClick={resetFilters}>Tout effacer</button>319 )}320 </div>321 )}322 {pages > 1 && (323 <div className="pager">324 <button disabled={page <= 1} onClick={() => setParam("page", String(page - 1))}>← Précédent</button>325 <span className="pg-pos">{page} / {pages}</span>326 <button disabled={page >= pages} onClick={() => setParam("page", String(page + 1))}>Suivant →</button>327 </div>328 )}329 </main>330331 {/* panneau bas mobile — rendu en continu pour la transition de sortie */}332 {sheetOpen && <div className="ka-overlay" onClick={() => setSheetOpen(false)} />}333 <div334 className={sheetOpen ? "fsheet open" : "fsheet"}335 role="dialog"336 aria-modal="true"337 aria-label="Filtrer les offres"338 aria-hidden={!sheetOpen}339 >340 <div className="fs-head">341 <span className="klabel">Affiner la recherche</span>342 <button343 type="button"344 className="fs-close"345 aria-label="Fermer les filtres"346 onClick={() => setSheetOpen(false)}347 tabIndex={sheetOpen ? undefined : -1}348 >349 ✕350 </button>351 </div>352 <div className="fs-body">353 <FilterSelects facets={facets} filters={filters} setParam={setParam} />354 <label className="f-toggle" data-on={filters.with_salary === 1 ? "1" : "0"}>355 <input356 type="checkbox"357 checked={filters.with_salary === 1}358 onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")}359 tabIndex={sheetOpen ? undefined : -1}360 />361 Salaire affiché seulement362 </label>363 </div>364 <div className="fs-foot">365 <button366 type="button"367 className="btn btn-ghost"368 onClick={resetFilters}369 tabIndex={sheetOpen ? undefined : -1}370 >371 Réinitialiser372 </button>373 <button374 type="button"375 className="btn btn-accent"376 onClick={() => setSheetOpen(false)}377 tabIndex={sheetOpen ? undefined : -1}378 >379 Voir {loading ? "les" : total.toLocaleString("fr-CA")} offre{total > 1 ? "s" : ""}380 </button>381 </div>382 </div>383 </>384 );385}386