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 | 7 | import { useEffect, useState } from "react"; |
| 8 | 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; |
| 9 | 9 | import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; |
| 10 | +import { kaLogin, useAccount } from "./account"; | |
| 11 | +import AccountPage from "./pages/Account"; | |
| 10 | 12 | import AgenciesPage from "./pages/Agencies"; |
| 11 | 13 | import ContactPage from "./pages/Contact"; |
| 12 | 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 | 117 | const NAV_LINKS = [ |
| 50 | 118 | { to: "/", label: "Homes", icon: "home", end: true }, |
| 51 | 119 | { to: "/?view=map", label: "Map", icon: "map", end: false, force: true }, |
@@ -90,6 +158,9 @@ function Header() { | ||
| 90 | 158 | Contact |
| 91 | 159 | </NavLink> |
| 92 | 160 | </nav> |
| 161 | + <div className="header-acct"> | |
| 162 | + <AccountNav /> | |
| 163 | + </div> | |
| 93 | 164 | <button |
| 94 | 165 | className={`menu-btn ${open ? "open" : ""}`} |
| 95 | 166 | aria-expanded={open} |
@@ -119,6 +190,7 @@ function Header() { | ||
| 119 | 190 | <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span> |
| 120 | 191 | </NavLink> |
| 121 | 192 | ))} |
| 193 | + <AccountMobile close={() => setOpen(false)} /> | |
| 122 | 194 | <div className="mm-foot"> |
| 123 | 195 | <p> |
| 124 | 196 | Independent aggregator — continuously updated, every listing links |
@@ -142,7 +214,7 @@ function MobileTabBar() { | ||
| 142 | 214 | { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap }, |
| 143 | 215 | { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap }, |
| 144 | 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 | 219 | return ( |
| 148 | 220 | <nav className="tabbar" aria-label="Mobile navigation"> |
@@ -217,6 +289,7 @@ export default function App() { | ||
| 217 | 289 | <Route path="/rates" element={<RatesPage />} /> |
| 218 | 290 | <Route path="/agencies" element={<AgenciesPage />} /> |
| 219 | 291 | <Route path="/contact" element={<ContactPage />} /> |
| 292 | + <Route path="/account" element={<AccountPage />} /> | |
| 220 | 293 | <Route path="/terms" element={<TermsPage />} /> |
| 221 | 294 | <Route path="/privacy" element={<PrivacyPage />} /> |
| 222 | 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 | 383 | get<CommercesNearby>( |
| 384 | 384 | `/api/commerces?lat=${lat}&lng=${lng}` + |
| 385 | 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 | 8 | import ReactDOM from "react-dom/client"; |
| 9 | 9 | import { BrowserRouter } from "react-router-dom"; |
| 10 | 10 | import App from "./App"; |
| 11 | +import { AccountProvider } from "./account"; | |
| 11 | 12 | import "./ka/tokens.css"; |
| 12 | 13 | import "./styles.css"; |
| 13 | 14 | |
| 14 | 15 | ReactDOM.createRoot(document.getElementById("root")!).render( |
| 15 | 16 | <React.StrictMode> |
| 16 | 17 | <BrowserRouter> |
| 17 | − <App /> | |
| 18 | + <AccountProvider> | |
| 19 | + <App /> | |
| 20 | + </AccountProvider> | |
| 18 | 21 | </BrowserRouter> |
| 19 | 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 | 18 | import { Ico } from "../components/Icons"; |
| 19 | 19 | import AmenityIco from "../components/AmenityIco"; |
| 20 | 20 | import { TypeFallback } from "../components/PropertyImg"; |
| 21 | +import { useAccount } from "../account"; | |
| 21 | 22 | |
| 22 | 23 | // --- Lightbox: pinch to zoom + pan + swipe between photos --------------------- |
| 23 | 24 | function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) { |
@@ -207,6 +208,8 @@ export default function ListingPage() { | ||
| 207 | 208 | window.scrollTo(0, 0); |
| 208 | 209 | }, [uid]); |
| 209 | 210 | |
| 211 | + const { favs, toggleFav, enabled: accountEnabled } = useAccount(); | |
| 212 | + | |
| 210 | 213 | if (error) |
| 211 | 214 | return ( |
| 212 | 215 | <div className="notice container"> |
@@ -290,7 +293,18 @@ export default function ListingPage() { | ||
| 290 | 293 | {l.price != null && l.area_sqft != null && l.area_sqft > 200 && ( |
| 291 | 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 | 308 | <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div> |
| 295 | 309 | |
| 296 | 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 | 1825 | .hk-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); } |
| 1826 | 1826 | } |
| 1827 | 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 | 13 | # URLs de rappel à autoriser dans Google Cloud Console (Identifiants → |
| 14 | 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 | 16 | # https://www.lou-ka.com/api/auth/google/callback |
| 17 | 17 | # http://localhost:8090/api/auth/google/callback (développement) |
| 18 | 18 | # |
@@ -257,7 +257,7 @@ def _ka_verify(token: str) -> dict | None: | ||
| 257 | 257 | claims = json.loads(_unb64(body)) |
| 258 | 258 | if claims.get("iss") != KA_HUB_URL: |
| 259 | 259 | return None |
| 260 | − if claims.get("aud") != "immo-ka": | |
| 260 | + if claims.get("aud") != "house-ka": | |
| 261 | 261 | return None |
| 262 | 262 | if claims.get("exp", 0) < time.time(): |
| 263 | 263 | return None |
@@ -274,7 +274,7 @@ def ka_login(next: str = "/"): | ||
| 274 | 274 | status_code=503) |
| 275 | 275 | state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) |
| 276 | 276 | params = { |
| 277 | − "client_id": "immo-ka", | |
| 277 | + "client_id": "house-ka", | |
| 278 | 278 | "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", |
| 279 | 279 | "state": state, |
| 280 | 280 | } |
modified
immoka/hubfav.py
+1 −1
@@ -19,7 +19,7 @@ import time | ||
| 19 | 19 | |
| 20 | 20 | import requests |
| 21 | 21 | |
| 22 | −CLIENT_ID = "immo-ka" | |
| 22 | +CLIENT_ID = "house-ka" | |
| 23 | 23 | KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") |
| 24 | 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 | 23 | import requests |
| 24 | 24 | |
| 25 | 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 | 27 | CACHE_TTL = 60 # secondes |
| 28 | 28 | TIMEOUT = 5 # secondes |
| 29 | 29 | |
modified
immoka/normalize.py
+1 −1
@@ -193,7 +193,7 @@ def parse_year(text) -> int | None: | ||
| 193 | 193 | _TYPE_MAP = [ |
| 194 | 194 | # (keywords in the normalized source text, canonical type) |
| 195 | 195 | (("maison mobile", "unimodulaire", "mobile home", "manufactured home", |
| 196 | − "modular",), "Mobile home"), | |
| 196 | + "mfghome", "mfg home", "modular",), "Mobile home"), | |
| 197 | 197 | (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",), |
| 198 | 198 | "Semi-detached"), |
| 199 | 199 | (("maison de ville", "townhouse", "town house", "en rangee", "row house", |
| 200 | 200 | |