SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
7.9 KB · 200 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : global layout (header + live ticker + Groupe KA footer) + routing.5// -----------------------------------------------------------------------------6import { Ico } from "./components/Icons";7import { useEffect, useState } from "react";8import { NavLink, Route, Routes, useLocation } from "react-router-dom";9import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";10import GroupeKaBadge from "./ka/GroupeKaBadge";11import KaFooter from "./ka/KaFooter";12import ContactPage from "./pages/Contact";13import Home from "./pages/Home";14import { PrivacyPage, TermsPage } from "./pages/Legal";15import ListingPage from "./pages/Listing";16import SourcesPage from "./pages/Sources";17import StatsPage from "./pages/Stats";18import AdminPage from "./pages/Admin";1920function Ticker() {21  const [items, setItems] = useState<string[]>([]);2223  useEffect(() => {24    Promise.all([fetchStats(), fetchFacets(), fetchSources()])25      .then(([stats, facets, src]) => {26        registerSourceNames(src.sources);27        const parts: string[] = [`${stats.total.toLocaleString("en-US")} homes for sale`];28        if (stats.states) parts.push(`${stats.states} states`);29        if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-US")} cities`);30        if (stats.avg_price != null)31          parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-US")}`);32        for (const s of facets.sources.slice(0, 12))33          parts.push(`${sourceName(s.source)} · ${s.n}`);34        parts.push("Updated automatically");35        setItems(parts);36      })37      .catch(() => setItems(["Home-Ka — every home for sale in America"]));38  }, []);3940  if (items.length === 0) return null;41  return (42    <div className="ticker" aria-hidden="true">43      <div className="ticker-track">44        {[...items, ...items].map((t, i) => (45          <span key={i}>{t}</span>46        ))}47      </div>48    </div>49  );50}5152const NAV_LINKS = [53  { to: "/", label: "Homes", icon: "home", end: true },54  { to: "/?view=map", label: "Map", icon: "map", end: false, force: true },55  { to: "/stats", label: "Stats", icon: "chart", end: false },56  { to: "/sources", label: "Sources", icon: "building", end: false },57  { to: "/contact", label: "Contact", icon: "arrow", end: false },58];5960function Header() {61  const [open, setOpen] = useState(false);62  const location = useLocation();6364  useEffect(() => { setOpen(false); }, [location]);65  useEffect(() => {66    document.documentElement.classList.toggle("ka-scroll-lock", open);67    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };68  }, [open]);6970  return (71    <>72      <header className="header">73        <div className="container header-inner">74          <NavLink to="/" className="brand" aria-label="Home-Ka — home">75            Home<span className="ka">Ka</span>76          </NavLink>77          <GroupeKaBadge />78          <nav className="nav" aria-label="Main navigation">79            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>80              Homes81            </NavLink>82            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>83              Stats84            </NavLink>85            <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>86              Sources87            </NavLink>88            <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>89              Contact90            </NavLink>91          </nav>92          <button93            className={`menu-btn ${open ? "open" : ""}`}94            aria-expanded={open}95            aria-label={open ? "Close menu" : "Open menu"}96            onClick={() => setOpen(!open)}97          >98            <span /><span /><span />99          </button>100        </div>101102        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">103          <button type="button" className="mm-close" aria-label="Close menu"104                  onClick={() => setOpen(false)}>✕</button>105          {NAV_LINKS.map((l, i) => (106            <NavLink107              key={l.to}108              to={l.to}109              end={l.end}110              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}111              className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}112              onClick={() => setOpen(false)}113            >114              <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>115              {l.label}116              <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>117            </NavLink>118          ))}119          <div className="mm-foot">120            <GroupeKaBadge />121            <p>122              Independent aggregator — updated automatically; every listing123              links back to the original announcement at the source.124            </p>125          </div>126        </div>127      </header>128      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}129      <Ticker />130    </>131  );132}133134const LOCAL_LEGAL = [135  { label: "Terms of Use (Home-Ka)", href: "/terms" },136  { label: "Privacy Policy (Home-Ka)", href: "/privacy" },137  { label: "Contact", href: "/contact" },138];139140/** Bottom navigation bar (mobile) — floating pill, same language as Lou-Ka v2. */141function MobileTabBar() {142  const location = useLocation();143  const isMap = new URLSearchParams(location.search).get("view") === "map";144  const tabs = [145    { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap },146    { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap },147    { to: "/stats", label: "Stats", icon: <Ico name="chart" size={20} stroke={1.6} />, on: location.pathname.startsWith("/stats") },148    { to: "/sources", label: "Sources", icon: <Ico name="building" size={20} stroke={1.6} />, on: location.pathname.startsWith("/sources") },149  ];150  return (151    <nav className="tabbar" aria-label="Mobile navigation">152      {tabs.map((t) => (153        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>154          {t.icon}155          {t.label}156        </NavLink>157      ))}158    </nav>159  );160}161162export default function App() {163  return (164    <>165      <Header />166      <main>167        <Routes>168          <Route path="/" element={<Home />} />169          <Route path="/property/:uid" element={<ListingPage />} />170          {/* SEO canonical URL: /property/{uid}/{slug} — without this route any171              shared/direct link would land on the 404 page */}172          <Route path="/property/:uid/:slug" element={<ListingPage />} />173          <Route path="/stats" element={<StatsPage />} />174          <Route path="/sources" element={<SourcesPage />} />175          <Route path="/contact" element={<ContactPage />} />176          <Route path="/terms" element={<TermsPage />} />177          <Route path="/privacy" element={<PrivacyPage />} />178          {/* Admin — reachable by URL only, not linked in the nav */}179          <Route path="/admin" element={<AdminPage tab="sources" />} />180          <Route path="/admin/sources" element={<AdminPage tab="sources" />} />181          <Route path="/admin/connectors" element={<AdminPage tab="connectors" />} />182          <Route path="/admin/brokerages" element={<AdminPage tab="brokerages" />} />183          <Route184            path="*"185            element={186              <div className="notice container">187                <div className="big">🧭</div>188                <h2>Page not found</h2>189                <p>The requested link does not exist.</p>190              </div>191            }192          />193        </Routes>194      </main>195      <MobileTabBar />196      <KaFooter siteId="home-ka" localLegal={LOCAL_LEGAL} />197    </>198  );199}200