SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%
4.6 KB · 120 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// pages/Category.tsx : pages programmatiques /louer/{ville}[/{type}],6//   /acheter/{ville}[/{type}] et /{tx}/type/{type} — mêmes URL que le rendu7//   SEO serveur (toitka/seo.py), le SPA prend le relais au montage.8// -----------------------------------------------------------------------------9import { useEffect, useState } from "react";10import { Link, useParams, useSearchParams } from "react-router-dom";11import { Listing, Tx, fetchListings, fmtPrice, resolveSeo, setDocTitle } from "../api";12import ListingCard from "../components/ListingCard";13import { Ico } from "../components/Icons";1415const PAGE_SIZE = 48;   // aligné sur toitka/seo.py1617export default function CategoryPage({ tx }: { tx: Tx }) {18  const { ville, type } = useParams<{ ville?: string; type?: string }>();19  const [params, setParams] = useSearchParams();20  const page = Math.max(1, Number(params.get("page")) || 1);21  const louer = tx === "louer";2223  const [labels, setLabels] = useState<{ city?: string; type?: string } | null>(null);24  const [listings, setListings] = useState<Listing[] | null>(null);25  const [total, setTotal] = useState(0);26  const [error, setError] = useState<string | null>(null);2728  useEffect(() => {29    setLabels(null); setListings(null); setError(null);30    resolveSeo(tx, ville, type)31      .then(setLabels)32      .catch(() => setError("Page introuvable."));33  }, [tx, ville, type]);3435  useEffect(() => {36    if (!labels) return;37    setListings(null);38    fetchListings(39      { tx, city: labels.city, type: labels.type, sort: "price_asc" },40      PAGE_SIZE, (page - 1) * PAGE_SIZE)41      .then((r) => { setListings(r.listings); setTotal(r.total); })42      .catch((e) => setError(String(e)));43    window.scrollTo(0, 0);44  }, [tx, labels, page]);4546  const verbe = louer ? "à louer" : "à vendre";47  const noun = louer ? "Logements" : "Propriétés";48  const h1 = labels49    ? labels.city && labels.type50      ? `${labels.type} ${verbe} à ${labels.city}`51      : labels.city52        ? `${noun} ${verbe} à ${labels.city}`53        : `${labels.type} ${verbe} au Québec`54    : "";55  useEffect(() => { if (h1) setDocTitle(h1); }, [h1]);5657  if (error)58    return (59      <div className="notice container">60        <div className="big"><Ico name="alert" size={44} /></div>61        <h2>Page introuvable</h2>62        <p>{error}</p>63        <Link className="btn btn-primary" to="/">Toutes les annonces</Link>64      </div>65    );6667  const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));68  const refine = new URLSearchParams({ tx });69  if (labels?.city) refine.set("city", labels.city);70  if (labels?.type) refine.set("type", labels.type);7172  return (73    <div className="container">74      <nav className="crumbs" aria-label="Fil d'Ariane" style={{ margin: "14px 0 4px" }}>75        <Link to={`/?tx=${tx}`}>Accueil</Link>76        {labels?.city && ville && <> › <Link to={`/${tx}/${ville}`}>{verbe.charAt(0).toUpperCase() + verbe.slice(1)} à {labels.city}</Link></>}77        {labels?.type && <> › <span>{labels.type}</span></>}78      </nav>79      <h1>{h1 || "Chargement…"}</h1>80      {listings && (81        <p style={{ opacity: 0.75, margin: "6px 0 14px" }}>82          <b>{total.toLocaleString("fr-CA")}</b> annonces83          {listings.length > 0 && listings[0].price != null && (84            <> · à partir de <b>{fmtPrice(listings[0].price, tx)}</b></>85          )}86          {" · "}87          <Link to={`/?${refine}`}>Affiner la recherche (carte, filtres)</Link>88        </p>89      )}90      {!listings && !error && (91        <div className="grid" aria-busy="true">92          {Array.from({ length: 8 }).map((_, i) => (93            <div key={i} className="card skel"><div className="sk-img" /><div className="sk-line" /></div>94          ))}95        </div>96      )}97      {listings && (98        <div className="grid">99          {listings.map((l) => <ListingCard key={l.uid} l={l} />)}100        </div>101      )}102      {pages > 1 && (103        <nav className="pager" aria-label="Pagination" style={{ margin: "18px 0" }}>104          {page > 1 && (105            <button className="btn" onClick={() => setParams(page === 2 ? {} : { page: String(page - 1) })}>106              ← Précédente107            </button>108          )}109          <span style={{ margin: "0 10px" }}>Page {page} de {pages}</span>110          {page < pages && (111            <button className="btn" onClick={() => setParams({ page: String(page + 1) })}>112              Suivante →113            </button>114          )}115        </nav>116      )}117    </div>118  );119}120