spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
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">172 ✕173 </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