SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
17.8 KB · 456 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Forma-Ka — Agrégateur de formations (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille5//   Filtres : recherche, type, domaine, mode, ville, prix, gratuit, langue,6//   niveau, source, tri — avec pastilles de filtres actifs.7// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { useSearchParams } from "react-router-dom";10import {11  Facets, Formation, FormationFilters, Stats,12  fetchFacets, fetchFormations, fetchSources, fetchStats,13  registerSourceNames, sourceName,14} from "../api";15import FormationCard from "../components/FormationCard";1617const PRICE_STEPS = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000];18const PAGE_SIZE = 12;1920/** Numéros de pages à afficher : 1 … autour de la courante … dernière. */21function pageNumbers(current: number, total: number): (number | "…")[] {22  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);23  const around = [current - 1, current, current + 1]24    .filter((p) => p > 1 && p < total);25  const out: (number | "…")[] = [1];26  if (around[0] > 2) out.push("…");27  out.push(...around);28  if (around[around.length - 1] < total - 1) out.push("…");29  out.push(total);30  return out;31}3233const SORTS: { key: string; label: string }[] = [34  { key: "recent", label: "Nouveautés" },35  { key: "start", label: "Prochaines dates" },36  { key: "price", label: "Prix croissant" },37  { key: "title", label: "A → Z" },38];3940export default function Home() {41  const [formations, setFormations] = useState<Formation[] | null>(null);42  const [total, setTotal] = useState(0);43  const [facets, setFacets] = useState<Facets | null>(null);44  const [stats, setStats] = useState<Stats | null>(null);45  const [error, setError] = useState<string | null>(null);4647  // filtres (pré-remplis depuis l'URL, ex. /?training_type=Séminaire)48  const [params] = useSearchParams();49  const [q, setQ] = useState(params.get("q") ?? "");50  const [type, setType] = useState(params.get("training_type") ?? "");51  const [category, setCategory] = useState(params.get("category") ?? "");52  const [mode, setMode] = useState(params.get("mode") ?? "");53  const [city, setCity] = useState(params.get("city") ?? "");54  const [language, setLanguage] = useState(params.get("language") ?? "");55  const [level, setLevel] = useState(params.get("level") ?? "");56  const [source, setSource] = useState(params.get("source") ?? "");57  const [free, setFree] = useState(params.get("free") ?? "");58  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");59  const [sort, setSort] = useState(params.get("sort") ?? "recent");60  const [page, setPage] = useState(1);61  // feuille de filtres mobile (bottom sheet) + panneau avancé desktop62  const [sheetOpen, setSheetOpen] = useState(false);63  const [advOpen, setAdvOpen] = useState(false);64  const activeFilters = [q, type, category, mode, city, language, level,65    source, free, priceMax].filter(Boolean).length;66  const advCount = [language, level, source, priceMax].filter(Boolean).length;6768  const filters: FormationFilters = useMemo(() => ({69    q, training_type: type, category, mode, city, language, level,70    source, free, price_max: priceMax, sort,71  }), [q, type, category, mode, city, language, level, source, free, priceMax, sort]);7273  // tout changement de filtre/tri ramène à la première page74  useEffect(() => { setPage(1); }, [filters]);7576  // re-rendu quand le registre des noms de sources est chargé77  const [, setSourcesReady] = useState(false);78  useEffect(() => {79    fetchSources()80      .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); })81      .catch(() => {});82    fetchFacets().then(setFacets).catch(() => {});83    fetchStats().then(setStats).catch(() => {});84  }, []);8586  useEffect(() => {87    let cancelled = false;88    setFormations(null);89    setError(null);90    fetchFormations({91      ...filters,92      limit: String(PAGE_SIZE),93      offset: String((page - 1) * PAGE_SIZE),94    })95      .then((r) => {96        if (!cancelled) {97          setFormations(r.formations);98          setTotal(r.total);99        }100      })101      .catch((e) => !cancelled && setError(String(e)));102    return () => {103      cancelled = true;104    };105  }, [filters, page]);106107  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));108  const goto = (p: number) => {109    setPage(Math.min(Math.max(1, p), totalPages));110    // remonter en haut des résultats, pas de la page111    document.querySelector(".results-head")?.scrollIntoView({ behavior: "smooth" });112  };113114  const resetAll = () => {115    setQ(""); setType(""); setCategory(""); setMode(""); setCity("");116    setLanguage(""); setLevel(""); setSource(""); setFree(""); setPriceMax("");117  };118119  // pastilles « filtres actifs » — libellé + action de retrait120  const pills: { label: string; clear: () => void }[] = [];121  if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });122  if (type) pills.push({ label: type, clear: () => setType("") });123  if (category) pills.push({ label: category, clear: () => setCategory("") });124  if (mode) pills.push({ label: mode, clear: () => setMode("") });125  if (city) pills.push({ label: city, clear: () => setCity("") });126  if (free) pills.push({ label: "Gratuites", clear: () => setFree("") });127  if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });128  if (language) pills.push({ label: language === "fr" ? "Français" : language === "en" ? "Anglais" : language, clear: () => setLanguage("") });129  if (level) pills.push({ label: level, clear: () => setLevel("") });130  if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });131132  const types = facets?.types ?? [];133134  return (135    <div className="container">136      <section className="hero">137        <span className="kicker">Agrégateur — toutes les formations du Québec</span>138        <h1>139          Toutes les <span className="outline">formations</span>,<br />140          <span className="hl">un seul</span> endroit.141        </h1>142        <p className="lede">143          Forma-Ka rassemble les cours en ligne, cours universitaires et collégiaux,144          séminaires, ateliers et certifications offerts au Québec — mis à jour145          automatiquement, avec tous les détails et un lien direct vers la formation146          originale.147        </p>148        <div className="stat-row">149          <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>150          {stats && (151            <>152              <span className="stat-chip"><b>{stats.total}</b> formations actives</span>153              {(stats.gratuites ?? 0) > 0 && (154                <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span>155              )}156              {(stats.en_ligne ?? 0) > 0 && (157                <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span>158              )}159              <span className="stat-chip"><b>{stats.sources}</b> établissements</span>160            </>161          )}162        </div>163      </section>164165      {sheetOpen && (166        <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />167      )}168      <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">169        <div className="sheet-head">170          <span>Filtres</span>171          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">172173          </button>174        </div>175176        {/* — rangée principale : recherche, domaine, mode, ville, + filtres — */}177        <div className="f-primary">178          <div className="f-search">179            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">180              <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />181            </svg>182            <input183              id="f-q" placeholder="Sujet, sigle, compétence…" value={q}184              onChange={(e) => setQ(e.target.value)}185              aria-label="Recherche"186            />187            {q && (188              <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>189            )}190          </div>191          <label className="f-ctl">192            <span>Domaine</span>193            <select value={category} onChange={(e) => setCategory(e.target.value)}>194              <option value="">Tous</option>195              {(facets?.categories ?? []).map((c) => (196                <option key={c.category} value={c.category}>{c.category} ({c.n})</option>197              ))}198            </select>199          </label>200          <label className="f-ctl">201            <span>Mode</span>202            <select value={mode} onChange={(e) => setMode(e.target.value)}>203              <option value="">Tous</option>204              {(facets?.modes ?? ["en ligne", "présentiel", "hybride", "asynchrone"]).map((m) => (205                <option key={m} value={m}>{m}</option>206              ))}207            </select>208          </label>209          <label className="f-ctl">210            <span>Ville</span>211            <select value={city} onChange={(e) => setCity(e.target.value)}>212              <option value="">Toutes</option>213              {(facets?.cities ?? []).map((c) => (214                <option key={c} value={c}>{c}</option>215              ))}216            </select>217          </label>218          <button219            className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}220            onClick={() => setAdvOpen(!advOpen)}221            aria-expanded={advOpen}222          >223            Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}224          </button>225        </div>226227        {/* — panneau avancé : segments & sélecteurs — */}228        {(advOpen || sheetOpen) && (229          <div className="f-adv">230            <div className="f-group">231              <label>Prix</label>232              <div className="seg" role="group">233                <button className={free === "" && !priceMax ? "on" : ""}234                  onClick={() => { setFree(""); setPriceMax(""); }}>235                  Peu importe236                </button>237                <button className={free === "1" ? "on" : ""}238                  onClick={() => { setFree(free === "1" ? "" : "1"); setPriceMax(""); }}>239                  Gratuites240                </button>241              </div>242            </div>243            <div className="f-group">244              <label>Prix maximum</label>245              <select className="f-native" value={priceMax}246                onChange={(e) => { setPriceMax(e.target.value); setFree(""); }}>247                <option value="">Aucun</option>248                {PRICE_STEPS.map((p) => (249                  <option key={p} value={p}>≤ {p.toLocaleString("fr-CA")} $</option>250                ))}251              </select>252            </div>253            <div className="f-group">254              <label>Langue</label>255              <div className="seg" role="group">256                {[["", "Peu importe"], ["fr", "Français"], ["en", "Anglais"]].map(([v, l]) => (257                  <button key={v} className={language === v ? "on" : ""}258                    onClick={() => setLanguage(v)}>259                    {l}260                  </button>261                ))}262              </div>263            </div>264            <div className="f-group">265              <label>Niveau</label>266              <div className="seg" role="group">267                <button className={level === "" ? "on" : ""} onClick={() => setLevel("")}>268                  Peu importe269                </button>270                {(facets?.levels ?? []).map((l) => (271                  <button key={l} className={level === l ? "on" : ""}272                    onClick={() => setLevel(l)}>273                    {l}274                  </button>275                ))}276              </div>277            </div>278            <div className="f-group">279              <label>Établissement</label>280              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>281                <option value="">Tous</option>282                {(facets?.sources ?? []).map((s) => (283                  <option key={s.source} value={s.source}>284                    {sourceName(s.source)} ({s.n})285                  </option>286                ))}287              </select>288            </div>289            <div className="f-group f-group-end">290              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>291                Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}292              </button>293            </div>294          </div>295        )}296297        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>298          Voir les résultats {formations ? `(${total})` : ""}299        </button>300      </section>301302      <div className="chips" role="group" aria-label="Filtres rapides — type de formation">303        {types.slice(0, 8).map(({ t, n }) => (304          <button305            key={t}306            className={`chip ${type === t ? "on" : ""}`}307            onClick={() => setType(type === t ? "" : t)}308            title={`${n} formations`}309          >310            {t}311          </button>312        ))}313        <span className="chip-sep" aria-hidden="true" />314        <button315          className={`chip ${free === "1" ? "on" : ""}`}316          onClick={() => setFree(free === "1" ? "" : "1")}317        >318          🆓 Gratuites319        </button>320        <button321          className={`chip ${mode === "en ligne" ? "on" : ""}`}322          onClick={() => setMode(mode === "en ligne" ? "" : "en ligne")}323        >324          💻 En ligne325        </button>326      </div>327328      {pills.length > 0 && (329        <div className="pills" aria-label="Filtres actifs">330          {pills.map((p) => (331            <button key={p.label} className="pill" onClick={p.clear}332              aria-label={`Retirer le filtre ${p.label}`}>333              {p.label} <span className="pill-x">✕</span>334            </button>335          ))}336          <button className="pill pill-clear" onClick={resetAll}>337            Tout effacer338          </button>339        </div>340      )}341342      <div className="results-head">343        <h2>Formations offertes</h2>344        <div className="results-tools">345          {formations && <span>{total} résultat{total > 1 ? "s" : ""}</span>}346          <div className="view-toggle" role="tablist" aria-label="Tri">347            {SORTS.map((s) => (348              <button349                key={s.key}350                role="tab" aria-selected={sort === s.key}351                className={sort === s.key ? "on" : ""}352                onClick={() => setSort(s.key)}353              >354                {s.label}355              </button>356            ))}357          </div>358        </div>359      </div>360361      {error && (362        <div className="notice">363          <div className="big">⚠️</div>364          <h2>Impossible de charger les formations</h2>365          <p>{error}</p>366          <button className="btn btn-primary" onClick={() => window.location.reload()}>367            Réessayer368          </button>369        </div>370      )}371372      {!error && formations === null && (373        <div className="grid" aria-busy="true">374          {Array.from({ length: 8 }).map((_, i) => (375            <div className="skel" key={i}>376              <div className="sk-img" />377              <div className="sk-line" />378              <div className="sk-line short" />379            </div>380          ))}381        </div>382      )}383384      {!error && formations !== null && formations.length === 0 && (385        <div className="notice">386          <div className="big">🔍</div>387          <h2>Aucune formation ne correspond</h2>388          <p>389            Essayez d'élargir vos critères390            {activeFilters > 0 && (391              <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>392            )}.393          </p>394        </div>395      )}396397      {!error && formations !== null && formations.length > 0 && (398        <>399          <div className="grid">400            {formations.map((f) => (401              <FormationCard key={f.uid} f={f} />402            ))}403          </div>404405          {totalPages > 1 && (406            <nav className="pagination" aria-label="Pagination des résultats">407              <button408                className="page-btn page-prev"409                onClick={() => goto(page - 1)}410                disabled={page <= 1}411                aria-label="Page précédente"412              >413                ← Précédent414              </button>415              {pageNumbers(page, totalPages).map((p, i) =>416                p === "…" ? (417                  <span key={`e${i}`} className="page-ellipsis" aria-hidden="true">…</span>418                ) : (419                  <button420                    key={p}421                    className={`page-btn ${p === page ? "on" : ""}`}422                    onClick={() => goto(p)}423                    aria-current={p === page ? "page" : undefined}424                  >425                    {p}426                  </button>427                )428              )}429              <button430                className="page-btn page-next"431                onClick={() => goto(page + 1)}432                disabled={page >= totalPages}433                aria-label="Page suivante"434              >435                Suivant →436              </button>437            </nav>438          )}439          <div className="fine" style={{ textAlign: "center", marginTop: 8 }}>440            Page {page} de {totalPages} — {total.toLocaleString("fr-CA")} formations441          </div>442        </>443      )}444445      {/* Bouton flottant mobile — ouvre la feuille de filtres */}446      <button447        className="fab"448        onClick={() => setSheetOpen(true)}449        aria-label="Ouvrir les filtres"450      >451        ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}452      </button>453    </div>454  );455}456