SPB Git forge

spb/job-ka

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