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/Formation.tsx : fiche d'une formation — l'accent est mis sur les5// DÉTAILS (le prix est souvent absent, p. ex. cours universitaires) :6// description → objectifs → plan de cours → préalables → clientèle →7// infos pratiques. Colonne droite : synthèse, dates offertes, CTA source,8// formations similaires. CTA sticky en bas d'écran (mobile).9// -----------------------------------------------------------------------------10import { useEffect, useState } from "react";11import { Link, useParams } from "react-router-dom";12import {13 Formation, MODE_ICONS, fetchFormation, fetchSources, fmtDate, fmtHours,14 fmtPrice, registerSourceNames, sourceName,15} from "../api";1617const NBSP = " ";1819export default function FormationPage() {20 const { uid } = useParams<{ uid: string }>();21 const [f, setF] = useState<Formation | null>(null);22 const [error, setError] = useState<string | null>(null);23 // re-rendu quand le registre des noms de sources est chargé24 const [, setSourcesReady] = useState(false);2526 useEffect(() => {27 fetchSources()28 .then((r) => { registerSourceNames(r.sources); setSourcesReady(true); })29 .catch(() => {});30 if (!uid) return;31 fetchFormation(uid).then(setF).catch((e) => setError(String(e)));32 window.scrollTo(0, 0);33 }, [uid]);3435 if (error)36 return (37 <div className="notice container">38 <div className="big">⚠️</div>39 <h2>Formation introuvable</h2>40 <p>{error}</p>41 <Link className="btn btn-primary" to="/">Retour aux formations</Link>42 </div>43 );4445 if (!f)46 return (47 <div className="container detail">48 <div className="fiche" aria-busy="true">49 <div className="skel"><div className="sk-img" /></div>50 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>51 </div>52 </div>53 );5455 const updated = f.updated_at56 ? new Date(f.updated_at * 1000).toLocaleDateString("fr-CA", {57 day: "numeric", month: "long", year: "numeric" }) : null;5859 // chips clés — tout ce qui décrit la formation d'un coup d'œil60 const chips: string[] = [];61 if (f.training_type) chips.push(f.training_type);62 if (f.mode) chips.push(`${MODE_ICONS[f.mode] ?? ""} ${f.mode}`.trim());63 const dur = f.duration || fmtHours(f.duration_hours);64 if (dur) chips.push(dur);65 if (f.credits) chips.push(f.credits);66 if (f.level) chips.push(`Niveau ${f.level}`);67 if (f.language) chips.push(f.language === "fr" ? "Français" : f.language === "en" ? "Anglais" : f.language);68 if (f.city) chips.push(f.city);69 if (f.code) chips.push(f.code);7071 const sessions = (f.sessions ?? []).filter(Boolean);72 const img = f.images && f.images.length > 0 ? f.images[0] : null;7374 return (75 <div className="container detail">76 <nav className="crumbs" aria-label="Fil d'Ariane">77 <Link to="/">Formations</Link> ›78 {f.category && <span>{f.category}</span>} ›79 <span>{f.title}</span>80 </nav>8182 <div className="fiche">83 {/* ------- colonne gauche (desktop) : les DÉTAILS de la formation ---- */}84 <div className="f-col">85 {img && (86 <section className="f-bloc f-galerie" aria-label="Visuel">87 <div className="carousel">88 <div className="carousel-track">89 <img src={img} alt={f.title} loading="eager" />90 </div>91 </div>92 </section>93 )}9495 <section className="f-bloc f-desc" id="description">96 <h2>Description</h2>97 {f.description98 ? f.description.split(/\n{2,}/).map((p, i) => (99 <p key={i} style={{ color: "var(--ink-2)" }}>{p}</p>100 ))101 : <p className="fine">La source ne fournit pas de description pour cette formation.</p>}102 </section>103104 {f.objectives.length > 0 && (105 <section className="f-bloc" id="objectifs">106 <h2>Objectifs d'apprentissage</h2>107 <div className="amenity-row">108 {f.objectives.map((o) => (109 <span className="amenity confirmed" key={o}>✓ {o}</span>110 ))}111 </div>112 </section>113 )}114115 {f.program.length > 0 && (116 <section className="f-bloc" id="plan">117 <h2>Plan de la formation</h2>118 <ol style={{ color: "var(--ink-2)", lineHeight: 1.7, paddingLeft: 22, margin: 0 }}>119 {f.program.map((p, i) => (120 <li key={i}>{p}</li>121 ))}122 </ol>123 </section>124 )}125126 {(f.prerequisites || f.audience) && (127 <section className="f-bloc" id="admission">128 <h2>Admission</h2>129 <div className="kv">130 {f.prerequisites && (131 <div className="cell"><div className="k">Préalables</div><div className="v">{f.prerequisites}</div></div>132 )}133 {f.audience && (134 <div className="cell"><div className="k">Clientèle visée</div><div className="v">{f.audience}</div></div>135 )}136 </div>137 </section>138 )}139140 <section className="f-bloc f-pratique" id="pratique">141 <h2>Infos pratiques</h2>142 <div className="kv">143 <div className="cell"><div className="k">Établissement</div><div className="v">{sourceName(f.source)}</div></div>144 {f.code && (145 <div className="cell"><div className="k">Sigle</div><div className="v">{f.code}</div></div>146 )}147 {f.credential && (148 <div className="cell"><div className="k">Reconnaissance</div><div className="v">{f.credential}</div></div>149 )}150 {f.credits && (151 <div className="cell"><div className="k">Crédits / UEC</div><div className="v">{f.credits}</div></div>152 )}153 {dur && (154 <div className="cell"><div className="k">Durée</div><div className="v">{dur}</div></div>155 )}156 {f.instructor && (157 <div className="cell"><div className="k">Formateur·trice</div><div className="v">{f.instructor}</div></div>158 )}159 {f.price_label && (160 <div className="cell"><div className="k">Prix affiché</div><div className="v">{f.price_label}</div></div>161 )}162 {f.schedule_label && (163 <div className="cell"><div className="k">Horaire affiché</div><div className="v">{f.schedule_label}</div></div>164 )}165 {updated && (166 <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>167 )}168 </div>169 </section>170 </div>171172 {/* ------- colonne droite (desktop) : synthèse, dates, similaires ---- */}173 <div className="f-col">174 <section className="f-bloc f-hero">175 <div className="price">176 {f.price === 0 || f.is_free ? (177 "Gratuit"178 ) : f.price != null ? (179 <>{fmtPrice(f.price, f.price_label)}</>180 ) : (181 <span style={{ fontSize: ".72em" }}>Prix non affiché par la source</span>182 )}183 </div>184 <h1>{f.title}</h1>185 <div className="loc">186 {[f.category, f.city].filter(Boolean).join(" · ")}187 </div>188 <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">189 {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}190 </div>191 <nav className="ancres" aria-label="Sections de la fiche">192 <a href="#description">Description</a>193 {f.objectives.length > 0 && <a href="#objectifs">Objectifs</a>}194 {f.program.length > 0 && <a href="#plan">Plan</a>}195 <a href="#pratique">Infos pratiques</a>196 </nav>197 <a className="cta cta-desktop" href={f.url}198 target="_blank" rel="noopener noreferrer">199 Voir chez {sourceName(f.source)} ↗200 </a>201 </section>202203 {sessions.length > 0 && (204 <section className="f-bloc" id="dates">205 <h2>Dates offertes</h2>206 <div className="amenity-row">207 {sessions.map((s) => (208 <span className="amenity confirmed" key={s}>📅 {fmtDate(s) ?? s}</span>209 ))}210 </div>211 </section>212 )}213214 {f.tags.length > 0 && (215 <section className="f-bloc">216 <h2>Thèmes</h2>217 <div className="amenity-row">218 {f.tags.map((t) => (219 <span className="amenity unconfirmed" key={t}>{t}</span>220 ))}221 </div>222 </section>223 )}224225 {(f.similar ?? []).length > 0 && (226 <section className="f-bloc">227 <h2>Formations similaires</h2>228 <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 10 }}>229 {(f.similar ?? []).map((s) => (230 <li key={s.uid}>231 <Link to={`/formation/${encodeURIComponent(s.uid)}`}232 style={{ color: "var(--ink)", fontWeight: 600 }}>233 {s.title}234 </Link>235 <div className="fine">236 {[s.training_type, s.mode, s.duration,237 s.price != null ? fmtPrice(s.price) : "",238 sourceName(s.source)].filter(Boolean).join(" · ")}239 </div>240 </li>241 ))}242 </ul>243 </section>244 )}245 </div>246 </div>247248 <div className="fine f-foot">249 {updated && <>Dernière synchronisation : {updated}. </>}250 Les prix, dates et détails sont ceux affichés par la source — chaque fiche251 renvoie à la page originale de la formation.252 </div>253254 {/* CTA sticky mobile — toujours visible */}255 <div className="cta-sticky">256 <span className="cta-sticky-prix">257 {f.price === 0 || f.is_free ? "Gratuit"258 : f.price != null ? fmtPrice(f.price, f.price_label)259 : (f.credits || "Détails")}260 </span>261 <a className="cta" href={f.url} target="_blank" rel="noopener noreferrer">262 Voir chez {sourceName(f.source)} ↗263 </a>264 </div>265 </div>266 );267}268