SPB Git

spb/house-ka

Public

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
Simon-Pierre Boucher committed (Aug 28, 2026) parent 3659ba5 Browse files

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 ? `&region=${encodeURIComponent(region)}` : "")); 385 (region ? `&region=${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
Menu

Theme