feat(ka-id): connexion KA ID + compte + favoris (hub groupe-ka)
- auth/hubfav/hubprofile : client_id house-ka (aud, SSO, favoris signes) ; KA_SSO_SECRET partage avec le hub (KA_SSO_SECRET_HOUSE_KA cote hub) - frontend : AccountProvider, Sign in with KA (header + menu mobile), page /account EN (carte de membre KA-ID, profil hub lecture seule, liste de favoris), coeur ♥ sur la fiche (optimiste, magasin central Mon univers Ka du hub), onglet Account dans la tabbar mobile
11 changed files +533 −9
modified
frontend/src/App.tsx
+74 −1
@@ -7,6 +7,8 @@ import { Ico } from "./components/Icons"; | |||||
| 7 | import { useEffect, useState } from "react"; | 7 | import { useEffect, useState } from "react"; | ||
| 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | ||
| 9 | import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | 9 | import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | ||
| 10 | +import { kaLogin, useAccount } from "./account"; | ||||
| 11 | +import AccountPage from "./pages/Account"; | ||||
| 10 | import AgenciesPage from "./pages/Agencies"; | 12 | import AgenciesPage from "./pages/Agencies"; | ||
| 11 | import ContactPage from "./pages/Contact"; | 13 | import ContactPage from "./pages/Contact"; | ||
| 12 | import Home from "./pages/Home"; | 14 | import Home from "./pages/Home"; | ||
@@ -46,6 +48,72 @@ function Ticker() { | |||||
| 46 | ); | 48 | ); | ||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 51 | +/** Header account zone: "Sign in with KA" or a link to the profile. */ | ||||
| 52 | +function AccountNav() { | ||||
| 53 | + const { user, enabled, loaded } = useAccount(); | ||||
| 54 | + if (!loaded || !enabled) return null; | ||||
| 55 | + if (user) { | ||||
| 56 | + return ( | ||||
| 57 | + <NavLink | ||||
| 58 | + 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 | + <a | ||||
| 76 | + className="ka-signup" | ||||
| 77 | + href="https://www.groupe-ka.com/connexion" | ||||
| 78 | + target="_blank" | ||||
| 79 | + rel="noopener noreferrer" | ||||
| 80 | + > | ||||
| 81 | + Create an account | ||||
| 82 | + </a> | ||||
| 83 | + </div> | ||||
| 84 | + ); | ||||
| 85 | +} | ||||
| 86 | + | ||||
| 87 | +/** Same account zone, mobile-menu version. */ | ||||
| 88 | +function 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 | + <a | ||||
| 106 | + className="ka-signup" | ||||
| 107 | + href="https://www.groupe-ka.com/connexion" | ||||
| 108 | + target="_blank" | ||||
| 109 | + rel="noopener noreferrer" | ||||
| 110 | + > | ||||
| 111 | + Create an account | ||||
| 112 | + </a> | ||||
| 113 | + </div> | ||||
| 114 | + ); | ||||
| 115 | +} | ||||
| 116 | + | ||||
| 49 | const NAV_LINKS = [ | 117 | const NAV_LINKS = [ | ||
| 50 | { to: "/", label: "Homes", icon: "home", end: true }, | 118 | { to: "/", label: "Homes", icon: "home", end: true }, | ||
| 51 | { to: "/?view=map", label: "Map", icon: "map", end: false, force: true }, | 119 | { to: "/?view=map", label: "Map", icon: "map", end: false, force: true }, | ||
@@ -90,6 +158,9 @@ function Header() { | |||||
| 90 | Contact | 158 | Contact | ||
| 91 | </NavLink> | 159 | </NavLink> | ||
| 92 | </nav> | 160 | </nav> | ||
| 161 | + <div className="header-acct"> | ||||
| 162 | + <AccountNav /> | ||||
| 163 | + </div> | ||||
| 93 | <button | 164 | <button | ||
| 94 | className={`menu-btn ${open ? "open" : ""}`} | 165 | className={`menu-btn ${open ? "open" : ""}`} | ||
| 95 | aria-expanded={open} | 166 | aria-expanded={open} | ||
@@ -119,6 +190,7 @@ function Header() { | |||||
| 119 | <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span> | 190 | <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span> | ||
| 120 | </NavLink> | 191 | </NavLink> | ||
| 121 | ))} | 192 | ))} | ||
| 193 | + <AccountMobile close={() => setOpen(false)} /> | ||||
| 122 | <div className="mm-foot"> | 194 | <div className="mm-foot"> | ||
| 123 | <p> | 195 | <p> | ||
| 124 | Independent aggregator — continuously updated, every listing links | 196 | Independent aggregator — continuously updated, every listing links | ||
@@ -142,7 +214,7 @@ function MobileTabBar() { | |||||
| 142 | { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap }, | 214 | { 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 }, | 215 | { 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") }, | 216 | { 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") }, | 217 | + { to: "/account", label: "Account", icon: <Ico name="people" size={20} stroke={1.6} />, on: location.pathname.startsWith("/account") }, | ||
| 146 | ]; | 218 | ]; | ||
| 147 | return ( | 219 | return ( | ||
| 148 | <nav className="tabbar" aria-label="Mobile navigation"> | 220 | <nav className="tabbar" aria-label="Mobile navigation"> | ||
@@ -217,6 +289,7 @@ export default function App() { | |||||
| 217 | <Route path="/rates" element={<RatesPage />} /> | 289 | <Route path="/rates" element={<RatesPage />} /> | ||
| 218 | <Route path="/agencies" element={<AgenciesPage />} /> | 290 | <Route path="/agencies" element={<AgenciesPage />} /> | ||
| 219 | <Route path="/contact" element={<ContactPage />} /> | 291 | <Route path="/contact" element={<ContactPage />} /> | ||
| 292 | + <Route path="/account" element={<AccountPage />} /> | ||||
| 220 | <Route path="/terms" element={<TermsPage />} /> | 293 | <Route path="/terms" element={<TermsPage />} /> | ||
| 221 | <Route path="/privacy" element={<PrivacyPage />} /> | 294 | <Route path="/privacy" element={<PrivacyPage />} /> | ||
| 222 | <Route | 295 | <Route | ||
added
frontend/src/account.tsx
+82 −0
@@ -0,0 +1,82 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// House-Ka — Homes-for-sale aggregator (Canada outside Québec) | ||||
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// account.tsx : shared account context — signed-in profile + ♥ favourites. | ||||
| 5 | +// One fetch when the app mounts; the listing page, header and account page | ||||
| 6 | +// all read the same state. Favourites live at the Groupe KA HUB (central | ||||
| 7 | +// "My Ka universe" store): each ♥ is optimistic on screen and pushed to the | ||||
| 8 | +// server, which relays to the hub. Signed out → ♥ redirects to KA ID login. | ||||
| 9 | +// ----------------------------------------------------------------------------- | ||||
| 10 | +import { | ||||
| 11 | + ReactNode, createContext, useCallback, useContext, useEffect, useState, | ||||
| 12 | +} from "react"; | ||||
| 13 | +import { | ||||
| 14 | + Listing, User, favItemFromListing, fetchFavorites, fetchMe, toggleFavorite, | ||||
| 15 | +} from "./api"; | ||||
| 16 | + | ||||
| 17 | +interface AccountState { | ||||
| 18 | + user: User | null; | ||||
| 19 | + enabled: boolean; // SSO configured server-side | ||||
| 20 | + loaded: boolean; // first fetch done | ||||
| 21 | + favs: Set<string>; // favourite uids (hub mirror) | ||||
| 22 | + toggleFav: (l: Listing) => void; | ||||
| 23 | + refresh: () => void; // re-fetch profile + favourites (after login) | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +const Ctx = createContext<AccountState>({ | ||||
| 27 | + user: null, enabled: false, loaded: false, favs: new Set(), | ||||
| 28 | + toggleFav: () => {}, refresh: () => {}, | ||||
| 29 | +}); | ||||
| 30 | + | ||||
| 31 | +export const useAccount = () => useContext(Ctx); | ||||
| 32 | + | ||||
| 33 | +/** Redirects to the KA ID login, returning to the current page. */ | ||||
| 34 | +export function kaLogin() { | ||||
| 35 | + const next = window.location.pathname + window.location.search; | ||||
| 36 | + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`; | ||||
| 37 | +} | ||||
| 38 | + | ||||
| 39 | +export function AccountProvider({ children }: { children: ReactNode }) { | ||||
| 40 | + const [user, setUser] = useState<User | null>(null); | ||||
| 41 | + const [enabled, setEnabled] = useState(false); | ||||
| 42 | + const [loaded, setLoaded] = useState(false); | ||||
| 43 | + const [favs, setFavs] = useState<Set<string>>(new Set()); | ||||
| 44 | + | ||||
| 45 | + const refresh = useCallback(() => { | ||||
| 46 | + fetchMe() | ||||
| 47 | + .then((r) => { | ||||
| 48 | + setUser(r.user); | ||||
| 49 | + setEnabled(r.enabled !== false); | ||||
| 50 | + setLoaded(true); | ||||
| 51 | + if (r.user && r.user.ka_id) { | ||||
| 52 | + fetchFavorites() | ||||
| 53 | + .then((f) => setFavs(new Set(f.ids))) | ||||
| 54 | + .catch(() => setFavs(new Set())); | ||||
| 55 | + } else { | ||||
| 56 | + setFavs(new Set()); | ||||
| 57 | + } | ||||
| 58 | + }) | ||||
| 59 | + .catch(() => { setUser(null); setLoaded(true); }); | ||||
| 60 | + }, []); | ||||
| 61 | + | ||||
| 62 | + useEffect(() => { refresh(); }, [refresh]); | ||||
| 63 | + | ||||
| 64 | + const toggleFav = useCallback((l: Listing) => { | ||||
| 65 | + if (!user || !user.ka_id) { kaLogin(); return; } // ♥ = KA account required | ||||
| 66 | + setFavs((prev) => { | ||||
| 67 | + const next = new Set(prev); | ||||
| 68 | + const on = !next.has(l.uid); | ||||
| 69 | + if (on) next.add(l.uid); else next.delete(l.uid); | ||||
| 70 | + toggleFavorite(on, favItemFromListing(l)).catch((e) => { | ||||
| 71 | + if (String(e).includes("401")) kaLogin(); // session expired | ||||
| 72 | + }); | ||||
| 73 | + return next; | ||||
| 74 | + }); | ||||
| 75 | + }, [user]); | ||||
| 76 | + | ||||
| 77 | + return ( | ||||
| 78 | + <Ctx.Provider value={{ user, enabled, loaded, favs, toggleFav, refresh }}> | ||||
| 79 | + {children} | ||||
| 80 | + </Ctx.Provider> | ||||
| 81 | + ); | ||||
| 82 | +} | ||||
modified
frontend/src/api.ts
+70 −0
@@ -383,3 +383,73 @@ export const fetchCommerces = (lat: number, lng: number, region?: string) => | |||||
| 383 | get<CommercesNearby>( | 383 | get<CommercesNearby>( | ||
| 384 | `/api/commerces?lat=${lat}&lng=${lng}` + | 384 | `/api/commerces?lat=${lat}&lng=${lng}` + | ||
| 385 | (region ? `®ion=${encodeURIComponent(region)}` : "")); | 385 | (region ? `®ion=${encodeURIComponent(region)}` : "")); | ||
| 386 | + | ||||
| 387 | +// ----------------------------------------------------------------------------- | ||||
| 388 | +// KA ID account (SSO hub groupe-ka.com) + "My Ka universe" favourites | ||||
| 389 | +// ----------------------------------------------------------------------------- | ||||
| 390 | +export type Socials = Partial<Record< | ||||
| 391 | + "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string | ||||
| 392 | +>>; | ||||
| 393 | + | ||||
| 394 | +export interface User { | ||||
| 395 | + sub: string; | ||||
| 396 | + email: string; | ||||
| 397 | + name: string; | ||||
| 398 | + picture?: string; | ||||
| 399 | + ka_id?: string; | ||||
| 400 | + provider?: string; // "ka-id" | "google" | ||||
| 401 | + created_at?: number | null; // epoch (s) | ||||
| 402 | + last_login?: number | null; // epoch (s) | ||||
| 403 | + // profile enriched by the Groupe KA HUB (source of truth — groupe-ka.com/compte) | ||||
| 404 | + bio?: string; | ||||
| 405 | + city?: string; | ||||
| 406 | + phone?: string; | ||||
| 407 | + website?: string; | ||||
| 408 | + socials?: Socials; | ||||
| 409 | + public?: boolean; | ||||
| 410 | + role_label?: string; | ||||
| 411 | + job_title?: string; | ||||
| 412 | + company?: string; | ||||
| 413 | + age?: number | null; | ||||
| 414 | + public_url?: string; | ||||
| 415 | + profile_source?: string; // "groupe-ka" | "local" | ||||
| 416 | +} | ||||
| 417 | + | ||||
| 418 | +/** Session profile ({user: null} if signed out; `enabled` = SSO configured). */ | ||||
| 419 | +export const fetchMe = () => get<{ user: User | null; enabled: boolean }>("/api/auth/me"); | ||||
| 420 | + | ||||
| 421 | +export const logout = () => fetch("/api/auth/logout", { method: "POST" }); | ||||
| 422 | + | ||||
| 423 | +/** Favourite item as expected by the hub (see immoka/favorites.py). */ | ||||
| 424 | +export interface FavItem { | ||||
| 425 | + item_id: string; | ||||
| 426 | + title: string; | ||||
| 427 | + subtitle: string; | ||||
| 428 | + price_label: string; | ||||
| 429 | + image_url: string; | ||||
| 430 | + url: string; | ||||
| 431 | +} | ||||
| 432 | + | ||||
| 433 | +export function favItemFromListing(l: Listing): FavItem { | ||||
| 434 | + return { | ||||
| 435 | + item_id: l.uid, | ||||
| 436 | + title: l.title || l.address || l.property_type || "Property", | ||||
| 437 | + subtitle: [l.city, l.region].filter(Boolean).join(", "), | ||||
| 438 | + price_label: l.price_label || fmtPrice(l.price), | ||||
| 439 | + image_url: l.images?.[0] ?? "", | ||||
| 440 | + url: `/property/${l.uid}`, | ||||
| 441 | + }; | ||||
| 442 | +} | ||||
| 443 | + | ||||
| 444 | +export const fetchFavorites = () => | ||||
| 445 | + get<{ ids: string[]; items: FavItem[] }>("/api/favorites"); | ||||
| 446 | + | ||||
| 447 | +export async function toggleFavorite(on: boolean, item: FavItem) { | ||||
| 448 | + const res = await fetch("/api/favorites/toggle", { | ||||
| 449 | + method: "POST", | ||||
| 450 | + headers: { "Content-Type": "application/json" }, | ||||
| 451 | + body: JSON.stringify({ on, item }), | ||||
| 452 | + }); | ||||
| 453 | + if (!res.ok) throw new Error(`favourites ${res.status}`); | ||||
| 454 | + return (await res.json()) as { ok: boolean; on: boolean }; | ||||
| 455 | +} | ||||
modified
frontend/src/main.tsx
+4 −1
@@ -8,13 +8,16 @@ import React from "react"; | |||||
| 8 | import ReactDOM from "react-dom/client"; | 8 | import ReactDOM from "react-dom/client"; | ||
| 9 | import { BrowserRouter } from "react-router-dom"; | 9 | import { BrowserRouter } from "react-router-dom"; | ||
| 10 | import App from "./App"; | 10 | import App from "./App"; | ||
| 11 | +import { AccountProvider } from "./account"; | ||||
| 11 | import "./ka/tokens.css"; | 12 | import "./ka/tokens.css"; | ||
| 12 | import "./styles.css"; | 13 | import "./styles.css"; | ||
| 13 | 14 | ||||
| 14 | ReactDOM.createRoot(document.getElementById("root")!).render( | 15 | ReactDOM.createRoot(document.getElementById("root")!).render( | ||
| 15 | <React.StrictMode> | 16 | <React.StrictMode> | ||
| 16 | <BrowserRouter> | 17 | <BrowserRouter> | ||
| 17 | − <App /> | 18 | + <AccountProvider> | ||
| 19 | + <App /> | ||||
| 20 | + </AccountProvider> | ||||
| 18 | </BrowserRouter> | 21 | </BrowserRouter> | ||
| 19 | </React.StrictMode> | 22 | </React.StrictMode> | ||
| 20 | ); | 23 | ); | ||
added
frontend/src/pages/Account.tsx
+272 −0
@@ -0,0 +1,272 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// House-Ka — Homes-for-sale aggregator (Canada outside Québec) | ||||
| 3 | +// Author: Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// pages/Account.tsx : user profile — Groupe KA membership card (KA-ID), | ||||
| 5 | +// read-only hub profile (edited at groupe-ka.com), favourites, actions | ||||
| 6 | +// ----------------------------------------------------------------------------- | ||||
| 7 | +import { useEffect, useState } from "react"; | ||||
| 8 | +import { Link, useNavigate } from "react-router-dom"; | ||||
| 9 | +import { FavItem, fetchFavorites, fetchMe, logout } from "../api"; | ||||
| 10 | +import type { Socials, User } from "../api"; | ||||
| 11 | +import { Ico } from "../components/Icons"; | ||||
| 12 | + | ||||
| 13 | +const fmtEpoch = (ts: number | null | undefined): string => { | ||||
| 14 | + if (!ts) return "—"; | ||||
| 15 | + return new Date(ts * 1000).toLocaleDateString("en-CA", { | ||||
| 16 | + day: "numeric", month: "long", year: "numeric", | ||||
| 17 | + }); | ||||
| 18 | +}; | ||||
| 19 | + | ||||
| 20 | +const SOCIAL_BASE: Record<string, string> = { | ||||
| 21 | + instagram: "https://www.instagram.com/", | ||||
| 22 | + facebook: "https://www.facebook.com/", | ||||
| 23 | + x: "https://x.com/", | ||||
| 24 | + linkedin: "https://www.linkedin.com/in/", | ||||
| 25 | + tiktok: "https://www.tiktok.com/@", | ||||
| 26 | + youtube: "https://www.youtube.com/@", | ||||
| 27 | +}; | ||||
| 28 | + | ||||
| 29 | +/** "@handle" or "handle" -> full platform URL; URLs kept as-is */ | ||||
| 30 | +function socialUrl(key: string, value: string): string { | ||||
| 31 | + const v = value.trim(); | ||||
| 32 | + if (/^https?:\/\//i.test(v)) return v; | ||||
| 33 | + if (key === "website") return `https://${v}`; | ||||
| 34 | + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, ""); | ||||
| 35 | +} | ||||
| 36 | + | ||||
| 37 | +const SOCIAL_FIELDS: { key: keyof Socials; label: string }[] = [ | ||||
| 38 | + { key: "instagram", label: "Instagram" }, | ||||
| 39 | + { key: "facebook", label: "Facebook" }, | ||||
| 40 | + { key: "x", label: "X (Twitter)" }, | ||||
| 41 | + { key: "linkedin", label: "LinkedIn" }, | ||||
| 42 | + { key: "tiktok", label: "TikTok" }, | ||||
| 43 | + { key: "youtube", label: "YouTube" }, | ||||
| 44 | +]; | ||||
| 45 | + | ||||
| 46 | +/** Profile managed at the Groupe KA HUB — READ-ONLY (edit it once at | ||||
| 47 | + * groupe-ka.com/compte, visible on every Groupe KA platform). */ | ||||
| 48 | +function HubProfileSection({ me }: { me: User }) { | ||||
| 49 | + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim()); | ||||
| 50 | + return ( | ||||
| 51 | + <section className="hub-profile"> | ||||
| 52 | + <h3>My Groupe KA profile</h3> | ||||
| 53 | + {me.bio && <p className="hub-bio">{me.bio}</p>} | ||||
| 54 | + <div className="pub-meta"> | ||||
| 55 | + {(me.job_title || me.company) && ( | ||||
| 56 | + <span className="stat-chip"> | ||||
| 57 | + {[me.job_title, me.company].filter(Boolean).join(" · ")} | ||||
| 58 | + </span> | ||||
| 59 | + )} | ||||
| 60 | + {me.city && <span className="stat-chip">{me.city}</span>} | ||||
| 61 | + {me.age != null && <span className="stat-chip">{me.age} yrs</span>} | ||||
| 62 | + {me.website && ( | ||||
| 63 | + <a className="stat-chip" href={socialUrl("website", me.website)} | ||||
| 64 | + target="_blank" rel="noopener noreferrer">Website ↗</a> | ||||
| 65 | + )} | ||||
| 66 | + {socialLinks.map((s) => ( | ||||
| 67 | + <a key={s.key} className="stat-chip hub-social-chip" | ||||
| 68 | + href={socialUrl(s.key, me.socials![s.key]!)} | ||||
| 69 | + target="_blank" rel="noopener noreferrer" title={s.label}> | ||||
| 70 | + <Ico name={s.key} size={15} /> {s.label} | ||||
| 71 | + </a> | ||||
| 72 | + ))} | ||||
| 73 | + </div> | ||||
| 74 | + <div> | ||||
| 75 | + <a className="btn btn-primary" | ||||
| 76 | + href="https://www.groupe-ka.com/compte" | ||||
| 77 | + target="_blank" rel="noopener noreferrer"> | ||||
| 78 | + Edit my profile on groupe-ka.com ↗ | ||||
| 79 | + </a> | ||||
| 80 | + </div> | ||||
| 81 | + <p className="hub-hint"> | ||||
| 82 | + Your profile is managed at the group level: enter it once, visible on | ||||
| 83 | + every Groupe KA platform. | ||||
| 84 | + </p> | ||||
| 85 | + </section> | ||||
| 86 | + ); | ||||
| 87 | +} | ||||
| 88 | + | ||||
| 89 | +/** House-Ka favourites from the "My Ka universe" central store. */ | ||||
| 90 | +function FavouritesSection() { | ||||
| 91 | + const [items, setItems] = useState<FavItem[] | null>(null); | ||||
| 92 | + useEffect(() => { | ||||
| 93 | + fetchFavorites().then((f) => setItems(f.items)).catch(() => setItems([])); | ||||
| 94 | + }, []); | ||||
| 95 | + if (items === null || items.length === 0) return null; | ||||
| 96 | + return ( | ||||
| 97 | + <section className="hub-profile"> | ||||
| 98 | + <h3>My favourites ({items.length})</h3> | ||||
| 99 | + <div className="dups-list"> | ||||
| 100 | + {items.map((f) => ( | ||||
| 101 | + <Link key={f.item_id} className="dup-item" to={f.url}> | ||||
| 102 | + <span className="dup-src">{f.price_label}</span> | ||||
| 103 | + <span className="dup-broker">{f.title}{f.subtitle ? ` — ${f.subtitle}` : ""}</span> | ||||
| 104 | + <span className="dup-go">See the listing <Ico name="arrow" size={13} /></span> | ||||
| 105 | + </Link> | ||||
| 106 | + ))} | ||||
| 107 | + </div> | ||||
| 108 | + <p className="hub-hint"> | ||||
| 109 | + Your ♥ favourites follow you across the whole Groupe KA ecosystem — | ||||
| 110 | + manage them on <a href="https://www.groupe-ka.com/mon-ka" target="_blank" | ||||
| 111 | + rel="noopener noreferrer">groupe-ka.com/mon-ka</a>. | ||||
| 112 | + </p> | ||||
| 113 | + </section> | ||||
| 114 | + ); | ||||
| 115 | +} | ||||
| 116 | + | ||||
| 117 | +export default function AccountPage() { | ||||
| 118 | + const [me, setMe] = useState<User | null | undefined>(undefined); // undefined = loading | ||||
| 119 | + const [copied, setCopied] = useState(false); | ||||
| 120 | + const nav = useNavigate(); | ||||
| 121 | + | ||||
| 122 | + useEffect(() => { | ||||
| 123 | + document.title = "My account | House-Ka"; | ||||
| 124 | + fetchMe().then((r) => setMe(r.user)).catch(() => setMe(null)); | ||||
| 125 | + }, []); | ||||
| 126 | + | ||||
| 127 | + const copyKaId = async () => { | ||||
| 128 | + if (!me?.ka_id) return; | ||||
| 129 | + try { | ||||
| 130 | + await navigator.clipboard.writeText(me.ka_id); | ||||
| 131 | + setCopied(true); | ||||
| 132 | + setTimeout(() => setCopied(false), 1800); | ||||
| 133 | + } catch { /* clipboard unavailable: too bad */ } | ||||
| 134 | + }; | ||||
| 135 | + | ||||
| 136 | + if (me === undefined) { | ||||
| 137 | + return <div className="container profil"><div className="notice">Loading…</div></div>; | ||||
| 138 | + } | ||||
| 139 | + | ||||
| 140 | + if (me === null) { | ||||
| 141 | + return ( | ||||
| 142 | + <div className="container profil"> | ||||
| 143 | + <span className="kicker">My account</span> | ||||
| 144 | + <h1>Sign in for <span className="hl">your profile</span>.</h1> | ||||
| 145 | + <p className="lede"> | ||||
| 146 | + Sign in with your <b>KA ID</b> account — you get your member | ||||
| 147 | + identifier, valid across the whole Groupe KA ecosystem. | ||||
| 148 | + </p> | ||||
| 149 | + <a className="btn btn-primary" href="/api/auth/ka/login?next=%2Faccount"> | ||||
| 150 | + Sign in with KA ID | ||||
| 151 | + </a> | ||||
| 152 | + </div> | ||||
| 153 | + ); | ||||
| 154 | + } | ||||
| 155 | + | ||||
| 156 | + return ( | ||||
| 157 | + <div className="container profil"> | ||||
| 158 | + <span className="kicker">My account</span> | ||||
| 159 | + <h1> | ||||
| 160 | + {me.name ? <>Hi, <span className="hl">{me.name.split(" ")[0]}</span>.</> | ||||
| 161 | + : <>Your <span className="hl">profile</span>.</>} | ||||
| 162 | + </h1> | ||||
| 163 | + | ||||
| 164 | + {/* ——— Groupe KA membership card ——— */} | ||||
| 165 | + <div className="pc" role="img" aria-label={`Membership card ${me.ka_id || me.sub}`}> | ||||
| 166 | + <div className="pc-watermark" aria-hidden="true">KA</div> | ||||
| 167 | + <div className="pc-head"> | ||||
| 168 | + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span> | ||||
| 169 | + <span className="pc-label">Membership card · Groupe KA</span> | ||||
| 170 | + </div> | ||||
| 171 | + <div className="pc-id-block"> | ||||
| 172 | + <span className="pc-id-label">KA-ID</span> | ||||
| 173 | + <span className="pc-id">{me.ka_id || "—"}</span> | ||||
| 174 | + </div> | ||||
| 175 | + <div className="pc-foot"> | ||||
| 176 | + <div className="pc-holder"> | ||||
| 177 | + <span className="pc-holder-name">{me.name || me.email}</span> | ||||
| 178 | + {me.role_label && ( | ||||
| 179 | + <span className="pc-role-badge">{me.role_label}</span> | ||||
| 180 | + )} | ||||
| 181 | + <span className="pc-holder-since">Member since {fmtEpoch(me.created_at)}</span> | ||||
| 182 | + </div> | ||||
| 183 | + {me.picture && ( | ||||
| 184 | + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" /> | ||||
| 185 | + )} | ||||
| 186 | + </div> | ||||
| 187 | + <div className="pc-strip" aria-hidden="true"> | ||||
| 188 | + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)} | ||||
| 189 | + </div> | ||||
| 190 | + </div> | ||||
| 191 | + | ||||
| 192 | + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}> | ||||
| 193 | + {copied ? "✓ Copied" : "Copy my KA-ID"} | ||||
| 194 | + </button> | ||||
| 195 | + | ||||
| 196 | + {/* ——— Details ——— */} | ||||
| 197 | + <section className="profil-grid"> | ||||
| 198 | + <div className="pg-item"> | ||||
| 199 | + <span className="pg-label">Name</span> | ||||
| 200 | + <span className="pg-value">{me.name || "—"}</span> | ||||
| 201 | + </div> | ||||
| 202 | + <div className="pg-item"> | ||||
| 203 | + <span className="pg-label">Email</span> | ||||
| 204 | + <span className="pg-value">{me.email || "—"}</span> | ||||
| 205 | + </div> | ||||
| 206 | + <div className="pg-item"> | ||||
| 207 | + <span className="pg-label">Member ID</span> | ||||
| 208 | + <span className="pg-value mono">{me.ka_id || me.sub}</span> | ||||
| 209 | + </div> | ||||
| 210 | + <div className="pg-item"> | ||||
| 211 | + <span className="pg-label">Sign-in</span> | ||||
| 212 | + <span className="pg-value"> | ||||
| 213 | + {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Google account"} | ||||
| 214 | + </span> | ||||
| 215 | + </div> | ||||
| 216 | + {me.city && ( | ||||
| 217 | + <div className="pg-item"> | ||||
| 218 | + <span className="pg-label">City</span> | ||||
| 219 | + <span className="pg-value">{me.city}</span> | ||||
| 220 | + </div> | ||||
| 221 | + )} | ||||
| 222 | + {me.role_label && ( | ||||
| 223 | + <div className="pg-item"> | ||||
| 224 | + <span className="pg-label">Status</span> | ||||
| 225 | + <span className="pg-value">{me.role_label}</span> | ||||
| 226 | + </div> | ||||
| 227 | + )} | ||||
| 228 | + <div className="pg-item"> | ||||
| 229 | + <span className="pg-label">Member since</span> | ||||
| 230 | + <span className="pg-value">{fmtEpoch(me.created_at)}</span> | ||||
| 231 | + </div> | ||||
| 232 | + <div className="pg-item"> | ||||
| 233 | + <span className="pg-label">Last sign-in</span> | ||||
| 234 | + <span className="pg-value">{fmtEpoch(me.last_login)}</span> | ||||
| 235 | + </div> | ||||
| 236 | + </section> | ||||
| 237 | + | ||||
| 238 | + <FavouritesSection /> | ||||
| 239 | + | ||||
| 240 | + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />} | ||||
| 241 | + | ||||
| 242 | + {me.profile_source === "groupe-ka" && me.public && me.public_url && ( | ||||
| 243 | + <p className="profil-note"> | ||||
| 244 | + Your public profile:{" "} | ||||
| 245 | + <a href={me.public_url} target="_blank" rel="noopener noreferrer" className="mono"> | ||||
| 246 | + {me.public_url.replace(/^https?:\/\//, "")} | ||||
| 247 | + </a>{" "} | ||||
| 248 | + — visibility is managed on groupe-ka.com/compte. | ||||
| 249 | + </p> | ||||
| 250 | + )} | ||||
| 251 | + | ||||
| 252 | + <p className="profil-note"> | ||||
| 253 | + Your <b>KA-ID</b> is your unique identifier across the{" "} | ||||
| 254 | + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"> | ||||
| 255 | + Groupe KA | ||||
| 256 | + </a>{" "} | ||||
| 257 | + ecosystem — it follows you on every platform of the group. House-Ka | ||||
| 258 | + keeps only the information on this profile; nothing else, never sold. | ||||
| 259 | + </p> | ||||
| 260 | + | ||||
| 261 | + {/* ——— Actions ——— */} | ||||
| 262 | + <div className="profil-actions"> | ||||
| 263 | + <button | ||||
| 264 | + className="btn btn-ghost" | ||||
| 265 | + onClick={async () => { await logout(); nav("/"); window.location.reload(); }} | ||||
| 266 | + > | ||||
| 267 | + Sign out | ||||
| 268 | + </button> | ||||
| 269 | + </div> | ||||
| 270 | + </div> | ||||
| 271 | + ); | ||||
| 272 | +} | ||||
modified
frontend/src/pages/Listing.tsx
+15 −1
@@ -18,6 +18,7 @@ import NearbyPlaces from "../components/NearbyPlaces"; | |||||
| 18 | import { Ico } from "../components/Icons"; | 18 | import { Ico } from "../components/Icons"; | ||
| 19 | import AmenityIco from "../components/AmenityIco"; | 19 | import AmenityIco from "../components/AmenityIco"; | ||
| 20 | import { TypeFallback } from "../components/PropertyImg"; | 20 | import { TypeFallback } from "../components/PropertyImg"; | ||
| 21 | +import { useAccount } from "../account"; | ||||
| 21 | 22 | ||||
| 22 | // --- Lightbox: pinch to zoom + pan + swipe between photos --------------------- | 23 | // --- Lightbox: pinch to zoom + pan + swipe between photos --------------------- | ||
| 23 | function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) { | 24 | function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) { | ||
@@ -207,6 +208,8 @@ export default function ListingPage() { | |||||
| 207 | window.scrollTo(0, 0); | 208 | window.scrollTo(0, 0); | ||
| 208 | }, [uid]); | 209 | }, [uid]); | ||
| 209 | 210 | ||||
| 211 | + const { favs, toggleFav, enabled: accountEnabled } = useAccount(); | ||||
| 212 | + | ||||
| 210 | if (error) | 213 | if (error) | ||
| 211 | return ( | 214 | return ( | ||
| 212 | <div className="notice container"> | 215 | <div className="notice container"> | ||
@@ -290,7 +293,18 @@ export default function ListingPage() { | |||||
| 290 | {l.price != null && l.area_sqft != null && l.area_sqft > 200 && ( | 293 | {l.price != null && l.area_sqft != null && l.area_sqft > 200 && ( | ||
| 291 | <div className="price-sub">${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area</div> | 294 | <div className="price-sub">${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area</div> | ||
| 292 | )} | 295 | )} | ||
| 293 | − <h1>{l.address || l.title}</h1> | 296 | + <h1> | ||
| 297 | + {l.address || l.title} | ||||
| 298 | + {accountEnabled && ( | ||||
| 299 | + <button | ||||
| 300 | + className={`fav-heart ${favs.has(l.uid) ? "on" : ""}`} | ||||
| 301 | + aria-label={favs.has(l.uid) ? "Remove from favourites" : "Add to favourites"} | ||||
| 302 | + onClick={() => toggleFav(l)} | ||||
| 303 | + > | ||||
| 304 | + {favs.has(l.uid) ? "♥" : "♡"} | ||||
| 305 | + </button> | ||||
| 306 | + )} | ||||
| 307 | + </h1> | ||||
| 294 | <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div> | 308 | <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div> | ||
| 295 | 309 | ||||
| 296 | <div className="spec-list"> | 310 | <div className="spec-list"> | ||
modified
frontend/src/styles.css
+10 −0
@@ -1825,3 +1825,13 @@ body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !importan | |||||
| 1825 | .hk-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); } | 1825 | .hk-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); } | ||
| 1826 | } | 1826 | } | ||
| 1827 | .legal .legal-date { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; } | 1827 | .legal .legal-date { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; } | ||
| 1828 | + | ||||
| 1829 | +/* ♥ favourite on the listing hero (KA ID account) */ | ||||
| 1830 | +.fav-heart { | ||||
| 1831 | + margin-left: 10px; border: 1.5px solid var(--hairline-strong, #d8d2c6); | ||||
| 1832 | + background: transparent; border-radius: 999px; width: 40px; height: 40px; | ||||
| 1833 | + font-size: 20px; line-height: 1; cursor: pointer; color: var(--ink-2); | ||||
| 1834 | + vertical-align: middle; transition: all 0.15s ease; | ||||
| 1835 | +} | ||||
| 1836 | +.fav-heart:hover { border-color: var(--accent); color: var(--accent); } | ||||
| 1837 | +.fav-heart.on { background: var(--accent); border-color: var(--accent); color: #fff; } | ||||
modified
immoka/auth.py
+3 −3
@@ -12,7 +12,7 @@ | |||||
| 12 | # | 12 | # | ||
| 13 | # URLs de rappel à autoriser dans Google Cloud Console (Identifiants → | 13 | # URLs de rappel à autoriser dans Google Cloud Console (Identifiants → | ||
| 14 | # ID client OAuth « Groupe-Ka ») : | 14 | # ID client OAuth « Groupe-Ka ») : | ||
| 15 | −# https://www.immo-ka.com/api/auth/google/callback | 15 | +# https://www.house-ka.com/api/auth/google/callback | ||
| 16 | # https://www.lou-ka.com/api/auth/google/callback | 16 | # https://www.lou-ka.com/api/auth/google/callback | ||
| 17 | # http://localhost:8090/api/auth/google/callback (développement) | 17 | # http://localhost:8090/api/auth/google/callback (développement) | ||
| 18 | # | 18 | # | ||
@@ -257,7 +257,7 @@ def _ka_verify(token: str) -> dict | None: | |||||
| 257 | claims = json.loads(_unb64(body)) | 257 | claims = json.loads(_unb64(body)) | ||
| 258 | if claims.get("iss") != KA_HUB_URL: | 258 | if claims.get("iss") != KA_HUB_URL: | ||
| 259 | return None | 259 | return None | ||
| 260 | − if claims.get("aud") != "immo-ka": | 260 | + if claims.get("aud") != "house-ka": | ||
| 261 | return None | 261 | return None | ||
| 262 | if claims.get("exp", 0) < time.time(): | 262 | if claims.get("exp", 0) < time.time(): | ||
| 263 | return None | 263 | return None | ||
@@ -274,7 +274,7 @@ def ka_login(next: str = "/"): | |||||
| 274 | status_code=503) | 274 | status_code=503) | ||
| 275 | state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | 275 | state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | ||
| 276 | params = { | 276 | params = { | ||
| 277 | − "client_id": "immo-ka", | 277 | + "client_id": "house-ka", | ||
| 278 | "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", | 278 | "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", | ||
| 279 | "state": state, | 279 | "state": state, | ||
| 280 | } | 280 | } | ||
modified
immoka/hubfav.py
+1 −1
@@ -19,7 +19,7 @@ import time | |||||
| 19 | 19 | ||||
| 20 | import requests | 20 | import requests | ||
| 21 | 21 | ||||
| 22 | −CLIENT_ID = "immo-ka" | 22 | +CLIENT_ID = "house-ka" | ||
| 23 | KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | 23 | KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | ||
| 24 | LIST_TTL = 30 # secondes — cache mémoire de hub_list par ka_id | 24 | LIST_TTL = 30 # secondes — cache mémoire de hub_list par ka_id | ||
| 25 | 25 | ||||
modified
immoka/hubprofile.py
+1 −1
@@ -23,7 +23,7 @@ from datetime import datetime, timezone | |||||
| 23 | import requests | 23 | import requests | ||
| 24 | 24 | ||||
| 25 | KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | 25 | KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | ||
| 26 | −CLIENT_ID = "immo-ka" | 26 | +CLIENT_ID = "house-ka" | ||
| 27 | CACHE_TTL = 60 # secondes | 27 | CACHE_TTL = 60 # secondes | ||
| 28 | TIMEOUT = 5 # secondes | 28 | TIMEOUT = 5 # secondes | ||
| 29 | 29 | ||||
modified
immoka/normalize.py
+1 −1
@@ -193,7 +193,7 @@ def parse_year(text) -> int | None: | |||||
| 193 | _TYPE_MAP = [ | 193 | _TYPE_MAP = [ | ||
| 194 | # (keywords in the normalized source text, canonical type) | 194 | # (keywords in the normalized source text, canonical type) | ||
| 195 | (("maison mobile", "unimodulaire", "mobile home", "manufactured home", | 195 | (("maison mobile", "unimodulaire", "mobile home", "manufactured home", | ||
| 196 | − "modular",), "Mobile home"), | 196 | + "mfghome", "mfg home", "modular",), "Mobile home"), | ||
| 197 | (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",), | 197 | (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",), | ||
| 198 | "Semi-detached"), | 198 | "Semi-detached"), | ||
| 199 | (("maison de ville", "townhouse", "town house", "en rangee", "row house", | 199 | (("maison de ville", "townhouse", "town house", "en rangee", "row house", | ||
| 200 | 200 | ||||