SPB Git

spb/house-ka

Public
9.6 KB · 239 lines tsx
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : global layout (header + live ticker + footer) and 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 AgenciesPage from "./pages/Agencies";11import ContactPage from "./pages/Contact";12import Home from "./pages/Home";13import { PrivacyPage, TermsPage } from "./pages/Legal";14import ListingPage from "./pages/Listing";15import RatesPage from "./pages/Rates";16import StatsPage from "./pages/Stats";1718function Ticker() {19  const [items, setItems] = useState<string[]>([]);2021  useEffect(() => {22    Promise.all([fetchStats(), fetchFacets(), fetchSources()])23      .then(([stats, facets, src]) => {24        registerSourceNames(src.sources);25        const parts: string[] = [`${stats.total.toLocaleString("en-CA")} homes for sale`];26        if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-CA")} cities & towns`);27        if (stats.avg_price != null)28          parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-CA")}`);29        for (const s of facets.sources.slice(0, 12))30          parts.push(`${sourceName(s.source)} · ${s.n.toLocaleString("en-CA")}`);31        parts.push("Continuously updated");32        setItems(parts);33      })34      .catch(() => setItems(["House-Ka — homes for sale across Canada"]));35  }, []);3637  if (items.length === 0) return null;38  return (39    <div className="ticker" aria-hidden="true">40      <div className="ticker-track">41        {[...items, ...items].map((t, i) => (42          <span key={i}>{t}</span>43        ))}44      </div>45    </div>46  );47}4849const NAV_LINKS = [50  { to: "/", label: "Homes", icon: "home", end: true },51  { to: "/?view=map", label: "Map", icon: "map", end: false, force: true },52  { to: "/rates", label: "Mortgage rates", icon: "trendup", end: false },53  { to: "/stats", label: "Stats", icon: "chart", end: false },54  { to: "/agencies", label: "Brokerages", icon: "building", end: false },55  { to: "/contact", label: "Contact", icon: "arrow", end: false },56];5758function Header() {59  const [open, setOpen] = useState(false);60  const location = useLocation();6162  useEffect(() => { setOpen(false); }, [location]);63  useEffect(() => {64    document.documentElement.classList.toggle("ka-scroll-lock", open);65    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };66  }, [open]);6768  return (69    <>70      <header className="header">71        <div className="container header-inner">72          <NavLink to="/" className="brand" aria-label="House-Ka — home">73            House<span className="ka">Ka</span>74          </NavLink>75          <span className="brand-tag">A Groupe KA service</span>76          <nav className="nav" aria-label="Main navigation">77            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>78              Homes79            </NavLink>80            <NavLink to="/rates" className={({ isActive }) => (isActive ? "active" : "")}>81              Rates82            </NavLink>83            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>84              Stats85            </NavLink>86            <NavLink to="/agencies" className={({ isActive }) => (isActive ? "active" : "")}>87              Brokerages88            </NavLink>89            <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>90              Contact91            </NavLink>92          </nav>93          <button94            className={`menu-btn ${open ? "open" : ""}`}95            aria-expanded={open}96            aria-label={open ? "Close the menu" : "Open the menu"}97            onClick={() => setOpen(!open)}98          >99            <span /><span /><span />100          </button>101        </div>102103        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">104          {/* fixed ✕ of the panel: visible regardless of scroll; the header105              burger is hidden while the panel is open */}106          <button type="button" className="mm-close" aria-label="Close the menu"107                  onClick={() => setOpen(false)}>✕</button>108          {NAV_LINKS.map((l, i) => (109            <NavLink110              key={l.to}111              to={l.to}112              end={l.end}113              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}114              className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}115              onClick={() => setOpen(false)}116            >117              <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>118              {l.label}119              <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>120            </NavLink>121          ))}122          <div className="mm-foot">123            <p>124              Independent aggregator — continuously updated, every listing links125              back to the brokerage's original page.126            </p>127          </div>128        </div>129      </header>130      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}131      <Ticker />132    </>133  );134}135136/** Bottom navigation bar (mobile) — floating pill detached from the edges,137    accent underline below the active tab. */138function MobileTabBar() {139  const location = useLocation();140  const isMap = new URLSearchParams(location.search).get("view") === "map";141  const tabs = [142    { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap },143    { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap },144    { to: "/rates", label: "Rates", icon: <Ico name="trendup" size={20} stroke={1.6} />, on: location.pathname.startsWith("/rates") },145    { to: "/stats", label: "Stats", icon: <Ico name="chart" size={20} stroke={1.6} />, on: location.pathname.startsWith("/stats") },146  ];147  return (148    <nav className="tabbar" aria-label="Mobile navigation">149      {tabs.map((t) => (150        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>151          {t.icon}152          {t.label}153        </NavLink>154      ))}155    </nav>156  );157}158159/** House-Ka footer — ink panel, Groupe KA credit + sister sites. */160function Footer() {161  const year = new Date().getFullYear();162  const sisters = [163    { name: "Groupe·Ka", url: "https://www.groupe-ka.com", note: "the Groupe KA portal" },164    { name: "Immo·Ka", url: "https://www.immo-ka.com", note: "homes for sale in Québec" },165    { name: "Lou·Ka", url: "https://www.lou-ka.com", note: "rentals in Québec" },166    { name: "Vrai·Prix", url: "https://www.vrai-prix.com", note: "Québec market-value estimates" },167  ];168  return (169    <footer className="hk-footer" id="contact">170      <div className="container">171        <div className="hk-foot-brand">House<span className="ka">Ka</span></div>172        <p className="hk-foot-desc">173          House-Ka continuously aggregates homes for sale publicly listed by174          Canadian real-estate brokerages, teams and national networks — every175          province and territory except Québec, which lives on our sister site176          Immo-Ka. Every listing links back to the source's original page.177          House-Ka is a service of <b>Groupe KA</b>.178        </p>179        <p className="hk-foot-notice">180          House-Ka is an independent aggregator: it is not a brokerage, does not181          represent buyers or sellers, and is not affiliated with the sources it182          indexes. Prices and availability are those displayed by each source.183        </p>184        <ul className="hk-foot-sites">185          {sisters.map((s) => (186            <li key={s.url}>187              <a href={s.url} target="_blank" rel="noopener noreferrer">188                {s.name}189              </a>190              <span>{s.note}</span>191            </li>192          ))}193        </ul>194        <div className="hk-foot-legal">195          <a href="/terms">Terms of use</a>196          <a href="/privacy">Privacy</a>197          <a href="/contact">Contact</a>198          <span>© {year} Groupe-Ka</span>199        </div>200      </div>201    </footer>202  );203}204205export default function App() {206  return (207    <>208      <Header />209      <main>210        <Routes>211          <Route path="/" element={<Home />} />212          <Route path="/property/:uid" element={<ListingPage />} />213          {/* canonical SEO URL: /property/{uid}/{slug} (server 301) — without214              this route any shared/direct link would land on the 404 page */}215          <Route path="/property/:uid/:slug" element={<ListingPage />} />216          <Route path="/stats" element={<StatsPage />} />217          <Route path="/rates" element={<RatesPage />} />218          <Route path="/agencies" element={<AgenciesPage />} />219          <Route path="/contact" element={<ContactPage />} />220          <Route path="/terms" element={<TermsPage />} />221          <Route path="/privacy" element={<PrivacyPage />} />222          <Route223            path="*"224            element={225              <div className="notice container">226                <div className="big">🧭</div>227                <h2>Page not found</h2>228                <p>The requested link does not exist.</p>229              </div>230            }231          />232        </Routes>233      </main>234      <MobileTabBar />235      <Footer />236    </>237  );238}239
Files · 852c914b66dca5351aaa5df6ece6070a6e3d64c5
Menu

Theme