SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
18.2 KB · 488 lines tsx
Raw Blame History
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