HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/pages/Ville.tsx7 * Rôle : Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.8 * Le serveur rend le même contenu en HTML (jobka/seo.py) ; ici, la9 * version interactive avec les cartes d'offres de l'app.10 * Créé : 2026-08-23 Modifié : 2026-08-2311 * =============================================================================12 */13import { useEffect, useState } from "react";14import { Link, useParams } from "react-router-dom";15import { fetchJobs, Job } from "../api";16import JobCard from "../components/JobCard";1718interface VilleRow {19 city: string;20 slug: string;21 n: number;22 employers: number;23 with_salary: number;24}2526interface VilleData {27 city: string;28 slug: string;29 n: number;30 employers: number;31 with_salary: number;32 neighbors: VilleRow[];33}3435async function get<T>(path: string): Promise<T> {36 const res = await fetch(path);37 if (!res.ok) throw new Error(`${res.status}`);38 return res.json();39}4041export function VillesPage() {42 const [villes, setVilles] = useState<VilleRow[] | null>(null);43 const [error, setError] = useState<string | null>(null);4445 useEffect(() => {46 get<{ villes: VilleRow[] }>("/api/seo/villes")47 .then((r) => setVilles(r.villes))48 .catch((e) => setError(String(e)));49 window.scrollTo(0, 0);50 }, []);5152 return (53 <div className="container">54 <h1>Offres d'emploi par ville</h1>55 <p className="sub">56 Toutes les villes du Québec où Job-Ka recense des offres d'emploi,57 avec le nombre de postes ouverts et d'employeurs qui recrutent.58 </p>59 {error && <div className="empty">⚠️ {error}</div>}60 {!villes && !error && <div className="empty">Chargement…</div>}61 {villes && (62 <ul style={{ marginTop: 24, lineHeight: 2, paddingLeft: 20 }}>63 {villes.map((v) => (64 <li key={v.slug}>65 <Link to={`/ville/${v.slug}`}>{v.city}</Link>66 {" — "}67 {v.n} offre{v.n > 1 ? "s" : ""} · {v.employers} employeur68 {v.employers > 1 ? "s" : ""}69 </li>70 ))}71 </ul>72 )}73 </div>74 );75}7677export default function VillePage() {78 const { ville = "" } = useParams();79 const [data, setData] = useState<VilleData | null>(null);80 const [jobs, setJobs] = useState<Job[] | null>(null);81 const [error, setError] = useState<string | null>(null);8283 useEffect(() => {84 setData(null);85 setJobs(null);86 setError(null);87 get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}`)88 .then((d) => {89 setData(d);90 return fetchJobs({ city: d.city }, 60).then((r) => setJobs(r.jobs));91 })92 .catch((e) =>93 setError(e.message === "404" ? "Ville inconnue." : String(e)));94 window.scrollTo(0, 0);95 }, [ville]);9697 if (error)98 return (99 <div className="container">100 <h1>Page introuvable</h1>101 <div className="empty">102 ⚠️ {error} <Link to="/villes">Voir toutes les villes</Link>103 </div>104 </div>105 );106 if (!data)107 return (108 <div className="container">109 <div className="empty">Chargement…</div>110 </div>111 );112113 return (114 <div className="container">115 <p style={{ margin: "0 0 4px" }}>116 <Link to="/villes">Villes</Link> — {data.city}117 </p>118 <h1>Offres d'emploi à {data.city}</h1>119 <p className="sub">120 {data.n} poste{data.n > 1 ? "s" : ""} chez {data.employers} employeur121 {data.employers > 1 ? "s" : ""}122 {data.with_salary > 0 && (123 <> · {data.with_salary} avec salaire affiché</>124 )}125 </p>126127 <div className="job-list" style={{ marginTop: 24 }}>128 {!jobs && <div className="empty">Chargement des offres…</div>}129 {jobs && jobs.map((j) => <JobCard key={j.uid} job={j} />)}130 </div>131132 {data.neighbors.length > 0 && (133 <>134 <h2 style={{ marginTop: 40 }}>Autres villes</h2>135 <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>136 {data.neighbors.map((v) => (137 <Link key={v.slug} to={`/ville/${v.slug}`}>138 {v.city} ({v.n})139 </Link>140 ))}141 </p>142 </>143 )}144 <p style={{ marginTop: 24 }}>145 <Link to="/">← Toutes les offres d'emploi au Québec</Link>146 </p>147 </div>148 );149}150