// ----------------------------------------------------------------------------- // Forma-Ka — Agrégateur de formations (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille // Filtres : recherche, type, domaine, mode, ville, prix, gratuit, langue, // niveau, source, tri — avec pastilles de filtres actifs. // ----------------------------------------------------------------------------- import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Facets, Formation, FormationFilters, Stats, fetchFacets, fetchFormations, fetchSources, fetchStats, registerSourceNames, sourceName, } from "../api"; import FormationCard from "../components/FormationCard"; const PRICE_STEPS = [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000]; const PAGE_SIZE = 12; /** Numéros de pages à afficher : 1 … autour de la courante … dernière. */ function pageNumbers(current: number, total: number): (number | "…")[] { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); const around = [current - 1, current, current + 1] .filter((p) => p > 1 && p < total); const out: (number | "…")[] = [1]; if (around[0] > 2) out.push("…"); out.push(...around); if (around[around.length - 1] < total - 1) out.push("…"); out.push(total); return out; } const SORTS: { key: string; label: string }[] = [ { key: "recent", label: "Nouveautés" }, { key: "start", label: "Prochaines dates" }, { key: "price", label: "Prix croissant" }, { key: "title", label: "A → Z" }, ]; export default function Home() { const [formations, setFormations] = useState(null); const [total, setTotal] = useState(0); const [facets, setFacets] = useState(null); const [stats, setStats] = useState(null); const [error, setError] = useState(null); // filtres (pré-remplis depuis l'URL, ex. /?training_type=Séminaire) const [params] = useSearchParams(); const [q, setQ] = useState(params.get("q") ?? ""); const [type, setType] = useState(params.get("training_type") ?? ""); const [category, setCategory] = useState(params.get("category") ?? ""); const [mode, setMode] = useState(params.get("mode") ?? ""); const [city, setCity] = useState(params.get("city") ?? ""); const [language, setLanguage] = useState(params.get("language") ?? ""); const [level, setLevel] = useState(params.get("level") ?? ""); const [source, setSource] = useState(params.get("source") ?? ""); const [free, setFree] = useState(params.get("free") ?? ""); const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); const [sort, setSort] = useState(params.get("sort") ?? "recent"); const [page, setPage] = useState(1); // feuille de filtres mobile (bottom sheet) + panneau avancé desktop const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); const activeFilters = [q, type, category, mode, city, language, level, source, free, priceMax].filter(Boolean).length; const advCount = [language, level, source, priceMax].filter(Boolean).length; const filters: FormationFilters = useMemo(() => ({ q, training_type: type, category, mode, city, language, level, source, free, price_max: priceMax, sort, }), [q, type, category, mode, city, language, level, source, free, priceMax, sort]); // tout changement de filtre/tri ramène à la première page useEffect(() => { setPage(1); }, [filters]); // re-rendu quand le registre des noms de sources est chargé const [, setSourcesReady] = useState(false); useEffect(() => { fetchSources() .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); }) .catch(() => {}); fetchFacets().then(setFacets).catch(() => {}); fetchStats().then(setStats).catch(() => {}); }, []); useEffect(() => { let cancelled = false; setFormations(null); setError(null); fetchFormations({ ...filters, limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), }) .then((r) => { if (!cancelled) { setFormations(r.formations); setTotal(r.total); } }) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; }, [filters, page]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const goto = (p: number) => { setPage(Math.min(Math.max(1, p), totalPages)); // remonter en haut des résultats, pas de la page document.querySelector(".results-head")?.scrollIntoView({ behavior: "smooth" }); }; const resetAll = () => { setQ(""); setType(""); setCategory(""); setMode(""); setCity(""); setLanguage(""); setLevel(""); setSource(""); setFree(""); setPriceMax(""); }; // pastilles « filtres actifs » — libellé + action de retrait const pills: { label: string; clear: () => void }[] = []; if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") }); if (type) pills.push({ label: type, clear: () => setType("") }); if (category) pills.push({ label: category, clear: () => setCategory("") }); if (mode) pills.push({ label: mode, clear: () => setMode("") }); if (city) pills.push({ label: city, clear: () => setCity("") }); if (free) pills.push({ label: "Gratuites", clear: () => setFree("") }); if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") }); if (language) pills.push({ label: language === "fr" ? "Français" : language === "en" ? "Anglais" : language, clear: () => setLanguage("") }); if (level) pills.push({ label: level, clear: () => setLevel("") }); if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); const types = facets?.types ?? []; return (
Agrégateur — toutes les formations du Québec

Toutes les formations,
un seul endroit.

Forma-Ka rassemble les cours en ligne, cours universitaires et collégiaux, séminaires, ateliers et certifications offerts au Québec — mis à jour automatiquement, avec tous les détails et un lien direct vers la formation originale.

Données synchronisées en continu {stats && ( <> {stats.total} formations actives {(stats.gratuites ?? 0) > 0 && ( {stats.gratuites} gratuites )} {(stats.en_ligne ?? 0) > 0 && ( {stats.en_ligne} en ligne )} {stats.sources} établissements )}
{sheetOpen && (
setSheetOpen(false)} aria-hidden="true" /> )}
Filtres
{/* — rangée principale : recherche, domaine, mode, ville, + filtres — */}
setQ(e.target.value)} aria-label="Recherche" /> {q && ( )}
{/* — panneau avancé : segments & sélecteurs — */} {(advOpen || sheetOpen) && (
{[["", "Peu importe"], ["fr", "Français"], ["en", "Anglais"]].map(([v, l]) => ( ))}
{(facets?.levels ?? []).map((l) => ( ))}
)}
{types.slice(0, 8).map(({ t, n }) => ( ))}
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

Formations offertes

{formations && {total} résultat{total > 1 ? "s" : ""}}
{SORTS.map((s) => ( ))}
{error && (
⚠️

Impossible de charger les formations

{error}

)} {!error && formations === null && (
{Array.from({ length: 8 }).map((_, i) => (
))}
)} {!error && formations !== null && formations.length === 0 && (
🔍

Aucune formation ne correspond

Essayez d'élargir vos critères {activeFilters > 0 && ( <> — ou )}.

)} {!error && formations !== null && formations.length > 0 && ( <>
{formations.map((f) => ( ))}
{totalPages > 1 && ( )}
Page {page} de {totalPages} — {total.toLocaleString("fr-CA")} formations
)} {/* Bouton flottant mobile — ouvre la feuille de filtres */}
); }