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%
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'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'ici (Fabri·Ka) ou une aubaine87 d'é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">124 ♥125 </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'écosystème Groupe KA — il vous380 suit sur chaque plateforme. Le hub ne conserve que ce profil : rien381 d'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'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'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