SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
10.7 KB · 268 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/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