SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
17.8 KB · 487 lines tsx
Raw Blame History
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