SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%
10.7 KB · 273 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// App.tsx : layout global (header + ticker en direct + footer encre) et routage.6//   L'ACCENT DU SITE SUIT L'UNIVERS AFFICHÉ (html[data-mode]) : lime en mode7//   Louer, rouge en mode Acheter, terracotta (marque) sur les pages neutres.8// -----------------------------------------------------------------------------9import { useEffect, useState } from "react";10import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";11import { fetchStats, logout, setMode } from "./api";12import { AccountProvider, useAccount } from "./account";13import { Ico, IcoHeart } from "./components/Icons";14import CategoryPage from "./pages/Category";15import Home from "./pages/Home";16import { PrivacyPage, TermsPage } from "./pages/Legal";17import ListingPage from "./pages/Listing";18import ProfilPage from "./pages/Profil";19import StatsPage from "./pages/Stats";2021/** L'accent global suit la route : /louer/* -> lime, /acheter/* -> rouge,22 *  accueil -> géré par Home (selon le toggle), ailleurs -> marque terracotta. */23function ModeSync() {24  const location = useLocation();25  useEffect(() => {26    const p = location.pathname;27    if (p.startsWith("/louer")) setMode("louer");28    else if (p.startsWith("/acheter")) setMode("acheter");29    else if (p === "/" || p.startsWith("/annonce/")) { /* géré par la page */ }30    else setMode(null);31  }, [location]);32  return null;33}3435function Ticker() {36  const [items, setItems] = useState<string[]>([]);3738  useEffect(() => {39    fetchStats()40      .then((stats) => {41        const parts: string[] = [42          `${stats.louer.total.toLocaleString("fr-CA")} logements à louer`,43          `${stats.acheter.total.toLocaleString("fr-CA")} propriétés à vendre`,44        ];45        if (stats.louer.avg_price != null)46          parts.push(`Loyer moyen ${Math.round(stats.louer.avg_price).toLocaleString("fr-CA")} $`);47        if (stats.acheter.avg_price != null)48          parts.push(`Prix moyen ${Math.round(stats.acheter.avg_price).toLocaleString("fr-CA")} $`);49        for (const c of stats.louer.top_cities.slice(0, 5))50          parts.push(`Louer à ${c.city} · ${c.n}`);51        for (const c of stats.acheter.top_cities.slice(0, 5))52          parts.push(`Acheter à ${c.city} · ${c.n}`);53        parts.push("Mise à jour automatique");54        setItems(parts);55      })56      .catch(() => setItems(["Toit-Ka — louer ou acheter un toit au Québec"]));57  }, []);5859  if (items.length === 0) return null;60  return (61    <div className="ticker" aria-hidden="true">62      <div className="ticker-track">63        {[...items, ...items].map((t, i) => (64          <span key={i}>{t}</span>65        ))}66      </div>67    </div>68  );69}7071function AuthWidget() {72  const { user, enabled, refresh } = useAccount();73  const [open, setOpen] = useState(false);7475  if (!user && !enabled) return null;76  if (!user)77    return (78      <a className="auth-btn" href={`/api/auth/ka/login?next=${encodeURIComponent(location.pathname)}`}>79        <span className="auth-kab" aria-hidden="true">KA</span>80        KA ID81      </a>82    );83  return (84    <div className="auth-user">85      <button className="auth-avatar" onClick={() => setOpen(!open)}86              aria-label={`Compte de ${user.name}`} aria-expanded={open}>87        {user.picture88          ? <img src={user.picture} alt="" referrerPolicy="no-referrer" />89          : <span>{(user.name || user.email || "?").slice(0, 1).toUpperCase()}</span>}90      </button>91      {open && (92        <div className="auth-menu" role="menu">93          <div className="auth-id">94            <b>{user.name}</b>95            <span>{user.email}</span>96          </div>97          {user.ka_id && (98            <div className="auth-note" style={{ fontFamily: "monospace", letterSpacing: "0.08em" }}>99              KA-ID : {user.ka_id}100            </div>101          )}102          <div className="auth-note">Compte Groupe KA — le même KA-ID sur toutes les plateformes du groupe</div>103          <Link className="auth-menu-link" to="/profil" onClick={() => setOpen(false)}>104            Mon profil105          </Link>106          <a className="auth-menu-link" href="https://www.groupe-ka.com/compte"107             target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}>108            <IcoHeart size={13} filled /> Mes favoris — Mon univers Ka ↗109          </a>110          <button onClick={() => logout().then(() => { refresh(); setOpen(false); })}>111            Se déconnecter112          </button>113        </div>114      )}115    </div>116  );117}118119const NAV_LINKS = [120  { to: "/?tx=louer", label: "Louer", icon: "key", dot: "louer" },121  { to: "/?tx=acheter", label: "Acheter", icon: "home", dot: "acheter" },122  { to: "/stats", label: "Stats", icon: "chart", dot: "" },123  { to: "/profil", label: "Mon profil", icon: "people", dot: "" },124];125126function Header() {127  const [open, setOpen] = useState(false);128  const location = useLocation();129  const tx = new URLSearchParams(location.search).get("tx") ?? "louer";130131  useEffect(() => { setOpen(false); }, [location]);132  useEffect(() => {133    document.body.style.overflow = open ? "hidden" : "";134    return () => { document.body.style.overflow = ""; };135  }, [open]);136137  const navActive = (label: string) => {138    if (label === "Louer") return location.pathname === "/" && tx === "louer";139    if (label === "Acheter") return location.pathname === "/" && tx === "acheter";140    return false;141  };142143  return (144    <>145      <header className="header">146        <div className="container header-inner">147          <NavLink to="/" className="brand" aria-label="Toit-Ka — accueil">148            Toit<span className="ka">Ka</span>149            <span className="brand-tag">Louer ou acheter — un seul endroit</span>150          </NavLink>151          <nav className="nav" aria-label="Navigation principale">152            <NavLink to="/?tx=louer" className={navActive("Louer") ? "active" : ""}>153              <span className="nav-dot louer" aria-hidden="true" /> Louer154            </NavLink>155            <NavLink to="/?tx=acheter" className={navActive("Acheter") ? "active" : ""}>156              <span className="nav-dot acheter" aria-hidden="true" /> Acheter157            </NavLink>158            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>159              Stats160            </NavLink>161          </nav>162          <div className="auth-slot">163            <AuthWidget />164          </div>165          <button166            className={`menu-btn ${open ? "open" : ""}`}167            aria-expanded={open}168            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}169            onClick={() => setOpen(!open)}170          >171            <span /><span /><span />172          </button>173        </div>174175        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">176          {NAV_LINKS.map((l, i) => (177            <NavLink178              key={l.to}179              to={l.to}180              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}181              className={`mm-link ${navActive(l.label) ? "active" : ""}`}182              onClick={() => setOpen(false)}183            >184              <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>185              {l.label}186              <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>187            </NavLink>188          ))}189          <div className="mm-foot">190            Agrégateur indépendant — location (Lou-Ka) et vente (Immo-Ka) réunies.191            Chaque fiche renvoie à l'annonce originale de la source.192          </div>193        </div>194      </header>195      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}196      <Ticker />197    </>198  );199}200201function Footer() {202  return (203    <footer className="footer">204      <div className="container">205        <div className="fbrand">206          Toit<span className="ka">Ka</span>207        </div>208        <div className="frow">209          <div>210            <b>Agrégateur indépendant</b> des logements à louer et des propriétés à211            vendre dans la province de Québec — la fusion de <b>Lou-Ka</b> (location)212            et d'<b>Immo-Ka</b> (vente). Les annonces proviennent des sites publics des213            gestionnaires immobiliers, agences de courtage et plateformes, rafraîchies214            automatiquement — chaque fiche renvoie vers l'annonce originale.215          </div>216        </div>217        <div className="flegal">218          <NavLink to="/conditions">Conditions d'utilisation</NavLink>219          <span aria-hidden="true"> · </span>220          <NavLink to="/confidentialite">Politique de confidentialité</NavLink>221          <span aria-hidden="true"> · </span>222          <a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou-Ka</a>223          <span aria-hidden="true"> · </span>224          <a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo-Ka</a>225        </div>226        <div className="fmono">227          Toit-Ka est une application du <b>Groupe-Ka</b> ·228          © {new Date().getFullYear()} Groupe-Ka —{" "}229          <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>230        </div>231      </div>232    </footer>233  );234}235236export default function App() {237  return (238    <AccountProvider>239      <ModeSync />240      <Header />241      <main>242        <Routes>243          <Route path="/" element={<Home />} />244          <Route path="/annonce/:uid" element={<ListingPage />} />245          <Route path="/annonce/:uid/:slug" element={<ListingPage />} />246          {/* pages programmatiques SEO — mêmes URL que le rendu serveur */}247          <Route path="/louer/type/:type" element={<CategoryPage tx="louer" />} />248          <Route path="/louer/:ville" element={<CategoryPage tx="louer" />} />249          <Route path="/louer/:ville/:type" element={<CategoryPage tx="louer" />} />250          <Route path="/acheter/type/:type" element={<CategoryPage tx="acheter" />} />251          <Route path="/acheter/:ville" element={<CategoryPage tx="acheter" />} />252          <Route path="/acheter/:ville/:type" element={<CategoryPage tx="acheter" />} />253          <Route path="/stats" element={<StatsPage />} />254          <Route path="/profil" element={<ProfilPage />} />255          <Route path="/conditions" element={<TermsPage />} />256          <Route path="/confidentialite" element={<PrivacyPage />} />257          <Route258            path="*"259            element={260              <div className="notice container">261                <div className="big"><Ico name="alert" size={40} /></div>262                <h2>Page introuvable</h2>263                <p>Le lien demandé n'existe pas.</p>264              </div>265            }266          />267        </Routes>268      </main>269      <Footer />270    </AccountProvider>271  );272}273