SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Comptes v2 — KA-ID, profil « carte de membre », rôles, favoris, pages gestionnaires

- KA-ID : identifiant membre unique « ka- » + 10 chiffres, attribué à la
  création du compte (index unique, backfill au premier /api/me)
- /profil : carte de membre Groupe KA (encre + lime, KA-ID en vedette, avatar,
  ancienneté, bande décorative), infos du compte, copie du KA-ID
- Profil PUBLIC opt-in : /u/{ka_id} (nom, avatar, ancienneté — jamais le
  courriel ; 404 indistinguable si non public), toggle + lien copiable
- Rôles à l'intégration (/bienvenue après le premier login Google) :
  · locataire — favoris (cœur sur les cartes, page /favoris, toggle optimiste)
  · gestionnaire — réclame la page de SA source (1/compte, 1 compte/source)
    et la personnalise (/gestion : accroche, description, contact, logo) ;
    page publique /g/{source} avec inventaire relié + pagination
- louka/accounts.py (rôles, favoris, source_profiles), tables favorites +
  source_profiles + colonnes users (ka_id, public, role, org_source)
- Contexte de compte partagé (account.tsx) : header, cartes et pages reliés
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 242723e

16 changed files +1,469 −12

modified frontend/src/App.tsx +57 −6
@@ -6,9 +6,10 @@
6 6 import { useEffect, useState } from "react";
7 7 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 8 import {
9 − Me, fetchAuthConfig, fetchFacets, fetchMe, fetchSources, fetchStats,
9 + fetchAuthConfig, fetchFacets, fetchSources, fetchStats,
10 10 logout, registerSourceNames, sourceName,
11 11 } from "./api";
12 +import { AccountProvider, useAccount } from "./account";
12 13 import CookieConsent from "./components/CookieConsent";
13 14 import {
14 15 IcoChart, IcoCompass, IcoDoc, IcoFolder, IcoHouse, IcoLock, IcoMap,
@@ -18,6 +19,12 @@ import ListingPage from "./pages/Listing";
18 19 import BotPage from "./pages/Bot";
19 20 import PasserellePage from "./pages/Passerelle";
20 21 import PrivacyPage from "./pages/Privacy";
22 +import ProfilePage from "./pages/Profile";
23 +import PublicProfilePage from "./pages/PublicProfile";
24 +import BienvenuePage from "./pages/Bienvenue";
25 +import FavorisPage from "./pages/Favoris";
26 +import GestionPage from "./pages/Gestion";
27 +import GestionPublicPage from "./pages/GestionPublic";
21 28 import TermsPage from "./pages/Terms";
22 29 import SourcesPage from "./pages/Sources";
23 30 import StatsPage from "./pages/Stats";
@@ -70,12 +77,11 @@ const NAV_LINKS = [
70 77 /** Bouton « Connexion » ou avatar + menu compte (connexion Google) */
71 78 function AccountMenu() {
72 79 const [enabled, setEnabled] = useState(false);
73 − const [me, setMe] = useState<Me | null>(null);
80 + const { me, refresh } = useAccount();
74 81 const [menuOpen, setMenuOpen] = useState(false);
75 82
76 83 useEffect(() => {
77 84 fetchAuthConfig().then((c) => setEnabled(c.google)).catch(() => {});
78 − fetchMe().then(setMe);
79 85 }, []);
80 86
81 87 if (!enabled) return null;
@@ -111,10 +117,49 @@ function AccountMenu() {
111 117 <div className="account-id">
112 118 <b>{me.name || "Mon compte"}</b>
113 119 <span>{me.email}</span>
120 + {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}
114 121 </div>
122 + <NavLink
123 + to="/profil"
124 + role="menuitem"
125 + className="account-link"
126 + onClick={() => setMenuOpen(false)}
127 + >
128 + Mon profil
129 + </NavLink>
130 + {me.role === "locataire" && (
131 + <NavLink
132 + to="/favoris"
133 + role="menuitem"
134 + className="account-link"
135 + onClick={() => setMenuOpen(false)}
136 + >
137 + Mes favoris
138 + </NavLink>
139 + )}
140 + {me.role === "gestionnaire" && (
141 + <NavLink
142 + to="/gestion"
143 + role="menuitem"
144 + className="account-link"
145 + onClick={() => setMenuOpen(false)}
146 + >
147 + Ma page gestion
148 + </NavLink>
149 + )}
150 + {!me.role && (
151 + <NavLink
152 + to="/bienvenue"
153 + role="menuitem"
154 + className="account-link"
155 + onClick={() => setMenuOpen(false)}
156 + >
157 + Choisir mon profil
158 + </NavLink>
159 + )}
115 160 <button
116 161 role="menuitem"
117 − onClick={async () => { await logout(); setMe(null); setMenuOpen(false); }}
162 + onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}
118 163 >
119 164 Se déconnecter
120 165 </button>
@@ -238,7 +283,7 @@ function Footer() {
238 283
239 284 export default function App() {
240 285 return (
241 − <>
286 + <AccountProvider>
242 287 <Header />
243 288 <main>
244 289 <Routes>
@@ -248,6 +293,12 @@ export default function App() {
248 293 <Route path="/sources" element={<SourcesPage />} />
249 294 <Route path="/confidentialite" element={<PrivacyPage />} />
250 295 <Route path="/conditions" element={<TermsPage />} />
296 + <Route path="/profil" element={<ProfilePage />} />
297 + <Route path="/u/:kaId" element={<PublicProfilePage />} />
298 + <Route path="/bienvenue" element={<BienvenuePage />} />
299 + <Route path="/favoris" element={<FavorisPage />} />
300 + <Route path="/gestion" element={<GestionPage />} />
301 + <Route path="/g/:sourceId" element={<GestionPublicPage />} />
251 302 <Route path="/passerelle/:uid" element={<PasserellePage />} />
252 303 <Route path="/bot" element={<BotPage />} />
253 304 <Route
@@ -264,6 +315,6 @@ export default function App() {
264 315 </main>
265 316 <Footer />
266 317 <CookieConsent />
267 − </>
318 + </AccountProvider>
268 319 );
269 320 }
added frontend/src/account.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// account.tsx : contexte de compte partagé — profil connecté + favoris
5 +// Un seul fetch au montage de l'app ; le cœur des cartes, le menu compte et
6 +// les pages Favoris/Gestion lisent le même état (toggle optimiste).
7 +// -----------------------------------------------------------------------------
8 +import {
9 + ReactNode, createContext, useCallback, useContext, useEffect, useState,
10 +} from "react";
11 +import { Me, addFavorite, fetchFavorites, fetchMe, removeFavorite } from "./api";
12 +
13 +interface AccountState {
14 + me: Me | null;
15 + loaded: boolean; // premier fetch terminé
16 + favs: Set<string>; // uids favoris
17 + toggleFav: (uid: string) => void;
18 + refresh: () => void; // re-fetch profil + favoris (après login/rôle)
19 +}
20 +
21 +const Ctx = createContext<AccountState>({
22 + me: null, loaded: false, favs: new Set(),
23 + toggleFav: () => {}, refresh: () => {},
24 +});
25 +
26 +export const useAccount = () => useContext(Ctx);
27 +
28 +export function AccountProvider({ children }: { children: ReactNode }) {
29 + const [me, setMe] = useState<Me | null>(null);
30 + const [loaded, setLoaded] = useState(false);
31 + const [favs, setFavs] = useState<Set<string>>(new Set());
32 +
33 + const refresh = useCallback(() => {
34 + fetchMe().then((m) => {
35 + setMe(m);
36 + setLoaded(true);
37 + if (m) {
38 + fetchFavorites()
39 + .then((f) => setFavs(new Set(f.uids)))
40 + .catch(() => setFavs(new Set()));
41 + } else {
42 + setFavs(new Set());
43 + }
44 + });
45 + }, []);
46 +
47 + useEffect(() => { refresh(); }, [refresh]);
48 +
49 + const toggleFav = useCallback((uid: string) => {
50 + setFavs((prev) => {
51 + const next = new Set(prev);
52 + if (next.has(uid)) {
53 + next.delete(uid);
54 + removeFavorite(uid).catch(() => {});
55 + } else {
56 + next.add(uid);
57 + addFavorite(uid).catch(() => {});
58 + }
59 + return next;
60 + });
61 + }, []);
62 +
63 + return (
64 + <Ctx.Provider value={{ me, loaded, favs, toggleFav, refresh }}>
65 + {children}
66 + </Ctx.Provider>
67 + );
68 +}
modified frontend/src/api.ts +58 −0
@@ -248,10 +248,68 @@ export const fetchStats = () => get<Stats>("/api/stats");
248 248 // -- compte utilisateur (connexion Google) ------------------------------------
249 249 export interface Me {
250 250 uid: number;
251 + ka_id: string; // identifiant membre « ka-0123456789 » (écosystème Groupe KA)
251 252 email: string;
252 253 name: string;
253 254 picture: string;
255 + public: boolean; // profil public /u/{ka_id} activé (opt-in)
256 + role: "locataire" | "gestionnaire" | null;
257 + org_source: string | null; // source réclamée (gestionnaire)
258 + created_at: number | null;
259 + last_login: number | null;
260 + provider: string;
254 261 }
262 +
263 +export const setRole = (role: "locataire" | "gestionnaire") =>
264 + fetch(`/api/me/role?role=${role}`, { method: "POST" });
265 +
266 +// -- favoris (locataire) -------------------------------------------------------
267 +export const fetchFavorites = () =>
268 + get<{ uids: string[]; listings: Listing[] }>("/api/favorites");
269 +export const addFavorite = (uid: string) =>
270 + fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "POST" });
271 +export const removeFavorite = (uid: string) =>
272 + fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "DELETE" });
273 +
274 +// -- page gestionnaire ---------------------------------------------------------
275 +export interface OrgProfile {
276 + source: string;
277 + name: string;
278 + active_listings: number;
279 + claimed?: boolean;
280 + tagline?: string;
281 + description?: string;
282 + website?: string;
283 + phone?: string;
284 + email?: string;
285 + logo_url?: string;
286 +}
287 +export const fetchMyOrg = () =>
288 + get<{ source: string | null; name?: string; active_listings?: number;
289 + profile?: Record<string, string> }>("/api/org/mine");
290 +export const claimOrg = (source: string) =>
291 + fetch(`/api/org/claim?source=${encodeURIComponent(source)}`, { method: "POST" });
292 +export const updateOrg = (fields: {
293 + tagline: string; description: string; website: string;
294 + phone: string; email: string; logo_url: string;
295 +}) => fetch("/api/org", {
296 + method: "PUT",
297 + headers: { "Content-Type": "application/json" },
298 + body: JSON.stringify(fields),
299 +});
300 +export const fetchOrg = (sourceId: string) =>
301 + get<OrgProfile>(`/api/org/${encodeURIComponent(sourceId)}`);
302 +
303 +export interface PublicProfile {
304 + ka_id: string;
305 + name: string;
306 + picture: string;
307 + created_at: number | null;
308 +}
309 +export const fetchPublicProfile = (kaId: string) =>
310 + get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`);
311 +export const setPublicProfile = (enabled: boolean) =>
312 + fetch(`/api/me/public?enabled=${enabled}`, { method: "POST" });
255 313 /** Profil connecté, ou null (401 = simplement pas connecté). */
256 314 export async function fetchMe(): Promise<Me | null> {
257 315 try {
modified frontend/src/components/Icons.tsx +9 −0
@@ -152,6 +152,15 @@ export const IcoDoc = (p: P) => (
152 152 </Base>
153 153 );
154 154
155 +export const IcoHeart = (p: P & { filled?: boolean }) => {
156 + const { filled, ...rest } = p;
157 + return (
158 + <Base {...rest} fill={filled ? "currentColor" : "none"}>
159 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
160 + </Base>
161 + );
162 +};
163 +
155 164 export const IcoChevronLeft = (p: P) => (
156 165 <Base {...p}>
157 166 <path d="m14.5 5-7 7 7 7" />
modified frontend/src/components/ListingCard.tsx +14 −1
@@ -5,10 +5,13 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { Link } from "react-router-dom";
7 7 import { Listing, fmtPrice, sourceName } from "../api";
8 −import { IcoBuilding, IcoCamera } from "./Icons";
8 +import { useAccount } from "../account";
9 +import { IcoBuilding, IcoCamera, IcoHeart } from "./Icons";
9 10
10 11 export default function ListingCard({ l }: { l: Listing }) {
11 12 const img = l.images && l.images.length > 0 ? l.images[0] : null;
13 + const { me, favs, toggleFav } = useAccount();
14 + const fav = favs.has(l.uid);
12 15 return (
13 16 <Link to={`/logement/${encodeURIComponent(l.uid)}`} className="card">
14 17 <div className="card-img">
@@ -21,6 +24,16 @@ export default function ListingCard({ l }: { l: Listing }) {
21 24 {l.images.length > 1 && (
22 25 <span className="badge right"><IcoCamera size={12} /> {l.images.length}</span>
23 26 )}
27 + {me && (
28 + <button
29 + className={`fav-btn ${fav ? "on" : ""}`}
30 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
31 + aria-pressed={fav}
32 + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(l.uid); }}
33 + >
34 + <IcoHeart size={16} filled={fav} />
35 + </button>
36 + )}
24 37 </div>
25 38 <div className="card-body">
26 39 <div className="card-price">
added frontend/src/pages/Bienvenue.tsx +86 −0
@@ -0,0 +1,86 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Bienvenue.tsx : intégration après la création du compte — choix du rôle
5 +// « Je cherche un logement » (locataire : favoris…) ou « Je gère des
6 +// logements » (gestionnaire : page personnalisable de sa gestion).
7 +// -----------------------------------------------------------------------------
8 +import { useState } from "react";
9 +import { useNavigate } from "react-router-dom";
10 +import { setRole } from "../api";
11 +import { useAccount } from "../account";
12 +import { IcoBuilding, IcoHeart, IcoHouse, IcoSliders } from "../components/Icons";
13 +
14 +export default function BienvenuePage() {
15 + const { me, loaded, refresh } = useAccount();
16 + const [busy, setBusy] = useState<"locataire" | "gestionnaire" | null>(null);
17 + const nav = useNavigate();
18 +
19 + const choose = async (role: "locataire" | "gestionnaire") => {
20 + setBusy(role);
21 + await setRole(role);
22 + refresh();
23 + nav(role === "gestionnaire" ? "/gestion" : "/", { replace: true });
24 + };
25 +
26 + if (loaded && !me) {
27 + return (
28 + <div className="container profil">
29 + <span className="kicker">Bienvenue</span>
30 + <h1>Créez d'abord votre <span className="hl">compte</span>.</h1>
31 + <a className="btn btn-primary" href="/api/auth/google/login">
32 + Se connecter avec Google
33 + </a>
34 + </div>
35 + );
36 + }
37 +
38 + return (
39 + <div className="container bienvenue">
40 + <span className="kicker">Bienvenue{me?.name ? `, ${me.name.split(" ")[0]}` : ""}</span>
41 + <h1>Vous êtes plutôt<br /><span className="hl">quel profil</span> ?</h1>
42 + <p className="lede">
43 + Une dernière étape : dites-nous comment vous utiliserez Lou-Ka.
44 + Vous pourrez changer d'avis plus tard dans votre profil.
45 + </p>
46 +
47 + <div className="role-grid">
48 + <button
49 + className="role-card"
50 + disabled={busy !== null}
51 + onClick={() => choose("locataire")}
52 + >
53 + <span className="role-ico"><IcoHouse size={30} /></span>
54 + <span className="role-title">Je cherche un logement</span>
55 + <span className="role-desc">
56 + Locataire potentiel — magasinez parmi tous les logements du Québec.
57 + </span>
58 + <ul className="role-perks">
59 + <li><IcoHeart size={13} /> Favoris sur les annonces</li>
60 + <li><IcoSliders size={13} /> Filtres et recherche avancée</li>
61 + <li>Et d'autres outils à venir (alertes…)</li>
62 + </ul>
63 + <span className="role-cta">{busy === "locataire" ? "Un instant…" : "C'est moi →"}</span>
64 + </button>
65 +
66 + <button
67 + className="role-card"
68 + disabled={busy !== null}
69 + onClick={() => choose("gestionnaire")}
70 + >
71 + <span className="role-ico"><IcoBuilding size={30} /></span>
72 + <span className="role-title">Je gère des logements</span>
73 + <span className="role-desc">
74 + Gestionnaire ou propriétaire dont les logements sont affichés sur Lou-Ka.
75 + </span>
76 + <ul className="role-perks">
77 + <li>Réclamez la page de votre gestion</li>
78 + <li>Personnalisez-la : accroche, contact, logo</li>
79 + <li>Votre inventaire, relié automatiquement</li>
80 + </ul>
81 + <span className="role-cta">{busy === "gestionnaire" ? "Un instant…" : "C'est moi →"}</span>
82 + </button>
83 + </div>
84 + </div>
85 + );
86 +}
added frontend/src/pages/Favoris.tsx +61 −0
@@ -0,0 +1,61 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Favoris.tsx : les logements mis en favori par le compte connecté
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Listing, fetchFavorites } from "../api";
9 +import { useAccount } from "../account";
10 +import ListingCard from "../components/ListingCard";
11 +import { IcoHeart } from "../components/Icons";
12 +
13 +export default function FavorisPage() {
14 + const { me, loaded, favs } = useAccount();
15 + const [listings, setListings] = useState<Listing[] | null>(null);
16 +
17 + useEffect(() => {
18 + if (me) fetchFavorites().then((f) => setListings(f.listings)).catch(() => setListings([]));
19 + }, [me]);
20 +
21 + if (loaded && !me) {
22 + return (
23 + <div className="container profil">
24 + <span className="kicker">Mes favoris</span>
25 + <h1>Connectez-vous pour garder vos <span className="hl">coups de cœur</span>.</h1>
26 + <a className="btn btn-primary" href="/api/auth/google/login">
27 + Se connecter avec Google
28 + </a>
29 + </div>
30 + );
31 + }
32 +
33 + // ne montrer que ce qui est encore favori (toggle instantané sans re-fetch)
34 + const visible = (listings ?? []).filter((l) => favs.has(l.uid));
35 +
36 + return (
37 + <div className="container profil">
38 + <span className="kicker">Mes favoris</span>
39 + <h1>Vos <span className="hl">coups de cœur</span>.</h1>
40 +
41 + {listings === null && me && <div className="notice">Chargement…</div>}
42 +
43 + {listings !== null && visible.length === 0 && (
44 + <div className="notice">
45 + <div className="big"><IcoHeart size={40} /></div>
46 + <h2>Aucun favori pour l'instant</h2>
47 + <p>
48 + Touchez le cœur sur une annonce pour la retrouver ici.
49 + </p>
50 + <Link className="btn btn-primary" to="/">Explorer les logements</Link>
51 + </div>
52 + )}
53 +
54 + {visible.length > 0 && (
55 + <div className="grid" style={{ marginTop: 24 }}>
56 + {visible.map((l) => <ListingCard key={l.uid} l={l} />)}
57 + </div>
58 + )}
59 + </div>
60 + );
61 +}
added frontend/src/pages/Gestion.tsx +218 −0
@@ -0,0 +1,218 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Gestion.tsx : tableau de bord gestionnaire — réclamer SA page (source)
5 +// puis la personnaliser (accroche, description, coordonnées, logo). La page
6 +// publique correspondante vit sur /g/{source}.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useMemo, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import {
11 + Source, claimOrg, fetchMyOrg, fetchSources, updateOrg,
12 +} from "../api";
13 +import { useAccount } from "../account";
14 +import { IcoBuilding } from "../components/Icons";
15 +
16 +interface OrgMine {
17 + source: string | null;
18 + name?: string;
19 + active_listings?: number;
20 + profile?: Record<string, string>;
21 +}
22 +
23 +export default function GestionPage() {
24 + const { me, loaded, refresh } = useAccount();
25 + const [org, setOrg] = useState<OrgMine | null>(null);
26 + const [sources, setSources] = useState<Source[]>([]);
27 + const [pick, setPick] = useState("");
28 + const [saving, setSaving] = useState(false);
29 + const [saved, setSaved] = useState(false);
30 + const [error, setError] = useState("");
31 + const [form, setForm] = useState({
32 + tagline: "", description: "", website: "", phone: "", email: "", logo_url: "",
33 + });
34 +
35 + useEffect(() => {
36 + if (me) fetchMyOrg().then((o) => {
37 + setOrg(o);
38 + const p = o.profile ?? {};
39 + setForm({
40 + tagline: p.tagline ?? "", description: p.description ?? "",
41 + website: p.website ?? "", phone: p.phone ?? "",
42 + email: p.email ?? "", logo_url: p.logo_url ?? "",
43 + });
44 + }).catch(() => setOrg({ source: null }));
45 + }, [me]);
46 +
47 + useEffect(() => {
48 + if (me && org && !org.source) {
49 + fetchSources().then((r) => setSources(r.sources)).catch(() => {});
50 + }
51 + }, [me, org]);
52 +
53 + const claimable = useMemo(
54 + () => sources
55 + .filter((s) => s.connector && s.active_listings > 0)
56 + .sort((a, b) => a.name.localeCompare(b.name, "fr")),
57 + [sources]);
58 +
59 + if (loaded && !me) {
60 + return (
61 + <div className="container profil">
62 + <span className="kicker">Espace gestionnaire</span>
63 + <h1>Connectez-vous pour gérer <span className="hl">votre page</span>.</h1>
64 + <a className="btn btn-primary" href="/api/auth/google/login">
65 + Se connecter avec Google
66 + </a>
67 + </div>
68 + );
69 + }
70 +
71 + if (me && me.role !== "gestionnaire") {
72 + return (
73 + <div className="container profil">
74 + <span className="kicker">Espace gestionnaire</span>
75 + <h1>Réservé aux <span className="hl">gestionnaires</span>.</h1>
76 + <p className="lede">
77 + Votre compte est en mode locataire. Si vous gérez des logements
78 + affichés sur Lou-Ka, changez de profil.
79 + </p>
80 + <Link className="btn btn-primary" to="/bienvenue">Changer de profil</Link>
81 + </div>
82 + );
83 + }
84 +
85 + const doClaim = async () => {
86 + if (!pick) return;
87 + setError("");
88 + const res = await claimOrg(pick);
89 + if (!res.ok) {
90 + setError(res.status === 409
91 + ? "Cette page est déjà réclamée par un autre compte."
92 + : "Impossible de réclamer cette page.");
93 + return;
94 + }
95 + refresh();
96 + const o = await fetchMyOrg();
97 + setOrg(o);
98 + };
99 +
100 + const doSave = async () => {
101 + setSaving(true);
102 + setError("");
103 + const res = await updateOrg(form);
104 + setSaving(false);
105 + if (!res.ok) { setError("Enregistrement impossible — réessayez."); return; }
106 + setSaved(true);
107 + setTimeout(() => setSaved(false), 2000);
108 + };
109 +
110 + return (
111 + <div className="container profil">
112 + <span className="kicker">Espace gestionnaire</span>
113 +
114 + {org && !org.source && (
115 + <>
116 + <h1>Réclamez la page de <span className="hl">votre gestion</span>.</h1>
117 + <p className="lede">
118 + Choisissez votre organisation parmi les sources affichées sur
119 + Lou-Ka. Vous pourrez ensuite personnaliser sa page publique —
120 + votre inventaire y est déjà relié automatiquement.
121 + </p>
122 + <div className="claim-row">
123 + <select value={pick} onChange={(e) => setPick(e.target.value)}>
124 + <option value="">— Votre gestion immobilière —</option>
125 + {claimable.map((s) => (
126 + <option key={s.id} value={s.id}>
127 + {s.name} ({s.active_listings} logements)
128 + </option>
129 + ))}
130 + </select>
131 + <button className="btn btn-primary" disabled={!pick} onClick={doClaim}>
132 + Réclamer cette page
133 + </button>
134 + </div>
135 + {error && <p className="claim-error">{error}</p>}
136 + <p className="profil-note">
137 + Vous ne trouvez pas votre organisation ? Écrivez-nous à{" "}
138 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> et
139 + nous ajouterons votre site comme source.
140 + </p>
141 + </>
142 + )}
143 +
144 + {org && org.source && (
145 + <>
146 + <h1><span className="hl">{org.name}</span></h1>
147 + <div className="org-head">
148 + <span className="org-stat">
149 + <IcoBuilding size={15} /> <b>{org.active_listings ?? 0}</b> logements actifs reliés
150 + </span>
151 + <Link className="btn btn-ghost" to={`/g/${org.source}`}>
152 + Voir ma page publique ↗
153 + </Link>
154 + </div>
155 +
156 + <section className="org-form">
157 + <h3>Personnaliser ma page</h3>
158 + <label>
159 + <span>Accroche (140 caractères)</span>
160 + <input
161 + value={form.tagline} maxLength={140}
162 + placeholder="Ex. Des logements bien entretenus à Québec depuis 1998"
163 + onChange={(e) => setForm({ ...form, tagline: e.target.value })}
164 + />
165 + </label>
166 + <label>
167 + <span>Description</span>
168 + <textarea
169 + value={form.description} rows={5} maxLength={2000}
170 + placeholder="Présentez votre gestion : secteurs, services, valeurs…"
171 + onChange={(e) => setForm({ ...form, description: e.target.value })}
172 + />
173 + </label>
174 + <div className="org-form-row">
175 + <label>
176 + <span>Site web</span>
177 + <input
178 + value={form.website} placeholder="https://…"
179 + onChange={(e) => setForm({ ...form, website: e.target.value })}
180 + />
181 + </label>
182 + <label>
183 + <span>Téléphone</span>
184 + <input
185 + value={form.phone} placeholder="418 555-0123"
186 + onChange={(e) => setForm({ ...form, phone: e.target.value })}
187 + />
188 + </label>
189 + </div>
190 + <div className="org-form-row">
191 + <label>
192 + <span>Courriel public</span>
193 + <input
194 + value={form.email} placeholder="location@…"
195 + onChange={(e) => setForm({ ...form, email: e.target.value })}
196 + />
197 + </label>
198 + <label>
199 + <span>Logo (URL d'image)</span>
200 + <input
201 + value={form.logo_url} placeholder="https://…/logo.png"
202 + onChange={(e) => setForm({ ...form, logo_url: e.target.value })}
203 + />
204 + </label>
205 + </div>
206 + {error && <p className="claim-error">{error}</p>}
207 + <button
208 + className={`btn btn-primary ${saved ? "pc-copy ok" : ""}`}
209 + disabled={saving} onClick={doSave}
210 + >
211 + {saved ? "✓ Enregistré" : saving ? "Enregistrement…" : "Enregistrer"}
212 + </button>
213 + </section>
214 + </>
215 + )}
216 + </div>
217 + );
218 +}
added frontend/src/pages/GestionPublic.tsx +106 −0
@@ -0,0 +1,106 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/GestionPublic.tsx : page publique d'une gestion immobilière — /g/{source}
5 +// Bandeau personnalisé par le gestionnaire (accroche, description, contact,
6 +// logo) + inventaire relié automatiquement (12/page, pagination).
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { Listing, OrgProfile, fetchListings, fetchOrg } from "../api";
11 +import ListingCard from "../components/ListingCard";
12 +import Pager from "../components/Pager";
13 +import { IcoCompass, IcoPin } from "../components/Icons";
14 +
15 +const PAGE_SIZE = 12;
16 +
17 +export default function GestionPublicPage() {
18 + const { sourceId = "" } = useParams();
19 + const [org, setOrg] = useState<OrgProfile | null | undefined>(undefined);
20 + const [listings, setListings] = useState<Listing[] | null>(null);
21 + const [total, setTotal] = useState(0);
22 + const [page, setPage] = useState(1);
23 +
24 + useEffect(() => {
25 + fetchOrg(sourceId).then(setOrg).catch(() => setOrg(null));
26 + setPage(1);
27 + }, [sourceId]);
28 +
29 + useEffect(() => {
30 + setListings(null);
31 + fetchListings({ source: sourceId }, PAGE_SIZE, (page - 1) * PAGE_SIZE)
32 + .then((r) => { setListings(r.listings); setTotal(r.total); })
33 + .catch(() => setListings([]));
34 + }, [sourceId, page]);
35 +
36 + if (org === undefined) {
37 + return <div className="container profil"><div className="notice">Chargement…</div></div>;
38 + }
39 + if (org === null) {
40 + return (
41 + <div className="container notice">
42 + <div className="big"><IcoCompass size={40} /></div>
43 + <h2>Page introuvable</h2>
44 + <p>Cette gestion n'existe pas sur Lou-Ka.</p>
45 + <Link className="btn btn-primary" to="/">Explorer les logements</Link>
46 + </div>
47 + );
48 + }
49 +
50 + return (
51 + <div className="container profil orgpub">
52 + <div className="orgpub-head">
53 + {org.logo_url && (
54 + <img className="orgpub-logo" src={org.logo_url} alt="" referrerPolicy="no-referrer" />
55 + )}
56 + <div className="orgpub-id">
57 + <span className="kicker">Gestion immobilière</span>
58 + <h1>{org.name}</h1>
59 + {org.tagline && <p className="orgpub-tagline">{org.tagline}</p>}
60 + <div className="orgpub-meta">
61 + <span className="stat-chip"><b>{org.active_listings}</b> logements actifs</span>
62 + {org.claimed && <span className="stat-chip orgpub-verified">Page gérée par l'organisation</span>}
63 + {org.phone && <a className="stat-chip" href={`tel:${org.phone}`}>{org.phone}</a>}
64 + {org.email && <a className="stat-chip" href={`mailto:${org.email}`}>{org.email}</a>}
65 + {org.website && (
66 + <a className="stat-chip" href={org.website} target="_blank" rel="noopener noreferrer">
67 + Site web ↗
68 + </a>
69 + )}
70 + </div>
71 + </div>
72 + </div>
73 +
74 + {org.description && <p className="orgpub-desc">{org.description}</p>}
75 +
76 + <div className="results-head">
77 + <h2><IcoPin size={16} /> Logements offerts</h2>
78 + <div className="results-tools">
79 + {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
80 + </div>
81 + </div>
82 +
83 + {listings === null && (
84 + <div className="grid" aria-busy="true">
85 + {Array.from({ length: 4 }).map((_, i) => (
86 + <div className="skel" key={i}>
87 + <div className="sk-img" /><div className="sk-line" /><div className="sk-line short" />
88 + </div>
89 + ))}
90 + </div>
91 + )}
92 +
93 + {listings !== null && (
94 + <>
95 + <div className="grid">
96 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
97 + </div>
98 + <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={(p) => {
99 + setPage(p);
100 + window.scrollTo({ top: 0, behavior: "smooth" });
101 + }} />
102 + </>
103 + )}
104 + </div>
105 + );
106 +}
added frontend/src/pages/Profile.tsx +219 −0
@@ -0,0 +1,219 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Profile.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),
5 +// informations du compte, actions (déconnexion, témoins, légal)
6 +// -----------------------------------------------------------------------------
7 +import { useEffect, useState } from "react";
8 +import { Link, useNavigate } from "react-router-dom";
9 +import { Me, fetchMe, logout, setPublicProfile } from "../api";
10 +import { IcoBuilding, IcoDoc, IcoHeart, IcoLock } from "../components/Icons";
11 +
12 +const fmtEpoch = (ts: number | null | undefined): string => {
13 + if (!ts) return "—";
14 + return new Date(ts * 1000).toLocaleDateString("fr-CA", {
15 + day: "numeric", month: "long", year: "numeric",
16 + }).replace(/^1 /, "1ᵉʳ ");
17 +};
18 +
19 +export default function ProfilePage() {
20 + const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement
21 + const [copied, setCopied] = useState(false);
22 + const [isPublic, setIsPublic] = useState(false);
23 + const [linkCopied, setLinkCopied] = useState(false);
24 + const nav = useNavigate();
25 +
26 + useEffect(() => {
27 + fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); });
28 + }, []);
29 +
30 + const publicUrl = me?.ka_id
31 + ? `${window.location.origin}/u/${me.ka_id}` : "";
32 +
33 + const copyKaId = async () => {
34 + if (!me?.ka_id) return;
35 + try {
36 + await navigator.clipboard.writeText(me.ka_id);
37 + setCopied(true);
38 + setTimeout(() => setCopied(false), 1800);
39 + } catch { /* presse-papiers indisponible : tant pis */ }
40 + };
41 +
42 + if (me === undefined) {
43 + return <div className="container profil"><div className="notice">Chargement…</div></div>;
44 + }
45 +
46 + if (me === null) {
47 + return (
48 + <div className="container profil">
49 + <span className="kicker">Mon compte</span>
50 + <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1>
51 + <p className="lede">
52 + Créez votre compte en un clic avec Google — vous recevrez votre
53 + identifiant de membre <b>KA-ID</b>, valide dans tout l'écosystème
54 + Groupe KA.
55 + </p>
56 + <a className="btn btn-primary" href="/api/auth/google/login">
57 + Se connecter avec Google
58 + </a>
59 + </div>
60 + );
61 + }
62 +
63 + return (
64 + <div className="container profil">
65 + <span className="kicker">Mon compte</span>
66 + <h1>
67 + {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</>
68 + : <>Votre <span className="hl">profil</span>.</>}
69 + </h1>
70 +
71 + {/* ——— Carte de membre Groupe KA ——— */}
72 + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>
73 + <div className="pc-watermark" aria-hidden="true">KA</div>
74 + <div className="pc-head">
75 + <span className="pc-brand">Lou<span className="pc-ka">Ka</span></span>
76 + <span className="pc-label">Carte de membre · Groupe KA</span>
77 + </div>
78 + <div className="pc-id-block">
79 + <span className="pc-id-label">KA-ID</span>
80 + <span className="pc-id">{me.ka_id}</span>
81 + </div>
82 + <div className="pc-foot">
83 + <div className="pc-holder">
84 + <span className="pc-holder-name">{me.name || me.email}</span>
85 + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>
86 + </div>
87 + {me.picture && (
88 + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
89 + )}
90 + </div>
91 + <div className="pc-strip" aria-hidden="true">
92 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
93 + </div>
94 + </div>
95 +
96 + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}>
97 + {copied ? "✓ Copié" : "Copier mon KA-ID"}
98 + </button>
99 +
100 + {/* ——— Informations ——— */}
101 + <section className="profil-grid">
102 + <div className="pg-item">
103 + <span className="pg-label">Nom</span>
104 + <span className="pg-value">{me.name || "—"}</span>
105 + </div>
106 + <div className="pg-item">
107 + <span className="pg-label">Courriel</span>
108 + <span className="pg-value">{me.email}</span>
109 + </div>
110 + <div className="pg-item">
111 + <span className="pg-label">Identifiant membre</span>
112 + <span className="pg-value mono">{me.ka_id}</span>
113 + </div>
114 + <div className="pg-item">
115 + <span className="pg-label">Connexion</span>
116 + <span className="pg-value">Compte Google</span>
117 + </div>
118 + <div className="pg-item">
119 + <span className="pg-label">Profil</span>
120 + <span className="pg-value">
121 + {me.role === "locataire" ? "Locataire — je cherche un logement"
122 + : me.role === "gestionnaire" ? "Gestionnaire — je gère des logements"
123 + : <Link to="/bienvenue" className="mono">À choisir →</Link>}
124 + </span>
125 + </div>
126 + <div className="pg-item">
127 + <span className="pg-label">Membre depuis</span>
128 + <span className="pg-value">{fmtEpoch(me.created_at)}</span>
129 + </div>
130 + <div className="pg-item">
131 + <span className="pg-label">Dernière connexion</span>
132 + <span className="pg-value">{fmtEpoch(me.last_login)}</span>
133 + </div>
134 + </section>
135 +
136 + <p className="profil-note">
137 + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
138 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
139 + Groupe KA
140 + </a>{" "}
141 + — il vous suit sur toutes les plateformes du groupe. Lou-Ka ne conserve
142 + que votre nom, votre courriel et votre avatar Google ; rien d'autre,
143 + et jamais revendus.
144 + </p>
145 +
146 + {/* ——— Profil public ——— */}
147 + <section className="profil-public">
148 + <h3>Profil public</h3>
149 + <p>
150 + Activez votre page publique pour partager votre carte de membre —
151 + elle affiche votre nom, votre avatar et votre ancienneté,{" "}
152 + <b>jamais votre courriel</b>.
153 + </p>
154 + <div className="seg" role="group" aria-label="Profil public">
155 + {[[false, "Privé"], [true, "Public"]].map(([v, l]) => (
156 + <button
157 + key={String(v)}
158 + className={isPublic === v ? "on" : ""}
159 + onClick={async () => {
160 + await setPublicProfile(v as boolean);
161 + setIsPublic(v as boolean);
162 + }}
163 + >
164 + {l as string}
165 + </button>
166 + ))}
167 + </div>
168 + {isPublic && (
169 + <div className="public-link">
170 + <a href={publicUrl} target="_blank" rel="noopener noreferrer" className="mono">
171 + {publicUrl.replace(/^https?:\/\//, "")}
172 + </a>
173 + <button
174 + className={`btn btn-ghost ${linkCopied ? "pc-copy ok" : ""}`}
175 + onClick={async () => {
176 + try {
177 + await navigator.clipboard.writeText(publicUrl);
178 + setLinkCopied(true);
179 + setTimeout(() => setLinkCopied(false), 1800);
180 + } catch { /* presse-papiers indisponible */ }
181 + }}
182 + >
183 + {linkCopied ? "✓ Copié" : "Copier le lien"}
184 + </button>
185 + </div>
186 + )}
187 + </section>
188 +
189 + {/* ——— Actions ——— */}
190 + <div className="profil-actions">
191 + {me.role === "locataire" && (
192 + <Link className="btn btn-primary" to="/favoris">
193 + <IcoHeart size={14} /> Mes favoris
194 + </Link>
195 + )}
196 + {me.role === "gestionnaire" && (
197 + <Link className="btn btn-primary" to="/gestion">
198 + <IcoBuilding size={14} /> Ma page gestion
199 + </Link>
200 + )}
201 + <Link className="btn btn-ghost" to="/bienvenue">Changer de profil</Link>
202 + <button
203 + className="btn btn-ghost"
204 + onClick={async () => { await logout(); nav("/"); window.location.reload(); }}
205 + >
206 + Se déconnecter
207 + </button>
208 + <button
209 + className="btn btn-ghost"
210 + onClick={() => window.dispatchEvent(new Event("louka:openConsent"))}
211 + >
212 + Gérer mes témoins
213 + </button>
214 + <Link className="btn btn-ghost" to="/conditions"><IcoDoc size={14} /> Conditions</Link>
215 + <Link className="btn btn-ghost" to="/confidentialite"><IcoLock size={14} /> Confidentialité</Link>
216 + </div>
217 + </div>
218 + );
219 +}
added frontend/src/pages/PublicProfile.tsx +87 −0
@@ -0,0 +1,87 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/PublicProfile.tsx : profil membre PUBLIC — /u/{ka_id}
5 +// Carte de membre partageable (nom, avatar, ancienneté, KA-ID) — jamais le
6 +// courriel. Visible seulement si le membre a activé son profil public.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { PublicProfile, fetchPublicProfile } from "../api";
11 +import { IcoCompass } from "../components/Icons";
12 +
13 +const fmtEpoch = (ts: number | null | undefined): string => {
14 + if (!ts) return "—";
15 + return new Date(ts * 1000).toLocaleDateString("fr-CA", {
16 + day: "numeric", month: "long", year: "numeric",
17 + }).replace(/^1 /, "1ᵉʳ ");
18 +};
19 +
20 +export default function PublicProfilePage() {
21 + const { kaId = "" } = useParams();
22 + const [profile, setProfile] = useState<PublicProfile | null | undefined>(undefined);
23 +
24 + useEffect(() => {
25 + fetchPublicProfile(kaId).then(setProfile).catch(() => setProfile(null));
26 + }, [kaId]);
27 +
28 + if (profile === undefined) {
29 + return <div className="container profil"><div className="notice">Chargement…</div></div>;
30 + }
31 +
32 + if (profile === null) {
33 + return (
34 + <div className="container notice">
35 + <div className="big"><IcoCompass size={40} /></div>
36 + <h2>Profil introuvable</h2>
37 + <p>Ce membre n'existe pas, ou son profil n'est pas public.</p>
38 + <Link className="btn btn-primary" to="/">Explorer les logements</Link>
39 + </div>
40 + );
41 + }
42 +
43 + return (
44 + <div className="container profil">
45 + <span className="kicker">Profil membre</span>
46 + <h1>
47 + <span className="hl">{profile.name || "Membre Lou-Ka"}</span>
48 + </h1>
49 +
50 + <div className="pc" role="img" aria-label={`Carte de membre ${profile.ka_id}`}>
51 + <div className="pc-watermark" aria-hidden="true">KA</div>
52 + <div className="pc-head">
53 + <span className="pc-brand">Lou<span className="pc-ka">Ka</span></span>
54 + <span className="pc-label">Carte de membre · Groupe KA</span>
55 + </div>
56 + <div className="pc-id-block">
57 + <span className="pc-id-label">KA-ID</span>
58 + <span className="pc-id">{profile.ka_id}</span>
59 + </div>
60 + <div className="pc-foot">
61 + <div className="pc-holder">
62 + <span className="pc-holder-name">{profile.name || "Membre Lou-Ka"}</span>
63 + <span className="pc-holder-since">Membre depuis le {fmtEpoch(profile.created_at)}</span>
64 + </div>
65 + {profile.picture && (
66 + <img className="pc-avatar" src={profile.picture} alt="" referrerPolicy="no-referrer" />
67 + )}
68 + </div>
69 + <div className="pc-strip" aria-hidden="true">
70 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
71 + </div>
72 + </div>
73 +
74 + <p className="profil-note">
75 + Membre de <b>Lou-Ka</b>, l'agrégateur de logements à louer du{" "}
76 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
77 + Groupe KA
78 + </a>.
79 + </p>
80 +
81 + <div className="profil-actions">
82 + <Link className="btn btn-primary" to="/">Explorer les logements</Link>
83 + <a className="btn btn-ghost" href="/api/auth/google/login">Créer mon compte</a>
84 + </div>
85 + </div>
86 + );
87 +}
modified frontend/src/styles.css +149 −0
@@ -1157,3 +1157,152 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1157 1157 }
1158 1158 .account-menu button:hover { background: var(--lime-soft); color: var(--ink); }
1159 1159 @media (max-width: 640px) { .login-btn { padding: 8px 12px; font-size: 12.5px; } }
1160 +
1161 +/* ================= Profil — carte de membre Groupe KA ================= */
1162 +.profil { padding-top: 40px; padding-bottom: 60px; }
1163 +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; }
1164 +.profil h1 .hl { background: var(--lime); padding: 0 8px; }
1165 +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; }
1166 +
1167 +.pc {
1168 + position: relative; max-width: 520px; overflow: hidden;
1169 + background: var(--ink); color: var(--paper);
1170 + border: 2px solid var(--ink); border-radius: 16px;
1171 + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18);
1172 +}
1173 +.pc-watermark {
1174 + position: absolute; right: -18px; top: -34px; pointer-events: none;
1175 + font-family: var(--font-display); font-weight: 700; font-size: 170px;
1176 + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07);
1177 + transform: rotate(-8deg); line-height: 1;
1178 +}
1179 +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1180 +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; }
1181 +.pc-ka { background: var(--lime); color: var(--ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); }
1182 +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.55); }
1183 +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
1184 +.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); }
1185 +.pc-id {
1186 + font-family: var(--font-mono); font-weight: 700; color: var(--lime);
1187 + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em;
1188 + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
1189 +}
1190 +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
1191 +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1192 +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1193 +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
1194 +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; }
1195 +.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; }
1196 +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--lime); opacity: 0.7; }
1197 +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; }
1198 +.pc-strip i:nth-child(3n+1) { height: 9px; }
1199 +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; }
1200 +.pc-copy { margin-top: 16px; }
1201 +.pc-copy.ok { background: var(--lime); border-color: var(--ink); color: var(--ink); }
1202 +
1203 +.profil-grid {
1204 + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
1205 + gap: 12px; margin-top: 30px; max-width: 760px;
1206 +}
1207 +.pg-item {
1208 + background: var(--surface); border: 1.5px solid var(--ink);
1209 + border-radius: var(--r-card); padding: 14px 16px;
1210 + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat);
1211 +}
1212 +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); }
1213 +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
1214 +.pg-value.mono { font-family: var(--font-mono); color: var(--green-deep); }
1215 +
1216 +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
1217 +.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
1218 +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
1219 +.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; }
1220 +
1221 +.account-kaid { font-family: var(--font-mono); font-size: 10.5px; color: var(--green); letter-spacing: 0.08em; }
1222 +.account-link {
1223 + display: block; width: 100%; text-align: left; padding: 11px 14px;
1224 + font-weight: 600; font-size: 13.5px; color: var(--ink);
1225 + border-bottom: 1.5px solid var(--line);
1226 +}
1227 +.account-link:hover { background: var(--lime-soft); }
1228 +
1229 +/* profil public — section + lien partageable */
1230 +.profil-public { margin-top: 30px; max-width: 640px; }
1231 +.profil-public h3 { font-size: 18px; margin-bottom: 6px; }
1232 +.profil-public p { color: var(--ink-2); font-size: 13.5px; margin: 0 0 12px; }
1233 +.public-link {
1234 + display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
1235 + margin-top: 14px; padding: 12px 14px; background: var(--lime-soft);
1236 + border: 1.5px solid var(--green); border-radius: var(--r-card);
1237 +}
1238 +.public-link a { font-size: 13px; color: var(--green-deep); text-decoration: underline; text-underline-offset: 3px; word-break: break-all; }
1239 +
1240 +/* ================= Bienvenue — choix du rôle ================= */
1241 +.bienvenue { padding-top: 44px; padding-bottom: 70px; }
1242 +.bienvenue h1 { font-size: clamp(30px, 5vw, 46px); margin: 10px 0 14px; }
1243 +.bienvenue h1 .hl { background: var(--lime); padding: 0 8px; }
1244 +.bienvenue .lede { max-width: 480px; color: var(--ink-2); margin-bottom: 30px; }
1245 +.role-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; max-width: 780px; }
1246 +.role-card {
1247 + display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
1248 + text-align: left; padding: 24px 24px 20px; cursor: pointer;
1249 + background: var(--surface); border: 2px solid var(--ink);
1250 + border-radius: var(--r-card); box-shadow: var(--shadow-flat);
1251 + font-family: var(--font-body); transition: all 0.15s ease;
1252 +}
1253 +.role-card:hover:not(:disabled) { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); background: var(--lime-soft); }
1254 +.role-card:disabled { opacity: 0.6; cursor: wait; }
1255 +.role-ico { display: flex; width: 54px; height: 54px; align-items: center; justify-content: center; background: var(--ink); color: var(--lime); border-radius: 12px; }
1256 +.role-title { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
1257 +.role-desc { color: var(--ink-2); font-size: 13.5px; }
1258 +.role-perks { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
1259 +.role-perks li { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--ink-2); }
1260 +.role-perks li::before { content: "◆"; color: var(--green); font-size: 8px; }
1261 +.role-cta { margin-top: 10px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--green-deep); }
1262 +
1263 +/* ================= Favoris — cœur sur les cartes ================= */
1264 +.fav-btn {
1265 + position: absolute; right: 10px; bottom: 10px; z-index: 2;
1266 + width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
1267 + background: rgba(255, 255, 255, 0.94); border: 1.5px solid var(--ink);
1268 + border-radius: 999px; cursor: pointer; color: var(--ink);
1269 + transition: transform 0.12s ease, background 0.12s ease;
1270 +}
1271 +.fav-btn:hover { transform: scale(1.12); }
1272 +.fav-btn.on { background: var(--lime); color: var(--danger); }
1273 +.fav-btn.on svg { filter: drop-shadow(0 0 3px rgba(179, 66, 58, 0.35)); }
1274 +
1275 +/* ================= Espace gestionnaire ================= */
1276 +.claim-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; max-width: 640px; }
1277 +.claim-row select {
1278 + flex: 1; min-width: 260px; padding: 11px 12px; font-size: 14px;
1279 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1280 + background: var(--surface); font-family: var(--font-body);
1281 +}
1282 +.claim-error { color: var(--danger); font-size: 13.5px; font-weight: 600; margin-top: 10px; }
1283 +.org-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 6px 0 22px; }
1284 +.org-stat { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; color: var(--ink-2); }
1285 +.org-form { max-width: 640px; display: flex; flex-direction: column; gap: 14px; }
1286 +.org-form h3 { font-size: 18px; }
1287 +.org-form label { display: flex; flex-direction: column; gap: 5px; flex: 1; }
1288 +.org-form label span { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
1289 +.org-form input, .org-form textarea {
1290 + padding: 10px 12px; font-size: 14px; font-family: var(--font-body);
1291 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);
1292 +}
1293 +.org-form textarea { resize: vertical; }
1294 +.org-form input:focus, .org-form textarea:focus { outline: 2px solid var(--lime); outline-offset: 1px; }
1295 +.org-form-row { display: flex; gap: 12px; flex-wrap: wrap; }
1296 +.org-form .btn { align-self: flex-start; }
1297 +
1298 +/* ================= Page publique /g/{source} ================= */
1299 +.orgpub-head { display: flex; gap: 20px; align-items: flex-start; margin-top: 8px; }
1300 +.orgpub-logo { width: 84px; height: 84px; object-fit: contain; background: var(--surface); border: 2px solid var(--ink); border-radius: 14px; padding: 8px; flex: none; }
1301 +.orgpub-id h1 { font-size: clamp(26px, 4.4vw, 40px); margin: 6px 0 8px; }
1302 +.orgpub-tagline { font-size: 16px; color: var(--ink-2); margin: 0 0 12px; max-width: 640px; }
1303 +.orgpub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
1304 +.orgpub-meta a.stat-chip:hover { background: var(--lime-soft); }
1305 +.orgpub-verified { background: var(--lime); border-color: var(--ink); font-weight: 600; }
1306 +.orgpub-desc { max-width: 720px; color: var(--ink-2); margin: 18px 0 6px; white-space: pre-line; }
1307 +.orgpub .results-head h2 { display: inline-flex; align-items: center; gap: 8px; }
1308 +@media (max-width: 560px) { .orgpub-head { flex-direction: column; } }
added louka/accounts.py +228 −0
@@ -0,0 +1,228 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# accounts.py : fonctionnalités de compte au-delà de l'authentification
5 +# · Rôle : « locataire » (cherche un logement) ou « gestionnaire » (gère des
6 +# logements affichés) — choisi à l'accueil après la création du compte.
7 +# · Locataire : favoris (POST/DELETE /api/favorites/{uid}, GET /api/favorites).
8 +# · Gestionnaire : réclame la page de SA source (première arrivée, une source
9 +# par compte, un compte par source) puis la personnalise (accroche,
10 +# description, coordonnées, logo) — affichée publiquement sur /g/{source}.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import json
15 +import time
16 +from pathlib import Path
17 +
18 +from fastapi import APIRouter, HTTPException, Request
19 +from pydantic import BaseModel
20 +
21 +from . import db
22 +from .auth import current_user
23 +
24 +SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json"
25 +
26 +router = APIRouter(prefix="/api")
27 +
28 +ROLES = ("locataire", "gestionnaire")
29 +
30 +
31 +def _require(request: Request) -> dict:
32 + user = current_user(request)
33 + if user is None:
34 + raise HTTPException(401, "non connecté")
35 + return user
36 +
37 +
38 +def _row_listing(row) -> dict:
39 + d = dict(row)
40 + d["amenities"] = json.loads(d.get("amenities") or "[]")
41 + d["images"] = json.loads(d.get("images") or "[]")
42 + d["details"] = json.loads(d.get("details") or "{}")
43 + if d.get("furnished") is not None:
44 + d["furnished"] = bool(d["furnished"])
45 + return d
46 +
47 +
48 +# -- rôle -----------------------------------------------------------------------
49 +
50 +@router.post("/me/role")
51 +def set_role(request: Request, role: str):
52 + user = _require(request)
53 + if role not in ROLES:
54 + raise HTTPException(400, f"rôle invalide (choix : {', '.join(ROLES)})")
55 + con = db.connect()
56 + con.execute("UPDATE users SET role=? WHERE id=?", (role, user["uid"]))
57 + con.commit()
58 + con.close()
59 + return {"role": role}
60 +
61 +
62 +# -- favoris (locataire) ---------------------------------------------------------
63 +
64 +@router.get("/favorites")
65 +def favorites(request: Request):
66 + user = _require(request)
67 + con = db.connect()
68 + rows = con.execute(
69 + """SELECT l.* FROM favorites f JOIN listings l ON l.uid = f.uid
70 + WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall()
71 + uids = [r["uid"] for r in con.execute(
72 + "SELECT uid FROM favorites WHERE user_id=?", (user["uid"],))]
73 + con.close()
74 + return {"uids": uids, "listings": [_row_listing(r) for r in rows]}
75 +
76 +
77 +@router.post("/favorites/{uid}")
78 +def add_favorite(request: Request, uid: str):
79 + user = _require(request)
80 + con = db.connect()
81 + if con.execute("SELECT 1 FROM listings WHERE uid=?", (uid,)).fetchone() is None:
82 + con.close()
83 + raise HTTPException(404, "annonce introuvable")
84 + con.execute("INSERT OR IGNORE INTO favorites (user_id, uid, ts) VALUES (?,?,?)",
85 + (user["uid"], uid, time.time()))
86 + con.commit()
87 + con.close()
88 + return {"ok": True}
89 +
90 +
91 +@router.delete("/favorites/{uid}")
92 +def remove_favorite(request: Request, uid: str):
93 + user = _require(request)
94 + con = db.connect()
95 + con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?",
96 + (user["uid"], uid))
97 + con.commit()
98 + con.close()
99 + return {"ok": True}
100 +
101 +
102 +# -- page gestionnaire -----------------------------------------------------------
103 +
104 +def _source_name(source_id: str) -> str | None:
105 + try:
106 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
107 + except Exception:
108 + return None
109 + for s in registry:
110 + if s["id"] == source_id:
111 + return s.get("name") or source_id
112 + return None
113 +
114 +
115 +class OrgUpdate(BaseModel):
116 + tagline: str = ""
117 + description: str = ""
118 + website: str = ""
119 + phone: str = ""
120 + email: str = ""
121 + logo_url: str = ""
122 +
123 +
124 +@router.post("/org/claim")
125 +def claim_org(request: Request, source: str):
126 + """Réclame la page d'une source (gestionnaire) — 1 source/compte, 1 compte/source."""
127 + user = _require(request)
128 + name = _source_name(source)
129 + if name is None:
130 + raise HTTPException(404, "source inconnue")
131 + con = db.connect()
132 + me_row = con.execute("SELECT role, org_source FROM users WHERE id=?",
133 + (user["uid"],)).fetchone()
134 + if me_row["role"] != "gestionnaire":
135 + con.close()
136 + raise HTTPException(403, "réservé aux comptes gestionnaires")
137 + if me_row["org_source"] and me_row["org_source"] != source:
138 + con.close()
139 + raise HTTPException(409, "votre compte gère déjà une autre page")
140 + owner = con.execute(
141 + "SELECT owner_user_id FROM source_profiles WHERE source_id=?",
142 + (source,)).fetchone()
143 + if owner and owner["owner_user_id"] not in (None, user["uid"]):
144 + con.close()
145 + raise HTTPException(409, "cette page est déjà réclamée par un autre compte")
146 + con.execute(
147 + """INSERT INTO source_profiles (source_id, owner_user_id, updated_at)
148 + VALUES (?,?,?)
149 + ON CONFLICT(source_id) DO UPDATE SET
150 + owner_user_id=excluded.owner_user_id, updated_at=excluded.updated_at""",
151 + (source, user["uid"], time.time()))
152 + con.execute("UPDATE users SET org_source=? WHERE id=?", (source, user["uid"]))
153 + con.commit()
154 + con.close()
155 + return {"source": source, "name": name}
156 +
157 +
158 +@router.get("/org/mine")
159 +def my_org(request: Request):
160 + user = _require(request)
161 + con = db.connect()
162 + me_row = con.execute("SELECT org_source FROM users WHERE id=?",
163 + (user["uid"],)).fetchone()
164 + source = me_row["org_source"] if me_row else None
165 + if not source:
166 + con.close()
167 + return {"source": None}
168 + prof = con.execute("SELECT * FROM source_profiles WHERE source_id=?",
169 + (source,)).fetchone()
170 + n = con.execute(
171 + "SELECT COUNT(*) c FROM listings WHERE source=? AND active=1",
172 + (source,)).fetchone()["c"]
173 + con.close()
174 + return {
175 + "source": source,
176 + "name": _source_name(source) or source,
177 + "active_listings": n,
178 + "profile": dict(prof) if prof else {},
179 + }
180 +
181 +
182 +@router.put("/org")
183 +def update_org(request: Request, body: OrgUpdate):
184 + user = _require(request)
185 + con = db.connect()
186 + me_row = con.execute("SELECT org_source FROM users WHERE id=?",
187 + (user["uid"],)).fetchone()
188 + source = me_row["org_source"] if me_row else None
189 + if not source:
190 + con.close()
191 + raise HTTPException(409, "aucune page réclamée")
192 + owner = con.execute(
193 + "SELECT owner_user_id FROM source_profiles WHERE source_id=?",
194 + (source,)).fetchone()
195 + if not owner or owner["owner_user_id"] != user["uid"]:
196 + con.close()
197 + raise HTTPException(403, "vous n'êtes pas propriétaire de cette page")
198 + con.execute(
199 + """UPDATE source_profiles SET tagline=?, description=?, website=?,
200 + phone=?, email=?, logo_url=?, updated_at=? WHERE source_id=?""",
201 + (body.tagline.strip()[:140], body.description.strip()[:2000],
202 + body.website.strip()[:300], body.phone.strip()[:40],
203 + body.email.strip()[:120], body.logo_url.strip()[:500],
204 + time.time(), source))
205 + con.commit()
206 + con.close()
207 + return {"ok": True}
208 +
209 +
210 +@router.get("/org/{source_id}")
211 +def public_org(source_id: str):
212 + """Page publique d'un gestionnaire : nom + personnalisation + inventaire."""
213 + name = _source_name(source_id)
214 + if name is None:
215 + raise HTTPException(404, "source inconnue")
216 + con = db.connect()
217 + prof = con.execute("SELECT * FROM source_profiles WHERE source_id=?",
218 + (source_id,)).fetchone()
219 + n = con.execute(
220 + "SELECT COUNT(*) c FROM listings WHERE source=? AND active=1",
221 + (source_id,)).fetchone()["c"]
222 + con.close()
223 + out = {"source": source_id, "name": name, "active_listings": n,
224 + "claimed": bool(prof and prof["owner_user_id"])}
225 + if prof:
226 + for f in ("tagline", "description", "website", "phone", "email", "logo_url"):
227 + out[f] = prof[f] or ""
228 + return out
modified louka/auth.py +75 −5
@@ -91,6 +91,22 @@ def current_user(request: Request) -> dict | None:
91 91 return _verify(token) if token else None
92 92
93 93
94 +def _ensure_ka_id(con, uid: int) -> str:
95 + """Attribue (si absent) l'identifiant membre unique « ka-0123456789 »."""
96 + import sqlite3
97 + row = con.execute("SELECT ka_id FROM users WHERE id=?", (uid,)).fetchone()
98 + if row and row["ka_id"]:
99 + return row["ka_id"]
100 + while True:
101 + kid = "ka-" + "".join(secrets.choice("0123456789") for _ in range(10))
102 + try:
103 + con.execute("UPDATE users SET ka_id=? WHERE id=?", (kid, uid))
104 + con.commit()
105 + return kid
106 + except sqlite3.IntegrityError: # collision (1 chance sur 10 milliards)
107 + continue
108 +
109 +
94 110 # -- routes --------------------------------------------------------------------
95 111
96 112 @router.get("/auth/config")
@@ -149,8 +165,10 @@ def google_callback(request: Request, code: str = "", state: str = "",
149 165 (sub, info.get("email") or "", info.get("name") or "",
150 166 info.get("picture") or "", now, now))
151 167 con.commit()
152 − uid = con.execute("SELECT id FROM users WHERE google_sub=?",
153 − (sub,)).fetchone()["id"]
168 + row2 = con.execute("SELECT id, role FROM users WHERE google_sub=?",
169 + (sub,)).fetchone()
170 + uid, role = row2["id"], row2["role"]
171 + _ensure_ka_id(con, uid) # identifiant membre dès la création
154 172 con.close()
155 173
156 174 session = _sign({
@@ -160,7 +178,8 @@ def google_callback(request: Request, code: str = "", state: str = "",
160 178 "picture": info.get("picture") or "",
161 179 "exp": time.time() + SESSION_DAYS * 86400,
162 180 })
163 − resp = RedirectResponse("/?login=ok")
181 + # nouveau compte (ou rôle jamais choisi) -> accueil d'intégration
182 + resp = RedirectResponse("/bienvenue" if not role else "/?login=ok")
164 183 resp.set_cookie(
165 184 COOKIE, session,
166 185 max_age=SESSION_DAYS * 86400,
@@ -177,8 +196,59 @@ def me(request: Request):
177 196 user = current_user(request)
178 197 if user is None:
179 198 raise HTTPException(401, "non connecté")
180 − return {"uid": user["uid"], "email": user["email"],
181 − "name": user["name"], "picture": user["picture"]}
199 + con = db.connect()
200 + row = con.execute("SELECT * FROM users WHERE id=?",
201 + (user["uid"],)).fetchone()
202 + if row is None:
203 + con.close()
204 + raise HTTPException(401, "compte introuvable")
205 + ka_id = row["ka_id"] or _ensure_ka_id(con, row["id"])
206 + con.close()
207 + return {
208 + "uid": row["id"],
209 + "ka_id": ka_id,
210 + "email": row["email"],
211 + "name": row["name"],
212 + "picture": row["picture"],
213 + "public": bool(row["public"]),
214 + "role": row["role"],
215 + "org_source": row["org_source"],
216 + "created_at": row["created_at"],
217 + "last_login": row["last_login"],
218 + "provider": "google",
219 + }
220 +
221 +
222 +@router.post("/me/public")
223 +def set_public(request: Request, enabled: bool):
224 + """Active/désactive le profil public /u/{ka_id} (opt-in explicite)."""
225 + user = current_user(request)
226 + if user is None:
227 + raise HTTPException(401, "non connecté")
228 + con = db.connect()
229 + con.execute("UPDATE users SET public=? WHERE id=?",
230 + (1 if enabled else 0, user["uid"]))
231 + con.commit()
232 + con.close()
233 + return {"public": enabled}
234 +
235 +
236 +@router.get("/users/{ka_id}")
237 +def public_profile(ka_id: str):
238 + """Profil public d'un membre — nom, avatar, ancienneté. JAMAIS le courriel.
239 + 404 si le membre n'existe pas OU n'a pas activé son profil public
240 + (indistinguable, volontairement)."""
241 + con = db.connect()
242 + row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone()
243 + con.close()
244 + if row is None or not row["public"]:
245 + raise HTTPException(404, "profil introuvable")
246 + return {
247 + "ka_id": row["ka_id"],
248 + "name": row["name"],
249 + "picture": row["picture"],
250 + "created_at": row["created_at"],
251 + }
182 252
183 253
184 254 @router.post("/auth/logout")
modified louka/db.py +30 −0
@@ -112,12 +112,36 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
112 112 CREATE TABLE IF NOT EXISTS users (
113 113 id INTEGER PRIMARY KEY AUTOINCREMENT,
114 114 google_sub TEXT UNIQUE, -- identifiant stable Google (OpenID « sub »)
115 + ka_id TEXT, -- identifiant membre « ka-0123456789 » (unique)
115 116 email TEXT,
116 117 name TEXT,
117 118 picture TEXT, -- URL de l'avatar Google
119 + public INTEGER DEFAULT 0, -- profil public /u/{ka_id} (opt-in)
120 + role TEXT, -- « locataire » | « gestionnaire » | NULL
121 + org_source TEXT, -- source réclamée (gestionnaire)
118 122 created_at REAL,
119 123 last_login REAL
120 124 );
125 +CREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);
126 +
127 +CREATE TABLE IF NOT EXISTS favorites (
128 + user_id INTEGER NOT NULL,
129 + uid TEXT NOT NULL, -- listings.uid
130 + ts REAL,
131 + PRIMARY KEY (user_id, uid)
132 +);
133 +
134 +CREATE TABLE IF NOT EXISTS source_profiles (
135 + source_id TEXT PRIMARY KEY, -- id de la source (data/sources.json)
136 + owner_user_id INTEGER, -- gestionnaire qui a réclamé la page
137 + tagline TEXT, -- accroche personnalisée
138 + description TEXT,
139 + website TEXT,
140 + phone TEXT,
141 + email TEXT,
142 + logo_url TEXT,
143 + updated_at REAL
144 +);
121 145 """
122 146
123 147 # Colonnes ajoutées après la v1 — migration automatique des bases existantes.
@@ -136,6 +160,12 @@ _MIGRATIONS = {
136 160 "sync_log": {
137 161 "stats": "TEXT",
138 162 },
163 + "users": {
164 + "ka_id": "TEXT",
165 + "public": "INTEGER DEFAULT 0",
166 + "role": "TEXT",
167 + "org_source": "TEXT",
168 + },
139 169 }
140 170
141 171
modified louka/web.py +4 −0
@@ -30,6 +30,10 @@ _sync_lock = threading.Lock()
30 30 # comptes utilisateurs (connexion Google) — voir louka/auth.py
31 31 app.include_router(auth.router)
32 32
33 +# rôles, favoris, pages gestionnaires — voir louka/accounts.py
34 +from . import accounts # noqa: E402 (import tardif : évite le cycle web<->accounts)
35 +app.include_router(accounts.router)
36 +
33 37
34 38 def _row_to_dict(row) -> dict:
35 39 d = dict(row)
36 40