SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
6.0 KB · 168 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : layout global (header + ticker en direct + footer encre) et routage5// -----------------------------------------------------------------------------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 Home from "./pages/Home";11import ListingPage from "./pages/Listing";12import SourcesPage from "./pages/Sources";13import StatsPage from "./pages/Stats";1415function Ticker() {16  const [items, setItems] = useState<string[]>([]);1718  useEffect(() => {19    Promise.all([fetchStats(), fetchFacets(), fetchSources()])20      .then(([stats, facets, src]) => {21        registerSourceNames(src.sources);22        const parts: string[] = [`${stats.total.toLocaleString("fr-CA")} propriétés à vendre`];23        if (stats.cities) parts.push(`${stats.cities} villes`);24        if (stats.avg_price != null)25          parts.push(`Prix moyen ${Math.round(stats.avg_price).toLocaleString("fr-CA")} $`);26        for (const s of facets.sources.slice(0, 12))27          parts.push(`${sourceName(s.source)} · ${s.n}`);28        parts.push("Mise à jour automatique");29        setItems(parts);30      })31      .catch(() => setItems(["Immo-Ka — propriétés à vendre au Québec"]));32  }, []);3334  if (items.length === 0) return null;35  return (36    <div className="ticker" aria-hidden="true">37      <div className="ticker-track">38        {[...items, ...items].map((t, i) => (39          <span key={i}>{t}</span>40        ))}41      </div>42    </div>43  );44}4546const NAV_LINKS = [47  { to: "/", label: "Propriétés", icon: "home", end: true },48  { to: "/?view=carte", label: "Carte", icon: "map", end: false, force: true },49  { to: "/stats", label: "Stats", icon: "chart", end: false },50  { to: "/agences", label: "Agences", icon: "building", end: false },51];5253function Header() {54  const [open, setOpen] = useState(false);55  const location = useLocation();5657  useEffect(() => { setOpen(false); }, [location]);58  useEffect(() => {59    document.body.style.overflow = open ? "hidden" : "";60    return () => { document.body.style.overflow = ""; };61  }, [open]);6263  return (64    <>65      <header className="header">66        <div className="container header-inner">67          <NavLink to="/" className="brand" aria-label="Immo-Ka — accueil">68            Immo<span className="ka">Ka</span>69            <span className="brand-tag">Toutes les agences du Québec — toujours à jour</span>70          </NavLink>71          <nav className="nav" aria-label="Navigation principale">72            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>73              Propriétés74            </NavLink>75            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>76              Stats77            </NavLink>78            <NavLink to="/agences" className={({ isActive }) => (isActive ? "active" : "")}>79              Agences80            </NavLink>81          </nav>82          <button83            className={`menu-btn ${open ? "open" : ""}`}84            aria-expanded={open}85            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}86            onClick={() => setOpen(!open)}87          >88            <span /><span /><span />89          </button>90        </div>9192        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">93          {NAV_LINKS.map((l, i) => (94            <NavLink95              key={l.to}96              to={l.to}97              end={l.end}98              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}99              className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}100              onClick={() => setOpen(false)}101            >102              <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>103              {l.label}104              <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>105            </NavLink>106          ))}107          <div className="mm-foot">108            Agrégateur indépendant — mis à jour automatiquement, chaque fiche109            renvoie à l'annonce originale de l'agence.110          </div>111        </div>112      </header>113      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}114      <Ticker />115    </>116  );117}118119function Footer() {120  return (121    <footer className="footer">122      <div className="container">123        <div className="fbrand">124          Immo<span className="ka">Ka</span>125        </div>126        <div className="frow">127          <div>128            <b>Agrégateur indépendant</b> des propriétés à vendre dans la province de Québec.129            Les annonces proviennent des sites publics des agences de courtage (RE/MAX, Sutton,130            Via Capitale, Proprio Direct, Barnes, M Immobilier…) et sont rafraîchies131            automatiquement — chaque fiche renvoie vers l'annonce originale.132          </div>133        </div>134        <div className="fmono">135          © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai136        </div>137      </div>138    </footer>139  );140}141142export default function App() {143  return (144    <>145      <Header />146      <main>147        <Routes>148          <Route path="/" element={<Home />} />149          <Route path="/propriete/:uid" element={<ListingPage />} />150          <Route path="/stats" element={<StatsPage />} />151          <Route path="/agences" element={<SourcesPage />} />152          <Route153            path="*"154            element={155              <div className="notice container">156                <div className="big">🧭</div>157                <h2>Page introuvable</h2>158                <p>Le lien demandé n'existe pas.</p>159              </div>160            }161          />162        </Routes>163      </main>164      <Footer />165    </>166  );167}168