Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Category.tsx : pages programmatiques /a-vendre/{ville}[/{type}] et5// /type/{type} — mêmes URL que le rendu SEO serveur (immoka/seo.py), le SPA6// prend le relais au montage. Résolution slug → valeurs via /api/seo/resolve.7// -----------------------------------------------------------------------------8import { useEffect, useState } from "react";9import { Link, useParams, useSearchParams } from "react-router-dom";10import {11 Listing, fetchListings, fetchSources, fmtPrice, registerSourceNames,12 resolveSeo, setDocTitle,13} from "../api";14import ListingCard from "../components/ListingCard";15import { Ico } from "../components/Icons";1617const PAGE_SIZE = 48; // aligné sur immoka/seo.py1819export default function CategoryPage() {20 const { ville, type } = useParams<{ ville?: string; type?: string }>();21 const [params, setParams] = useSearchParams();22 const page = Math.max(1, Number(params.get("page")) || 1);2324 const [labels, setLabels] = useState<{ city?: string; property_type?: string } | null>(null);25 const [listings, setListings] = useState<Listing[] | null>(null);26 const [total, setTotal] = useState(0);27 const [error, setError] = useState<string | null>(null);2829 useEffect(() => {30 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});31 }, []);3233 useEffect(() => {34 setLabels(null); setListings(null); setError(null);35 resolveSeo(ville, type)36 .then(setLabels)37 .catch(() => setError("Page introuvable."));38 }, [ville, type]);3940 useEffect(() => {41 if (!labels) return;42 setListings(null);43 fetchListings(44 { city: labels.city, property_type: labels.property_type, sort: "price_asc" },45 PAGE_SIZE, (page - 1) * PAGE_SIZE)46 .then((r) => { setListings(r.listings); setTotal(r.total); })47 .catch((e) => setError(String(e)));48 window.scrollTo(0, 0);49 }, [labels, page]);5051 const h1 = labels52 ? labels.city && labels.property_type53 ? `${labels.property_type} à vendre à ${labels.city}`54 : labels.city55 ? `Propriétés à vendre à ${labels.city}`56 : `${labels.property_type} à vendre au Québec`57 : "";58 useEffect(() => { if (h1) setDocTitle(h1); }, [h1]);5960 if (error)61 return (62 <div className="notice container">63 <div className="big"><Ico name="alert" size={44} /></div>64 <h2>Page introuvable</h2>65 <p>{error}</p>66 <Link className="btn btn-primary" to="/">Toutes les propriétés</Link>67 </div>68 );6970 const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));71 const refine = new URLSearchParams();72 if (labels?.city) refine.set("city", labels.city);73 if (labels?.property_type) refine.set("property_type", labels.property_type);7475 return (76 <div className="container">77 <nav className="crumbs" aria-label="Fil d'Ariane" style={{ margin: "14px 0 4px" }}>78 <Link to="/">Accueil</Link>79 {labels?.city && ville && <> › <Link to={`/a-vendre/${ville}`}>À vendre à {labels.city}</Link></>}80 {labels?.property_type && <> › <span>{labels.property_type}</span></>}81 </nav>82 <h1>{h1 || "Chargement…"}</h1>83 {listings && (84 <p style={{ opacity: 0.75, margin: "6px 0 14px" }}>85 <b>{total.toLocaleString("fr-CA")}</b> annonces86 {listings.length > 0 && listings[0].price != null && (87 <> · à partir de <b>{fmtPrice(listings[0].price)}</b></>88 )}89 {" · "}90 <Link to={`/?${refine}`}>Affiner la recherche (carte, filtres)</Link>91 </p>92 )}93 {!listings && !error && (94 <div className="grid" aria-busy="true">95 {Array.from({ length: 8 }).map((_, i) => (96 <div key={i} className="card skel"><div className="sk-img" /><div className="sk-line" /></div>97 ))}98 </div>99 )}100 {listings && (101 <div className="grid">102 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}103 </div>104 )}105 {pages > 1 && (106 <nav className="pager" aria-label="Pagination" style={{ margin: "18px 0" }}>107 {page > 1 && (108 <button className="btn" onClick={() => setParams(page === 2 ? {} : { page: String(page - 1) })}>109 ← Précédente110 </button>111 )}112 <span style={{ margin: "0 10px" }}>Page {page} de {pages}</span>113 {page < pages && (114 <button className="btn" onClick={() => setParams({ page: String(page + 1) })}>115 Suivante →116 </button>117 )}118 </nav>119 )}120 </div>121 );122}123