Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Profile.tsx: user profile — Groupe KA member card (KA-ID),5// account information, actions (sign out, cookies, legal)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("en-CA", {21 day: "numeric", month: "long", year: "numeric",22 });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/** "@handle" or "handle" -> full platform URL; a URL is left as is */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: "@handle or URL" },45 { key: "facebook", label: "Facebook", icon: <IcoFacebook size={15} />, placeholder: "profile or URL" },46 { key: "x", label: "X (Twitter)", icon: <IcoX size={15} />, placeholder: "@handle or URL" },47 { key: "linkedin", label: "LinkedIn", icon: <IcoLinkedIn size={15} />, placeholder: "profile or URL" },48 { key: "tiktok", label: "TikTok", icon: <IcoTikTok size={15} />, placeholder: "@handle or URL" },49 { key: "youtube", label: "YouTube", icon: <IcoYouTube size={15} />, placeholder: "channel or URL" },50];5152/** Edit section: photo, display name, bio, contact info, social networks */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("Could not save — try again."); 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 rejected — JPG/PNG/WebP image, 8 MB max."); return; }86 onSaved();87 };8889 return (90 <section className="org-form profil-edit">91 <h3>Customize my profile</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 ? "Uploading…" : "Change photo"}104 </button>105 {me.avatar_url && (106 <button className="btn btn-ghost" disabled={avatarBusy}107 onClick={async () => { await deleteAvatar(); onSaved(); }}>108 Remove (back to Google)109 </button>110 )}111 <span className="pe-hint">JPG, PNG or WebP — cropped to a 512 px square.</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>Display name</span>122 <input123 value={form.display_name} maxLength={80}124 placeholder={me.google_name || "Your name"}125 onChange={(e) => setForm({ ...form, display_name: e.target.value })}126 />127 </label>128 <label>129 <span>City</span>130 <input131 value={form.city} maxLength={60} placeholder="Toronto, Vancouver…"132 onChange={(e) => setForm({ ...form, city: e.target.value })}133 />134 </label>135 </div>136137 <label>138 <span>Bio ({280 - form.bio.length} characters left)</span>139 <textarea140 value={form.bio} rows={3} maxLength={280}141 placeholder="Introduce yourself in a few words…"142 onChange={(e) => setForm({ ...form, bio: e.target.value })}143 />144 </label>145146 <div className="org-form-row">147 <label>148 <span>Phone</span>149 <input150 value={form.phone} maxLength={40} placeholder="416 555-0123"151 onChange={(e) => setForm({ ...form, phone: e.target.value })}152 />153 </label>154 <label>155 <span>Website</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">Social networks</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 ? "✓ Saved" : saving ? "Saving…" : "Save my profile"}186 </button>187 </section>188 );189}190191/** Profile managed at the Groupe KA HUB — READ ONLY (editing happens on192 * groupe-ka.com/compte, one entry for all the platforms). */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>My Groupe KA profile</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} years old</span>}207 {me.website && (208 <a className="stat-chip" href={socialUrl("website", me.website)}209 target="_blank" rel="noopener noreferrer">Website ↗</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 Edit my profile on groupe-ka.com ↗224 </a>225 </div>226 <p className="pe-hint">227 Your profile is managed at the group level: one entry, visible228 across all the platforms.229 </p>230 </section>231 );232}233234export default function ProfilePage() {235 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = loading236 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 { /* clipboard unavailable: never mind */ }255 };256257 if (me === undefined) {258 return <div className="container profil"><div className="notice">Loading…</div></div>;259 }260261 if (me === null) {262 return (263 <div className="container profil">264 <span className="kicker">My account</span>265 <h1>Sign in to see <span className="hl">your profile</span>.</h1>266 <p className="lede">267 Create your account in one click with Google — you'll receive your268 <b>KA-ID</b> member identifier, valid across the whole Groupe KA269 ecosystem.270 </p>271 <a className="btn btn-primary" href="/api/auth/ka/login">272 Sign in with KA ID273 </a>274 </div>275 );276 }277278 return (279 <div className="container profil">280 <span className="kicker">My account</span>281 <h1>282 {me.name ? <>Hi, <span className="hl">{me.name.split(" ")[0]}</span>.</>283 : <>Your <span className="hl">profile</span>.</>}284 </h1>285286 {/* ——— Carte de membre Groupe KA ——— */}287 <div className="pc" role="img" aria-label={`Member card ${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">Member card · 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">Member since {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 ? "✓ Copied" : "Copy my KA-ID"}316 </button>317318 {/* ——— Informations ——— */}319 <section className="profil-grid">320 <div className="pg-item">321 <span className="pg-label">Name</span>322 <span className="pg-value">{me.name || "—"}</span>323 </div>324 <div className="pg-item">325 <span className="pg-label">Email</span>326 <span className="pg-value">{me.email}</span>327 </div>328 <div className="pg-item">329 <span className="pg-label">Member ID</span>330 <span className="pg-value mono">{me.ka_id}</span>331 </div>332 <div className="pg-item">333 <span className="pg-label">Sign-in</span>334 <span className="pg-value">335 {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Google account"}336 </span>337 </div>338 {me.city && (339 <div className="pg-item">340 <span className="pg-label">City</span>341 <span className="pg-value">{me.city}</span>342 </div>343 )}344 <div className="pg-item">345 <span className="pg-label">Profile</span>346 <span className="pg-value">347 {me.role === "locataire" ? "Tenant — I'm looking for a rental"348 : me.role === "gestionnaire" ? "Manager — I manage rentals"349 : <Link to="/welcome" className="mono">To choose →</Link>}350 </span>351 </div>352 <div className="pg-item">353 <span className="pg-label">Member since</span>354 <span className="pg-value">{fmtEpoch(me.created_at)}</span>355 </div>356 <div className="pg-item">357 <span className="pg-label">Last sign-in</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 /* old account not yet linked to the hub: local editing kept */366 <ProfileEditor367 me={me}368 onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}369 />370 )}371372 <p className="profil-note">373 Your <b>KA-ID</b> is your unique identifier in the{" "}374 <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">375 Groupe KA376 </a>{" "}377 ecosystem — it follows you across all the group's platforms. Rent-Ka378 keeps only this profile's information (and what you choose to add);379 nothing else, and never resold.380 </p>381382 {/* ——— Profil public ——— */}383 {me.profile_source === "groupe-ka" ? (384 <section className="profil-public">385 <h3>Public profile</h3>386 <p>387 The visibility of your public page is managed at the Groupe KA388 level — it shows your name, photo, bio, city and social networks,389 <b> never your email or phone</b>.390 </p>391 <div className="pub-meta">392 <span className="stat-chip">393 {me.public ? "Public" : "Private"}394 </span>395 </div>396 {me.public && me.public_url && (397 <div className="public-link">398 <a href={me.public_url} target="_blank" rel="noopener noreferrer"399 className="mono">400 {me.public_url.replace(/^https?:\/\//, "")}401 </a>402 </div>403 )}404 <div style={{ marginTop: 14 }}>405 <a className="btn btn-ghost"406 href="https://www.groupe-ka.com/compte"407 target="_blank" rel="noopener noreferrer">408 Manage on groupe-ka.com ↗409 </a>410 </div>411 </section>412 ) : (413 <section className="profil-public">414 <h3>Public profile</h3>415 <p>416 Enable your public page to share your member card — it shows your417 name, photo, bio, city and social networks, <b>never your email or418 phone</b>.419 </p>420 <div className="seg" role="group" aria-label="Public profile">421 {[[false, "Private"], [true, "Public"]].map(([v, l]) => (422 <button423 key={String(v)}424 className={isPublic === v ? "on" : ""}425 onClick={async () => {426 await setPublicProfile(v as boolean);427 setIsPublic(v as boolean);428 }}429 >430 {l as string}431 </button>432 ))}433 </div>434 {isPublic && (435 <div className="public-link">436 <a href={publicUrl} target="_blank" rel="noopener noreferrer" className="mono">437 {publicUrl.replace(/^https?:\/\//, "")}438 </a>439 <button440 className={`btn btn-ghost ${linkCopied ? "pc-copy ok" : ""}`}441 onClick={async () => {442 try {443 await navigator.clipboard.writeText(publicUrl);444 setLinkCopied(true);445 setTimeout(() => setLinkCopied(false), 1800);446 } catch { /* clipboard unavailable */ }447 }}448 >449 {linkCopied ? "✓ Copied" : "Copy the link"}450 </button>451 </div>452 )}453 </section>454 )}455456 {/* ——— Actions ——— */}457 <div className="profil-actions">458 {me.role === "locataire" && (459 <Link className="btn btn-primary" to="/favorites">460 <IcoHeart size={14} /> My saved rentals461 </Link>462 )}463 {me.role === "gestionnaire" && (464 <Link className="btn btn-primary" to="/manage">465 <IcoBuilding size={14} /> My manager page466 </Link>467 )}468 <Link className="btn btn-ghost" to="/welcome">Change profile</Link>469 <button470 className="btn btn-ghost"471 onClick={async () => { await logout(); nav("/"); window.location.reload(); }}472 >473 Sign out474 </button>475 <button476 className="btn btn-ghost"477 onClick={() => window.dispatchEvent(new Event("rentka:openConsent"))}478 >479 Manage cookies480 </button>481 <Link className="btn btn-ghost" to="/terms"><IcoDoc size={14} /> Terms</Link>482 <Link className="btn btn-ghost" to="/privacy"><IcoLock size={14} /> Privacy</Link>483 </div>484 </div>485 );486}487