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%
14.2 KB · 389 lines tsx
Raw Blame History
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