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 and teams on the CREA DDF feed —175 starting with Ontario and growing across the rest of Canada. Every176 listing links back to the brokerage's original page. House-Ka is a177 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