Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Profile.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),5// informations du compte, actions (déconnexion, témoins, légal)6// -----------------------------------------------------------------------------7import { useRef, useEffect, useState } from "react";8import { Link, useNavigate } from "react-router-dom";9import {10 Me, Socials, deleteAvatar, fetchMe, logout, setPublicProfile,11 updateMyProfile, uploadAvatar,12} from "../api";13import {14 IcoBuilding, IcoCamera, IcoDoc, IcoFacebook, IcoHeart, IcoInstagram,15 IcoLinkedIn, IcoLock, IcoTikTok, IcoX, IcoYouTube,16} from "../components/Icons";1718const fmtEpoch = (ts: number | null | undefined): string => {19 if (!ts) return "—";20 return new Date(ts * 1000).toLocaleDateString("fr-CA", {21 day: "numeric", month: "long", year: "numeric",22 }).replace(/^1 /, "1ᵉʳ ");23};2425const SOCIAL_BASE: Record<string, string> = {26 instagram: "https://www.instagram.com/",27 facebook: "https://www.facebook.com/",28 x: "https://x.com/",29 linkedin: "https://www.linkedin.com/in/",30 tiktok: "https://www.tiktok.com/@",31 youtube: "https://www.youtube.com/@",32};3334/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */35function socialUrl(key: string, value: string): string {36 const v = value.trim();37 if (/^https?:\/\//i.test(v)) return v;38 if (key === "website") return `https://${v}`;39 return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");40}4142const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element;43 placeholder: string }[] = [44 { key: "instagram", label: "Instagram", icon: <IcoInstagram size={15} />, placeholder: "@pseudo ou URL" },45 { key: "facebook", label: "Facebook", icon: <IcoFacebook size={15} />, placeholder: "profil ou URL" },46 { key: "x", label: "X (Twitter)", icon: <IcoX size={15} />, placeholder: "@pseudo ou URL" },47 { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={15} />, placeholder: "profil ou URL" },48 { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={15} />, placeholder: "@pseudo ou URL" },49 { key: "youtube", label: "YouTube", icon: <IcoYouTube size={15} />, placeholder: "chaîne ou URL" },50];5152/** Section d'édition : photo, nom affiché, bio, coordonnées, réseaux sociaux */53function ProfileEditor({ me, onSaved }: { me: Me; onSaved: () => void }) {54 const [form, setForm] = useState({55 display_name: me.display_name || "",56 bio: me.bio || "",57 city: me.city || "",58 phone: me.phone || "",59 website: me.website || "",60 socials: { ...me.socials } as Socials,61 });62 const [saving, setSaving] = useState(false);63 const [saved, setSaved] = useState(false);64 const [error, setError] = useState("");65 const [avatarBusy, setAvatarBusy] = useState(false);66 const fileRef = useRef<HTMLInputElement | null>(null);6768 const save = async () => {69 setSaving(true);70 setError("");71 const res = await updateMyProfile(form);72 setSaving(false);73 if (!res.ok) { setError("Enregistrement impossible — réessayez."); return; }74 setSaved(true);75 setTimeout(() => setSaved(false), 2000);76 onSaved();77 };7879 const pickPhoto = async (f: File | undefined) => {80 if (!f) return;81 setAvatarBusy(true);82 setError("");83 const url = await uploadAvatar(f);84 setAvatarBusy(false);85 if (!url) { setError("Photo refusée — image JPG/PNG/WebP de 8 Mo max."); return; }86 onSaved();87 };8889 return (90 <section className="org-form profil-edit">91 <h3>Personnaliser mon profil</h3>9293 {/* — photo — */}94 <div className="pe-avatar-row">95 {me.picture96 ? <img className="pe-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />97 : <span className="pe-avatar pe-avatar-empty">98 {(me.name || me.email).charAt(0).toUpperCase()}99 </span>}100 <div className="pe-avatar-actions">101 <button className="btn btn-ghost" disabled={avatarBusy}102 onClick={() => fileRef.current?.click()}>103 <IcoCamera size={14} /> {avatarBusy ? "Téléversement…" : "Changer la photo"}104 </button>105 {me.avatar_url && (106 <button className="btn btn-ghost" disabled={avatarBusy}107 onClick={async () => { await deleteAvatar(); onSaved(); }}>108 Retirer (revenir à Google)109 </button>110 )}111 <span className="pe-hint">JPG, PNG ou WebP — recadrée en carré 512 px.</span>112 </div>113 <input114 ref={fileRef} type="file" accept="image/*" hidden115 onChange={(e) => pickPhoto(e.target.files?.[0])}116 />117 </div>118119 <div className="org-form-row">120 <label>121 <span>Nom affiché</span>122 <input123 value={form.display_name} maxLength={80}124 placeholder={me.google_name || "Votre nom"}125 onChange={(e) => setForm({ ...form, display_name: e.target.value })}126 />127 </label>128 <label>129 <span>Ville</span>130 <input131 value={form.city} maxLength={60} placeholder="Québec, Montréal…"132 onChange={(e) => setForm({ ...form, city: e.target.value })}133 />134 </label>135 </div>136137 <label>138 <span>Bio ({280 - form.bio.length} caractères restants)</span>139 <textarea140 value={form.bio} rows={3} maxLength={280}141 placeholder="Présentez-vous en quelques mots…"142 onChange={(e) => setForm({ ...form, bio: e.target.value })}143 />144 </label>145146 <div className="org-form-row">147 <label>148 <span>Téléphone</span>149 <input150 value={form.phone} maxLength={40} placeholder="418 555-0123"151 onChange={(e) => setForm({ ...form, phone: e.target.value })}152 />153 </label>154 <label>155 <span>Site web</span>156 <input157 value={form.website} maxLength={300} placeholder="https://…"158 onChange={(e) => setForm({ ...form, website: e.target.value })}159 />160 </label>161 </div>162163 <h4 className="pe-socials-title">Réseaux sociaux</h4>164 <div className="pe-socials">165 {SOCIAL_FIELDS.map((s) => (166 <label key={s.key} className="pe-social">167 <span className="pe-social-ico" title={s.label}>{s.icon}</span>168 <input169 value={form.socials[s.key] ?? ""} maxLength={200}170 placeholder={`${s.label} — ${s.placeholder}`}171 onChange={(e) => setForm({172 ...form,173 socials: { ...form.socials, [s.key]: e.target.value },174 })}175 />176 </label>177 ))}178 </div>179180 {error && <p className="claim-error">{error}</p>}181 <button182 className={`btn btn-primary ${saved ? "pc-copy ok" : ""}`}183 disabled={saving} onClick={save}184 >185 {saved ? "✓ Enregistré" : saving ? "Enregistrement…" : "Enregistrer mon profil"}186 </button>187 </section>188 );189}190191/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur192 * groupe-ka.com/compte, une seule saisie pour les sept plateformes). */193function HubProfileSection({ me }: { me: Me }) {194 const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim());195 return (196 <section className="org-form profil-edit hub-profile">197 <h3>Mon profil Groupe KA</h3>198 {me.bio && <p className="hub-bio">{me.bio}</p>}199 <div className="pub-meta">200 {(me.job_title || me.company) && (201 <span className="stat-chip">202 {[me.job_title, me.company].filter(Boolean).join(" · ")}203 </span>204 )}205 {me.city && <span className="stat-chip">{me.city}</span>}206 {me.age != null && <span className="stat-chip">{me.age} ans</span>}207 {me.website && (208 <a className="stat-chip" href={socialUrl("website", me.website)}209 target="_blank" rel="noopener noreferrer">Site web ↗</a>210 )}211 {socialLinks.map((s) => (212 <a key={s.key} className="stat-chip hub-social-chip"213 href={socialUrl(s.key, me.socials[s.key]!)}214 target="_blank" rel="noopener noreferrer" title={s.label}>215 {s.icon} {s.label}216 </a>217 ))}218 </div>219 <div>220 <a className="btn btn-primary"221 href="https://www.groupe-ka.com/compte"222 target="_blank" rel="noopener noreferrer">223 Modifier mon profil sur groupe-ka.com ↗224 </a>225 </div>226 <p className="pe-hint">227 Votre profil est géré au niveau du groupe : une seule saisie, visible228 sur les sept plateformes.229 </p>230 </section>231 );232}233234export default function ProfilePage() {235 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement236 const [copied, setCopied] = useState(false);237 const [isPublic, setIsPublic] = useState(false);238 const [linkCopied, setLinkCopied] = useState(false);239 const nav = useNavigate();240241 useEffect(() => {242 fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); });243 }, []);244245 const publicUrl = me?.ka_id246 ? `${window.location.origin}/u/${me.ka_id}` : "";247248 const copyKaId = async () => {249 if (!me?.ka_id) return;250 try {251 await navigator.clipboard.writeText(me.ka_id);252 setCopied(true);253 setTimeout(() => setCopied(false), 1800);254 } catch { /* presse-papiers indisponible : tant pis */ }255 };256257 if (me === undefined) {258 return <div className="container profil"><div className="notice">Chargement…</div></div>;259 }260261 if (me === null) {262 return (263 <div className="container profil">264 <span className="kicker">Mon compte</span>265 <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1>266 <p className="lede">267 Créez votre compte en un clic avec Google — vous recevrez votre268 identifiant de membre <b>KA-ID</b>, valide dans tout l'écosystème269 Groupe KA.270 </p>271 <a className="btn btn-primary" href="/api/auth/ka/login">272 Se connecter avec KA ID273 </a>274 </div>275 );276 }277278 return (279 <div className="container profil">280 <span className="kicker">Mon compte</span>281 <h1>282 {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</>283 : <>Votre <span className="hl">profil</span>.</>}284 </h1>285286 {/* ——— Carte de membre Groupe KA ——— */}287 <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>288 <div className="pc-watermark" aria-hidden="true">KA</div>289 <div className="pc-head">290 <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>291 <span className="pc-label">Carte de membre · Groupe KA</span>292 </div>293 <div className="pc-id-block">294 <span className="pc-id-label">KA-ID</span>295 <span className="pc-id">{me.ka_id}</span>296 </div>297 <div className="pc-foot">298 <div className="pc-holder">299 <span className="pc-holder-name">{me.name || me.email}</span>300 {me.role_label && (301 <span className="pc-role-badge">{me.role_label}</span>302 )}303 <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>304 </div>305 {me.picture && (306 <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />307 )}308 </div>309 <div className="pc-strip" aria-hidden="true">310 {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}311 </div>312 </div>313314 <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}>315 {copied ? "✓ Copié" : "Copier mon KA-ID"}316 </button>317318 {/* ——— Informations ——— */}319 <section className="profil-grid">320 <div className="pg-item">321 <span className="pg-label">Nom</span>322 <span className="pg-value">{me.name || "—"}</span>323 </div>324 <div className="pg-item">325 <span className="pg-label">Courriel</span>326 <span className="pg-value">{me.email}</span>327 </div>328 <div className="pg-item">329 <span className="pg-label">Identifiant membre</span>330 <span className="pg-value mono">{me.ka_id}</span>331 </div>332 <div className="pg-item">333 <span className="pg-label">Connexion</span>334 <span className="pg-value">335 {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Compte Google"}336 </span>337 </div>338 {me.city && (339 <div className="pg-item">340 <span className="pg-label">Ville</span>341 <span className="pg-value">{me.city}</span>342 </div>343 )}344 <div className="pg-item">345 <span className="pg-label">Profil</span>346 <span className="pg-value">347 {me.role === "locataire" ? "Locataire — je cherche un logement"348 : me.role === "gestionnaire" ? "Gestionnaire — je gère des logements"349 : <Link to="/bienvenue" className="mono">À choisir →</Link>}350 </span>351 </div>352 <div className="pg-item">353 <span className="pg-label">Membre depuis</span>354 <span className="pg-value">{fmtEpoch(me.created_at)}</span>355 </div>356 <div className="pg-item">357 <span className="pg-label">Dernière connexion</span>358 <span className="pg-value">{fmtEpoch(me.last_login)}</span>359 </div>360 </section>361362 {me.profile_source === "groupe-ka" ? (363 <HubProfileSection me={me} />364 ) : (365 /* vieux compte pas encore relié au hub : édition locale conservée */366 <ProfileEditor367 me={me}368 onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}369 />370 )}371372 <p className="profil-note">373 Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}374 <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">375 Groupe KA376 </a>{" "}377 — il vous suit sur toutes les plateformes du groupe. Lou-Ka ne conserve378 que les informations de ce profil (et ce que vous choisissez d'y379 ajouter) ; rien d'autre, et jamais revendus.380 </p>381382 {/* ——— Profil public ——— */}383 {me.profile_source === "groupe-ka" ? (384 <section className="profil-public">385 <h3>Profil public</h3>386 <p>387 La visibilité de votre page publique est gérée au niveau du388 Groupe KA — elle affiche votre nom, votre photo, votre bio, votre389 ville et vos réseaux sociaux, <b>jamais votre courriel ni votre390 téléphone</b>.391 </p>392 <div className="pub-meta">393 <span className="stat-chip">394 {me.public ? "Public" : "Privé"}395 </span>396 </div>397 {me.public && me.public_url && (398 <div className="public-link">399 <a href={me.public_url} target="_blank" rel="noopener noreferrer"400 className="mono">401 {me.public_url.replace(/^https?:\/\//, "")}402 </a>403 </div>404 )}405 <div style={{ marginTop: 14 }}>406 <a className="btn btn-ghost"407 href="https://www.groupe-ka.com/compte"408 target="_blank" rel="noopener noreferrer">409 Gérer sur groupe-ka.com ↗410 </a>411 </div>412 </section>413 ) : (414 <section className="profil-public">415 <h3>Profil public</h3>416 <p>417 Activez votre page publique pour partager votre carte de membre —418 elle affiche votre nom, votre photo, votre bio, votre ville et vos419 réseaux sociaux, <b>jamais votre courriel ni votre téléphone</b>.420 </p>421 <div className="seg" role="group" aria-label="Profil public">422 {[[false, "Privé"], [true, "Public"]].map(([v, l]) => (423 <button424 key={String(v)}425 className={isPublic === v ? "on" : ""}426 onClick={async () => {427 await setPublicProfile(v as boolean);428 setIsPublic(v as boolean);429 }}430 >431 {l as string}432 </button>433 ))}434 </div>435 {isPublic && (436 <div className="public-link">437 <a href={publicUrl} target="_blank" rel="noopener noreferrer" className="mono">438 {publicUrl.replace(/^https?:\/\//, "")}439 </a>440 <button441 className={`btn btn-ghost ${linkCopied ? "pc-copy ok" : ""}`}442 onClick={async () => {443 try {444 await navigator.clipboard.writeText(publicUrl);445 setLinkCopied(true);446 setTimeout(() => setLinkCopied(false), 1800);447 } catch { /* presse-papiers indisponible */ }448 }}449 >450 {linkCopied ? "✓ Copié" : "Copier le lien"}451 </button>452 </div>453 )}454 </section>455 )}456457 {/* ——— Actions ——— */}458 <div className="profil-actions">459 {me.role === "locataire" && (460 <Link className="btn btn-primary" to="/favoris">461 <IcoHeart size={14} /> Mes favoris462 </Link>463 )}464 {me.role === "gestionnaire" && (465 <Link className="btn btn-primary" to="/gestion">466 <IcoBuilding size={14} /> Ma page gestion467 </Link>468 )}469 <Link className="btn btn-ghost" to="/bienvenue">Changer de profil</Link>470 <button471 className="btn btn-ghost"472 onClick={async () => { await logout(); nav("/"); window.location.reload(); }}473 >474 Se déconnecter475 </button>476 <button477 className="btn btn-ghost"478 onClick={() => window.dispatchEvent(new Event("louka:openConsent"))}479 >480 Gérer mes témoins481 </button>482 <Link className="btn btn-ghost" to="/conditions"><IcoDoc size={14} /> Conditions</Link>483 <Link className="btn btn-ghost" to="/confidentialite"><IcoLock size={14} /> Confidentialité</Link>484 </div>485 </div>486 );487}488