SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
4.7 KB · 123 lines tsx
Raw Blame History
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