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// App.tsx: global layout (header + live ticker + ink footer) and routing5// -----------------------------------------------------------------------------6import { useEffect, useState } from "react";7import { createPortal } from "react-dom";8import { NavLink, Route, Routes, useLocation } from "react-router-dom";9import {10 fetchAuthConfig, fetchFacets, fetchSources, fetchStats,11 logout, registerSourceNames, sourceName, PROVINCE_NAMES,12} from "./api";13import { AccountProvider, useAccount } from "./account";14import CookieConsent from "./components/CookieConsent";15import {16 IcoChart, IcoCompass, IcoDoc, IcoFolder, IcoHeart, IcoHouse, IcoLock,17 IcoMap, IcoSearch, IcoUser,18} from "./components/Icons";19import { LogoIcon } from "./components/Logo";20import GroupeKaBadge from "./ka/GroupeKaBadge";21import KaFooter from "./ka/KaFooter";22import ContactPage from "./pages/Contact";23import Home from "./pages/Home";24import ListingPage from "./pages/Listing";25import BotPage from "./pages/Bot";26import JusteValeurPage from "./pages/JusteValeur";27import KaScoresPage from "./pages/KaScores";28import PasserellePage from "./pages/Passerelle";29import PrivacyPage from "./pages/Privacy";30import ProfilePage from "./pages/Profile";31import PublicProfilePage from "./pages/PublicProfile";32import BienvenuePage from "./pages/Bienvenue";33import FavorisPage from "./pages/Favoris";34import GestionPage from "./pages/Gestion";35import GestionPublicPage from "./pages/GestionPublic";36import TermsPage from "./pages/Terms";37import SourcesPage from "./pages/Sources";38import StatsPage from "./pages/Stats";39import VillePage, { VillesPage } from "./pages/Ville";4041function Ticker() {42 const [items, setItems] = useState<string[]>([]);4344 useEffect(() => {45 Promise.all([fetchStats(), fetchFacets(), fetchSources()])46 .then(([stats, facets, src]) => {47 registerSourceNames(src.sources);48 const parts: string[] = [`${stats.total} active rentals`];49 const provs = stats.provinces || {};50 for (const [code, n] of Object.entries(provs)) {51 if ((n as number) > 0) parts.push(`${PROVINCE_NAMES[code] || code} ${n}`);52 }53 if (stats.avg_price != null)54 parts.push(`Average rent $${Math.round(stats.avg_price).toLocaleString("en-CA")}`);55 for (const s of facets.sources.slice(0, 10))56 parts.push(`${sourceName(s.source)} · ${s.n}`);57 parts.push("Updated automatically");58 setItems(parts);59 })60 .catch(() => setItems(["Rent-Ka — rentals across Canada"]));61 }, []);6263 if (items.length === 0) return null;64 // content doubled for a continuous scroll loop65 return (66 <div className="ticker" aria-hidden="true">67 <div className="ticker-track">68 {[...items, ...items].map((t, i) => (69 <span key={i}>{t}</span>70 ))}71 </div>72 </div>73 );74}7576const NAV_LINKS = [77 { to: "/", label: "Rentals", icon: <IcoHouse size={17} />, end: true },78 { to: "/?view=map", label: "Map", icon: <IcoMap size={17} />, end: false, force: true },79 { to: "/cities", label: "Cities", icon: <IcoCompass size={17} />, end: false },80 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },81 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },82 { to: "/contact", label: "Contact", icon: <IcoUser size={17} />, end: false },83 { to: "/terms", label: "Terms", icon: <IcoDoc size={17} />, end: false },84 { to: "/privacy", label: "Privacy", icon: <IcoLock size={17} />, end: false },85];8687/** "Sign in" button or avatar + account menu (Google sign-in) */88function AccountMenu() {89 const [enabled, setEnabled] = useState(false);90 const { me, refresh } = useAccount();91 const [menuOpen, setMenuOpen] = useState(false);9293 useEffect(() => {94 fetchAuthConfig().then((c) => setEnabled(c.ka || c.google)).catch(() => {});95 }, []);9697 // menu open: close on Escape (outside tap goes through the backdrop)98 useEffect(() => {99 if (!menuOpen) return;100 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };101 window.addEventListener("keydown", onKey);102 return () => window.removeEventListener("keydown", onKey);103 }, [menuOpen]);104105 if (!enabled) return null;106 if (!me) {107 return (108 <a className="login-btn" href="/api/auth/ka/login">109 <span110 aria-hidden="true"111 style={{112 display: "inline-flex",113 alignItems: "center",114 justifyContent: "center",115 width: 18,116 height: 16,117 borderRadius: 5,118 background: "var(--accent, #2456e6)",119 color: "#ffffff",120 font: "700 9px/1 'Inter', system-ui, sans-serif",121 letterSpacing: "-0.02em",122 }}123 >124 KA125 </span>126 Sign in127 </a>128 );129 }130 return (131 <div className="account">132 <button133 className="account-btn"134 onClick={() => setMenuOpen(!menuOpen)}135 aria-expanded={menuOpen}136 aria-label={`Account: ${me.name || me.email}`}137 >138 {me.picture139 ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />140 : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}141 </button>142 {menuOpen && (143 <>144 {/* Backdrop rendered at the root (createPortal): the header's145 backdrop-filter re-parented position:fixed → veil clipped to header. */}146 {createPortal(147 <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />,148 document.body,149 )}150 <div className="account-menu" role="menu">151 <div className="account-id">152 <b>{me.name || "My account"}</b>153 <span>{me.email}</span>154 {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}155 </div>156 <NavLink157 to="/profile"158 role="menuitem"159 className="account-link"160 onClick={() => setMenuOpen(false)}161 >162 My profile163 </NavLink>164 {me.role === "locataire" && (165 <NavLink166 to="/favorites"167 role="menuitem"168 className="account-link"169 onClick={() => setMenuOpen(false)}170 >171 My saved rentals172 </NavLink>173 )}174 {me.role === "gestionnaire" && (175 <NavLink176 to="/manage"177 role="menuitem"178 className="account-link"179 onClick={() => setMenuOpen(false)}180 >181 My manager page182 </NavLink>183 )}184 {!me.role && (185 <NavLink186 to="/welcome"187 role="menuitem"188 className="account-link"189 onClick={() => setMenuOpen(false)}190 >191 Choose my profile192 </NavLink>193 )}194 <button195 role="menuitem"196 onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}197 >198 Sign out199 </button>200 </div>201 </>202 )}203 </div>204 );205}206207function Header() {208 const [open, setOpen] = useState(false);209 const location = useLocation();210211 // close the menu on every navigation + lock scrolling underneath212 useEffect(() => { setOpen(false); }, [location]);213 useEffect(() => {214 document.documentElement.classList.toggle("ka-scroll-lock", open);215 return () => { document.documentElement.classList.remove("ka-scroll-lock"); };216 }, [open]);217218 return (219 <>220 <header className="header">221 <div className="container header-inner">222 <NavLink to="/" className="brand" aria-label="Rent-Ka — home">223 <LogoIcon size={30} />224 Rent<span className="ka">·Ka</span>225 <span className="brand-tag">Apartments and homes for rent across Canada.</span>226 </NavLink>227 <GroupeKaBadge />228 <nav className="nav" aria-label="Main navigation">229 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>230 Rentals231 </NavLink>232 <NavLink to="/cities" className={({ isActive }) => (isActive ? "active" : "")}>233 Cities234 </NavLink>235 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>236 Stats237 </NavLink>238 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>239 Sources240 </NavLink>241 </nav>242 <AccountMenu />243 <button244 className={`menu-btn ${open ? "open" : ""}`}245 aria-expanded={open}246 aria-label={open ? "Close menu" : "Open menu"}247 onClick={() => setOpen(!open)}248 >249 <span /><span /><span />250 </button>251 </div>252253 {/* mobile drawer menu */}254 <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">255 {/* fixed ✕ of the panel: visible regardless of scroll; the header burger is hidden while open */}256 <button type="button" className="mm-close" aria-label="Close menu"257 onClick={() => setOpen(false)}>✕</button>258 {NAV_LINKS.map((l, i) => (259 <NavLink260 key={l.to}261 to={l.to}262 end={l.end}263 style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}264 className={({ isActive }) =>265 `mm-link ${isActive && !l.force ? "active" : ""}`}266 onClick={() => setOpen(false)}267 >268 <span className="mm-ico" aria-hidden="true">{l.icon}</span>269 {l.label}270 <span className="mm-arrow" aria-hidden="true">→</span>271 </NavLink>272 ))}273 <div className="mm-badge">274 <GroupeKaBadge />275 </div>276 <div className="mm-foot">277 Independent aggregator — updated automatically; every listing links278 back to the original ad.279 </div>280 </div>281 </header>282 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}283 <Ticker />284 </>285 );286}287288/** Footer: local navigation row + shared Groupe KA footer. */289function Footer() {290 return (291 <>292 <div className="prefooter">293 <div className="container">294 <span className="baseline">295 <b>Rent-Ka</b> — Apartments and homes for rent across Canada.296 Independent aggregator: every listing links back to the original ad.297 </span>298 <NavLink to="/">Rentals</NavLink>299 <NavLink to="/cities">Cities</NavLink>300 <NavLink to="/stats">Stats</NavLink>301 <NavLink to="/sources">Sources</NavLink>302 <NavLink to="/contact">Contact</NavLink>303 <button304 className="flink"305 onClick={() => window.dispatchEvent(new Event("rentka:openConsent"))}306 >307 Manage cookies308 </button>309 </div>310 </div>311 <KaFooter312 siteId="rent-ka"313 localLegal={[314 { label: "Documentation", href: "/doc/" },315 { label: "Terms of use (Rent-Ka)", href: "/terms" },316 { label: "Privacy (Rent-Ka)", href: "/privacy" },317 ]}318 />319 </>320 );321}322323/** Bottom navigation bar (mobile) — active icon in accent. */324function MobileTabBar() {325 const location = useLocation();326 const isMap = new URLSearchParams(location.search).get("view") === "map";327 const tabs = [328 { to: "/", label: "Search", icon: <IcoSearch size={20} />, on: location.pathname === "/" && !isMap },329 { to: "/?view=map", label: "Map", icon: <IcoMap size={20} />, on: location.pathname === "/" && isMap },330 { to: "/favorites", label: "Saved", icon: <IcoHeart size={20} />, on: location.pathname.startsWith("/favorites") },331 { to: "/profile", label: "Profile", icon: <IcoUser size={20} />, on: location.pathname.startsWith("/profile") },332 ];333 return (334 <nav className="tabbar" aria-label="Mobile navigation">335 {tabs.map((t) => (336 <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>337 {t.icon}338 {t.label}339 </NavLink>340 ))}341 </nav>342 );343}344345export default function App() {346 return (347 <AccountProvider>348 <Header />349 <main>350 <Routes>351 <Route path="/" element={<Home />} />352 <Route path="/listing/:uid" element={<ListingPage />} />353 <Route path="/cities" element={<VillesPage />} />354 <Route path="/city/:ville" element={<VillePage />} />355 <Route path="/city/:ville/:type" element={<VillePage />} />356 <Route path="/stats" element={<StatsPage />} />357 <Route path="/sources" element={<SourcesPage />} />358 <Route path="/contact" element={<ContactPage />} />359 <Route path="/privacy" element={<PrivacyPage />} />360 <Route path="/terms" element={<TermsPage />} />361 <Route path="/profile" element={<ProfilePage />} />362 <Route path="/u/:kaId" element={<PublicProfilePage />} />363 <Route path="/welcome" element={<BienvenuePage />} />364 <Route path="/favorites" element={<FavorisPage />} />365 <Route path="/manage" element={<GestionPage />} />366 <Route path="/g/:sourceId" element={<GestionPublicPage />} />367 <Route path="/gateway/:uid" element={<PasserellePage />} />368 <Route path="/bot" element={<BotPage />} />369 <Route path="/fair-value" element={<JusteValeurPage />} />370 <Route path="/ka-scores" element={<KaScoresPage />} />371 <Route372 path="*"373 element={374 <div className="notice container">375 <div className="big"><IcoCompass size={40} /></div>376 <h2>Page not found</h2>377 <p>The requested link does not exist.</p>378 </div>379 }380 />381 </Routes>382 </main>383 <Footer />384 <MobileTabBar />385 <CookieConsent />386 </AccountProvider>387 );388}389