Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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