SPB Git

spb/groupe-ka Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

TypeScript 93.9% CSS 6%
18.2 KB · 472 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Mon compte — la carte de membre Groupe KA (même gabarit que sur Lou·Ka :3// carte KA-ID, grille d'informations, plateformes, actions).4import type { Metadata } from "next";5import { redirect } from "next/navigation";6import QRCode from "qrcode";7import { getSessionUser } from "@/lib/auth";8import { ensureKaId } from "@/lib/db";9import { SSO_CLIENTS } from "@/lib/sso";10import { roleLabel, roleCard } from "@/lib/roles";11import {12  db,13  parseSocials,14  favoritesOf,15  type UserRow,16  type FavoriteRow,17} from "@/lib/db";18import RemoveFavorite from "./RemoveFavorite";19import LogoutButton from "./LogoutButton";20import CopyKaId from "./CopyKaId";21import AvatarEditor from "./AvatarEditor";22import RolePicker from "./RolePicker";23import ProfileForm from "./ProfileForm";24import CopyText from "./CopyText";2526export const metadata: Metadata = {27  title: "Mon KA ID | Groupe KA",28  description: "Votre compte unique pour les plateformes du Groupe KA.",29};3031export const dynamic = "force-dynamic";3233const PLATFORM_URLS: Record<string, string> = {34  "lou-ka": "https://www.lou-ka.com",35  "immo-ka": "https://www.immo-ka.com",36  "vrai-prix": "https://www.vrai-prix.com",37  valoplex: "https://www.valoplex.com",38  "auto-ka": "https://www.auto-ka.com",39  "fabri-ka": "https://www.fabri-ka.com",40  "food-ka": "https://www.food-ka.com",41  "ora-ka": "https://www.ora-ka.com",42};4344function fmtDate(iso: string | null): string {45  if (!iso) return "—";46  const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");47  if (Number.isNaN(d.getTime())) return "—";48  return d49    .toLocaleDateString("fr-CA", {50      day: "numeric",51      month: "long",52      year: "numeric",53    })54    .replace(/^1 /, "1ᵉʳ ");55}5657const APP_ACCENTS: Record<string, string> = {58  "lou-ka": "#d9f26b",59  "immo-ka": "#e23744",60  "vrai-prix": "#ff5148",61  valoplex: "#ff9f45",62  "auto-ka": "#ff5a2a",63  "fabri-ka": "#C4532E",64  "food-ka": "#d9f26b",65  "ora-ka": "#d9f26b",66};6768function UniversSection({ userId }: { userId: number }) {69  const favs = favoritesOf(userId);70  const byApp = new Map<string, FavoriteRow[]>();71  for (const f of favs) {72    if (!byApp.has(f.app)) byApp.set(f.app, []);73    byApp.get(f.app)!.push(f);74  }75  return (76    <section className="mt-10">77      <p className="kicker">Mon univers Ka · tous mes coups de cœur</p>78      <h2 className="gk-display mt-2 text-[20px] font-bold uppercase">79        Ce que j&apos;ai sauvegardé, partout80      </h2>81      {favs.length === 0 ? (82        <div className="gk-card mt-4 p-6 text-center">83          <p className="text-[13.5px] text-ink-2">84            Vos coups de cœur des plateformes ·Ka apparaîtront ici — tapez ♥85            sur un logement (Lou·Ka), une propriété (Immo·Ka), une auto86            (Auto·Ka), un produit d&apos;ici (Fabri·Ka) ou une aubaine87            d&apos;épicerie (Food·Ka).88          </p>89        </div>90      ) : (91        <div className="mt-4 space-y-6">92          {[...byApp.entries()].map(([app, items]) => (93            <div key={app}>94              <p className="klabel flex items-center gap-2">95                <span96                  aria-hidden="true"97                  className="inline-flex h-[14px] w-[14px] items-center justify-center rounded-[3px] bg-ink"98                >99                  <span100                    className="h-[8px] w-[8px] rounded-[2px]"101                    style={{ background: APP_ACCENTS[app] ?? "#d9f26b" }}102                  />103                </span>104                {(SSO_CLIENTS[app]?.label ?? app)} · {items.length}105              </p>106              <ul className="mt-2 grid gap-3 sm:grid-cols-2">107                {items.map((f) => (108                  <li109                    key={f.item_id}110                    className="gk-card flex items-center gap-3 p-3"111                  >112                    {f.image_url ? (113                      // eslint-disable-next-line @next/next/no-img-element114                      <img115                        src={f.image_url}116                        alt=""117                        width={56}118                        height={56}119                        referrerPolicy="no-referrer"120                        className="h-14 w-14 flex-none rounded-md border border-[rgba(20,24,20,0.2)] object-cover"121                      />122                    ) : (123                      <span className="gk-display flex h-14 w-14 flex-none items-center justify-center rounded-md bg-lime-soft text-[18px] font-bold">124125                      </span>126                    )}127                    <div className="min-w-0 flex-1">128                      <a129                        href={f.url ?? "#"}130                        target="_blank"131                        rel="noopener noreferrer"132                        className="block truncate text-[13.5px] font-bold underline-offset-4 hover:underline"133                      >134                        {f.title}135                      </a>136                      {f.subtitle && (137                        <p className="truncate text-[12px] text-ink-2">138                          {f.subtitle}139                        </p>140                      )}141                      {f.price_label && (142                        <p className="gk-mono text-[12px] font-bold text-green">143                          {f.price_label}144                        </p>145                      )}146                    </div>147                    <RemoveFavorite app={f.app} itemId={f.item_id} />148                  </li>149                ))}150              </ul>151            </div>152          ))}153        </div>154      )}155    </section>156  );157}158159function ProfileSection({ userId, kaId }: { userId: number; kaId: string }) {160  const row = db161    .prepare("SELECT * FROM users WHERE id = ?")162    .get(userId) as UserRow;163  return (164    <section className="rise mt-8 [animation-delay:0.25s]">165      <div className="gk-card p-5 sm:p-7">166        <p className="kicker">Mon profil · une saisie, sept plateformes</p>167        <h2 className="gk-display mt-2 text-[18px] font-bold uppercase">168          Personnaliser mon profil169        </h2>170        <div className="mt-5">171          <ProfileForm172            initial={{173              name: row.name ?? "",174              bio: row.bio ?? "",175              city: row.city ?? "",176              phone: row.phone ?? "",177              website: row.website ?? "",178              job_title: row.job_title ?? "",179              company: row.company ?? "",180              birth_date: row.birth_date ?? "",181              socials: parseSocials(row.socials),182              isPublic: !!row.public,183              kaId,184            }}185          />186        </div>187      </div>188    </section>189  );190}191192export default async function Compte() {193  const user = await getSessionUser();194  if (!user) redirect("/connexion?next=%2Fcompte");195  const kaId = user.kaId ?? ensureKaId(user.id);196  // Code QR de la carte → page de vérification publique du KA-ID197  const qr = await QRCode.toDataURL(`https://www.groupe-ka.com/m/${kaId}`, {198    margin: 0,199    width: 220,200    color: { dark: "#141814", light: "#ffffff" },201  });202203  const infos: [string, React.ReactNode][] = [204    ["Nom", user.name || "—"],205    ["Courriel", user.email],206    [207      "Identifiant membre",208      <span key="k" className="gk-mono">{kaId}</span>,209    ],210    ["Type de compte", roleLabel(user.role) ?? "À choisir ci-dessous"],211    [212      "Connexion",213      (() => {214        const modes = [215          user.hasGoogle && "Google",216          user.hasApple && "Apple",217          user.hasPassword && "mot de passe",218        ].filter(Boolean) as string[];219        return modes.length ? modes.join(" + ") : "Courriel et mot de passe";220      })(),221    ],222    ["Membre depuis", fmtDate(user.createdAt)],223    ["Dernière connexion", fmtDate(user.lastLogin)],224  ];225226  return (227    <main className="mx-auto max-w-2xl px-4 pb-16 sm:px-6">228      <section className="pt-12 sm:pt-14">229        <p className="kicker rise">Mon compte · KA ID</p>230        <h1 className="gk-display rise mt-4 text-[clamp(30px,5vw,48px)] leading-[1] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">231          Salut, <span className="hl">{user.name.split(" ")[0]}</span>.232        </h1>233      </section>234235      {/* ——— Carte de membre Groupe KA ——— */}236      <div237        className="mc rise [animation-delay:0.14s]"238        role="img"239        aria-label={`Carte de membre ${kaId}`}240      >241        <div className="mc-watermark" aria-hidden="true">242          KA243        </div>244        <div className="mc-head">245          <span className="mc-brand">246            Groupe <span className="mc-ka">KA</span>247          </span>248          <span className="mc-label">Carte de membre · Groupe KA</span>249        </div>250        <div className="mc-id-row">251          <div className="mc-id-block">252            <span className="mc-id-label">KA-ID</span>253            <span className="mc-id">{kaId}</span>254          </div>255          {/* eslint-disable-next-line @next/next/no-img-element */}256          <img257            className="mc-qr"258            src={qr}259            alt={`Code QR de vérification du KA-ID ${kaId}`}260            width={78}261            height={78}262          />263        </div>264        <div className="mc-foot">265          <div className="mc-holder">266            <span className="mc-holder-name">{user.name || user.email}</span>267            {roleCard(user.role) && (268              <span className="mc-holder-role">{roleCard(user.role)}</span>269            )}270            <span className="mc-holder-since">271              Membre depuis le {fmtDate(user.createdAt)}272            </span>273          </div>274          {user.avatarUrl ? (275            // eslint-disable-next-line @next/next/no-img-element276            <img277              className="mc-avatar"278              src={user.avatarUrl}279              alt=""280              referrerPolicy="no-referrer"281            />282          ) : (283            <span className="mc-avatar mc-avatar-empty">284              {(user.name || user.email).charAt(0).toUpperCase()}285            </span>286          )}287        </div>288        <div className="mc-strip" aria-hidden="true">289          {Array.from({ length: 28 }).map((_, i) => (290            <i key={i} />291          ))}292        </div>293      </div>294295      <div className="rise mt-4 flex flex-wrap items-center gap-3 [animation-delay:0.2s]">296        {process.env.KA_PASS_DIR && process.env.KA_PASS_TYPE_ID && (297          <a298            href="/api/wallet/pass"299            className="inline-flex min-h-[44px] items-center gap-2 rounded-[10px] bg-black px-5 text-[14px] font-semibold text-white shadow-[4px_4px_0_rgba(20,24,20,0.25)] transition-transform active:translate-x-[2px] active:translate-y-[2px] active:shadow-none"300          >301            <svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">302              <path303                fill="currentColor"304                d="M17.05 12.54c-.03-2.9 2.37-4.29 2.48-4.36-1.35-1.97-3.45-2.24-4.2-2.27-1.79-.18-3.49 1.05-4.4 1.05-.9 0-2.3-1.02-3.79-1-1.95.03-3.74 1.13-4.74 2.87-2.02 3.5-.52 8.69 1.45 11.53.96 1.39 2.11 2.95 3.62 2.89 1.45-.06 2-.94 3.76-.94s2.25.94 3.79.91c1.56-.03 2.55-1.42 3.51-2.81 1.1-1.62 1.56-3.18 1.58-3.26-.03-.02-3.03-1.16-3.06-4.61ZM14.16 4.02c.8-.97 1.34-2.32 1.19-3.66-1.15.05-2.55.77-3.38 1.74-.74.85-1.39 2.23-1.22 3.54 1.29.1 2.6-.65 3.41-1.62Z"305              />306            </svg>307            Ajouter à Apple Wallet308          </a>309        )}310        <CopyKaId kaId={kaId} />311        <LogoutButton />312      </div>313314      {/* ——— Type de compte (obligatoire — les comptes Google le315              choisissent ici) ——— */}316      {!user.role && (317        <section className="rise mt-8 [animation-delay:0.22s]">318          <div className="gk-card border-[2px] !border-green p-5 sm:p-6">319            <p className="kicker">Une dernière chose · vous êtes…</p>320            <p className="mt-2 text-[13.5px] text-ink-2">321              Choisissez votre type de compte — il sera affiché sur votre322              carte de membre.323            </p>324            <div className="mt-4">325              <RolePicker current={user.role} />326            </div>327          </div>328        </section>329      )}330331      {/* ——— Photo de profil ——— */}332      <section className="rise mt-8 [animation-delay:0.23s]">333        <div className="gk-card flex flex-wrap items-center gap-5 p-5 sm:p-6">334          {user.avatarUrl ? (335            // eslint-disable-next-line @next/next/no-img-element336            <img337              src={user.avatarUrl}338              alt=""339              width={64}340              height={64}341              referrerPolicy="no-referrer"342              className="h-16 w-16 flex-none rounded-full border-2 border-ink object-cover"343            />344          ) : (345            <span className="gk-display flex h-16 w-16 flex-none items-center justify-center rounded-full border-2 border-ink bg-lime text-[24px] font-bold">346              {(user.name || user.email).charAt(0).toUpperCase()}347            </span>348          )}349          <div className="min-w-0 flex-1">350            <p className="klabel">Photo de profil</p>351            <div className="mt-2">352              <AvatarEditor hasAvatar={!!user.avatarUrl} />353            </div>354          </div>355        </div>356      </section>357358      {/* ——— Mon univers Ka (favoris unifiés) ——— */}359      <UniversSection userId={user.id} />360361      {/* ——— Mon profil (source de vérité pour toutes les plateformes) ——— */}362      <ProfileSection userId={user.id} kaId={kaId} />363364      {/* ——— Informations ——— */}365      <section className="rise mt-8 [animation-delay:0.26s]">366        <div className="gk-card grid gap-x-6 gap-y-0 p-0 sm:grid-cols-2">367          {infos.map(([label, value], i) => (368            <div369              key={label}370              className={`px-5 py-4 sm:px-6 ${i < infos.length - (infos.length % 2 === 0 ? 2 : 1) ? "border-b border-dashed border-[rgba(20,24,20,0.18)]" : ""}`}371            >372              <p className="klabel">{label}</p>373              <p className="mt-1 text-[14px] font-medium break-all">{value}</p>374            </div>375          ))}376        </div>377        <p className="mt-4 text-[12.5px] leading-relaxed text-ink-2">378          Votre <strong className="gk-display">KA-ID</strong> est votre379          identifiant unique dans tout l&apos;écosystème Groupe KA — il vous380          suit sur chaque plateforme. Le hub ne conserve que ce profil : rien381          d&apos;autre, et jamais revendu.382        </p>383      </section>384385      {/* ——— Carte physique & NFC ——— */}386      <section className="mt-10">387        <p className="kicker">Carte physique · NFC</p>388        <h2 className="gk-display mt-2 text-[20px] font-bold uppercase">389          Votre carte, en vrai390        </h2>391        <div className="gk-card mt-4 p-5 sm:p-6">392          <p className="text-[13.5px] leading-relaxed text-ink-2">393            Imprimez votre carte au format carte bancaire et collez-y un394            autocollant <strong className="text-ink">NFC (NTAG215, ~1 $)</strong> :395            un simple tap sur un iPhone ou un Android ouvre votre page396            d&apos;identification — comme le code QR, sans appli.397          </p>398          <div className="mt-4 flex flex-wrap items-center gap-3">399            <a href="/compte/carte" className="btn btn-primary">400              Carte imprimable (recto/verso) ↗401            </a>402            <CopyText403              text={`https://www.groupe-ka.com/m/${kaId}`}404              label="Copier le lien à écrire sur la puce NFC"405            />406          </div>407          <ol className="mt-5 space-y-2 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-4 text-[13px] text-ink-2">408            {[409              <>Procurez-vous des autocollants <strong className="text-ink">NTAG215</strong> (Amazon, ~10 $ les 10).</>,410              <>Installez l&apos;appli gratuite <strong className="text-ink">NFC Tools</strong> (iPhone ou Android).</>,411              <>Onglet <strong className="text-ink">Écrire</strong> → Ajouter un enregistrement → <strong className="text-ink">URL</strong> → collez le lien ci-dessus → <strong className="text-ink">Écrire</strong>, en tenant la puce contre le téléphone.</>,412              <>Optionnel : <strong className="text-ink">Verrouiller le tag</strong> pour le rendre non modifiable (irréversible).</>,413              <>Collez la puce au dos de la carte imprimée, dans la zone « NFC ».</>,414            ].map((step, i) => (415              <li key={i} className="flex gap-3">416                <span className="gk-mono flex-none font-bold text-green">417                  0{i + 1}418                </span>419                <span>{step}</span>420              </li>421            ))}422          </ol>423        </div>424      </section>425426      {/* ——— Plateformes ——— */}427      <section className="mt-10">428        <p className="kicker">Un compte · sept portes</p>429        <h2 className="gk-display mt-2 text-[20px] font-bold uppercase">430          Votre KA ID fonctionne ici431        </h2>432        <p className="mt-2 text-[13.5px] text-ink-2">433          Sur chaque plateforme, choisissez « Se connecter avec KA ID » : vous434          arrivez déjà connecté, avec le même profil et le même KA-ID.435        </p>436        <ul className="mt-5 grid gap-3 sm:grid-cols-2">437          {Object.entries(SSO_CLIENTS).map(([id, c]) => (438            <li key={id}>439              <a440                href={PLATFORM_URLS[id]}441                target="_blank"442                rel="noopener noreferrer"443                className="gk-card gk-card-hover flex items-center justify-between px-5 py-4"444              >445                <span className="gk-display text-[15px] font-bold">446                  {c.label}447                </span>448                <span className="gk-mono text-[11px] text-ink-3">449                  {PLATFORM_URLS[id].replace("https://www.", "")} ↗450                </span>451              </a>452            </li>453          ))}454        </ul>455      </section>456457      {/* ——— Actions ——— */}458      <div className="mt-8 flex flex-wrap gap-3">459        <a href="/conditions" className="btn btn-ghost">460          Conditions461        </a>462        <a href="/confidentialite" className="btn btn-ghost">463          Confidentialité464        </a>465        <a href="mailto:admin@groupe-ka.com" className="btn btn-ghost">466          Vie privée : admin@groupe-ka.com467        </a>468      </div>469    </main>470  );471}472