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
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 | |