SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
11.7 KB · 312 lines tsx
Raw Blame History
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 { kaLogin, useAccount } from "./account";11import AccountPage from "./pages/Account";12import AgenciesPage from "./pages/Agencies";13import ContactPage from "./pages/Contact";14import Home from "./pages/Home";15import { PrivacyPage, TermsPage } from "./pages/Legal";16import ListingPage from "./pages/Listing";17import RatesPage from "./pages/Rates";18import StatsPage from "./pages/Stats";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-CA")} homes for sale`];28        if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-CA")} cities & towns`);29        if (stats.avg_price != null)30          parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-CA")}`);31        for (const s of facets.sources.slice(0, 12))32          parts.push(`${sourceName(s.source)} · ${s.n.toLocaleString("en-CA")}`);33        parts.push("Continuously updated");34        setItems(parts);35      })36      .catch(() => setItems(["House-Ka — homes for sale across Canada"]));37  }, []);3839  if (items.length === 0) return null;40  return (41    <div className="ticker" aria-hidden="true">42      <div className="ticker-track">43        {[...items, ...items].map((t, i) => (44          <span key={i}>{t}</span>45        ))}46      </div>47    </div>48  );49}5051/** Header account zone: "Sign in with KA" or a link to the profile. */52function AccountNav() {53  const { user, enabled, loaded } = useAccount();54  if (!loaded || !enabled) return null;55  if (user) {56    return (57      <NavLink58        to="/account"59        className={({ isActive }) => `ka-acct ${isActive ? "active" : ""}`}60      >61        {user.picture ? (62          <img className="ka-acct-pic" src={user.picture} alt="" referrerPolicy="no-referrer" />63        ) : (64          <Ico name="people" size={16} />65        )}66        {user.name ? user.name.split(" ")[0] : "Account"}67      </NavLink>68    );69  }70  return (71    <div className="ka-auth">72      <button className="ka-login" onClick={() => kaLogin()}>73        Sign in with <b>KA</b>74      </button>75      <a76        className="ka-signup"77        href="https://www.groupe-ka.com/connexion"78        target="_blank"79        rel="noopener noreferrer"80      >81        Create an account82      </a>83    </div>84  );85}8687/** Same account zone, mobile-menu version. */88function AccountMobile({ close }: { close: () => void }) {89  const { user, enabled, loaded } = useAccount();90  if (!loaded || !enabled) return null;91  if (user) {92    return (93      <NavLink to="/account" className="mm-link" onClick={close}>94        <span className="mm-ico" aria-hidden="true"><Ico name="people" size={19} /></span>95        My account {user.name ? `— ${user.name.split(" ")[0]}` : ""}96        <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>97      </NavLink>98    );99  }100  return (101    <div className="mm-auth">102      <button className="ka-login" onClick={() => kaLogin()}>103        Sign in with <b>KA</b>104      </button>105      <a106        className="ka-signup"107        href="https://www.groupe-ka.com/connexion"108        target="_blank"109        rel="noopener noreferrer"110      >111        Create an account112      </a>113    </div>114  );115}116117const NAV_LINKS = [118  { to: "/", label: "Homes", icon: "home", end: true },119  { to: "/?view=map", label: "Map", icon: "map", end: false, force: true },120  { to: "/rates", label: "Mortgage rates", icon: "trendup", end: false },121  { to: "/stats", label: "Stats", icon: "chart", end: false },122  { to: "/agencies", label: "Brokerages", icon: "building", end: false },123  { to: "/contact", label: "Contact", icon: "arrow", end: false },124];125126function Header() {127  const [open, setOpen] = useState(false);128  const location = useLocation();129130  useEffect(() => { setOpen(false); }, [location]);131  useEffect(() => {132    document.documentElement.classList.toggle("ka-scroll-lock", open);133    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };134  }, [open]);135136  return (137    <>138      <header className="header">139        <div className="container header-inner">140          <NavLink to="/" className="brand" aria-label="House-Ka — home">141            House<span className="ka">Ka</span>142          </NavLink>143          <span className="brand-tag">A Groupe KA service</span>144          <nav className="nav" aria-label="Main navigation">145            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>146              Homes147            </NavLink>148            <NavLink to="/rates" className={({ isActive }) => (isActive ? "active" : "")}>149              Rates150            </NavLink>151            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>152              Stats153            </NavLink>154            <NavLink to="/agencies" className={({ isActive }) => (isActive ? "active" : "")}>155              Brokerages156            </NavLink>157            <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>158              Contact159            </NavLink>160          </nav>161          <div className="header-acct">162            <AccountNav />163          </div>164          <button165            className={`menu-btn ${open ? "open" : ""}`}166            aria-expanded={open}167            aria-label={open ? "Close the menu" : "Open the menu"}168            onClick={() => setOpen(!open)}169          >170            <span /><span /><span />171          </button>172        </div>173174        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">175          {/* fixed ✕ of the panel: visible regardless of scroll; the header176              burger is hidden while the panel is open */}177          <button type="button" className="mm-close" aria-label="Close the menu"178                  onClick={() => setOpen(false)}>✕</button>179          {NAV_LINKS.map((l, i) => (180            <NavLink181              key={l.to}182              to={l.to}183              end={l.end}184              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}185              className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}186              onClick={() => setOpen(false)}187            >188              <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>189              {l.label}190              <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>191            </NavLink>192          ))}193          <AccountMobile close={() => setOpen(false)} />194          <div className="mm-foot">195            <p>196              Independent aggregator — continuously updated, every listing links197              back to the brokerage's original page.198            </p>199          </div>200        </div>201      </header>202      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}203      <Ticker />204    </>205  );206}207208/** Bottom navigation bar (mobile) — floating pill detached from the edges,209    accent underline below the active tab. */210function MobileTabBar() {211  const location = useLocation();212  const isMap = new URLSearchParams(location.search).get("view") === "map";213  const tabs = [214    { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap },215    { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap },216    { to: "/rates", label: "Rates", icon: <Ico name="trendup" size={20} stroke={1.6} />, on: location.pathname.startsWith("/rates") },217    { to: "/account", label: "Account", icon: <Ico name="people" size={20} stroke={1.6} />, on: location.pathname.startsWith("/account") },218  ];219  return (220    <nav className="tabbar" aria-label="Mobile navigation">221      {tabs.map((t) => (222        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>223          {t.icon}224          {t.label}225        </NavLink>226      ))}227    </nav>228  );229}230231/** House-Ka footer — ink panel, Groupe KA credit + sister sites. */232function Footer() {233  const year = new Date().getFullYear();234  const sisters = [235    { name: "Groupe·Ka", url: "https://www.groupe-ka.com", note: "the Groupe KA portal" },236    { name: "Immo·Ka", url: "https://www.immo-ka.com", note: "homes for sale in Québec" },237    { name: "Lou·Ka", url: "https://www.lou-ka.com", note: "rentals in Québec" },238    { name: "Vrai·Prix", url: "https://www.vrai-prix.com", note: "Québec market-value estimates" },239  ];240  return (241    <footer className="hk-footer" id="contact">242      <div className="container">243        <div className="hk-foot-brand">House<span className="ka">Ka</span></div>244        <p className="hk-foot-desc">245          House-Ka continuously aggregates homes for sale publicly listed by246          Canadian real-estate brokerages, teams and national networks — every247          province and territory except Québec, which lives on our sister site248          Immo-Ka. Every listing links back to the source's original page.249          House-Ka is a service of <b>Groupe KA</b>.250        </p>251        <p className="hk-foot-notice">252          House-Ka is an independent aggregator: it is not a brokerage, does not253          represent buyers or sellers, and is not affiliated with the sources it254          indexes. Prices and availability are those displayed by each source.255        </p>256        <ul className="hk-foot-sites">257          {sisters.map((s) => (258            <li key={s.url}>259              <a href={s.url} target="_blank" rel="noopener noreferrer">260                {s.name}261              </a>262              <span>{s.note}</span>263            </li>264          ))}265        </ul>266        <div className="hk-foot-legal">267          <a href="/terms">Terms of use</a>268          <a href="/privacy">Privacy</a>269          <a href="/contact">Contact</a>270          <span>© {year} Groupe-Ka</span>271        </div>272      </div>273    </footer>274  );275}276277export default function App() {278  return (279    <>280      <Header />281      <main>282        <Routes>283          <Route path="/" element={<Home />} />284          <Route path="/property/:uid" element={<ListingPage />} />285          {/* canonical SEO URL: /property/{uid}/{slug} (server 301) — without286              this route any shared/direct link would land on the 404 page */}287          <Route path="/property/:uid/:slug" element={<ListingPage />} />288          <Route path="/stats" element={<StatsPage />} />289          <Route path="/rates" element={<RatesPage />} />290          <Route path="/agencies" element={<AgenciesPage />} />291          <Route path="/contact" element={<ContactPage />} />292          <Route path="/account" element={<AccountPage />} />293          <Route path="/terms" element={<TermsPage />} />294          <Route path="/privacy" element={<PrivacyPage />} />295          <Route296            path="*"297            element={298              <div className="notice container">299                <div className="big">🧭</div>300                <h2>Page not found</h2>301                <p>The requested link does not exist.</p>302              </div>303            }304          />305        </Routes>306      </main>307      <MobileTabBar />308      <Footer />309    </>310  );311}312