SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%

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 27 days ago (Aug 28, 2026) parent 3659ba5

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 ? `&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 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