SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
4.8 KB · 162 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Ville.tsx: programmatic SEO pages — /cities, /city/:ville[/:type]5// The server renders the same content as HTML (rentka/seo.py); here, the6// interactive version with the app's listing cards.7// -----------------------------------------------------------------------------8import { useEffect, useState } from "react";9import { Link, useParams } from "react-router-dom";10import { Listing } from "../api";11import ListingCard from "../components/ListingCard";1213interface VilleRow {14  city: string;15  slug: string;16  n: number;17  avg_price: number | null;18}1920interface VilleData {21  city: string;22  slug: string;23  unit_type: string | null;24  n: number;25  avg: number | null;26  med: number | null;27  types: { unit_type: string; slug: string; n: number }[];28  listings: Listing[];29  neighbors: VilleRow[];30}3132const fmt = (p: number | null) =>33  p == null ? "—" : `$${Math.round(p).toLocaleString("en-CA")}`;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  }, []);5051  return (52    <div className="container">53      <span className="kicker">Directory — rentals by city</span>54      <h1>Rentals by city</h1>55      <p className="sub">56        Every Canadian city where Rent-Ka tracks rentals, with the number of57        active listings and the average rent.58      </p>59      {error && <div className="notice">⚠️ {error}</div>}60      {!villes && !error && <div className="notice">Loading…</div>}61      {villes && (62        <ul className="ville-list" style={{ marginTop: 24, lineHeight: 2 }}>63          {villes.map((v) => (64            <li key={v.slug}>65              <Link to={`/city/${v.slug}`}>{v.city}</Link>66              {" — "}67              {v.n} listing{v.n > 1 ? "s" : ""}68              {v.avg_price != null && <> · average rent {fmt(v.avg_price)}</>}69            </li>70          ))}71        </ul>72      )}73    </div>74  );75}7677export default function VillePage() {78  const { ville = "", type } = useParams();79  const [data, setData] = useState<VilleData | null>(null);80  const [error, setError] = useState<string | null>(null);8182  useEffect(() => {83    setData(null);84    setError(null);85    const qs = type ? `?type=${encodeURIComponent(type)}` : "";86    get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}${qs}`)87      .then(setData)88      .catch((e) =>89        setError(e.message === "404" ? "Unknown city or type." : String(e)));90    window.scrollTo(0, 0);91  }, [ville, type]);9293  if (error)94    return (95      <div className="container">96        <h1>Page not found</h1>97        <div className="notice">98          ⚠️ {error} <Link to="/cities">See all cities</Link>99        </div>100      </div>101    );102  if (!data)103    return (104      <div className="container">105        <div className="notice">Loading…</div>106      </div>107    );108109  const what = data.unit_type ? `${data.unit_type} rentals` : "Rentals";110  return (111    <div className="container">112      <span className="kicker">113        <Link to="/cities">Cities</Link> — {data.city}114      </span>115      <h1>116        {what} in {data.city}117      </h1>118      <p className="sub">119        {data.n} active listing{data.n > 1 ? "s" : ""}120        {data.avg != null && <> · average rent {fmt(data.avg)}</>}121        {data.med != null && <> · median {fmt(data.med)}</>}122      </p>123124      {!data.unit_type && data.types.length > 1 && (125        <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>126          {data.types.map((t) => (127            <Link key={t.slug} className="pill" to={`/city/${data.slug}/${t.slug}`}>128              {t.unit_type} ({t.n})129            </Link>130          ))}131        </p>132      )}133      {data.unit_type && (134        <p>135          <Link to={`/city/${data.slug}`}>136            ← All rentals in {data.city}137          </Link>138        </p>139      )}140141      <div className="grid" style={{ marginTop: 24 }}>142        {data.listings.map((l) => (143          <ListingCard key={l.uid} l={l} />144        ))}145      </div>146147      {data.neighbors.length > 0 && (148        <>149          <h2 style={{ marginTop: 40 }}>Other cities</h2>150          <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>151            {data.neighbors.map((v) => (152              <Link key={v.slug} className="pill" to={`/city/${v.slug}`}>153                {v.city} ({v.n})154              </Link>155            ))}156          </p>157        </>158      )}159    </div>160  );161}162