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 91.8% CSS 8.1%

feat: initial commit

Simon-Pierre Boucher committed 2 h ago (Aug 13, 2026)

Showing 61 changed files with +8,620 and −0

added .gitignore +11 −0
@@ -0,0 +1,11 @@
1 +# dépendances & build
2 +node_modules/
3 +.next/
4 +*.tsbuildinfo
5 +
6 +# données locales (SQLite) & secrets
7 +data/
8 +.env*
9 +
10 +# système
11 +.DS_Store
added README.md +110 −0
@@ -0,0 +1,110 @@
1 +<!-- Auteur : Simon-Pierre Boucher — contact@spboucher.ai -->
2 +
3 +<div align="center">
4 +
5 +# Groupe KA — le hub & le KA ID
6 +
7 +**Site vitrine du holding + compte unique (KA ID) pour les sept plateformes du groupe.**
8 +
9 +[![Site](https://img.shields.io/website?url=https%3A%2F%2Fwww.groupe-ka.com&up_message=en%20ligne&down_message=hors%20ligne&label=groupe-ka.com&style=flat-square)](https://www.groupe-ka.com)
10 +[![Next.js](https://img.shields.io/badge/Next.js-16.3-000000?style=flat-square&logo=nextdotjs&logoColor=white)](https://nextjs.org)
11 +[![React](https://img.shields.io/badge/React-19.2-087EA4?style=flat-square&logo=react&logoColor=white)](https://react.dev)
12 +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
13 +[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)](https://tailwindcss.com)
14 +[![SQLite](https://img.shields.io/badge/SQLite-WAL-003B57?style=flat-square&logo=sqlite&logoColor=white)](https://sqlite.org)
15 +
16 +[![Google OAuth](https://img.shields.io/badge/Connexion-Google_OAuth_2.0-4285F4?style=flat-square&logo=google&logoColor=white)](#-authentification)
17 +[![Sign in with Apple](https://img.shields.io/badge/Connexion-Sign_in_with_Apple-000000?style=flat-square&logo=apple&logoColor=white)](#-authentification)
18 +[![JWT](https://img.shields.io/badge/Sessions-JWT_HS256_(jose)-d9f26b?style=flat-square)](#-authentification)
19 +[![scrypt](https://img.shields.io/badge/Mots_de_passe-scrypt_natif-1c5c41?style=flat-square)](#-authentification)
20 +[![Apple Wallet](https://img.shields.io/badge/Carte_membre-Apple_Wallet-999999?style=flat-square&logo=apple&logoColor=white)](#-fonctionnalités)
21 +
22 +[![Plateformes SSO](https://img.shields.io/badge/SSO-7_plateformes-1c5c41?style=flat-square)](#-sso--un-compte-sept-plateformes)
23 +[![Routes API](https://img.shields.io/badge/API-17_routes-141814?style=flat-square)](#-routes)
24 +[![Pages](https://img.shields.io/badge/Pages-8-141814?style=flat-square)](#-routes)
25 +[![Code](https://img.shields.io/badge/TS%2FTSX-~5_400_lignes-141814?style=flat-square)](#-métriques)
26 +[![PM2](https://img.shields.io/badge/Prod-PM2_+_ngrok-2B037A?style=flat-square&logo=pm2&logoColor=white)](#-déploiement)
27 +[![Licence](https://img.shields.io/badge/©_2026-Simon--Pierre_Boucher-d9f26b?style=flat-square)](#-auteur)
28 +
29 +</div>
30 +
31 +---
32 +
33 +## 📸 Aperçu
34 +
35 +| Accueil | Connexion (KA ID) |
36 +|:---:|:---:|
37 +| ![Accueil](assets/screenshots/accueil.png) | ![Connexion](assets/screenshots/connexion.png) |
38 +
39 +<div align="center">
40 +<img src="assets/screenshots/mobile.png" alt="Vue mobile — icône compte directement dans le header" width="260">
41 +</div>
42 +
43 +## ✨ Fonctionnalités
44 +
45 +- 🪪 **KA ID** — identifiant membre unique (`ka-` + 10 chiffres) créé au hub, source de vérité du groupe.
46 +- 🔑 **Trois modes de connexion** : Google OAuth 2.0, Sign in with Apple, courriel + mot de passe (scrypt natif, zéro dépendance).
47 +- 🛰 **SSO maison** vers les 7 plateformes (jeton JWT signé par plateforme, 5 min).
48 +- 👤 **Profil enrichi** (bio, ville, réseaux sociaux, rôle…) affiché par toutes les plateformes, avec page publique `/u/[kaId]`.
49 +-**Favoris unifiés** « Mon univers Ka » — magasin central, les plateformes poussent/lisent via `/api/sso/favorites`.
50 +- 💳 **Carte de membre** avec QR code + passe **Apple Wallet** signée (`/api/wallet/pass`).
51 +- 📱 **Menu mobile 100 % CSS** (case à cocher + `peer`, zéro JS) et icône compte directe dans le header.
52 +
53 +## 📊 Métriques
54 +
55 +| Métrique | Valeur |
56 +|---|---:|
57 +| Lignes TypeScript / TSX | ~5 400 |
58 +| CSS (design system maison) | ~850 lignes |
59 +| Routes API | **17** |
60 +| Pages | **8** |
61 +| Plateformes SSO servies | **7** |
62 +| Dépendances de prod | **7** seulement |
63 +| Base de données | SQLite (WAL), zéro serveur |
64 +
65 +## 🔐 Authentification
66 +
67 +| Mode | Détails |
68 +|---|---|
69 +| **Google** | OAuth 2.0 code flow, `id_token` vérifié par JWKS (`jose`), liaison par `google_sub` puis courriel |
70 +| **Apple** | Sign in with Apple, retour `form_post`, `client_secret` = JWT ES256 signé à la volée avec la clé `.p8`, liaison par `apple_sub` |
71 +| **Courriel** | scrypt natif Node (`crypto.scryptSync`), comparaison à temps constant |
72 +| **Sessions** | JWT HS256 (`jose`) en témoin HttpOnly `ka_session`, 30 jours |
73 +
74 +## 🛰 SSO — un compte, sept plateformes
75 +
76 +`/sso/authorize?client=<plateforme>` émet un JWT (5 min) signé avec le secret partagé de la plateforme :
77 +[Lou·Ka](https://www.lou-ka.com) · [Immo·Ka](https://www.immo-ka.com) · [Vrai-Prix](https://www.vrai-prix.com) · [ValoPlex](https://www.valoplex.com) · [Auto·Ka](https://www.auto-ka.com) · [Fabri·Ka](https://www.fabri-ka.com) · [Food·Ka](https://www.food-ka.com)
78 +
79 +## 🗂 Routes
80 +
81 +**Pages** : `/` · `/connexion` · `/compte` · `/compte/carte` · `/u/[kaId]` · `/m/[kaId]` · `/conditions` · `/confidentialite`
82 +
83 +**API** :
84 +
85 +| Groupe | Routes |
86 +|---|---|
87 +| Auth | `/api/auth/{login,register,logout,me}` · `/api/auth/{google,apple}` · `/api/auth/callback/{google,apple}` |
88 +| Profil | `/api/profile` · `/api/profile/{role,public,avatar}` |
89 +| SSO | `/sso/authorize` · `/api/sso/{profile,favorites}` |
90 +| Divers | `/api/favorites` · `/api/wallet/pass` · `/api/health` |
91 +
92 +## 🧱 Stack
93 +
94 +**Next.js 16 (App Router, Turbopack)** · **React 19** · **TypeScript 5** · **Tailwind CSS 4** · **better-sqlite3** (WAL) · **jose** (JWT/JWKS) · **sharp** (avatars) · **qrcode** (carte de membre)
95 +
96 +## 🚀 Déploiement
97 +
98 +```bash
99 +npm run dev # développement (http://localhost:3000)
100 +npm run build # build de production
101 +npm start # sert le build
102 +```
103 +
104 +En production : nœud **M3U96b** du cluster MacLustr, **PM2** (auto-restart) + tunnel **ngrok** → [www.groupe-ka.com](https://www.groupe-ka.com).
105 +
106 +Variables d'environnement : `BASE_URL`, `KA_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `APPLE_CLIENT_ID/TEAM_ID/KEY_ID/PRIVATE_KEY`, `KA_SSO_SECRET_*` (une par plateforme), `KA_PASS_*` (Apple Wallet), `KA_DB_PATH`.
107 +
108 +## 👤 Auteur
109 +
110 +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) · © 2026 Groupe KA
added assets/screenshots/accueil.png +0 −0

Binary file not shown.

added assets/screenshots/connexion.png +0 −0

Binary file not shown.

added assets/screenshots/mobile.png +0 −0

Binary file not shown.

added assets/wallet/icon.png +0 −0

Binary file not shown.

added assets/wallet/icon@2x.png +0 −0

Binary file not shown.

added assets/wallet/icon@3x.png +0 −0

Binary file not shown.

added assets/wallet/logo.png +0 −0

Binary file not shown.

added assets/wallet/logo@2x.png +0 −0

Binary file not shown.

added next-env.d.ts +7 −0
@@ -0,0 +1,7 @@
1 +/// <reference types="next" />
2 +/// <reference types="next/image-types/global" />
3 +import "./.next/types/routes.d.ts";
4 +import "./.next/types/root-params.d.ts";
5 +
6 +// NOTE: This file should not be edited
7 +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
added next.config.ts +8 −0
@@ -0,0 +1,8 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { NextConfig } from "next";
3 +
4 +const nextConfig: NextConfig = {
5 + serverExternalPackages: ["better-sqlite3", "sharp"],
6 +};
7 +
8 +export default nextConfig;
added package-lock.json +2145 −0
@@ -0,0 +1,2145 @@
1 +{
2 + "name": "groupe-ka",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "groupe-ka",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "better-sqlite3": "^13.0.3",
12 + "jose": "^6.2.8",
13 + "next": "16.3.0",
14 + "qrcode": "^1.5.4",
15 + "react": "19.2.8",
16 + "react-dom": "19.2.8",
17 + "sharp": "^0.35.3"
18 + },
19 + "devDependencies": {
20 + "@tailwindcss/postcss": "^4",
21 + "@types/better-sqlite3": "^9.6.0",
22 + "@types/node": "^20",
23 + "@types/qrcode": "^1.5.6",
24 + "@types/react": "^19",
25 + "@types/react-dom": "^19",
26 + "tailwindcss": "^4",
27 + "typescript": "^5"
28 + }
29 + },
30 + "node_modules/@alloc/quick-lru": {
31 + "version": "5.2.0",
32 + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
33 + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
34 + "dev": true,
35 + "license": "MIT",
36 + "engines": {
37 + "node": ">=10"
38 + },
39 + "funding": {
40 + "url": "https://github.com/sponsors/sindresorhus"
41 + }
42 + },
43 + "node_modules/@emnapi/runtime": {
44 + "version": "1.11.3",
45 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
46 + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
47 + "license": "MIT",
48 + "optional": true,
49 + "dependencies": {
50 + "tslib": "^2.4.0"
51 + }
52 + },
53 + "node_modules/@img/colour": {
54 + "version": "1.1.0",
55 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
56 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
57 + "license": "MIT",
58 + "engines": {
59 + "node": ">=18"
60 + }
61 + },
62 + "node_modules/@img/sharp-darwin-arm64": {
63 + "version": "0.35.3",
64 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
65 + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
66 + "cpu": [
67 + "arm64"
68 + ],
69 + "license": "Apache-2.0",
70 + "optional": true,
71 + "os": [
72 + "darwin"
73 + ],
74 + "engines": {
75 + "node": ">=20.9.0"
76 + },
77 + "funding": {
78 + "url": "https://opencollective.com/libvips"
79 + },
80 + "optionalDependencies": {
81 + "@img/sharp-libvips-darwin-arm64": "1.3.2"
82 + }
83 + },
84 + "node_modules/@img/sharp-darwin-x64": {
85 + "version": "0.35.3",
86 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
87 + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
88 + "cpu": [
89 + "x64"
90 + ],
91 + "license": "Apache-2.0",
92 + "optional": true,
93 + "os": [
94 + "darwin"
95 + ],
96 + "engines": {
97 + "node": ">=20.9.0"
98 + },
99 + "funding": {
100 + "url": "https://opencollective.com/libvips"
101 + },
102 + "optionalDependencies": {
103 + "@img/sharp-libvips-darwin-x64": "1.3.2"
104 + }
105 + },
106 + "node_modules/@img/sharp-freebsd-wasm32": {
107 + "version": "0.35.3",
108 + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
109 + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
110 + "license": "Apache-2.0",
111 + "optional": true,
112 + "os": [
113 + "freebsd"
114 + ],
115 + "dependencies": {
116 + "@img/sharp-wasm32": "0.35.3"
117 + },
118 + "engines": {
119 + "node": ">=20.9.0"
120 + },
121 + "funding": {
122 + "url": "https://opencollective.com/libvips"
123 + }
124 + },
125 + "node_modules/@img/sharp-libvips-darwin-arm64": {
126 + "version": "1.3.2",
127 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
128 + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
129 + "cpu": [
130 + "arm64"
131 + ],
132 + "license": "LGPL-3.0-or-later",
133 + "optional": true,
134 + "os": [
135 + "darwin"
136 + ],
137 + "funding": {
138 + "url": "https://opencollective.com/libvips"
139 + }
140 + },
141 + "node_modules/@img/sharp-libvips-darwin-x64": {
142 + "version": "1.3.2",
143 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
144 + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
145 + "cpu": [
146 + "x64"
147 + ],
148 + "license": "LGPL-3.0-or-later",
149 + "optional": true,
150 + "os": [
151 + "darwin"
152 + ],
153 + "funding": {
154 + "url": "https://opencollective.com/libvips"
155 + }
156 + },
157 + "node_modules/@img/sharp-libvips-linux-arm": {
158 + "version": "1.3.2",
159 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
160 + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
161 + "cpu": [
162 + "arm"
163 + ],
164 + "libc": [
165 + "glibc"
166 + ],
167 + "license": "LGPL-3.0-or-later",
168 + "optional": true,
169 + "os": [
170 + "linux"
171 + ],
172 + "funding": {
173 + "url": "https://opencollective.com/libvips"
174 + }
175 + },
176 + "node_modules/@img/sharp-libvips-linux-arm64": {
177 + "version": "1.3.2",
178 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
179 + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
180 + "cpu": [
181 + "arm64"
182 + ],
183 + "libc": [
184 + "glibc"
185 + ],
186 + "license": "LGPL-3.0-or-later",
187 + "optional": true,
188 + "os": [
189 + "linux"
190 + ],
191 + "funding": {
192 + "url": "https://opencollective.com/libvips"
193 + }
194 + },
195 + "node_modules/@img/sharp-libvips-linux-ppc64": {
196 + "version": "1.3.2",
197 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
198 + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
199 + "cpu": [
200 + "ppc64"
201 + ],
202 + "libc": [
203 + "glibc"
204 + ],
205 + "license": "LGPL-3.0-or-later",
206 + "optional": true,
207 + "os": [
208 + "linux"
209 + ],
210 + "funding": {
211 + "url": "https://opencollective.com/libvips"
212 + }
213 + },
214 + "node_modules/@img/sharp-libvips-linux-riscv64": {
215 + "version": "1.3.2",
216 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
217 + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
218 + "cpu": [
219 + "riscv64"
220 + ],
221 + "libc": [
222 + "glibc"
223 + ],
224 + "license": "LGPL-3.0-or-later",
225 + "optional": true,
226 + "os": [
227 + "linux"
228 + ],
229 + "funding": {
230 + "url": "https://opencollective.com/libvips"
231 + }
232 + },
233 + "node_modules/@img/sharp-libvips-linux-s390x": {
234 + "version": "1.3.2",
235 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
236 + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
237 + "cpu": [
238 + "s390x"
239 + ],
240 + "libc": [
241 + "glibc"
242 + ],
243 + "license": "LGPL-3.0-or-later",
244 + "optional": true,
245 + "os": [
246 + "linux"
247 + ],
248 + "funding": {
249 + "url": "https://opencollective.com/libvips"
250 + }
251 + },
252 + "node_modules/@img/sharp-libvips-linux-x64": {
253 + "version": "1.3.2",
254 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
255 + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
256 + "cpu": [
257 + "x64"
258 + ],
259 + "libc": [
260 + "glibc"
261 + ],
262 + "license": "LGPL-3.0-or-later",
263 + "optional": true,
264 + "os": [
265 + "linux"
266 + ],
267 + "funding": {
268 + "url": "https://opencollective.com/libvips"
269 + }
270 + },
271 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
272 + "version": "1.3.2",
273 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
274 + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
275 + "cpu": [
276 + "arm64"
277 + ],
278 + "libc": [
279 + "musl"
280 + ],
281 + "license": "LGPL-3.0-or-later",
282 + "optional": true,
283 + "os": [
284 + "linux"
285 + ],
286 + "funding": {
287 + "url": "https://opencollective.com/libvips"
288 + }
289 + },
290 + "node_modules/@img/sharp-libvips-linuxmusl-x64": {
291 + "version": "1.3.2",
292 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
293 + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
294 + "cpu": [
295 + "x64"
296 + ],
297 + "libc": [
298 + "musl"
299 + ],
300 + "license": "LGPL-3.0-or-later",
301 + "optional": true,
302 + "os": [
303 + "linux"
304 + ],
305 + "funding": {
306 + "url": "https://opencollective.com/libvips"
307 + }
308 + },
309 + "node_modules/@img/sharp-linux-arm": {
310 + "version": "0.35.3",
311 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
312 + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
313 + "cpu": [
314 + "arm"
315 + ],
316 + "libc": [
317 + "glibc"
318 + ],
319 + "license": "Apache-2.0",
320 + "optional": true,
321 + "os": [
322 + "linux"
323 + ],
324 + "engines": {
325 + "node": ">=20.9.0"
326 + },
327 + "funding": {
328 + "url": "https://opencollective.com/libvips"
329 + },
330 + "optionalDependencies": {
331 + "@img/sharp-libvips-linux-arm": "1.3.2"
332 + }
333 + },
334 + "node_modules/@img/sharp-linux-arm64": {
335 + "version": "0.35.3",
336 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
337 + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
338 + "cpu": [
339 + "arm64"
340 + ],
341 + "libc": [
342 + "glibc"
343 + ],
344 + "license": "Apache-2.0",
345 + "optional": true,
346 + "os": [
347 + "linux"
348 + ],
349 + "engines": {
350 + "node": ">=20.9.0"
351 + },
352 + "funding": {
353 + "url": "https://opencollective.com/libvips"
354 + },
355 + "optionalDependencies": {
356 + "@img/sharp-libvips-linux-arm64": "1.3.2"
357 + }
358 + },
359 + "node_modules/@img/sharp-linux-ppc64": {
360 + "version": "0.35.3",
361 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
362 + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
363 + "cpu": [
364 + "ppc64"
365 + ],
366 + "libc": [
367 + "glibc"
368 + ],
369 + "license": "Apache-2.0",
370 + "optional": true,
371 + "os": [
372 + "linux"
373 + ],
374 + "engines": {
375 + "node": ">=20.9.0"
376 + },
377 + "funding": {
378 + "url": "https://opencollective.com/libvips"
379 + },
380 + "optionalDependencies": {
381 + "@img/sharp-libvips-linux-ppc64": "1.3.2"
382 + }
383 + },
384 + "node_modules/@img/sharp-linux-riscv64": {
385 + "version": "0.35.3",
386 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
387 + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
388 + "cpu": [
389 + "riscv64"
390 + ],
391 + "libc": [
392 + "glibc"
393 + ],
394 + "license": "Apache-2.0",
395 + "optional": true,
396 + "os": [
397 + "linux"
398 + ],
399 + "engines": {
400 + "node": ">=20.9.0"
401 + },
402 + "funding": {
403 + "url": "https://opencollective.com/libvips"
404 + },
405 + "optionalDependencies": {
406 + "@img/sharp-libvips-linux-riscv64": "1.3.2"
407 + }
408 + },
409 + "node_modules/@img/sharp-linux-s390x": {
410 + "version": "0.35.3",
411 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
412 + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
413 + "cpu": [
414 + "s390x"
415 + ],
416 + "libc": [
417 + "glibc"
418 + ],
419 + "license": "Apache-2.0",
420 + "optional": true,
421 + "os": [
422 + "linux"
423 + ],
424 + "engines": {
425 + "node": ">=20.9.0"
426 + },
427 + "funding": {
428 + "url": "https://opencollective.com/libvips"
429 + },
430 + "optionalDependencies": {
431 + "@img/sharp-libvips-linux-s390x": "1.3.2"
432 + }
433 + },
434 + "node_modules/@img/sharp-linux-x64": {
435 + "version": "0.35.3",
436 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
437 + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
438 + "cpu": [
439 + "x64"
440 + ],
441 + "libc": [
442 + "glibc"
443 + ],
444 + "license": "Apache-2.0",
445 + "optional": true,
446 + "os": [
447 + "linux"
448 + ],
449 + "engines": {
450 + "node": ">=20.9.0"
451 + },
452 + "funding": {
453 + "url": "https://opencollective.com/libvips"
454 + },
455 + "optionalDependencies": {
456 + "@img/sharp-libvips-linux-x64": "1.3.2"
457 + }
458 + },
459 + "node_modules/@img/sharp-linuxmusl-arm64": {
460 + "version": "0.35.3",
461 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
462 + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
463 + "cpu": [
464 + "arm64"
465 + ],
466 + "libc": [
467 + "musl"
468 + ],
469 + "license": "Apache-2.0",
470 + "optional": true,
471 + "os": [
472 + "linux"
473 + ],
474 + "engines": {
475 + "node": ">=20.9.0"
476 + },
477 + "funding": {
478 + "url": "https://opencollective.com/libvips"
479 + },
480 + "optionalDependencies": {
481 + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
482 + }
483 + },
484 + "node_modules/@img/sharp-linuxmusl-x64": {
485 + "version": "0.35.3",
486 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
487 + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
488 + "cpu": [
489 + "x64"
490 + ],
491 + "libc": [
492 + "musl"
493 + ],
494 + "license": "Apache-2.0",
495 + "optional": true,
496 + "os": [
497 + "linux"
498 + ],
499 + "engines": {
500 + "node": ">=20.9.0"
501 + },
502 + "funding": {
503 + "url": "https://opencollective.com/libvips"
504 + },
505 + "optionalDependencies": {
506 + "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
507 + }
508 + },
509 + "node_modules/@img/sharp-wasm32": {
510 + "version": "0.35.3",
511 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
512 + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
513 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
514 + "optional": true,
515 + "dependencies": {
516 + "@emnapi/runtime": "^1.11.1"
517 + },
518 + "engines": {
519 + "node": ">=20.9.0"
520 + },
521 + "funding": {
522 + "url": "https://opencollective.com/libvips"
523 + }
524 + },
525 + "node_modules/@img/sharp-webcontainers-wasm32": {
526 + "version": "0.35.3",
527 + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
528 + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
529 + "cpu": [
530 + "wasm32"
531 + ],
532 + "license": "Apache-2.0",
533 + "optional": true,
534 + "dependencies": {
535 + "@img/sharp-wasm32": "0.35.3"
536 + },
537 + "engines": {
538 + "node": ">=20.9.0"
539 + },
540 + "funding": {
541 + "url": "https://opencollective.com/libvips"
542 + }
543 + },
544 + "node_modules/@img/sharp-win32-arm64": {
545 + "version": "0.35.3",
546 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
547 + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
548 + "cpu": [
549 + "arm64"
550 + ],
551 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
552 + "optional": true,
553 + "os": [
554 + "win32"
555 + ],
556 + "engines": {
557 + "node": ">=20.9.0"
558 + },
559 + "funding": {
560 + "url": "https://opencollective.com/libvips"
561 + }
562 + },
563 + "node_modules/@img/sharp-win32-ia32": {
564 + "version": "0.35.3",
565 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
566 + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
567 + "cpu": [
568 + "ia32"
569 + ],
570 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
571 + "optional": true,
572 + "os": [
573 + "win32"
574 + ],
575 + "engines": {
576 + "node": "^20.9.0"
577 + },
578 + "funding": {
579 + "url": "https://opencollective.com/libvips"
580 + }
581 + },
582 + "node_modules/@img/sharp-win32-x64": {
583 + "version": "0.35.3",
584 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
585 + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
586 + "cpu": [
587 + "x64"
588 + ],
589 + "license": "Apache-2.0 AND LGPL-3.0-or-later",
590 + "optional": true,
591 + "os": [
592 + "win32"
593 + ],
594 + "engines": {
595 + "node": ">=20.9.0"
596 + },
597 + "funding": {
598 + "url": "https://opencollective.com/libvips"
599 + }
600 + },
601 + "node_modules/@jridgewell/gen-mapping": {
602 + "version": "0.3.13",
603 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
604 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
605 + "dev": true,
606 + "license": "MIT",
607 + "dependencies": {
608 + "@jridgewell/sourcemap-codec": "^1.5.0",
609 + "@jridgewell/trace-mapping": "^0.3.24"
610 + }
611 + },
612 + "node_modules/@jridgewell/remapping": {
613 + "version": "2.3.5",
614 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
615 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
616 + "dev": true,
617 + "license": "MIT",
618 + "dependencies": {
619 + "@jridgewell/gen-mapping": "^0.3.5",
620 + "@jridgewell/trace-mapping": "^0.3.24"
621 + }
622 + },
623 + "node_modules/@jridgewell/resolve-uri": {
624 + "version": "3.1.2",
625 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
626 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
627 + "dev": true,
628 + "license": "MIT",
629 + "engines": {
630 + "node": ">=6.0.0"
631 + }
632 + },
633 + "node_modules/@jridgewell/sourcemap-codec": {
634 + "version": "1.5.5",
635 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
636 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
637 + "dev": true,
638 + "license": "MIT"
639 + },
640 + "node_modules/@jridgewell/trace-mapping": {
641 + "version": "0.3.31",
642 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
643 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
644 + "dev": true,
645 + "license": "MIT",
646 + "dependencies": {
647 + "@jridgewell/resolve-uri": "^3.1.0",
648 + "@jridgewell/sourcemap-codec": "^1.4.14"
649 + }
650 + },
651 + "node_modules/@next/env": {
652 + "version": "16.3.0",
653 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
654 + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
655 + "license": "MIT"
656 + },
657 + "node_modules/@next/swc-darwin-arm64": {
658 + "version": "16.3.0",
659 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
660 + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
661 + "cpu": [
662 + "arm64"
663 + ],
664 + "license": "MIT",
665 + "optional": true,
666 + "os": [
667 + "darwin"
668 + ],
669 + "engines": {
670 + "node": ">= 10"
671 + }
672 + },
673 + "node_modules/@next/swc-darwin-x64": {
674 + "version": "16.3.0",
675 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
676 + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
677 + "cpu": [
678 + "x64"
679 + ],
680 + "license": "MIT",
681 + "optional": true,
682 + "os": [
683 + "darwin"
684 + ],
685 + "engines": {
686 + "node": ">= 10"
687 + }
688 + },
689 + "node_modules/@next/swc-linux-arm64-gnu": {
690 + "version": "16.3.0",
691 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
692 + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
693 + "cpu": [
694 + "arm64"
695 + ],
696 + "libc": [
697 + "glibc"
698 + ],
699 + "license": "MIT",
700 + "optional": true,
701 + "os": [
702 + "linux"
703 + ],
704 + "engines": {
705 + "node": ">= 10"
706 + }
707 + },
708 + "node_modules/@next/swc-linux-arm64-musl": {
709 + "version": "16.3.0",
710 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
711 + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
712 + "cpu": [
713 + "arm64"
714 + ],
715 + "libc": [
716 + "musl"
717 + ],
718 + "license": "MIT",
719 + "optional": true,
720 + "os": [
721 + "linux"
722 + ],
723 + "engines": {
724 + "node": ">= 10"
725 + }
726 + },
727 + "node_modules/@next/swc-linux-x64-gnu": {
728 + "version": "16.3.0",
729 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
730 + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
731 + "cpu": [
732 + "x64"
733 + ],
734 + "libc": [
735 + "glibc"
736 + ],
737 + "license": "MIT",
738 + "optional": true,
739 + "os": [
740 + "linux"
741 + ],
742 + "engines": {
743 + "node": ">= 10"
744 + }
745 + },
746 + "node_modules/@next/swc-linux-x64-musl": {
747 + "version": "16.3.0",
748 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
749 + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
750 + "cpu": [
751 + "x64"
752 + ],
753 + "libc": [
754 + "musl"
755 + ],
756 + "license": "MIT",
757 + "optional": true,
758 + "os": [
759 + "linux"
760 + ],
761 + "engines": {
762 + "node": ">= 10"
763 + }
764 + },
765 + "node_modules/@next/swc-win32-arm64-msvc": {
766 + "version": "16.3.0",
767 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
768 + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
769 + "cpu": [
770 + "arm64"
771 + ],
772 + "license": "MIT",
773 + "optional": true,
774 + "os": [
775 + "win32"
776 + ],
777 + "engines": {
778 + "node": ">= 10"
779 + }
780 + },
781 + "node_modules/@next/swc-win32-x64-msvc": {
782 + "version": "16.3.0",
783 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
784 + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
785 + "cpu": [
786 + "x64"
787 + ],
788 + "license": "MIT",
789 + "optional": true,
790 + "os": [
791 + "win32"
792 + ],
793 + "engines": {
794 + "node": ">= 10"
795 + }
796 + },
797 + "node_modules/@swc/helpers": {
798 + "version": "0.5.15",
799 + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
800 + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
801 + "license": "Apache-2.0",
802 + "dependencies": {
803 + "tslib": "^2.8.0"
804 + }
805 + },
806 + "node_modules/@tailwindcss/node": {
807 + "version": "4.3.3",
808 + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
809 + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
810 + "dev": true,
811 + "license": "MIT",
812 + "dependencies": {
813 + "@jridgewell/remapping": "^2.3.5",
814 + "enhanced-resolve": "^5.24.1",
815 + "jiti": "^2.7.0",
816 + "lightningcss": "1.32.0",
817 + "magic-string": "^0.30.21",
818 + "source-map-js": "^1.2.1",
819 + "tailwindcss": "4.3.3"
820 + }
821 + },
822 + "node_modules/@tailwindcss/oxide": {
823 + "version": "4.3.3",
824 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
825 + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
826 + "dev": true,
827 + "license": "MIT",
828 + "engines": {
829 + "node": ">= 20"
830 + },
831 + "optionalDependencies": {
832 + "@tailwindcss/oxide-android-arm64": "4.3.3",
833 + "@tailwindcss/oxide-darwin-arm64": "4.3.3",
834 + "@tailwindcss/oxide-darwin-x64": "4.3.3",
835 + "@tailwindcss/oxide-freebsd-x64": "4.3.3",
836 + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
837 + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
838 + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
839 + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
840 + "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
841 + "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
842 + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
843 + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
844 + }
845 + },
846 + "node_modules/@tailwindcss/oxide-android-arm64": {
847 + "version": "4.3.3",
848 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
849 + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
850 + "cpu": [
851 + "arm64"
852 + ],
853 + "dev": true,
854 + "license": "MIT",
855 + "optional": true,
856 + "os": [
857 + "android"
858 + ],
859 + "engines": {
860 + "node": ">= 20"
861 + }
862 + },
863 + "node_modules/@tailwindcss/oxide-darwin-arm64": {
864 + "version": "4.3.3",
865 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
866 + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
867 + "cpu": [
868 + "arm64"
869 + ],
870 + "dev": true,
871 + "license": "MIT",
872 + "optional": true,
873 + "os": [
874 + "darwin"
875 + ],
876 + "engines": {
877 + "node": ">= 20"
878 + }
879 + },
880 + "node_modules/@tailwindcss/oxide-darwin-x64": {
881 + "version": "4.3.3",
882 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
883 + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
884 + "cpu": [
885 + "x64"
886 + ],
887 + "dev": true,
888 + "license": "MIT",
889 + "optional": true,
890 + "os": [
891 + "darwin"
892 + ],
893 + "engines": {
894 + "node": ">= 20"
895 + }
896 + },
897 + "node_modules/@tailwindcss/oxide-freebsd-x64": {
898 + "version": "4.3.3",
899 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
900 + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
901 + "cpu": [
902 + "x64"
903 + ],
904 + "dev": true,
905 + "license": "MIT",
906 + "optional": true,
907 + "os": [
908 + "freebsd"
909 + ],
910 + "engines": {
911 + "node": ">= 20"
912 + }
913 + },
914 + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
915 + "version": "4.3.3",
916 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
917 + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
918 + "cpu": [
919 + "arm"
920 + ],
921 + "dev": true,
922 + "license": "MIT",
923 + "optional": true,
924 + "os": [
925 + "linux"
926 + ],
927 + "engines": {
928 + "node": ">= 20"
929 + }
930 + },
931 + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
932 + "version": "4.3.3",
933 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
934 + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
935 + "cpu": [
936 + "arm64"
937 + ],
938 + "dev": true,
939 + "libc": [
940 + "glibc"
941 + ],
942 + "license": "MIT",
943 + "optional": true,
944 + "os": [
945 + "linux"
946 + ],
947 + "engines": {
948 + "node": ">= 20"
949 + }
950 + },
951 + "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
952 + "version": "4.3.3",
953 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
954 + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
955 + "cpu": [
956 + "arm64"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "musl"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ],
967 + "engines": {
968 + "node": ">= 20"
969 + }
970 + },
971 + "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
972 + "version": "4.3.3",
973 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
974 + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
975 + "cpu": [
976 + "x64"
977 + ],
978 + "dev": true,
979 + "libc": [
980 + "glibc"
981 + ],
982 + "license": "MIT",
983 + "optional": true,
984 + "os": [
985 + "linux"
986 + ],
987 + "engines": {
988 + "node": ">= 20"
989 + }
990 + },
991 + "node_modules/@tailwindcss/oxide-linux-x64-musl": {
992 + "version": "4.3.3",
993 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
994 + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
995 + "cpu": [
996 + "x64"
997 + ],
998 + "dev": true,
999 + "libc": [
1000 + "musl"
1001 + ],
1002 + "license": "MIT",
1003 + "optional": true,
1004 + "os": [
1005 + "linux"
1006 + ],
1007 + "engines": {
1008 + "node": ">= 20"
1009 + }
1010 + },
1011 + "node_modules/@tailwindcss/oxide-wasm32-wasi": {
1012 + "version": "4.3.3",
1013 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
1014 + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
1015 + "bundleDependencies": [
1016 + "@napi-rs/wasm-runtime",
1017 + "@emnapi/core",
1018 + "@emnapi/runtime",
1019 + "@tybys/wasm-util",
1020 + "@emnapi/wasi-threads",
1021 + "tslib"
1022 + ],
1023 + "cpu": [
1024 + "wasm32"
1025 + ],
1026 + "dev": true,
1027 + "license": "MIT",
1028 + "optional": true,
1029 + "dependencies": {
1030 + "@emnapi/core": "^1.11.1",
1031 + "@emnapi/runtime": "^1.11.1",
1032 + "@emnapi/wasi-threads": "^1.2.2",
1033 + "@napi-rs/wasm-runtime": "^1.1.4",
1034 + "@tybys/wasm-util": "^0.10.2",
1035 + "tslib": "^2.8.1"
1036 + },
1037 + "engines": {
1038 + "node": ">=14.0.0"
1039 + }
1040 + },
1041 + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
1042 + "version": "4.3.3",
1043 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
1044 + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
1045 + "cpu": [
1046 + "arm64"
1047 + ],
1048 + "dev": true,
1049 + "license": "MIT",
1050 + "optional": true,
1051 + "os": [
1052 + "win32"
1053 + ],
1054 + "engines": {
1055 + "node": ">= 20"
1056 + }
1057 + },
1058 + "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
1059 + "version": "4.3.3",
1060 + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
1061 + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
1062 + "cpu": [
1063 + "x64"
1064 + ],
1065 + "dev": true,
1066 + "license": "MIT",
1067 + "optional": true,
1068 + "os": [
1069 + "win32"
1070 + ],
1071 + "engines": {
1072 + "node": ">= 20"
1073 + }
1074 + },
1075 + "node_modules/@tailwindcss/postcss": {
1076 + "version": "4.3.3",
1077 + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
1078 + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
1079 + "dev": true,
1080 + "license": "MIT",
1081 + "dependencies": {
1082 + "@alloc/quick-lru": "^5.2.0",
1083 + "@tailwindcss/node": "4.3.3",
1084 + "@tailwindcss/oxide": "4.3.3",
1085 + "postcss": "^8.5.16",
1086 + "tailwindcss": "4.3.3"
1087 + }
1088 + },
1089 + "node_modules/@types/better-sqlite3": {
1090 + "version": "9.6.0",
1091 + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
1092 + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==",
1093 + "dev": true,
1094 + "license": "MIT",
1095 + "dependencies": {
1096 + "@types/node": "*"
1097 + }
1098 + },
1099 + "node_modules/@types/node": {
1100 + "version": "20.19.43",
1101 + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
1102 + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
1103 + "dev": true,
1104 + "license": "MIT",
1105 + "dependencies": {
1106 + "undici-types": "~6.21.0"
1107 + }
1108 + },
1109 + "node_modules/@types/qrcode": {
1110 + "version": "1.5.6",
1111 + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
1112 + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
1113 + "dev": true,
1114 + "license": "MIT",
1115 + "dependencies": {
1116 + "@types/node": "*"
1117 + }
1118 + },
1119 + "node_modules/@types/react": {
1120 + "version": "19.2.18",
1121 + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
1122 + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
1123 + "dev": true,
1124 + "license": "MIT",
1125 + "dependencies": {
1126 + "csstype": "^3.2.2"
1127 + }
1128 + },
1129 + "node_modules/@types/react-dom": {
1130 + "version": "19.2.4",
1131 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
1132 + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
1133 + "dev": true,
1134 + "license": "MIT",
1135 + "peerDependencies": {
1136 + "@types/react": "^19.2.0"
1137 + }
1138 + },
1139 + "node_modules/ansi-regex": {
1140 + "version": "5.0.1",
1141 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1142 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1143 + "license": "MIT",
1144 + "engines": {
1145 + "node": ">=8"
1146 + }
1147 + },
1148 + "node_modules/ansi-styles": {
1149 + "version": "4.3.0",
1150 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
1151 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
1152 + "license": "MIT",
1153 + "dependencies": {
1154 + "color-convert": "^2.0.1"
1155 + },
1156 + "engines": {
1157 + "node": ">=8"
1158 + },
1159 + "funding": {
1160 + "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1161 + }
1162 + },
1163 + "node_modules/baseline-browser-mapping": {
1164 + "version": "2.11.13",
1165 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1166 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1167 + "license": "Apache-2.0",
1168 + "bin": {
1169 + "baseline-browser-mapping": "dist/cli.cjs"
1170 + },
1171 + "engines": {
1172 + "node": ">=6.0.0"
1173 + }
1174 + },
1175 + "node_modules/better-sqlite3": {
1176 + "version": "13.0.3",
1177 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
1178 + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
1179 + "license": "MIT",
1180 + "dependencies": {
1181 + "node-addon-api": "^8.0.0"
1182 + },
1183 + "engines": {
1184 + "node": ">=22"
1185 + }
1186 + },
1187 + "node_modules/camelcase": {
1188 + "version": "5.3.1",
1189 + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
1190 + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
1191 + "license": "MIT",
1192 + "engines": {
1193 + "node": ">=6"
1194 + }
1195 + },
1196 + "node_modules/caniuse-lite": {
1197 + "version": "1.0.30001809",
1198 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1199 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1200 + "funding": [
1201 + {
1202 + "type": "opencollective",
1203 + "url": "https://opencollective.com/browserslist"
1204 + },
1205 + {
1206 + "type": "tidelift",
1207 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1208 + },
1209 + {
1210 + "type": "github",
1211 + "url": "https://github.com/sponsors/ai"
1212 + }
1213 + ],
1214 + "license": "CC-BY-4.0"
1215 + },
1216 + "node_modules/client-only": {
1217 + "version": "0.0.1",
1218 + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
1219 + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
1220 + "license": "MIT"
1221 + },
1222 + "node_modules/cliui": {
1223 + "version": "6.0.0",
1224 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
1225 + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
1226 + "license": "ISC",
1227 + "dependencies": {
1228 + "string-width": "^4.2.0",
1229 + "strip-ansi": "^6.0.0",
1230 + "wrap-ansi": "^6.2.0"
1231 + }
1232 + },
1233 + "node_modules/color-convert": {
1234 + "version": "2.0.1",
1235 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
1236 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
1237 + "license": "MIT",
1238 + "dependencies": {
1239 + "color-name": "~1.1.4"
1240 + },
1241 + "engines": {
1242 + "node": ">=7.0.0"
1243 + }
1244 + },
1245 + "node_modules/color-name": {
1246 + "version": "1.1.4",
1247 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
1248 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
1249 + "license": "MIT"
1250 + },
1251 + "node_modules/csstype": {
1252 + "version": "3.2.3",
1253 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1254 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1255 + "dev": true,
1256 + "license": "MIT"
1257 + },
1258 + "node_modules/decamelize": {
1259 + "version": "1.2.0",
1260 + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
1261 + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
1262 + "license": "MIT",
1263 + "engines": {
1264 + "node": ">=0.10.0"
1265 + }
1266 + },
1267 + "node_modules/detect-libc": {
1268 + "version": "2.1.2",
1269 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1270 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1271 + "license": "Apache-2.0",
1272 + "engines": {
1273 + "node": ">=8"
1274 + }
1275 + },
1276 + "node_modules/dijkstrajs": {
1277 + "version": "1.0.3",
1278 + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
1279 + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
1280 + "license": "MIT"
1281 + },
1282 + "node_modules/emoji-regex": {
1283 + "version": "8.0.0",
1284 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1285 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1286 + "license": "MIT"
1287 + },
1288 + "node_modules/enhanced-resolve": {
1289 + "version": "5.24.5",
1290 + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
1291 + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
1292 + "dev": true,
1293 + "license": "MIT",
1294 + "dependencies": {
1295 + "graceful-fs": "^4.2.4",
1296 + "tapable": "^2.3.3"
1297 + },
1298 + "engines": {
1299 + "node": ">=10.13.0"
1300 + }
1301 + },
1302 + "node_modules/find-up": {
1303 + "version": "4.1.0",
1304 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
1305 + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
1306 + "license": "MIT",
1307 + "dependencies": {
1308 + "locate-path": "^5.0.0",
1309 + "path-exists": "^4.0.0"
1310 + },
1311 + "engines": {
1312 + "node": ">=8"
1313 + }
1314 + },
1315 + "node_modules/get-caller-file": {
1316 + "version": "2.0.5",
1317 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
1318 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
1319 + "license": "ISC",
1320 + "engines": {
1321 + "node": "6.* || 8.* || >= 10.*"
1322 + }
1323 + },
1324 + "node_modules/graceful-fs": {
1325 + "version": "4.2.11",
1326 + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
1327 + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
1328 + "dev": true,
1329 + "license": "ISC"
1330 + },
1331 + "node_modules/is-fullwidth-code-point": {
1332 + "version": "3.0.0",
1333 + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
1334 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
1335 + "license": "MIT",
1336 + "engines": {
1337 + "node": ">=8"
1338 + }
1339 + },
1340 + "node_modules/jiti": {
1341 + "version": "2.7.0",
1342 + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
1343 + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
1344 + "dev": true,
1345 + "license": "MIT",
1346 + "bin": {
1347 + "jiti": "lib/jiti-cli.mjs"
1348 + }
1349 + },
1350 + "node_modules/jose": {
1351 + "version": "6.2.8",
1352 + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
1353 + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
1354 + "license": "MIT",
1355 + "funding": {
1356 + "url": "https://github.com/sponsors/panva"
1357 + }
1358 + },
1359 + "node_modules/lightningcss": {
1360 + "version": "1.32.0",
1361 + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
1362 + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
1363 + "dev": true,
1364 + "license": "MPL-2.0",
1365 + "dependencies": {
1366 + "detect-libc": "^2.0.3"
1367 + },
1368 + "engines": {
1369 + "node": ">= 12.0.0"
1370 + },
1371 + "funding": {
1372 + "type": "opencollective",
1373 + "url": "https://opencollective.com/parcel"
1374 + },
1375 + "optionalDependencies": {
1376 + "lightningcss-android-arm64": "1.32.0",
1377 + "lightningcss-darwin-arm64": "1.32.0",
1378 + "lightningcss-darwin-x64": "1.32.0",
1379 + "lightningcss-freebsd-x64": "1.32.0",
1380 + "lightningcss-linux-arm-gnueabihf": "1.32.0",
1381 + "lightningcss-linux-arm64-gnu": "1.32.0",
1382 + "lightningcss-linux-arm64-musl": "1.32.0",
1383 + "lightningcss-linux-x64-gnu": "1.32.0",
1384 + "lightningcss-linux-x64-musl": "1.32.0",
1385 + "lightningcss-win32-arm64-msvc": "1.32.0",
1386 + "lightningcss-win32-x64-msvc": "1.32.0"
1387 + }
1388 + },
1389 + "node_modules/lightningcss-android-arm64": {
1390 + "version": "1.32.0",
1391 + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
1392 + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
1393 + "cpu": [
1394 + "arm64"
1395 + ],
1396 + "dev": true,
1397 + "license": "MPL-2.0",
1398 + "optional": true,
1399 + "os": [
1400 + "android"
1401 + ],
1402 + "engines": {
1403 + "node": ">= 12.0.0"
1404 + },
1405 + "funding": {
1406 + "type": "opencollective",
1407 + "url": "https://opencollective.com/parcel"
1408 + }
1409 + },
1410 + "node_modules/lightningcss-darwin-arm64": {
1411 + "version": "1.32.0",
1412 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
1413 + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
1414 + "cpu": [
1415 + "arm64"
1416 + ],
1417 + "dev": true,
1418 + "license": "MPL-2.0",
1419 + "optional": true,
1420 + "os": [
1421 + "darwin"
1422 + ],
1423 + "engines": {
1424 + "node": ">= 12.0.0"
1425 + },
1426 + "funding": {
1427 + "type": "opencollective",
1428 + "url": "https://opencollective.com/parcel"
1429 + }
1430 + },
1431 + "node_modules/lightningcss-darwin-x64": {
1432 + "version": "1.32.0",
1433 + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
1434 + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
1435 + "cpu": [
1436 + "x64"
1437 + ],
1438 + "dev": true,
1439 + "license": "MPL-2.0",
1440 + "optional": true,
1441 + "os": [
1442 + "darwin"
1443 + ],
1444 + "engines": {
1445 + "node": ">= 12.0.0"
1446 + },
1447 + "funding": {
1448 + "type": "opencollective",
1449 + "url": "https://opencollective.com/parcel"
1450 + }
1451 + },
1452 + "node_modules/lightningcss-freebsd-x64": {
1453 + "version": "1.32.0",
1454 + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
1455 + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
1456 + "cpu": [
1457 + "x64"
1458 + ],
1459 + "dev": true,
1460 + "license": "MPL-2.0",
1461 + "optional": true,
1462 + "os": [
1463 + "freebsd"
1464 + ],
1465 + "engines": {
1466 + "node": ">= 12.0.0"
1467 + },
1468 + "funding": {
1469 + "type": "opencollective",
1470 + "url": "https://opencollective.com/parcel"
1471 + }
1472 + },
1473 + "node_modules/lightningcss-linux-arm-gnueabihf": {
1474 + "version": "1.32.0",
1475 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1476 + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
1477 + "cpu": [
1478 + "arm"
1479 + ],
1480 + "dev": true,
1481 + "license": "MPL-2.0",
1482 + "optional": true,
1483 + "os": [
1484 + "linux"
1485 + ],
1486 + "engines": {
1487 + "node": ">= 12.0.0"
1488 + },
1489 + "funding": {
1490 + "type": "opencollective",
1491 + "url": "https://opencollective.com/parcel"
1492 + }
1493 + },
1494 + "node_modules/lightningcss-linux-arm64-gnu": {
1495 + "version": "1.32.0",
1496 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
1497 + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
1498 + "cpu": [
1499 + "arm64"
1500 + ],
1501 + "dev": true,
1502 + "libc": [
1503 + "glibc"
1504 + ],
1505 + "license": "MPL-2.0",
1506 + "optional": true,
1507 + "os": [
1508 + "linux"
1509 + ],
1510 + "engines": {
1511 + "node": ">= 12.0.0"
1512 + },
1513 + "funding": {
1514 + "type": "opencollective",
1515 + "url": "https://opencollective.com/parcel"
1516 + }
1517 + },
1518 + "node_modules/lightningcss-linux-arm64-musl": {
1519 + "version": "1.32.0",
1520 + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
1521 + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
1522 + "cpu": [
1523 + "arm64"
1524 + ],
1525 + "dev": true,
1526 + "libc": [
1527 + "musl"
1528 + ],
1529 + "license": "MPL-2.0",
1530 + "optional": true,
1531 + "os": [
1532 + "linux"
1533 + ],
1534 + "engines": {
1535 + "node": ">= 12.0.0"
1536 + },
1537 + "funding": {
1538 + "type": "opencollective",
1539 + "url": "https://opencollective.com/parcel"
1540 + }
1541 + },
1542 + "node_modules/lightningcss-linux-x64-gnu": {
1543 + "version": "1.32.0",
1544 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
1545 + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
1546 + "cpu": [
1547 + "x64"
1548 + ],
1549 + "dev": true,
1550 + "libc": [
1551 + "glibc"
1552 + ],
1553 + "license": "MPL-2.0",
1554 + "optional": true,
1555 + "os": [
1556 + "linux"
1557 + ],
1558 + "engines": {
1559 + "node": ">= 12.0.0"
1560 + },
1561 + "funding": {
1562 + "type": "opencollective",
1563 + "url": "https://opencollective.com/parcel"
1564 + }
1565 + },
1566 + "node_modules/lightningcss-linux-x64-musl": {
1567 + "version": "1.32.0",
1568 + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
1569 + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
1570 + "cpu": [
1571 + "x64"
1572 + ],
1573 + "dev": true,
1574 + "libc": [
1575 + "musl"
1576 + ],
1577 + "license": "MPL-2.0",
1578 + "optional": true,
1579 + "os": [
1580 + "linux"
1581 + ],
1582 + "engines": {
1583 + "node": ">= 12.0.0"
1584 + },
1585 + "funding": {
1586 + "type": "opencollective",
1587 + "url": "https://opencollective.com/parcel"
1588 + }
1589 + },
1590 + "node_modules/lightningcss-win32-arm64-msvc": {
1591 + "version": "1.32.0",
1592 + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
1593 + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
1594 + "cpu": [
1595 + "arm64"
1596 + ],
1597 + "dev": true,
1598 + "license": "MPL-2.0",
1599 + "optional": true,
1600 + "os": [
1601 + "win32"
1602 + ],
1603 + "engines": {
1604 + "node": ">= 12.0.0"
1605 + },
1606 + "funding": {
1607 + "type": "opencollective",
1608 + "url": "https://opencollective.com/parcel"
1609 + }
1610 + },
1611 + "node_modules/lightningcss-win32-x64-msvc": {
1612 + "version": "1.32.0",
1613 + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
1614 + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
1615 + "cpu": [
1616 + "x64"
1617 + ],
1618 + "dev": true,
1619 + "license": "MPL-2.0",
1620 + "optional": true,
1621 + "os": [
1622 + "win32"
1623 + ],
1624 + "engines": {
1625 + "node": ">= 12.0.0"
1626 + },
1627 + "funding": {
1628 + "type": "opencollective",
1629 + "url": "https://opencollective.com/parcel"
1630 + }
1631 + },
1632 + "node_modules/locate-path": {
1633 + "version": "5.0.0",
1634 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
1635 + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
1636 + "license": "MIT",
1637 + "dependencies": {
1638 + "p-locate": "^4.1.0"
1639 + },
1640 + "engines": {
1641 + "node": ">=8"
1642 + }
1643 + },
1644 + "node_modules/magic-string": {
1645 + "version": "0.30.21",
1646 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1647 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1648 + "dev": true,
1649 + "license": "MIT",
1650 + "dependencies": {
1651 + "@jridgewell/sourcemap-codec": "^1.5.5"
1652 + }
1653 + },
1654 + "node_modules/nanoid": {
1655 + "version": "3.3.18",
1656 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1657 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1658 + "funding": [
1659 + {
1660 + "type": "github",
1661 + "url": "https://github.com/sponsors/ai"
1662 + }
1663 + ],
1664 + "license": "MIT",
1665 + "bin": {
1666 + "nanoid": "bin/nanoid.cjs"
1667 + },
1668 + "engines": {
1669 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1670 + }
1671 + },
1672 + "node_modules/next": {
1673 + "version": "16.3.0",
1674 + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
1675 + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
1676 + "license": "MIT",
1677 + "dependencies": {
1678 + "@next/env": "16.3.0",
1679 + "@swc/helpers": "0.5.15",
1680 + "baseline-browser-mapping": "^2.9.19",
1681 + "caniuse-lite": "^1.0.30001579",
1682 + "postcss": "8.5.23",
1683 + "styled-jsx": "5.1.6"
1684 + },
1685 + "bin": {
1686 + "next": "dist/bin/next"
1687 + },
1688 + "engines": {
1689 + "node": ">=20.9.0"
1690 + },
1691 + "optionalDependencies": {
1692 + "@next/swc-darwin-arm64": "16.3.0",
1693 + "@next/swc-darwin-x64": "16.3.0",
1694 + "@next/swc-linux-arm64-gnu": "16.3.0",
1695 + "@next/swc-linux-arm64-musl": "16.3.0",
1696 + "@next/swc-linux-x64-gnu": "16.3.0",
1697 + "@next/swc-linux-x64-musl": "16.3.0",
1698 + "@next/swc-win32-arm64-msvc": "16.3.0",
1699 + "@next/swc-win32-x64-msvc": "16.3.0",
1700 + "sharp": "^0.35.3"
1701 + },
1702 + "peerDependencies": {
1703 + "@opentelemetry/api": "^1.1.0",
1704 + "@playwright/test": "^1.51.1",
1705 + "babel-plugin-react-compiler": "*",
1706 + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1707 + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1708 + "sass": "^1.3.0"
1709 + },
1710 + "peerDependenciesMeta": {
1711 + "@opentelemetry/api": {
1712 + "optional": true
1713 + },
1714 + "@playwright/test": {
1715 + "optional": true
1716 + },
1717 + "babel-plugin-react-compiler": {
1718 + "optional": true
1719 + },
1720 + "sass": {
1721 + "optional": true
1722 + }
1723 + }
1724 + },
1725 + "node_modules/next/node_modules/postcss": {
1726 + "version": "8.5.23",
1727 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
1728 + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
1729 + "funding": [
1730 + {
1731 + "type": "opencollective",
1732 + "url": "https://opencollective.com/postcss/"
1733 + },
1734 + {
1735 + "type": "tidelift",
1736 + "url": "https://tidelift.com/funding/github/npm/postcss"
1737 + },
1738 + {
1739 + "type": "github",
1740 + "url": "https://github.com/sponsors/ai"
1741 + }
1742 + ],
1743 + "license": "MIT",
1744 + "dependencies": {
1745 + "nanoid": "^3.3.16",
1746 + "picocolors": "^1.1.1",
1747 + "source-map-js": "^1.2.1"
1748 + },
1749 + "engines": {
1750 + "node": "^10 || ^12 || >=14"
1751 + }
1752 + },
1753 + "node_modules/node-addon-api": {
1754 + "version": "8.9.2",
1755 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
1756 + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
1757 + "license": "MIT",
1758 + "engines": {
1759 + "node": "^18 || ^20 || >= 21"
1760 + }
1761 + },
1762 + "node_modules/p-limit": {
1763 + "version": "2.3.0",
1764 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
1765 + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
1766 + "license": "MIT",
1767 + "dependencies": {
1768 + "p-try": "^2.0.0"
1769 + },
1770 + "engines": {
1771 + "node": ">=6"
1772 + },
1773 + "funding": {
1774 + "url": "https://github.com/sponsors/sindresorhus"
1775 + }
1776 + },
1777 + "node_modules/p-locate": {
1778 + "version": "4.1.0",
1779 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
1780 + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
1781 + "license": "MIT",
1782 + "dependencies": {
1783 + "p-limit": "^2.2.0"
1784 + },
1785 + "engines": {
1786 + "node": ">=8"
1787 + }
1788 + },
1789 + "node_modules/p-try": {
1790 + "version": "2.2.0",
1791 + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
1792 + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
1793 + "license": "MIT",
1794 + "engines": {
1795 + "node": ">=6"
1796 + }
1797 + },
1798 + "node_modules/path-exists": {
1799 + "version": "4.0.0",
1800 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
1801 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
1802 + "license": "MIT",
1803 + "engines": {
1804 + "node": ">=8"
1805 + }
1806 + },
1807 + "node_modules/picocolors": {
1808 + "version": "1.1.1",
1809 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1810 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1811 + "license": "ISC"
1812 + },
1813 + "node_modules/pngjs": {
1814 + "version": "5.0.0",
1815 + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
1816 + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
1817 + "license": "MIT",
1818 + "engines": {
1819 + "node": ">=10.13.0"
1820 + }
1821 + },
1822 + "node_modules/postcss": {
1823 + "version": "8.5.26",
1824 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1825 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1826 + "dev": true,
1827 + "funding": [
1828 + {
1829 + "type": "opencollective",
1830 + "url": "https://opencollective.com/postcss/"
1831 + },
1832 + {
1833 + "type": "tidelift",
1834 + "url": "https://tidelift.com/funding/github/npm/postcss"
1835 + },
1836 + {
1837 + "type": "github",
1838 + "url": "https://github.com/sponsors/ai"
1839 + }
1840 + ],
1841 + "license": "MIT",
1842 + "dependencies": {
1843 + "nanoid": "^3.3.17",
1844 + "picocolors": "^1.1.1",
1845 + "source-map-js": "^1.2.1"
1846 + },
1847 + "engines": {
1848 + "node": "^10 || ^12 || >=14"
1849 + }
1850 + },
1851 + "node_modules/qrcode": {
1852 + "version": "1.5.4",
1853 + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
1854 + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
1855 + "license": "MIT",
1856 + "dependencies": {
1857 + "dijkstrajs": "^1.0.1",
1858 + "pngjs": "^5.0.0",
1859 + "yargs": "^15.3.1"
1860 + },
1861 + "bin": {
1862 + "qrcode": "bin/qrcode"
1863 + },
1864 + "engines": {
1865 + "node": ">=10.13.0"
1866 + }
1867 + },
1868 + "node_modules/react": {
1869 + "version": "19.2.8",
1870 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
1871 + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
1872 + "license": "MIT",
1873 + "engines": {
1874 + "node": ">=0.10.0"
1875 + }
1876 + },
1877 + "node_modules/react-dom": {
1878 + "version": "19.2.8",
1879 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
1880 + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
1881 + "license": "MIT",
1882 + "dependencies": {
1883 + "scheduler": "^0.27.0"
1884 + },
1885 + "peerDependencies": {
1886 + "react": "^19.2.8"
1887 + }
1888 + },
1889 + "node_modules/require-directory": {
1890 + "version": "2.1.1",
1891 + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
1892 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
1893 + "license": "MIT",
1894 + "engines": {
1895 + "node": ">=0.10.0"
1896 + }
1897 + },
1898 + "node_modules/require-main-filename": {
1899 + "version": "2.0.0",
1900 + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
1901 + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
1902 + "license": "ISC"
1903 + },
1904 + "node_modules/scheduler": {
1905 + "version": "0.27.0",
1906 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1907 + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1908 + "license": "MIT"
1909 + },
1910 + "node_modules/semver": {
1911 + "version": "7.8.5",
1912 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
1913 + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
1914 + "license": "ISC",
1915 + "bin": {
1916 + "semver": "bin/semver.js"
1917 + },
1918 + "engines": {
1919 + "node": ">=10"
1920 + }
1921 + },
1922 + "node_modules/set-blocking": {
1923 + "version": "2.0.0",
1924 + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
1925 + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
1926 + "license": "ISC"
1927 + },
1928 + "node_modules/sharp": {
1929 + "version": "0.35.3",
1930 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
1931 + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
1932 + "license": "Apache-2.0",
1933 + "dependencies": {
1934 + "@img/colour": "^1.1.0",
1935 + "detect-libc": "^2.1.2",
1936 + "semver": "^7.8.5"
1937 + },
1938 + "engines": {
1939 + "node": ">=20.9.0"
1940 + },
1941 + "funding": {
1942 + "url": "https://opencollective.com/libvips"
1943 + },
1944 + "optionalDependencies": {
1945 + "@img/sharp-darwin-arm64": "0.35.3",
1946 + "@img/sharp-darwin-x64": "0.35.3",
1947 + "@img/sharp-freebsd-wasm32": "0.35.3",
1948 + "@img/sharp-libvips-darwin-arm64": "1.3.2",
1949 + "@img/sharp-libvips-darwin-x64": "1.3.2",
1950 + "@img/sharp-libvips-linux-arm": "1.3.2",
1951 + "@img/sharp-libvips-linux-arm64": "1.3.2",
1952 + "@img/sharp-libvips-linux-ppc64": "1.3.2",
1953 + "@img/sharp-libvips-linux-riscv64": "1.3.2",
1954 + "@img/sharp-libvips-linux-s390x": "1.3.2",
1955 + "@img/sharp-libvips-linux-x64": "1.3.2",
1956 + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
1957 + "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
1958 + "@img/sharp-linux-arm": "0.35.3",
1959 + "@img/sharp-linux-arm64": "0.35.3",
1960 + "@img/sharp-linux-ppc64": "0.35.3",
1961 + "@img/sharp-linux-riscv64": "0.35.3",
1962 + "@img/sharp-linux-s390x": "0.35.3",
1963 + "@img/sharp-linux-x64": "0.35.3",
1964 + "@img/sharp-linuxmusl-arm64": "0.35.3",
1965 + "@img/sharp-linuxmusl-x64": "0.35.3",
1966 + "@img/sharp-webcontainers-wasm32": "0.35.3",
1967 + "@img/sharp-win32-arm64": "0.35.3",
1968 + "@img/sharp-win32-ia32": "0.35.3",
1969 + "@img/sharp-win32-x64": "0.35.3"
1970 + },
1971 + "peerDependenciesMeta": {
1972 + "@types/node": {
1973 + "optional": true
1974 + }
1975 + }
1976 + },
1977 + "node_modules/source-map-js": {
1978 + "version": "1.2.1",
1979 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1980 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1981 + "license": "BSD-3-Clause",
1982 + "engines": {
1983 + "node": ">=0.10.0"
1984 + }
1985 + },
1986 + "node_modules/string-width": {
1987 + "version": "4.2.3",
1988 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
1989 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
1990 + "license": "MIT",
1991 + "dependencies": {
1992 + "emoji-regex": "^8.0.0",
1993 + "is-fullwidth-code-point": "^3.0.0",
1994 + "strip-ansi": "^6.0.1"
1995 + },
1996 + "engines": {
1997 + "node": ">=8"
1998 + }
1999 + },
2000 + "node_modules/strip-ansi": {
2001 + "version": "6.0.1",
2002 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2003 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2004 + "license": "MIT",
2005 + "dependencies": {
2006 + "ansi-regex": "^5.0.1"
2007 + },
2008 + "engines": {
2009 + "node": ">=8"
2010 + }
2011 + },
2012 + "node_modules/styled-jsx": {
2013 + "version": "5.1.6",
2014 + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
2015 + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
2016 + "license": "MIT",
2017 + "dependencies": {
2018 + "client-only": "0.0.1"
2019 + },
2020 + "engines": {
2021 + "node": ">= 12.0.0"
2022 + },
2023 + "peerDependencies": {
2024 + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
2025 + },
2026 + "peerDependenciesMeta": {
2027 + "@babel/core": {
2028 + "optional": true
2029 + },
2030 + "babel-plugin-macros": {
2031 + "optional": true
2032 + }
2033 + }
2034 + },
2035 + "node_modules/tailwindcss": {
2036 + "version": "4.3.3",
2037 + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
2038 + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
2039 + "dev": true,
2040 + "license": "MIT"
2041 + },
2042 + "node_modules/tapable": {
2043 + "version": "2.3.3",
2044 + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
2045 + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
2046 + "dev": true,
2047 + "license": "MIT",
2048 + "engines": {
2049 + "node": ">=6"
2050 + },
2051 + "funding": {
2052 + "type": "opencollective",
2053 + "url": "https://opencollective.com/webpack"
2054 + }
2055 + },
2056 + "node_modules/tslib": {
2057 + "version": "2.8.1",
2058 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2059 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2060 + "license": "0BSD"
2061 + },
2062 + "node_modules/typescript": {
2063 + "version": "5.9.3",
2064 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2065 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2066 + "dev": true,
2067 + "license": "Apache-2.0",
2068 + "bin": {
2069 + "tsc": "bin/tsc",
2070 + "tsserver": "bin/tsserver"
2071 + },
2072 + "engines": {
2073 + "node": ">=14.17"
2074 + }
2075 + },
2076 + "node_modules/undici-types": {
2077 + "version": "6.21.0",
2078 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
2079 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
2080 + "dev": true,
2081 + "license": "MIT"
2082 + },
2083 + "node_modules/which-module": {
2084 + "version": "2.0.1",
2085 + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
2086 + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
2087 + "license": "ISC"
2088 + },
2089 + "node_modules/wrap-ansi": {
2090 + "version": "6.2.0",
2091 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
2092 + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
2093 + "license": "MIT",
2094 + "dependencies": {
2095 + "ansi-styles": "^4.0.0",
2096 + "string-width": "^4.1.0",
2097 + "strip-ansi": "^6.0.0"
2098 + },
2099 + "engines": {
2100 + "node": ">=8"
2101 + }
2102 + },
2103 + "node_modules/y18n": {
2104 + "version": "4.0.3",
2105 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
2106 + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
2107 + "license": "ISC"
2108 + },
2109 + "node_modules/yargs": {
2110 + "version": "15.4.1",
2111 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
2112 + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
2113 + "license": "MIT",
2114 + "dependencies": {
2115 + "cliui": "^6.0.0",
2116 + "decamelize": "^1.2.0",
2117 + "find-up": "^4.1.0",
2118 + "get-caller-file": "^2.0.1",
2119 + "require-directory": "^2.1.1",
2120 + "require-main-filename": "^2.0.0",
2121 + "set-blocking": "^2.0.0",
2122 + "string-width": "^4.2.0",
2123 + "which-module": "^2.0.0",
2124 + "y18n": "^4.0.0",
2125 + "yargs-parser": "^18.1.2"
2126 + },
2127 + "engines": {
2128 + "node": ">=8"
2129 + }
2130 + },
2131 + "node_modules/yargs-parser": {
2132 + "version": "18.1.3",
2133 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
2134 + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
2135 + "license": "ISC",
2136 + "dependencies": {
2137 + "camelcase": "^5.0.0",
2138 + "decamelize": "^1.2.0"
2139 + },
2140 + "engines": {
2141 + "node": ">=6"
2142 + }
2143 + }
2144 + }
2145 +}
added package.json +31 −0
@@ -0,0 +1,31 @@
1 +{
2 + "name": "groupe-ka",
3 + "version": "1.0.0",
4 + "private": true,
5 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 + "scripts": {
7 + "dev": "next dev",
8 + "build": "next build",
9 + "start": "next start",
10 + "lint": "eslint"
11 + },
12 + "dependencies": {
13 + "better-sqlite3": "^13.0.3",
14 + "jose": "^6.2.8",
15 + "next": "16.3.0",
16 + "qrcode": "^1.5.4",
17 + "react": "19.2.8",
18 + "react-dom": "19.2.8",
19 + "sharp": "^0.35.3"
20 + },
21 + "devDependencies": {
22 + "@tailwindcss/postcss": "^4",
23 + "@types/better-sqlite3": "^9.6.0",
24 + "@types/node": "^20",
25 + "@types/qrcode": "^1.5.6",
26 + "@types/react": "^19",
27 + "@types/react-dom": "^19",
28 + "tailwindcss": "^4",
29 + "typescript": "^5"
30 + }
31 +}
added postcss.config.mjs +8 −0
@@ -0,0 +1,8 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +const config = {
3 + plugins: {
4 + "@tailwindcss/postcss": {},
5 + },
6 +};
7 +
8 +export default config;
added src/app/UserButton.tsx +98 −0
@@ -0,0 +1,98 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useEffect, useState } from "react";
5 +
6 +type Me = {
7 + name: string;
8 + avatarUrl: string | null;
9 +} | null;
10 +
11 +export default function UserButton({
12 + variant = "full",
13 +}: {
14 + /** « icon » : pastille ronde seule (header mobile) ; « full » : avatar + prénom. */
15 + variant?: "full" | "icon";
16 +}) {
17 + const [me, setMe] = useState<Me>(null);
18 + const [loaded, setLoaded] = useState(false);
19 +
20 + useEffect(() => {
21 + fetch("/api/auth/me")
22 + .then((r) => r.json())
23 + .then((d) => setMe(d.user))
24 + .catch(() => setMe(null))
25 + .finally(() => setLoaded(true));
26 + }, []);
27 +
28 + if (variant === "icon") {
29 + const connected = loaded && me;
30 + return (
31 + <a
32 + href={connected ? "/compte" : "/connexion"}
33 + aria-label={connected ? "Mon compte KA ID" : "Se connecter"}
34 + className="flex h-11 w-11 items-center justify-center rounded-md border-[1.5px] border-ink bg-surface shadow-[3px_3px_0_rgba(20,24,20,0.25)] transition-colors hover:bg-lime"
35 + >
36 + {connected && me!.avatarUrl ? (
37 + // eslint-disable-next-line @next/next/no-img-element
38 + <img
39 + src={me!.avatarUrl}
40 + alt=""
41 + width={26}
42 + height={26}
43 + className="h-[26px] w-[26px] rounded-full border border-ink object-cover"
44 + />
45 + ) : connected ? (
46 + <span className="gk-display flex h-[26px] w-[26px] items-center justify-center rounded-full bg-ink text-[12px] font-bold text-lime">
47 + {me!.name.slice(0, 1).toUpperCase()}
48 + </span>
49 + ) : (
50 + <svg width="19" height="19" viewBox="0 0 24 24" aria-hidden="true">
51 + <path
52 + fill="none"
53 + stroke="currentColor"
54 + strokeWidth="2"
55 + strokeLinecap="round"
56 + d="M12 12a4.2 4.2 0 1 0 0-8.4 4.2 4.2 0 0 0 0 8.4Zm-7.4 8.4c.9-3.6 3.9-5.7 7.4-5.7s6.5 2.1 7.4 5.7"
57 + />
58 + </svg>
59 + )}
60 + </a>
61 + );
62 + }
63 +
64 + if (loaded && me) {
65 + return (
66 + <a
67 + href="/compte"
68 + className="gk-display inline-flex items-center gap-2 rounded-full border-[1.5px] border-ink px-3 py-[5px] text-[13.5px] font-bold text-ink transition-colors hover:bg-lime"
69 + aria-label="Mon compte KA ID"
70 + >
71 + {me.avatarUrl ? (
72 + // eslint-disable-next-line @next/next/no-img-element
73 + <img
74 + src={me.avatarUrl}
75 + alt=""
76 + width={22}
77 + height={22}
78 + className="h-[22px] w-[22px] rounded-full border border-ink object-cover"
79 + />
80 + ) : (
81 + <span className="flex h-[22px] w-[22px] items-center justify-center rounded-full bg-ink text-[11px] text-lime">
82 + {me.name.slice(0, 1).toUpperCase()}
83 + </span>
84 + )}
85 + {me.name.split(" ")[0]}
86 + </a>
87 + );
88 + }
89 +
90 + return (
91 + <a
92 + href="/connexion"
93 + className="gk-display inline-block rounded-full border-[1.5px] border-ink px-4 py-[7px] text-[13.5px] font-bold text-ink transition-colors hover:bg-lime"
94 + >
95 + KA ID
96 + </a>
97 + );
98 +}
added src/app/api/auth/apple/route.ts +42 −0
@@ -0,0 +1,42 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Départ du flux Sign in with Apple : pose state + next en témoins,
3 +// redirige vers l'écran de consentement Apple.
4 +//
5 +// ⚠️ Avec les scopes « name email », Apple impose response_mode=form_post :
6 +// le retour arrive en POST cross-site, donc les témoins d'état doivent être
7 +// SameSite=None; Secure (Lax ne serait pas renvoyé). Conséquence : le flux
8 +// exige HTTPS — Apple refuse de toute façon les redirect_uri en http.
9 +import { NextRequest, NextResponse } from "next/server";
10 +import crypto from "crypto";
11 +import { BASE_URL, safeNext } from "@/lib/auth";
12 +
13 +export async function GET(req: NextRequest) {
14 + const clientId = process.env.APPLE_CLIENT_ID;
15 + if (!clientId) {
16 + return NextResponse.redirect(
17 + new URL("/connexion?error=apple-non-configure", BASE_URL),
18 + );
19 + }
20 + const next = safeNext(req.nextUrl.searchParams.get("next"));
21 + const state = crypto.randomBytes(16).toString("hex");
22 +
23 + const url = new URL("https://appleid.apple.com/auth/authorize");
24 + url.searchParams.set("client_id", clientId);
25 + url.searchParams.set("redirect_uri", `${BASE_URL}/api/auth/callback/apple`);
26 + url.searchParams.set("response_type", "code");
27 + url.searchParams.set("scope", "name email");
28 + url.searchParams.set("response_mode", "form_post");
29 + url.searchParams.set("state", state);
30 +
31 + const res = NextResponse.redirect(url);
32 + const opts = {
33 + httpOnly: true,
34 + secure: true,
35 + sameSite: "none" as const,
36 + path: "/",
37 + maxAge: 600,
38 + };
39 + res.cookies.set("ka_apple_state", state, opts);
40 + res.cookies.set("ka_apple_next", next, opts);
41 + return res;
42 +}
added src/app/api/auth/callback/apple/route.ts +159 −0
@@ -0,0 +1,159 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Retour du flux Sign in with Apple : échange du code, vérification de
3 +// l'id_token (JWKS Apple), création/liaison du KA ID, ouverture de session.
4 +//
5 +// Différences avec Google :
6 +// - le retour arrive en POST (form_post) et non en GET ;
7 +// - le client_secret est un JWT ES256 signé avec la clé privée .p8
8 +// (APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY), regénéré à chaque
9 +// échange ;
10 +// - le nom (`user`) n'est transmis qu'à la PREMIÈRE autorisation ;
11 +// - email_verified peut être un booléen ou la chaîne "true".
12 +import { NextRequest, NextResponse } from "next/server";
13 +import { createRemoteJWKSet, jwtVerify, SignJWT, importPKCS8 } from "jose";
14 +import {
15 + db,
16 + findUserByEmail,
17 + findUserByAppleSub,
18 + ensureKaId,
19 + touchLastLogin,
20 +} from "@/lib/db";
21 +import {
22 + BASE_URL,
23 + mintSessionToken,
24 + SESSION_COOKIE,
25 + sessionCookieOptions,
26 + safeNext,
27 +} from "@/lib/auth";
28 +
29 +const APPLE_JWKS = createRemoteJWKSet(
30 + new URL("https://appleid.apple.com/auth/keys"),
31 +);
32 +
33 +function fail(reason: string) {
34 + return NextResponse.redirect(
35 + new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL),
36 + // 303 : on répond à un POST par une redirection GET.
37 + { status: 303 },
38 + );
39 +}
40 +
41 +/** client_secret Apple : JWT ES256 signé avec la clé .p8, valable 5 min. */
42 +async function appleClientSecret(clientId: string): Promise<string | null> {
43 + const teamId = process.env.APPLE_TEAM_ID;
44 + const keyId = process.env.APPLE_KEY_ID;
45 + const pem = process.env.APPLE_PRIVATE_KEY?.replace(/\\n/g, "\n");
46 + if (!teamId || !keyId || !pem) return null;
47 + const key = await importPKCS8(pem, "ES256");
48 + return await new SignJWT({})
49 + .setProtectedHeader({ alg: "ES256", kid: keyId })
50 + .setIssuedAt()
51 + .setIssuer(teamId)
52 + .setSubject(clientId)
53 + .setAudience("https://appleid.apple.com")
54 + .setExpirationTime("5m")
55 + .sign(key);
56 +}
57 +
58 +export async function POST(req: NextRequest) {
59 + const clientId = process.env.APPLE_CLIENT_ID;
60 + if (!clientId) return fail("apple-non-configure");
61 + const clientSecret = await appleClientSecret(clientId).catch(() => null);
62 + if (!clientSecret) return fail("apple-non-configure");
63 +
64 + const form = await req.formData().catch(() => null);
65 + if (!form) return fail("etat-oauth-invalide");
66 + if (form.get("error")) return fail("apple-refuse");
67 +
68 + const code = form.get("code")?.toString();
69 + const state = form.get("state")?.toString();
70 + const cookieState = req.cookies.get("ka_apple_state")?.value;
71 + const next = safeNext(req.cookies.get("ka_apple_next")?.value);
72 + if (!code || !state || !cookieState || state !== cookieState)
73 + return fail("etat-oauth-invalide");
74 +
75 + // Nom complet : transmis par Apple UNIQUEMENT à la première autorisation.
76 + let firstAuthName: string | null = null;
77 + try {
78 + const u = JSON.parse(form.get("user")?.toString() ?? "") as {
79 + name?: { firstName?: string; lastName?: string };
80 + };
81 + const full = [u.name?.firstName, u.name?.lastName]
82 + .filter(Boolean)
83 + .join(" ")
84 + .trim();
85 + if (full) firstAuthName = full;
86 + } catch {
87 + /* pas de champ user (connexions suivantes) */
88 + }
89 +
90 + // Échange code → jetons
91 + const tokenRes = await fetch("https://appleid.apple.com/auth/token", {
92 + method: "POST",
93 + headers: { "Content-Type": "application/x-www-form-urlencoded" },
94 + body: new URLSearchParams({
95 + code,
96 + client_id: clientId,
97 + client_secret: clientSecret,
98 + redirect_uri: `${BASE_URL}/api/auth/callback/apple`,
99 + grant_type: "authorization_code",
100 + }),
101 + });
102 + if (!tokenRes.ok) return fail("echange-apple-refuse");
103 + const tokens = (await tokenRes.json()) as { id_token?: string };
104 + if (!tokens.id_token) return fail("id-token-absent");
105 +
106 + // Vérification cryptographique de l'id_token
107 + let claims: {
108 + sub?: string;
109 + email?: string;
110 + email_verified?: boolean | string;
111 + };
112 + try {
113 + const { payload } = await jwtVerify(tokens.id_token, APPLE_JWKS, {
114 + issuer: "https://appleid.apple.com",
115 + audience: clientId,
116 + });
117 + claims = payload as typeof claims;
118 + } catch {
119 + return fail("id-token-invalide");
120 + }
121 + const emailVerified =
122 + claims.email_verified === true || claims.email_verified === "true";
123 + if (!claims.sub || !claims.email || !emailVerified)
124 + return fail("profil-apple-incomplet");
125 +
126 + const email = claims.email.toLowerCase();
127 + const name = firstAuthName ?? email.split("@")[0];
128 +
129 + // Upsert : par apple_sub, sinon liaison par courriel, sinon création.
130 + let user = findUserByAppleSub(claims.sub);
131 + if (!user) {
132 + const byEmail = findUserByEmail(email);
133 + if (byEmail) {
134 + db.prepare("UPDATE users SET apple_sub = ? WHERE id = ?").run(
135 + claims.sub,
136 + byEmail.id,
137 + );
138 + user = { ...byEmail, apple_sub: claims.sub };
139 + } else {
140 + db.prepare(
141 + "INSERT INTO users (email, name, apple_sub) VALUES (?, ?, ?)",
142 + ).run(email, name, claims.sub);
143 + user = findUserByAppleSub(claims.sub)!;
144 + ensureKaId(user.id);
145 + }
146 + }
147 +
148 + ensureKaId(user.id);
149 + touchLastLogin(user.id);
150 + const res = NextResponse.redirect(new URL(next, BASE_URL), { status: 303 });
151 + res.cookies.set(
152 + SESSION_COOKIE,
153 + await mintSessionToken(user.id),
154 + sessionCookieOptions,
155 + );
156 + res.cookies.set("ka_apple_state", "", { path: "/", maxAge: 0 });
157 + res.cookies.set("ka_apple_next", "", { path: "/", maxAge: 0 });
158 + return res;
159 +}
added src/app/api/auth/callback/google/route.ts +115 −0
@@ -0,0 +1,115 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Retour du flux Google OAuth : échange du code, vérification de
3 +// l'id_token (JWKS Google), création/liaison du KA ID, ouverture de session.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { createRemoteJWKSet, jwtVerify } from "jose";
6 +import { db, findUserByEmail, findUserByGoogleSub, ensureKaId, touchLastLogin } from "@/lib/db";
7 +import {
8 + BASE_URL,
9 + mintSessionToken,
10 + SESSION_COOKIE,
11 + sessionCookieOptions,
12 + safeNext,
13 +} from "@/lib/auth";
14 +
15 +const GOOGLE_JWKS = createRemoteJWKSet(
16 + new URL("https://www.googleapis.com/oauth2/v3/certs"),
17 +);
18 +
19 +function fail(reason: string) {
20 + return NextResponse.redirect(
21 + new URL(`/connexion?error=${encodeURIComponent(reason)}`, BASE_URL),
22 + );
23 +}
24 +
25 +export async function GET(req: NextRequest) {
26 + const clientId = process.env.GOOGLE_CLIENT_ID;
27 + const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
28 + if (!clientId || !clientSecret) return fail("google-non-configure");
29 +
30 + const code = req.nextUrl.searchParams.get("code");
31 + const state = req.nextUrl.searchParams.get("state");
32 + const cookieState = req.cookies.get("ka_oauth_state")?.value;
33 + const next = safeNext(req.cookies.get("ka_oauth_next")?.value);
34 + if (!code || !state || !cookieState || state !== cookieState)
35 + return fail("etat-oauth-invalide");
36 +
37 + // Échange code → jetons
38 + const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
39 + method: "POST",
40 + headers: { "Content-Type": "application/x-www-form-urlencoded" },
41 + body: new URLSearchParams({
42 + code,
43 + client_id: clientId,
44 + client_secret: clientSecret,
45 + redirect_uri: `${BASE_URL}/api/auth/callback/google`,
46 + grant_type: "authorization_code",
47 + }),
48 + });
49 + if (!tokenRes.ok) return fail("echange-google-refuse");
50 + const tokens = (await tokenRes.json()) as { id_token?: string };
51 + if (!tokens.id_token) return fail("id-token-absent");
52 +
53 + // Vérification cryptographique de l'id_token
54 + let claims: {
55 + sub?: string;
56 + email?: string;
57 + email_verified?: boolean;
58 + name?: string;
59 + picture?: string;
60 + };
61 + try {
62 + const { payload } = await jwtVerify(tokens.id_token, GOOGLE_JWKS, {
63 + issuer: ["https://accounts.google.com", "accounts.google.com"],
64 + audience: clientId,
65 + });
66 + claims = payload as typeof claims;
67 + } catch {
68 + return fail("id-token-invalide");
69 + }
70 + if (!claims.sub || !claims.email || claims.email_verified === false)
71 + return fail("profil-google-incomplet");
72 +
73 + const email = claims.email.toLowerCase();
74 + const name = claims.name ?? email.split("@")[0];
75 + const picture = claims.picture ?? null;
76 +
77 + // Upsert : par google_sub, sinon liaison par courriel, sinon création.
78 + let user = findUserByGoogleSub(claims.sub);
79 + if (user) {
80 + db.prepare("UPDATE users SET name = ?, avatar_url = ? WHERE id = ?").run(
81 + name,
82 + picture,
83 + user.id,
84 + );
85 + } else {
86 + const byEmail = findUserByEmail(email);
87 + if (byEmail) {
88 + db.prepare(
89 + "UPDATE users SET google_sub = ?, avatar_url = COALESCE(?, avatar_url) WHERE id = ?",
90 + ).run(claims.sub, picture, byEmail.id);
91 + user = { ...byEmail, google_sub: claims.sub };
92 + } else {
93 + const info = db
94 + .prepare(
95 + "INSERT INTO users (email, name, avatar_url, google_sub) VALUES (?, ?, ?, ?)",
96 + )
97 + .run(email, name, picture, claims.sub);
98 + user = findUserByGoogleSub(claims.sub)!;
99 + ensureKaId(user.id);
100 + void info;
101 + }
102 + }
103 +
104 + ensureKaId(user!.id);
105 + touchLastLogin(user!.id);
106 + const res = NextResponse.redirect(new URL(next, BASE_URL));
107 + res.cookies.set(
108 + SESSION_COOKIE,
109 + await mintSessionToken(user!.id),
110 + sessionCookieOptions,
111 + );
112 + res.cookies.set("ka_oauth_state", "", { path: "/", maxAge: 0 });
113 + res.cookies.set("ka_oauth_next", "", { path: "/", maxAge: 0 });
114 + return res;
115 +}
added src/app/api/auth/google/route.ts +37 −0
@@ -0,0 +1,37 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Départ du flux Google OAuth : pose state + next en témoins, redirige
3 +// vers l'écran de consentement Google.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import crypto from "crypto";
6 +import { BASE_URL, safeNext } from "@/lib/auth";
7 +
8 +export async function GET(req: NextRequest) {
9 + const clientId = process.env.GOOGLE_CLIENT_ID;
10 + if (!clientId) {
11 + return NextResponse.redirect(
12 + new URL("/connexion?error=google-non-configure", BASE_URL),
13 + );
14 + }
15 + const next = safeNext(req.nextUrl.searchParams.get("next"));
16 + const state = crypto.randomBytes(16).toString("hex");
17 +
18 + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
19 + url.searchParams.set("client_id", clientId);
20 + url.searchParams.set("redirect_uri", `${BASE_URL}/api/auth/callback/google`);
21 + url.searchParams.set("response_type", "code");
22 + url.searchParams.set("scope", "openid email profile");
23 + url.searchParams.set("state", state);
24 + url.searchParams.set("prompt", "select_account");
25 +
26 + const res = NextResponse.redirect(url);
27 + const opts = {
28 + httpOnly: true,
29 + secure: process.env.NODE_ENV === "production",
30 + sameSite: "lax" as const,
31 + path: "/",
32 + maxAge: 600,
33 + };
34 + res.cookies.set("ka_oauth_state", state, opts);
35 + res.cookies.set("ka_oauth_next", next, opts);
36 + return res;
37 +}
added src/app/api/auth/login/route.ts +46 −0
@@ -0,0 +1,46 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { findUserByEmail, touchLastLogin } from "@/lib/db";
4 +import {
5 + verifyPassword,
6 + mintSessionToken,
7 + SESSION_COOKIE,
8 + sessionCookieOptions,
9 +} from "@/lib/auth";
10 +
11 +export async function POST(req: NextRequest) {
12 + let body: { email?: string; password?: string };
13 + try {
14 + body = await req.json();
15 + } catch {
16 + return NextResponse.json({ error: "Requête invalide." }, { status: 400 });
17 + }
18 + const email = (body.email ?? "").trim().toLowerCase();
19 + const password = body.password ?? "";
20 +
21 + const user = findUserByEmail(email);
22 + if (!user || !user.password_hash) {
23 + return NextResponse.json(
24 + {
25 + error: user
26 + ? "Ce KA ID a été créé avec Google — utilisez « Continuer avec Google »."
27 + : "Courriel ou mot de passe incorrect.",
28 + },
29 + { status: 401 },
30 + );
31 + }
32 + if (!verifyPassword(password, user.password_hash))
33 + return NextResponse.json(
34 + { error: "Courriel ou mot de passe incorrect." },
35 + { status: 401 },
36 + );
37 +
38 + touchLastLogin(user.id);
39 + const res = NextResponse.json({ ok: true });
40 + res.cookies.set(
41 + SESSION_COOKIE,
42 + await mintSessionToken(user.id),
43 + sessionCookieOptions,
44 + );
45 + return res;
46 +}
added src/app/api/auth/logout/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextResponse } from "next/server";
3 +import { SESSION_COOKIE } from "@/lib/auth";
4 +
5 +export async function POST() {
6 + const res = NextResponse.json({ ok: true });
7 + res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
8 + return res;
9 +}
added src/app/api/auth/me/route.ts +8 −0
@@ -0,0 +1,8 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextResponse } from "next/server";
3 +import { getSessionUser } from "@/lib/auth";
4 +
5 +export async function GET() {
6 + const user = await getSessionUser();
7 + return NextResponse.json({ user });
8 +}
added src/app/api/auth/register/route.ts +76 −0
@@ -0,0 +1,76 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextRequest, NextResponse } from "next/server";
3 +import { db, findUserByEmail, ensureKaId, touchLastLogin } from "@/lib/db";
4 +import { ROLES } from "@/lib/roles";
5 +import {
6 + hashPassword,
7 + mintSessionToken,
8 + SESSION_COOKIE,
9 + sessionCookieOptions,
10 +} from "@/lib/auth";
11 +
12 +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
13 +
14 +export async function POST(req: NextRequest) {
15 + let body: {
16 + email?: string;
17 + password?: string;
18 + name?: string;
19 + role?: string;
20 + };
21 + try {
22 + body = await req.json();
23 + } catch {
24 + return NextResponse.json({ error: "Requête invalide." }, { status: 400 });
25 + }
26 + const email = (body.email ?? "").trim().toLowerCase();
27 + const password = body.password ?? "";
28 + const name = (body.name ?? "").trim();
29 +
30 + if (!EMAIL_RE.test(email))
31 + return NextResponse.json(
32 + { error: "Adresse courriel invalide." },
33 + { status: 400 },
34 + );
35 + if (password.length < 8)
36 + return NextResponse.json(
37 + { error: "Le mot de passe doit compter au moins 8 caractères." },
38 + { status: 400 },
39 + );
40 + if (name.length < 2)
41 + return NextResponse.json(
42 + { error: "Dites-nous au moins votre prénom." },
43 + { status: 400 },
44 + );
45 + const role = body.role ?? "";
46 + if (!ROLES[role])
47 + return NextResponse.json(
48 + { error: "Choisissez votre type de compte (utilisateur, fournisseur ou équipe)." },
49 + { status: 400 },
50 + );
51 +
52 + if (findUserByEmail(email))
53 + return NextResponse.json(
54 + {
55 + error:
56 + "Cette adresse a déjà un KA ID — connectez-vous (ou utilisez « Continuer avec Google » si le compte vient de Google).",
57 + },
58 + { status: 409 },
59 + );
60 +
61 + const info = db
62 + .prepare(
63 + "INSERT INTO users (email, name, password_hash, role) VALUES (?, ?, ?, ?)",
64 + )
65 + .run(email, name, hashPassword(password), role);
66 + ensureKaId(Number(info.lastInsertRowid));
67 + touchLastLogin(Number(info.lastInsertRowid));
68 +
69 + const res = NextResponse.json({ ok: true });
70 + res.cookies.set(
71 + SESSION_COOKIE,
72 + await mintSessionToken(Number(info.lastInsertRowid)),
73 + sessionCookieOptions,
74 + );
75 + return res;
76 +}
added src/app/api/favorites/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Favoris du membre connecté (pour la page /compte du hub).
3 +// GET → { favorites: [...] } POST { action:"remove", app, item_id }
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { getSessionUser } from "@/lib/auth";
6 +import { favoritesOf, removeFavorite } from "@/lib/db";
7 +
8 +export async function GET() {
9 + const user = await getSessionUser();
10 + if (!user)
11 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
12 + return NextResponse.json({ favorites: favoritesOf(user.id) });
13 +}
14 +
15 +export async function POST(req: NextRequest) {
16 + const user = await getSessionUser();
17 + if (!user)
18 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
19 + const body = (await req.json().catch(() => ({}))) as {
20 + action?: string;
21 + app?: string;
22 + item_id?: string;
23 + };
24 + if (body.action !== "remove" || !body.app || !body.item_id)
25 + return NextResponse.json({ error: "Requête invalide." }, { status: 400 });
26 + removeFavorite(user.id, body.app, body.item_id);
27 + return NextResponse.json({ ok: true });
28 +}
added src/app/api/health/route.ts +6 −0
@@ -0,0 +1,6 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import { NextResponse } from "next/server";
3 +
4 +export function GET() {
5 + return NextResponse.json({ status: "ok", app: "groupe-ka" });
6 +}
added src/app/api/profile/avatar/route.ts +74 −0
@@ -0,0 +1,74 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Photo de profil du KA ID — téléversée ici, elle apparaît sur la carte de
3 +// membre du site, sur la carte Apple Wallet (vignette) et, via le jeton SSO,
4 +// sur toutes les plateformes du groupe à la prochaine connexion.
5 +// POST multipart (champ « photo », JPG/PNG/WebP ≤ 8 Mo) → carré 512 px JPEG
6 +// DELETE → retire la photo (retour à l'initiale ; une connexion Google
7 +// ultérieure remettra la photo Google)
8 +import { NextRequest, NextResponse } from "next/server";
9 +import fs from "fs/promises";
10 +import path from "path";
11 +import sharp from "sharp";
12 +import { getSessionUser, BASE_URL } from "@/lib/auth";
13 +import { db, ensureKaId } from "@/lib/db";
14 +
15 +const AVATAR_DIR = path.join(process.cwd(), "data", "avatars");
16 +const MAX_BYTES = 8 * 1024 * 1024;
17 +
18 +export async function POST(req: NextRequest) {
19 + const user = await getSessionUser();
20 + if (!user)
21 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
22 +
23 + const fd = await req.formData().catch(() => null);
24 + const file = fd?.get("photo");
25 + if (!(file instanceof File) || file.size === 0)
26 + return NextResponse.json(
27 + { error: "Aucune image reçue (champ « photo »)." },
28 + { status: 400 },
29 + );
30 + if (file.size > MAX_BYTES)
31 + return NextResponse.json(
32 + { error: "Image trop lourde — 8 Mo maximum." },
33 + { status: 413 },
34 + );
35 + if (!/^image\/(jpeg|png|webp|heic|heif|gif)$/i.test(file.type))
36 + return NextResponse.json(
37 + { error: "Format refusé — JPG, PNG ou WebP." },
38 + { status: 415 },
39 + );
40 +
41 + const kaId = user.kaId ?? ensureKaId(user.id);
42 + let out: Buffer;
43 + try {
44 + out = await sharp(Buffer.from(await file.arrayBuffer()))
45 + .rotate() // respecte l'orientation EXIF
46 + .resize(512, 512, { fit: "cover", position: "attention" })
47 + .jpeg({ quality: 88 })
48 + .toBuffer();
49 + } catch {
50 + return NextResponse.json(
51 + { error: "Image illisible — réessayez avec un JPG ou un PNG." },
52 + { status: 422 },
53 + );
54 + }
55 +
56 + await fs.mkdir(AVATAR_DIR, { recursive: true });
57 + await fs.writeFile(path.join(AVATAR_DIR, `${kaId}.jpg`), out);
58 +
59 + // URL absolue (+cache-bust) : servie par /avatars/{kaId}, transportée
60 + // telle quelle dans le jeton SSO vers les plateformes.
61 + const url = `${BASE_URL}/avatars/${kaId}?v=${out.length.toString(36)}${Math.floor(out.byteLength % 997)}`;
62 + db.prepare("UPDATE users SET avatar_url = ? WHERE id = ?").run(url, user.id);
63 + return NextResponse.json({ ok: true, avatarUrl: url });
64 +}
65 +
66 +export async function DELETE() {
67 + const user = await getSessionUser();
68 + if (!user)
69 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
70 + const kaId = user.kaId ?? ensureKaId(user.id);
71 + await fs.rm(path.join(AVATAR_DIR, `${kaId}.jpg`), { force: true });
72 + db.prepare("UPDATE users SET avatar_url = NULL WHERE id = ?").run(user.id);
73 + return NextResponse.json({ ok: true });
74 +}
added src/app/api/profile/public/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Active/désactive le profil public groupe-ka.com/u/{ka_id} (opt-in).
3 +import { NextRequest, NextResponse } from "next/server";
4 +import { db } from "@/lib/db";
5 +import { getSessionUser } from "@/lib/auth";
6 +
7 +export async function POST(req: NextRequest) {
8 + const user = await getSessionUser();
9 + if (!user)
10 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
11 + const body = (await req.json().catch(() => ({}))) as { enabled?: boolean };
12 + const enabled = body.enabled === true;
13 + db.prepare("UPDATE users SET public = ? WHERE id = ?").run(
14 + enabled ? 1 : 0,
15 + user.id,
16 + );
17 + return NextResponse.json({ ok: true, public: enabled });
18 +}
added src/app/api/profile/role/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Définit (ou modifie) le type de compte du KA ID — obligatoire à la
3 +// création ; les comptes arrivés par Google le choisissent ici.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { db } from "@/lib/db";
6 +import { getSessionUser } from "@/lib/auth";
7 +import { ROLES } from "@/lib/roles";
8 +
9 +export async function POST(req: NextRequest) {
10 + const user = await getSessionUser();
11 + if (!user)
12 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
13 + const body = (await req.json().catch(() => ({}))) as { role?: string };
14 + const role = body.role ?? "";
15 + if (!ROLES[role])
16 + return NextResponse.json(
17 + { error: "Type de compte invalide." },
18 + { status: 400 },
19 + );
20 + db.prepare("UPDATE users SET role = ? WHERE id = ?").run(role, user.id);
21 + return NextResponse.json({ ok: true, role });
22 +}
added src/app/api/profile/route.ts +65 −0
@@ -0,0 +1,65 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Profil enrichi du KA ID — saisi ICI, au hub, et affiché par toutes les
3 +// plateformes du groupe (via /api/sso/profile). POST = mise à jour.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { db, SOCIAL_KEYS } from "@/lib/db";
6 +import { getSessionUser } from "@/lib/auth";
7 +
8 +const clip = (v: unknown, max: number): string =>
9 + typeof v === "string" ? v.trim().slice(0, max) : "";
10 +
11 +export async function POST(req: NextRequest) {
12 + const user = await getSessionUser();
13 + if (!user)
14 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
15 +
16 + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
17 +
18 + const name = clip(body.name, 80);
19 + const bio = clip(body.bio, 280);
20 + const city = clip(body.city, 60);
21 + const phone = clip(body.phone, 40);
22 + const website = clip(body.website, 300);
23 + const jobTitle = clip(body.job_title, 80);
24 + const company = clip(body.company, 80);
25 + const birthDate = clip(body.birth_date, 10);
26 +
27 + if (birthDate && !/^\d{4}-\d{2}-\d{2}$/.test(birthDate))
28 + return NextResponse.json(
29 + { error: "Date de naissance invalide (AAAA-MM-JJ)." },
30 + { status: 400 },
31 + );
32 + if (website && !/^https?:\/\//i.test(website) && website.length > 0)
33 + return NextResponse.json(
34 + { error: "Le site web doit commencer par http(s)://" },
35 + { status: 400 },
36 + );
37 +
38 + const socialsIn = (body.socials ?? {}) as Record<string, unknown>;
39 + const socials: Record<string, string> = {};
40 + for (const k of SOCIAL_KEYS) {
41 + const v = clip(socialsIn[k], 200);
42 + if (v) socials[k] = v;
43 + }
44 +
45 + db.prepare(
46 + `UPDATE users SET
47 + name = CASE WHEN ? != '' THEN ? ELSE name END,
48 + bio = ?, city = ?, phone = ?, website = ?,
49 + job_title = ?, company = ?, birth_date = ?, socials = ?
50 + WHERE id = ?`,
51 + ).run(
52 + name,
53 + name,
54 + bio,
55 + city,
56 + phone,
57 + website,
58 + jobTitle,
59 + company,
60 + birthDate,
61 + JSON.stringify(socials),
62 + user.id,
63 + );
64 + return NextResponse.json({ ok: true });
65 +}
added src/app/api/sso/favorites/route.ts +78 −0
@@ -0,0 +1,78 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Favoris unifiés « Mon univers Ka » — API serveur-à-serveur.
3 +// Le hub est LE magasin central : les plateformes poussent (POST) et
4 +// lisent (GET) les favoris de leurs membres, signés du secret SSO partagé.
5 +// POST { client_id, ka_id, ts, sig, action: "add"|"remove",
6 +// item: { item_id, title, subtitle?, price_label?, image_url?,
7 +// url?, meta? } }
8 +// GET ?client_id&ka_id&ts&sig[&scope=app|all] (défaut: app)
9 +import { NextRequest, NextResponse } from "next/server";
10 +import {
11 + findUserByKaId,
12 + upsertFavorite,
13 + removeFavorite,
14 + favoritesOf,
15 +} from "@/lib/db";
16 +import { verifyClientSig } from "@/lib/sso";
17 +
18 +const clip = (v: unknown, max: number): string =>
19 + typeof v === "string" ? v.trim().slice(0, max) : "";
20 +
21 +export async function POST(req: NextRequest) {
22 + const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
23 + const clientId = clip(body.client_id, 30);
24 + const kaId = clip(body.ka_id, 20);
25 + if (!verifyClientSig(clientId, kaId, String(body.ts ?? ""), String(body.sig ?? "")))
26 + return NextResponse.json({ error: "signature invalide" }, { status: 401 });
27 + const user = findUserByKaId(kaId);
28 + if (!user)
29 + return NextResponse.json({ error: "membre introuvable" }, { status: 404 });
30 +
31 + const item = (body.item ?? {}) as Record<string, unknown>;
32 + const itemId = clip(item.item_id, 200);
33 + if (!itemId)
34 + return NextResponse.json({ error: "item_id requis" }, { status: 400 });
35 +
36 + if (body.action === "remove") {
37 + removeFavorite(user.id, clientId, itemId);
38 + return NextResponse.json({ ok: true, action: "remove" });
39 + }
40 + const title = clip(item.title, 200);
41 + if (!title)
42 + return NextResponse.json({ error: "title requis" }, { status: 400 });
43 + upsertFavorite(user.id, clientId, {
44 + item_id: itemId,
45 + title,
46 + subtitle: clip(item.subtitle, 200) || undefined,
47 + price_label: clip(item.price_label, 60) || undefined,
48 + image_url: clip(item.image_url, 500) || undefined,
49 + url: clip(item.url, 500) || undefined,
50 + meta: item.meta,
51 + });
52 + return NextResponse.json({ ok: true, action: "add" });
53 +}
54 +
55 +export async function GET(req: NextRequest) {
56 + const q = req.nextUrl.searchParams;
57 + const clientId = q.get("client_id") ?? "";
58 + const kaId = q.get("ka_id") ?? "";
59 + if (!verifyClientSig(clientId, kaId, q.get("ts") ?? "", q.get("sig") ?? ""))
60 + return NextResponse.json({ error: "signature invalide" }, { status: 401 });
61 + const user = findUserByKaId(kaId);
62 + if (!user)
63 + return NextResponse.json({ error: "membre introuvable" }, { status: 404 });
64 + const scope = q.get("scope") === "all" ? undefined : clientId;
65 + const rows = favoritesOf(user.id, scope);
66 + return NextResponse.json({
67 + favorites: rows.map((r) => ({
68 + app: r.app,
69 + item_id: r.item_id,
70 + title: r.title,
71 + subtitle: r.subtitle,
72 + price_label: r.price_label,
73 + image_url: r.image_url,
74 + url: r.url,
75 + created_at: r.created_at,
76 + })),
77 + });
78 +}
added src/app/api/sso/profile/route.ts +63 −0
@@ -0,0 +1,63 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Profil serveur-à-serveur pour les plateformes du groupe : le hub est LA
3 +// source de vérité — Lou·Ka & cie affichent CE profil sur leurs pages
4 +// utilisateur. Authentification par HMAC du secret SSO partagé du client.
5 +// GET /api/sso/profile?client_id=lou-ka&ka_id=ka-…&ts=<unix>&sig=<hex>
6 +// sig = HMAC-SHA256(secret_client, `${client_id}.${ka_id}.${ts}`), ts ±5 min
7 +import { NextRequest, NextResponse } from "next/server";
8 +import crypto from "crypto";
9 +import { db, parseSocials, ageFromBirthDate, type UserRow } from "@/lib/db";
10 +import { SSO_CLIENTS } from "@/lib/sso";
11 +import { roleLabel } from "@/lib/roles";
12 +
13 +export async function GET(req: NextRequest) {
14 + const q = req.nextUrl.searchParams;
15 + const clientId = q.get("client_id") ?? "";
16 + const kaId = q.get("ka_id") ?? "";
17 + const ts = q.get("ts") ?? "";
18 + const sig = q.get("sig") ?? "";
19 +
20 + const client = SSO_CLIENTS[clientId];
21 + const secret = client ? process.env[client.secretEnv] : undefined;
22 + if (!client || !secret)
23 + return NextResponse.json({ error: "client inconnu" }, { status: 401 });
24 + if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
25 + return NextResponse.json({ error: "horodatage expiré" }, { status: 401 });
26 + const expected = crypto
27 + .createHmac("sha256", secret)
28 + .update(`${clientId}.${kaId}.${ts}`)
29 + .digest("hex");
30 + const a = Buffer.from(sig, "hex");
31 + const b = Buffer.from(expected, "hex");
32 + if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
33 + return NextResponse.json({ error: "signature invalide" }, { status: 401 });
34 +
35 + const row = db
36 + .prepare("SELECT * FROM users WHERE ka_id = ?")
37 + .get(kaId) as UserRow | undefined;
38 + if (!row)
39 + return NextResponse.json({ error: "membre introuvable" }, { status: 404 });
40 +
41 + return NextResponse.json({
42 + ka_id: row.ka_id,
43 + name: row.name,
44 + email: row.email,
45 + picture: row.avatar_url,
46 + role: row.role,
47 + role_label: roleLabel(row.role),
48 + bio: row.bio ?? "",
49 + city: row.city ?? "",
50 + phone: row.phone ?? "",
51 + website: row.website ?? "",
52 + job_title: row.job_title ?? "",
53 + company: row.company ?? "",
54 + birth_date: row.birth_date ?? "",
55 + age: ageFromBirthDate(row.birth_date),
56 + socials: parseSocials(row.socials),
57 + public: !!row.public,
58 + public_url: row.public
59 + ? `https://www.groupe-ka.com/u/${row.ka_id}`
60 + : null,
61 + created_at: row.created_at,
62 + });
63 +}
added src/app/api/wallet/pass/route.ts +213 −0
@@ -0,0 +1,213 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Carte de membre Groupe KA pour Apple Wallet (.pkpass).
3 +// Assemble pass.json + icônes, calcule le manifest (SHA-1), signe en PKCS#7
4 +// détaché via openssl (certificat Pass Type ID + WWDR), zippe et sert.
5 +// Config .env.local du nœud :
6 +// KA_PASS_DIR dossier contenant pass-cert.pem, pass-key.pem, wwdr.pem
7 +// KA_PASS_TYPE_ID ex. pass.com.groupe-ka.membre
8 +// KA_PASS_TEAM_ID ex. 3YM54G49SN
9 +import { NextResponse } from "next/server";
10 +import { execFile } from "child_process";
11 +import { promisify } from "util";
12 +import crypto from "crypto";
13 +import fs from "fs/promises";
14 +import os from "os";
15 +import path from "path";
16 +import sharp from "sharp";
17 +import { getSessionUser, BASE_URL } from "@/lib/auth";
18 +import { ensureKaId } from "@/lib/db";
19 +import { roleCard } from "@/lib/roles";
20 +
21 +const run = promisify(execFile);
22 +
23 +function fmtDate(iso: string | null): string {
24 + if (!iso) return "";
25 + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
26 + if (Number.isNaN(d.getTime())) return "";
27 + return d.toLocaleDateString("fr-CA", {
28 + day: "numeric",
29 + month: "long",
30 + year: "numeric",
31 + });
32 +}
33 +
34 +export async function GET() {
35 + const user = await getSessionUser();
36 + if (!user)
37 + return NextResponse.redirect(
38 + new URL("/connexion?next=%2Fcompte", BASE_URL),
39 + );
40 +
41 + const certDir = process.env.KA_PASS_DIR;
42 + const passTypeId = process.env.KA_PASS_TYPE_ID;
43 + const teamId = process.env.KA_PASS_TEAM_ID;
44 + if (!certDir || !passTypeId || !teamId)
45 + return NextResponse.json(
46 + {
47 + error:
48 + "Apple Wallet pas encore activé — certificat Pass Type ID en attente (KA_PASS_DIR / KA_PASS_TYPE_ID / KA_PASS_TEAM_ID).",
49 + },
50 + { status: 503 },
51 + );
52 +
53 + const kaId = user.kaId ?? ensureKaId(user.id);
54 + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "kapass-"));
55 + try {
56 + // 1. pass.json — carte générique aux couleurs du groupe
57 + const pass = {
58 + formatVersion: 1,
59 + passTypeIdentifier: passTypeId,
60 + teamIdentifier: teamId,
61 + serialNumber: kaId,
62 + organizationName: "Groupe KA",
63 + description: "Carte de membre Groupe KA",
64 + logoText: "",
65 + foregroundColor: "rgb(245,243,238)",
66 + backgroundColor: "rgb(20,24,20)",
67 + labelColor: "rgb(217,242,107)",
68 + generic: {
69 + primaryFields: [{ key: "kaid", label: "KA-ID", value: kaId }],
70 + secondaryFields: [
71 + { key: "membre", label: "MEMBRE", value: user.name || user.email },
72 + ...(roleCard(user.role)
73 + ? [{ key: "statut", label: "STATUT", value: roleCard(user.role)! }]
74 + : []),
75 + ],
76 + auxiliaryFields: [
77 + ...(fmtDate(user.createdAt)
78 + ? [
79 + {
80 + key: "depuis",
81 + label: "MEMBRE DEPUIS",
82 + value: fmtDate(user.createdAt),
83 + },
84 + ]
85 + : []),
86 + {
87 + key: "portee",
88 + label: "VALIDE SUR",
89 + value: "Les 7 plateformes ·Ka",
90 + },
91 + ],
92 + backFields: [
93 + {
94 + key: "verif",
95 + label: "Vérification",
96 + value: `${BASE_URL}/m/${kaId}`,
97 + },
98 + {
99 + key: "plateformes",
100 + label: "Plateformes",
101 + value:
102 + "Lou·Ka · Immo·Ka · Vrai-Prix · ValoPlex · Auto·Ka · Fabri·Ka · Food·Ka",
103 + },
104 + {
105 + key: "contact",
106 + label: "Contact",
107 + value: "contact@groupe-ka.com",
108 + },
109 + ],
110 + },
111 + barcodes: [
112 + {
113 + format: "PKBarcodeFormatQR",
114 + message: `${BASE_URL}/m/${kaId}`,
115 + messageEncoding: "iso-8859-1",
116 + altText: kaId,
117 + },
118 + ],
119 + };
120 + await fs.writeFile(
121 + path.join(tmp, "pass.json"),
122 + JSON.stringify(pass, null, 2),
123 + );
124 +
125 + // 2. icônes (embarquées dans le déploiement)
126 + const assets = path.join(process.cwd(), "assets", "wallet");
127 + const files = ["pass.json"];
128 + for (const f of [
129 + "icon.png",
130 + "icon@2x.png",
131 + "icon@3x.png",
132 + "logo.png",
133 + "logo@2x.png",
134 + ]) {
135 + await fs.copyFile(path.join(assets, f), path.join(tmp, f));
136 + files.push(f);
137 + }
138 +
139 + // Photo de profil téléversée → vignette du pass (thumbnail)
140 + const avatarPath = path.join(process.cwd(), "data", "avatars", `${kaId}.jpg`);
141 + try {
142 + const avatar = await fs.readFile(avatarPath);
143 + for (const [name, size] of [
144 + ["thumbnail.png", 90],
145 + ["thumbnail@2x.png", 180],
146 + ["thumbnail@3x.png", 270],
147 + ] as const) {
148 + await fs.writeFile(
149 + path.join(tmp, name),
150 + await sharp(avatar)
151 + .resize(size, size, { fit: "cover" })
152 + .png()
153 + .toBuffer(),
154 + );
155 + files.push(name);
156 + }
157 + } catch {
158 + /* pas de photo téléversée : pass sans vignette */
159 + }
160 +
161 + // 3. manifest.json — SHA-1 de chaque fichier
162 + const manifest: Record<string, string> = {};
163 + for (const f of files) {
164 + const buf = await fs.readFile(path.join(tmp, f));
165 + manifest[f] = crypto.createHash("sha1").update(buf).digest("hex");
166 + }
167 + await fs.writeFile(
168 + path.join(tmp, "manifest.json"),
169 + JSON.stringify(manifest),
170 + );
171 +
172 + // 4. signature PKCS#7 détachée (openssl smime, DER)
173 + await run("openssl", [
174 + "smime",
175 + "-binary",
176 + "-sign",
177 + "-certfile",
178 + path.join(certDir, "wwdr.pem"),
179 + "-signer",
180 + path.join(certDir, "pass-cert.pem"),
181 + "-inkey",
182 + path.join(certDir, "pass-key.pem"),
183 + "-in",
184 + path.join(tmp, "manifest.json"),
185 + "-out",
186 + path.join(tmp, "signature"),
187 + "-outform",
188 + "DER",
189 + ]);
190 +
191 + // 5. zip → .pkpass
192 + await run("zip", ["-q", "-j", "pass.pkpass", ...files, "manifest.json", "signature"], {
193 + cwd: tmp,
194 + });
195 + const pkpass = await fs.readFile(path.join(tmp, "pass.pkpass"));
196 +
197 + return new NextResponse(new Uint8Array(pkpass), {
198 + headers: {
199 + "Content-Type": "application/vnd.apple.pkpass",
200 + "Content-Disposition": `attachment; filename="groupe-ka-${kaId}.pkpass"`,
201 + "Cache-Control": "no-store",
202 + },
203 + });
204 + } catch (err) {
205 + console.error("pkpass:", err);
206 + return NextResponse.json(
207 + { error: "Impossible de générer la carte Wallet — réessayez." },
208 + { status: 500 },
209 + );
210 + } finally {
211 + await fs.rm(tmp, { recursive: true, force: true });
212 + }
213 +}
added src/app/avatars/[kaId]/route.ts +28 −0
@@ -0,0 +1,28 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Sert la photo de profil d'un membre (JPEG carré 512 px) — hotlinkée par la
3 +// carte de membre, la page publique /m/{ka_id} et les plateformes du groupe.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import fs from "fs/promises";
6 +import path from "path";
7 +
8 +export async function GET(
9 + _req: NextRequest,
10 + { params }: { params: Promise<{ kaId: string }> },
11 +) {
12 + const { kaId } = await params;
13 + if (!/^ka-\d{10}$/.test(kaId))
14 + return NextResponse.json({ error: "introuvable" }, { status: 404 });
15 + try {
16 + const buf = await fs.readFile(
17 + path.join(process.cwd(), "data", "avatars", `${kaId}.jpg`),
18 + );
19 + return new NextResponse(new Uint8Array(buf), {
20 + headers: {
21 + "Content-Type": "image/jpeg",
22 + "Cache-Control": "public, max-age=3600",
23 + },
24 + });
25 + } catch {
26 + return NextResponse.json({ error: "introuvable" }, { status: 404 });
27 + }
28 +}
added src/app/compte/AvatarEditor.tsx +74 −0
@@ -0,0 +1,74 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useRef, useState } from "react";
5 +
6 +export default function AvatarEditor({ hasAvatar }: { hasAvatar: boolean }) {
7 + const fileRef = useRef<HTMLInputElement | null>(null);
8 + const [busy, setBusy] = useState(false);
9 + const [error, setError] = useState<string | null>(null);
10 +
11 + async function upload(f: File | undefined) {
12 + if (!f) return;
13 + setBusy(true);
14 + setError(null);
15 + const fd = new FormData();
16 + fd.append("photo", f);
17 + const res = await fetch("/api/profile/avatar", { method: "POST", body: fd });
18 + const data = await res.json().catch(() => ({}));
19 + if (res.ok) {
20 + window.location.reload();
21 + } else {
22 + setError(data.error ?? "Téléversement impossible — réessayez.");
23 + setBusy(false);
24 + }
25 + }
26 +
27 + async function remove() {
28 + setBusy(true);
29 + setError(null);
30 + await fetch("/api/profile/avatar", { method: "DELETE" });
31 + window.location.reload();
32 + }
33 +
34 + return (
35 + <div className="flex flex-col gap-2">
36 + <div className="flex flex-wrap gap-3">
37 + <button
38 + type="button"
39 + className="btn btn-ghost"
40 + disabled={busy}
41 + onClick={() => fileRef.current?.click()}
42 + >
43 + {busy ? "Téléversement…" : hasAvatar ? "Changer la photo" : "Ajouter une photo"}
44 + </button>
45 + {hasAvatar && (
46 + <button
47 + type="button"
48 + className="btn btn-ghost"
49 + disabled={busy}
50 + onClick={remove}
51 + >
52 + Retirer la photo
53 + </button>
54 + )}
55 + </div>
56 + <p className="gk-mono text-[10.5px] text-ink-3">
57 + JPG, PNG ou WebP (8 Mo max) — recadrée en carré 512 px. Visible sur
58 + votre carte, votre carte Wallet et toutes les plateformes ·Ka.
59 + </p>
60 + {error && (
61 + <p className="rounded-md border-[1.5px] border-[#b3423a] bg-[#fbe9e7] px-3 py-2 text-[12.5px] font-medium text-[#7c2f2a]">
62 + {error}
63 + </p>
64 + )}
65 + <input
66 + ref={fileRef}
67 + type="file"
68 + accept="image/jpeg,image/png,image/webp"
69 + hidden
70 + onChange={(e) => upload(e.target.files?.[0] ?? undefined)}
71 + />
72 + </div>
73 + );
74 +}
added src/app/compte/CopyKaId.tsx +25 −0
@@ -0,0 +1,25 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +
6 +export default function CopyKaId({ kaId }: { kaId: string }) {
7 + const [copied, setCopied] = useState(false);
8 + return (
9 + <button
10 + type="button"
11 + className="btn btn-ghost"
12 + onClick={async () => {
13 + try {
14 + await navigator.clipboard.writeText(kaId);
15 + setCopied(true);
16 + setTimeout(() => setCopied(false), 1800);
17 + } catch {
18 + /* presse-papiers indisponible */
19 + }
20 + }}
21 + >
22 + {copied ? "✓ Copié" : "Copier mon KA-ID"}
23 + </button>
24 + );
25 +}
added src/app/compte/CopyText.tsx +29 −0
@@ -0,0 +1,29 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +
6 +export default function CopyText({
7 + text,
8 + label,
9 +}: {
10 + text: string;
11 + label: string;
12 +}) {
13 + const [copied, setCopied] = useState(false);
14 + return (
15 + <button
16 + type="button"
17 + className="btn btn-ghost !min-h-[36px] !px-3 !text-[12.5px]"
18 + onClick={async () => {
19 + try {
20 + await navigator.clipboard.writeText(text);
21 + setCopied(true);
22 + setTimeout(() => setCopied(false), 1800);
23 + } catch {}
24 + }}
25 + >
26 + {copied ? "✓ Copié" : label}
27 + </button>
28 + );
29 +}
added src/app/compte/LogoutButton.tsx +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +export default function LogoutButton() {
5 + return (
6 + <button
7 + type="button"
8 + className="btn btn-ghost"
9 + onClick={async () => {
10 + await fetch("/api/auth/logout", { method: "POST" });
11 + window.location.href = "/";
12 + }}
13 + >
14 + Se déconnecter
15 + </button>
16 + );
17 +}
added src/app/compte/ProfileForm.tsx +202 −0
@@ -0,0 +1,202 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +
6 +export type ProfileInitial = {
7 + name: string;
8 + bio: string;
9 + city: string;
10 + phone: string;
11 + website: string;
12 + job_title: string;
13 + company: string;
14 + birth_date: string;
15 + socials: Record<string, string>;
16 + isPublic: boolean;
17 + kaId: string;
18 +};
19 +
20 +const SOCIALS: { key: string; label: string; placeholder: string }[] = [
21 + { key: "instagram", label: "Instagram", placeholder: "@pseudo ou URL" },
22 + { key: "facebook", label: "Facebook", placeholder: "profil ou URL" },
23 + { key: "x", label: "X (Twitter)", placeholder: "@pseudo ou URL" },
24 + { key: "linkedin", label: "LinkedIn", placeholder: "profil ou URL" },
25 + { key: "tiktok", label: "TikTok", placeholder: "@pseudo ou URL" },
26 + { key: "youtube", label: "YouTube", placeholder: "chaîne ou URL" },
27 +];
28 +
29 +export default function ProfileForm({ initial }: { initial: ProfileInitial }) {
30 + const [form, setForm] = useState(initial);
31 + const [saving, setSaving] = useState(false);
32 + const [saved, setSaved] = useState(false);
33 + const [error, setError] = useState<string | null>(null);
34 + const [isPublic, setIsPublic] = useState(initial.isPublic);
35 + const [copied, setCopied] = useState(false);
36 +
37 + const publicUrl = `https://www.groupe-ka.com/u/${initial.kaId}`;
38 +
39 + async function save() {
40 + setSaving(true);
41 + setError(null);
42 + const res = await fetch("/api/profile", {
43 + method: "POST",
44 + headers: { "Content-Type": "application/json" },
45 + body: JSON.stringify(form),
46 + });
47 + const data = await res.json().catch(() => ({}));
48 + setSaving(false);
49 + if (!res.ok) {
50 + setError(data.error ?? "Enregistrement impossible — réessayez.");
51 + return;
52 + }
53 + setSaved(true);
54 + setTimeout(() => setSaved(false), 2000);
55 + }
56 +
57 + async function togglePublic(enabled: boolean) {
58 + await fetch("/api/profile/public", {
59 + method: "POST",
60 + headers: { "Content-Type": "application/json" },
61 + body: JSON.stringify({ enabled }),
62 + });
63 + setIsPublic(enabled);
64 + }
65 +
66 + const field =
67 + (key: keyof ProfileInitial, max: number) =>
68 + (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
69 + setForm({ ...form, [key]: e.target.value.slice(0, max) });
70 +
71 + return (
72 + <div className="space-y-5">
73 + <div className="grid gap-4 sm:grid-cols-2">
74 + <label className="block">
75 + <span className="klabel">Nom affiché</span>
76 + <input className="field mt-[6px]" value={form.name} onChange={field("name", 80)} placeholder="Prénom Nom" />
77 + </label>
78 + <label className="block">
79 + <span className="klabel">Ville</span>
80 + <input className="field mt-[6px]" value={form.city} onChange={field("city", 60)} placeholder="Québec, Montréal…" />
81 + </label>
82 + </div>
83 +
84 + <label className="block">
85 + <span className="klabel">Bio ({280 - form.bio.length} caractères restants)</span>
86 + <textarea
87 + className="field mt-[6px] resize-y"
88 + rows={3}
89 + value={form.bio}
90 + onChange={field("bio", 280)}
91 + placeholder="Présentez-vous en quelques mots…"
92 + />
93 + </label>
94 +
95 + <div className="grid gap-4 sm:grid-cols-2">
96 + <label className="block">
97 + <span className="klabel">Emploi / titre</span>
98 + <input className="field mt-[6px]" value={form.job_title} onChange={field("job_title", 80)} placeholder="Gestionnaire, courtier, développeur…" />
99 + </label>
100 + <label className="block">
101 + <span className="klabel">Entreprise</span>
102 + <input className="field mt-[6px]" value={form.company} onChange={field("company", 80)} placeholder="Nom de l'entreprise" />
103 + </label>
104 + <label className="block">
105 + <span className="klabel">Date de naissance</span>
106 + <input className="field mt-[6px]" type="date" value={form.birth_date} onChange={field("birth_date", 10)} />
107 + </label>
108 + <label className="block">
109 + <span className="klabel">Téléphone</span>
110 + <input className="field mt-[6px]" value={form.phone} onChange={field("phone", 40)} placeholder="418 555-0123" />
111 + </label>
112 + </div>
113 +
114 + <label className="block">
115 + <span className="klabel">Site web</span>
116 + <input className="field mt-[6px]" value={form.website} onChange={field("website", 300)} placeholder="https://…" />
117 + </label>
118 +
119 + <div>
120 + <p className="klabel">Réseaux sociaux</p>
121 + <div className="mt-2 grid gap-3 sm:grid-cols-2">
122 + {SOCIALS.map((s) => (
123 + <label key={s.key} className="block">
124 + <input
125 + className="field"
126 + value={form.socials[s.key] ?? ""}
127 + onChange={(e) =>
128 + setForm({
129 + ...form,
130 + socials: { ...form.socials, [s.key]: e.target.value.slice(0, 200) },
131 + })
132 + }
133 + placeholder={`${s.label} — ${s.placeholder}`}
134 + />
135 + </label>
136 + ))}
137 + </div>
138 + </div>
139 +
140 + {error && (
141 + <p className="rounded-md border-[1.5px] border-[#b3423a] bg-[#fbe9e7] px-4 py-3 text-[13px] font-medium text-[#7c2f2a]">
142 + {error}
143 + </p>
144 + )}
145 + <button type="button" className="btn btn-primary" disabled={saving} onClick={save}>
146 + {saved ? "✓ Enregistré" : saving ? "Enregistrement…" : "Enregistrer mon profil"}
147 + </button>
148 + <p className="gk-mono text-[10.5px] text-ink-3">
149 + Ces informations sont la source de vérité de votre profil sur TOUTES
150 + les plateformes ·Ka (Lou·Ka, Immo·Ka, Vrai-Prix…). Le courriel et le
151 + téléphone ne sont jamais affichés publiquement.
152 + </p>
153 +
154 + {/* ——— Profil public ——— */}
155 + <div className="border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-5">
156 + <p className="gk-display text-[16px] font-bold uppercase">Profil public</p>
157 + <p className="mt-1 text-[13px] text-ink-2">
158 + Activez votre page publique pour partager votre profil — nom, photo,
159 + statut, bio, emploi, ville et réseaux sociaux.{" "}
160 + <strong>Jamais votre courriel ni votre téléphone.</strong>
161 + </p>
162 + <div className="mt-3 inline-flex overflow-hidden rounded-md border-[1.5px] border-ink" role="group">
163 + {([[false, "Privé"], [true, "Public"]] as const).map(([v, l]) => (
164 + <button
165 + key={l}
166 + type="button"
167 + onClick={() => togglePublic(v)}
168 + className={`px-5 py-2 text-[13px] font-bold transition-colors ${isPublic === v ? "bg-ink text-lime" : "bg-surface text-ink hover:bg-lime-soft"}`}
169 + >
170 + {l}
171 + </button>
172 + ))}
173 + </div>
174 + {isPublic && (
175 + <div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border-[1.5px] border-green bg-lime-soft px-4 py-3">
176 + <a
177 + href={publicUrl}
178 + target="_blank"
179 + rel="noopener noreferrer"
180 + className="gk-mono text-[12.5px] break-all text-green-deep underline underline-offset-4"
181 + >
182 + {publicUrl.replace("https://", "")}
183 + </a>
184 + <button
185 + type="button"
186 + className="btn btn-ghost !min-h-[34px] !px-3 !text-[12px]"
187 + onClick={async () => {
188 + try {
189 + await navigator.clipboard.writeText(publicUrl);
190 + setCopied(true);
191 + setTimeout(() => setCopied(false), 1800);
192 + } catch {}
193 + }}
194 + >
195 + {copied ? "✓ Copié" : "Copier le lien"}
196 + </button>
197 + </div>
198 + )}
199 + </div>
200 + </div>
201 + );
202 +}
added src/app/compte/RemoveFavorite.tsx +34 −0
@@ -0,0 +1,34 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +
6 +export default function RemoveFavorite({
7 + app,
8 + itemId,
9 +}: {
10 + app: string;
11 + itemId: string;
12 +}) {
13 + const [busy, setBusy] = useState(false);
14 + return (
15 + <button
16 + type="button"
17 + aria-label="Retirer des favoris"
18 + title="Retirer des favoris"
19 + disabled={busy}
20 + className="gk-mono flex h-7 w-7 flex-none items-center justify-center rounded-full border-[1.5px] border-[rgba(20,24,20,0.3)] text-[12px] text-ink-2 transition-colors hover:border-[#b3423a] hover:bg-[#fbe9e7] hover:text-[#7c2f2a]"
21 + onClick={async () => {
22 + setBusy(true);
23 + await fetch("/api/favorites", {
24 + method: "POST",
25 + headers: { "Content-Type": "application/json" },
26 + body: JSON.stringify({ action: "remove", app, item_id: itemId }),
27 + });
28 + window.location.reload();
29 + }}
30 + >
31 +
32 + </button>
33 + );
34 +}
added src/app/compte/RolePicker.tsx +57 −0
@@ -0,0 +1,57 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +import { ROLES } from "@/lib/roles";
6 +
7 +export default function RolePicker({ current }: { current: string | null }) {
8 + const [busy, setBusy] = useState<string | null>(null);
9 + const [error, setError] = useState<string | null>(null);
10 +
11 + async function pick(role: string) {
12 + setBusy(role);
13 + setError(null);
14 + const res = await fetch("/api/profile/role", {
15 + method: "POST",
16 + headers: { "Content-Type": "application/json" },
17 + body: JSON.stringify({ role }),
18 + });
19 + if (res.ok) window.location.reload();
20 + else {
21 + setError("Impossible d'enregistrer — réessayez.");
22 + setBusy(null);
23 + }
24 + }
25 +
26 + return (
27 + <div>
28 + <div className="grid gap-2 sm:grid-cols-3">
29 + {Object.entries(ROLES).map(([value, r]) => (
30 + <button
31 + key={value}
32 + type="button"
33 + disabled={busy !== null}
34 + onClick={() => pick(value)}
35 + className={`rounded-md border-[1.5px] px-4 py-3 text-left transition-all ${
36 + current === value
37 + ? "border-ink bg-lime-soft shadow-[3px_3px_0_rgba(20,24,20,0.15)]"
38 + : "border-[rgba(20,24,20,0.3)] bg-surface hover:border-ink"
39 + }`}
40 + >
41 + <span className="gk-display block text-[13.5px] font-bold">
42 + {busy === value ? "Un instant…" : r.label}
43 + </span>
44 + <span className="mt-[2px] block text-[11.5px] leading-snug text-ink-2">
45 + {r.desc}
46 + </span>
47 + </button>
48 + ))}
49 + </div>
50 + {error && (
51 + <p className="mt-2 rounded-md border-[1.5px] border-[#b3423a] bg-[#fbe9e7] px-3 py-2 text-[12.5px] font-medium text-[#7c2f2a]">
52 + {error}
53 + </p>
54 + )}
55 + </div>
56 + );
57 +}
added src/app/compte/carte/PrintButton.tsx +10 −0
@@ -0,0 +1,10 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +export default function PrintButton() {
5 + return (
6 + <button type="button" className="btn btn-primary" onClick={() => window.print()}>
7 + Imprimer la carte
8 + </button>
9 + );
10 +}
added src/app/compte/carte/page.tsx +127 −0
@@ -0,0 +1,127 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Carte de membre IMPRIMABLE au format carte bancaire (CR80 : 85,6 × 54 mm),
3 +// recto (identité) / verso (QR + zone NFC). À imprimer à l'échelle 100 %,
4 +// avec les arrière-plans activés, puis coller un autocollant NFC (NTAG215)
5 +// au dos, programmé avec l'URL d'identification du membre.
6 +import type { Metadata } from "next";
7 +import { redirect } from "next/navigation";
8 +import QRCode from "qrcode";
9 +import { getSessionUser } from "@/lib/auth";
10 +import { ensureKaId } from "@/lib/db";
11 +import { roleCard } from "@/lib/roles";
12 +import PrintButton from "./PrintButton";
13 +
14 +export const metadata: Metadata = {
15 + title: "Carte imprimable | Groupe KA",
16 + robots: { index: false },
17 +};
18 +
19 +export const dynamic = "force-dynamic";
20 +
21 +function fmtDate(iso: string | null): string {
22 + if (!iso) return "";
23 + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
24 + if (Number.isNaN(d.getTime())) return "";
25 + return d.toLocaleDateString("fr-CA", { month: "2-digit", year: "numeric" });
26 +}
27 +
28 +export default async function CarteImprimable() {
29 + const user = await getSessionUser();
30 + if (!user) redirect("/connexion?next=%2Fcompte%2Fcarte");
31 + const kaId = user.kaId ?? ensureKaId(user.id);
32 + const url = `https://www.groupe-ka.com/m/${kaId}`;
33 + const qr = await QRCode.toDataURL(url, {
34 + margin: 0,
35 + width: 400,
36 + color: { dark: "#141814", light: "#ffffff" },
37 + });
38 +
39 + return (
40 + <main className="print-page mx-auto max-w-2xl px-4 pb-16 sm:px-6">
41 + <section className="no-print pt-10">
42 + <p className="kicker">Carte physique · à imprimer</p>
43 + <h1 className="gk-display mt-3 text-[clamp(24px,4.5vw,36px)] leading-[1.02] font-bold tracking-[-0.03em] uppercase">
44 + Votre carte, <span className="hl">en vrai</span>
45 + </h1>
46 + <p className="mt-3 max-w-lg text-[13.5px] text-ink-2">
47 + Format carte bancaire (85,6 × 54 mm). Imprimez à l&apos;échelle
48 + 100 % avec les arrière-plans activés, découpez le long des repères,
49 + et collez un autocollant NFC au dos (zone indiquée).
50 + </p>
51 + <div className="mt-5 flex flex-wrap gap-3">
52 + <PrintButton />
53 + <a href="/compte" className="btn btn-ghost">
54 + ← Mon compte
55 + </a>
56 + </div>
57 + </section>
58 +
59 + <div className="print-cards mt-8 flex flex-col items-center gap-8">
60 + {/* ---------- RECTO ---------- */}
61 + <div className="pcard pcard-front">
62 + <div className="pcard-watermark" aria-hidden="true">
63 + KA
64 + </div>
65 + <div className="pcard-head">
66 + <span className="pcard-brand">
67 + Groupe <span className="pcard-ka">KA</span>
68 + </span>
69 + <span className="pcard-label">CARTE DE MEMBRE</span>
70 + </div>
71 + <div className="pcard-id">
72 + <span className="pcard-id-label">KA-ID</span>
73 + <span className="pcard-id-value">{kaId}</span>
74 + </div>
75 + <div className="pcard-foot">
76 + <div className="pcard-holder">
77 + <span className="pcard-name">{user.name || user.email}</span>
78 + {roleCard(user.role) && (
79 + <span className="pcard-role">{roleCard(user.role)}</span>
80 + )}
81 + <span className="pcard-since">
82 + MEMBRE DEPUIS {fmtDate(user.createdAt)}
83 + </span>
84 + </div>
85 + {user.avatarUrl ? (
86 + // eslint-disable-next-line @next/next/no-img-element
87 + <img className="pcard-avatar" src={user.avatarUrl} alt="" />
88 + ) : (
89 + <span className="pcard-avatar pcard-avatar-empty">
90 + {(user.name || user.email).charAt(0).toUpperCase()}
91 + </span>
92 + )}
93 + </div>
94 + </div>
95 +
96 + {/* ---------- VERSO ---------- */}
97 + <div className="pcard pcard-back">
98 + <div className="pcard-back-left">
99 + {/* eslint-disable-next-line @next/next/no-img-element */}
100 + <img className="pcard-qr" src={qr} alt={`Code QR — ${url}`} />
101 + </div>
102 + <div className="pcard-back-right">
103 + <p className="pcard-back-title">
104 + Tapez la carte (NFC)
105 + <br />
106 + ou scannez le code
107 + </p>
108 + <p className="pcard-back-url">groupe-ka.com/m/{kaId.slice(3)}</p>
109 + <div className="pcard-nfc" aria-hidden="true">
110 + <span className="pcard-nfc-ico">)))</span> zone NFC — coller la
111 + puce ici
112 + </div>
113 + <p className="pcard-back-foot">
114 + Identité vérifiable en ligne · Lou·Ka — Immo·Ka — Vrai-Prix —
115 + ValoPlex — Auto·Ka — Fabri·Ka — Food·Ka
116 + </p>
117 + </div>
118 + </div>
119 + </div>
120 +
121 + <p className="no-print gk-mono mt-8 text-[11px] text-ink-3">
122 + Astuce : imprimez sur du papier cartonné (200 g+) ou plastifiez.
123 + L&apos;autocollant NFC se colle au dos, dans la zone « NFC ».
124 + </p>
125 + </main>
126 + );
127 +}
added src/app/compte/page.tsx +471 −0
@@ -0,0 +1,471 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Mon compte — la carte de membre Groupe KA (même gabarit que sur Lou·Ka :
3 +// carte KA-ID, grille d'informations, plateformes, actions).
4 +import type { Metadata } from "next";
5 +import { redirect } from "next/navigation";
6 +import QRCode from "qrcode";
7 +import { getSessionUser } from "@/lib/auth";
8 +import { ensureKaId } from "@/lib/db";
9 +import { SSO_CLIENTS } from "@/lib/sso";
10 +import { roleLabel, roleCard } from "@/lib/roles";
11 +import {
12 + db,
13 + parseSocials,
14 + favoritesOf,
15 + type UserRow,
16 + type FavoriteRow,
17 +} from "@/lib/db";
18 +import RemoveFavorite from "./RemoveFavorite";
19 +import LogoutButton from "./LogoutButton";
20 +import CopyKaId from "./CopyKaId";
21 +import AvatarEditor from "./AvatarEditor";
22 +import RolePicker from "./RolePicker";
23 +import ProfileForm from "./ProfileForm";
24 +import CopyText from "./CopyText";
25 +
26 +export const metadata: Metadata = {
27 + title: "Mon KA ID | Groupe KA",
28 + description: "Votre compte unique pour les plateformes du Groupe KA.",
29 +};
30 +
31 +export const dynamic = "force-dynamic";
32 +
33 +const 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 +};
43 +
44 +function 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 d
49 + .toLocaleDateString("fr-CA", {
50 + day: "numeric",
51 + month: "long",
52 + year: "numeric",
53 + })
54 + .replace(/^1 /, "1ᵉʳ ");
55 +}
56 +
57 +const 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 +};
67 +
68 +function 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é, partout
80 + </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 auto
86 + (Auto·Ka), un produit d&apos;ici (Fabri·Ka) ou une aubaine
87 + 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 + <span
96 + aria-hidden="true"
97 + className="inline-flex h-[14px] w-[14px] items-center justify-center rounded-[3px] bg-ink"
98 + >
99 + <span
100 + 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 + <li
109 + 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-element
114 + <img
115 + 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 + <a
129 + 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 +}
158 +
159 +function ProfileSection({ userId, kaId }: { userId: number; kaId: string }) {
160 + const row = db
161 + .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 profil
169 + </h2>
170 + <div className="mt-5">
171 + <ProfileForm
172 + 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 +}
191 +
192 +export 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-ID
197 + 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 + });
202 +
203 + 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 + ];
225 +
226 + 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>
234 +
235 + {/* ——— Carte de membre Groupe KA ——— */}
236 + <div
237 + 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 + KA
243 + </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 + <img
257 + 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-element
276 + <img
277 + 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>
294 +
295 + <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 + <a
298 + 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 + <path
303 + 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 Wallet
308 + </a>
309 + )}
310 + <CopyKaId kaId={kaId} />
311 + <LogoutButton />
312 + </div>
313 +
314 + {/* ——— Type de compte (obligatoire — les comptes Google le
315 + 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 votre
322 + carte de membre.
323 + </p>
324 + <div className="mt-4">
325 + <RolePicker current={user.role} />
326 + </div>
327 + </div>
328 + </section>
329 + )}
330 +
331 + {/* ——— 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-element
336 + <img
337 + 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>
357 +
358 + {/* ——— Mon univers Ka (favoris unifiés) ——— */}
359 + <UniversSection userId={user.id} />
360 +
361 + {/* ——— Mon profil (source de vérité pour toutes les plateformes) ——— */}
362 + <ProfileSection userId={user.id} kaId={kaId} />
363 +
364 + {/* ——— 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 + <div
369 + 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 votre
379 + identifiant unique dans tout l&apos;écosystème Groupe KA — il vous
380 + suit sur chaque plateforme. Le hub ne conserve que ce profil : rien
381 + d&apos;autre, et jamais revendu.
382 + </p>
383 + </section>
384 +
385 + {/* ——— 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 vrai
390 + </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 un
394 + autocollant <strong className="text-ink">NFC (NTAG215, ~1 $)</strong> :
395 + un simple tap sur un iPhone ou un Android ouvre votre page
396 + 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 + <CopyText
403 + 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>
425 +
426 + {/* ——— 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 ici
431 + </h2>
432 + <p className="mt-2 text-[13.5px] text-ink-2">
433 + Sur chaque plateforme, choisissez « Se connecter avec KA ID » : vous
434 + 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 + <a
440 + 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>
456 +
457 + {/* ——— Actions ——— */}
458 + <div className="mt-8 flex flex-wrap gap-3">
459 + <a href="/conditions" className="btn btn-ghost">
460 + Conditions
461 + </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.com
467 + </a>
468 + </div>
469 + </main>
470 + );
471 +}
added src/app/conditions/page.tsx +163 −0
@@ -0,0 +1,163 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +
4 +export const metadata: Metadata = {
5 + title: "Conditions d’utilisation — Groupe KA",
6 + description:
7 + "Conditions d’utilisation du site groupe-ka.com et principes communs aux plateformes du Groupe KA.",
8 +};
9 +
10 +const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [
11 + {
12 + num: "01",
13 + title: "Objet du site",
14 + body: (
15 + <>
16 + Le site groupe-ka.com est la vitrine corporative du Groupe KA, un
17 + holding québécois d&apos;agrégateurs automatisés. Il présente les
18 + plateformes du groupe et n&apos;offre lui-même aucun service
19 + transactionnel. Chaque plateforme (Lou·Ka, Immo·Ka, Vrai-Prix,
20 + ValoPlex, Auto·Ka, Fabri·Ka, Food·Ka) possède ses propres conditions
21 + d&apos;utilisation, disponibles sur son site respectif ; en cas de
22 + divergence, les conditions propres à une plateforme prévalent sur les
23 + présentes pour l&apos;usage de cette plateforme.
24 + </>
25 + ),
26 + },
27 + {
28 + num: "02",
29 + title: "Nature de l’information",
30 + body: (
31 + <>
32 + Les chiffres présentés (inventaires, connecteurs, couvertures) sont
33 + mesurés sur les plateformes du groupe et évoluent en continu ; ils
34 + sont fournis à titre indicatif, sans garantie d&apos;exactitude à un
35 + instant donné. Les plateformes du groupe agrègent des données lues à
36 + la source sur des sites tiers : chaque fiche renvoie à sa source
37 + originale, qui demeure la référence. Rien sur ce site ou sur les
38 + plateformes du groupe ne constitue un conseil professionnel —
39 + immobilier, financier, juridique ou autre.
40 + </>
41 + ),
42 + },
43 + {
44 + num: "03",
45 + title: "Propriété intellectuelle",
46 + body: (
47 + <>
48 + Les marques, logos, textes, figures et éléments visuels du site — y
49 + compris l&apos;identité « ·Ka » et ses déclinaisons — appartiennent au
50 + Groupe KA (Simon-Pierre Boucher). Toute reproduction à des fins
51 + commerciales sans autorisation écrite est interdite. Les contenus
52 + agrégés par les plateformes demeurent la propriété de leurs sources
53 + respectives, auxquelles chaque fiche renvoie.
54 + </>
55 + ),
56 + },
57 + {
58 + num: "04",
59 + title: "Utilisation acceptable",
60 + body: (
61 + <>
62 + Il est interdit d&apos;entraver le fonctionnement du site, d&apos;y
63 + accéder par des moyens automatisés abusifs, ou de présenter les
64 + données du groupe comme les siennes. L&apos;ironie ne nous échappe
65 + pas : nous agrégeons nous-mêmes — mais toujours en renvoyant à la
66 + source, en respectant les fichiers robots et sans jamais nous
67 + approprier le contenu d&apos;autrui. Nous demandons la même courtoisie.
68 + </>
69 + ),
70 + },
71 + {
72 + num: "05",
73 + title: "Limitation de responsabilité",
74 + body: (
75 + <>
76 + Le site et les plateformes du groupe sont fournis « tels quels ». Le
77 + Groupe KA ne saurait être tenu responsable des décisions prises sur la
78 + base des informations agrégées, ni des interruptions de service, ni du
79 + contenu des sites sources ou des sites tiers vers lesquels des liens
80 + pointent. Les estimations de valeur (Vrai-Prix, ValoPlex) sont des
81 + outils statistiques assortis de fourchettes d&apos;incertitude
82 + affichées ; elles ne remplacent pas l&apos;évaluation d&apos;un
83 + professionnel agréé.
84 + </>
85 + ),
86 + },
87 + {
88 + num: "06",
89 + title: "Droit applicable",
90 + body: (
91 + <>
92 + Les présentes conditions sont régies par les lois applicables au
93 + Québec et au Canada. Tout litige relève des tribunaux compétents du
94 + district judiciaire de Québec.
95 + </>
96 + ),
97 + },
98 + {
99 + num: "07",
100 + title: "Nous joindre",
101 + body: (
102 + <>
103 + Pour toute question relative aux présentes conditions :{" "}
104 + <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com">
105 + admin@groupe-ka.com
106 + </a>
107 + . Pour les questions générales :{" "}
108 + <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com">
109 + info@groupe-ka.com
110 + </a>
111 + . Pour les projets et partenariats :{" "}
112 + <a className="gk-mono font-bold underline underline-offset-4" href="mailto:contact@groupe-ka.com">
113 + contact@groupe-ka.com
114 + </a>
115 + .
116 + </>
117 + ),
118 + },
119 +];
120 +
121 +export default function Conditions() {
122 + return (
123 + <main className="mx-auto max-w-3xl px-4 pb-16 sm:px-6">
124 + <section className="pt-12 sm:pt-14">
125 + <p className="kicker rise">Le cadre · groupe-ka.com</p>
126 + <h1 className="gk-display rise mt-4 text-[clamp(30px,5vw,52px)] leading-[1] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
127 + Conditions <span className="hl">d&apos;utilisation</span>
128 + </h1>
129 + <p className="rise mt-5 max-w-xl text-[15px] text-ink-2 [animation-delay:0.16s]">
130 + Sept sections, écrites pour être lues — comme tout ce que nous
131 + publions, sans boîte noire. En vigueur depuis le 13 août 2026.
132 + </p>
133 + </section>
134 +
135 + <div className="mt-10 space-y-5">
136 + {SECTIONS.map((s, i) => (
137 + <article
138 + key={s.num}
139 + className="gk-card rise p-5 sm:p-7"
140 + style={{ animationDelay: `${0.15 + i * 0.06}s` }}
141 + >
142 + <span className="gk-mono text-[12px] font-bold text-green">
143 + {s.num}
144 + </span>
145 + <h2 className="gk-display mt-2 text-[17px] font-bold uppercase">
146 + {s.title}
147 + </h2>
148 + <p className="mt-3 text-[14px] leading-relaxed text-ink-2">
149 + {s.body}
150 + </p>
151 + </article>
152 + ))}
153 + </div>
154 +
155 + <p className="gk-mono mt-8 text-[11px] text-ink-3">
156 + Voir aussi :{" "}
157 + <a href="/confidentialite" className="underline underline-offset-4 hover:text-ink">
158 + Politique de confidentialité
159 + </a>
160 + </p>
161 + </main>
162 + );
163 +}
added src/app/confidentialite/page.tsx +154 −0
@@ -0,0 +1,154 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +
4 +export const metadata: Metadata = {
5 + title: "Politique de confidentialité — Groupe KA",
6 + description:
7 + "Politique de confidentialité du site groupe-ka.com — Loi 25, aucun traceur publicitaire, aucun témoin tiers.",
8 +};
9 +
10 +const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [
11 + {
12 + num: "01",
13 + title: "Le principe d’abord",
14 + body: (
15 + <>
16 + Nous agrégeons des annonces et des données publiques — pas des
17 + personnes. Le site groupe-ka.com ne contient aucun traceur
18 + publicitaire, aucun témoin (cookie) tiers, aucun outil de profilage,
19 + et ne revend aucune donnée. C&apos;est le même principe
20 + d&apos;indépendance que sur les sept plateformes du groupe.
21 + </>
22 + ),
23 + },
24 + {
25 + num: "02",
26 + title: "Ce que nous recueillons",
27 + body: (
28 + <>
29 + Sur ce site : rien de plus que les journaux techniques standards du
30 + serveur (adresse IP, page demandée, horodatage), conservés brièvement
31 + à des fins de sécurité et de diagnostic. Si vous nous écrivez par
32 + courriel, nous recevons évidemment votre adresse et le contenu de
33 + votre message — utilisés uniquement pour vous répondre, jamais
34 + ajoutés à une liste d&apos;envoi sans votre consentement.
35 + </>
36 + ),
37 + },
38 + {
39 + num: "03",
40 + title: "Données agrégées et renseignements personnels",
41 + body: (
42 + <>
43 + Les plateformes du groupe agrègent des annonces publiques et des
44 + données ouvertes (rôles d&apos;évaluation, ventes publiées). Lorsque
45 + ces sources contiennent des renseignements à caractère personnel
46 + (ex. coordonnées d&apos;un courtier sur son inscription), ils
47 + demeurent attribués à leur source, ne sont pas enrichis ni croisés à
48 + des fins de profilage, et disparaissent quand la source les retire.
49 + Chaque plateforme précise ses pratiques dans sa propre politique.
50 + </>
51 + ),
52 + },
53 + {
54 + num: "04",
55 + title: "Conservation et sécurité",
56 + body: (
57 + <>
58 + Les journaux techniques sont purgés sur un cycle court. Les courriels
59 + sont conservés le temps du suivi de la demande. Les données des
60 + plateformes vivent sur une infrastructure privée située au Canada,
61 + maintenue par le Groupe KA, avec accès restreint.
62 + </>
63 + ),
64 + },
65 + {
66 + num: "05",
67 + title: "Vos droits (Loi 25)",
68 + body: (
69 + <>
70 + Conformément à la Loi modernisant des dispositions législatives en
71 + matière de protection des renseignements personnels (Loi 25, Québec),
72 + vous pouvez demander l&apos;accès aux renseignements personnels que
73 + nous détenons sur vous, leur rectification ou leur suppression, et
74 + retirer un consentement donné. Écrivez au responsable de la
75 + protection des renseignements personnels — voir la section suivante.
76 + Nous répondons dans les 30 jours.
77 + </>
78 + ),
79 + },
80 + {
81 + num: "06",
82 + title: "Responsable & contact",
83 + body: (
84 + <>
85 + Responsable de la protection des renseignements personnels :
86 + Simon-Pierre Boucher, Groupe KA —{" "}
87 + <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com">
88 + admin@groupe-ka.com
89 + </a>{" "}
90 + (légal &amp; vie privée). Questions générales :{" "}
91 + <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com">
92 + info@groupe-ka.com
93 + </a>
94 + . Si notre réponse ne vous satisfait pas, vous pouvez saisir la
95 + Commission d&apos;accès à l&apos;information du Québec (cai.gouv.qc.ca).
96 + </>
97 + ),
98 + },
99 + {
100 + num: "07",
101 + title: "Modifications",
102 + body: (
103 + <>
104 + Toute mise à jour de la présente politique sera publiée sur cette
105 + page avec sa date d&apos;entrée en vigueur. Version en vigueur :
106 + 13 août 2026.
107 + </>
108 + ),
109 + },
110 +];
111 +
112 +export default function Confidentialite() {
113 + return (
114 + <main className="mx-auto max-w-3xl px-4 pb-16 sm:px-6">
115 + <section className="pt-12 sm:pt-14">
116 + <p className="kicker rise">Vie privée · Loi 25</p>
117 + <h1 className="gk-display rise mt-4 text-[clamp(30px,5vw,52px)] leading-[1] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
118 + Politique de <span className="hl">confidentialité</span>
119 + </h1>
120 + <p className="rise mt-5 max-w-xl text-[15px] text-ink-2 [animation-delay:0.16s]">
121 + Nous agrégeons des annonces, pas des personnes. Aucun traceur, aucun
122 + témoin tiers, aucune revente — voici le détail, sans boîte noire.
123 + </p>
124 + </section>
125 +
126 + <div className="mt-10 space-y-5">
127 + {SECTIONS.map((s, i) => (
128 + <article
129 + key={s.num}
130 + className="gk-card rise p-5 sm:p-7"
131 + style={{ animationDelay: `${0.15 + i * 0.06}s` }}
132 + >
133 + <span className="gk-mono text-[12px] font-bold text-green">
134 + {s.num}
135 + </span>
136 + <h2 className="gk-display mt-2 text-[17px] font-bold uppercase">
137 + {s.title}
138 + </h2>
139 + <p className="mt-3 text-[14px] leading-relaxed text-ink-2">
140 + {s.body}
141 + </p>
142 + </article>
143 + ))}
144 + </div>
145 +
146 + <p className="gk-mono mt-8 text-[11px] text-ink-3">
147 + Voir aussi :{" "}
148 + <a href="/conditions" className="underline underline-offset-4 hover:text-ink">
149 + Conditions d&apos;utilisation
150 + </a>
151 + </p>
152 + </main>
153 + );
154 +}
added src/app/connexion/AuthForm.tsx +241 −0
@@ -0,0 +1,241 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +"use client";
3 +
4 +import { useState } from "react";
5 +import { ROLES } from "@/lib/roles";
6 +
7 +const ERRORS: Record<string, string> = {
8 + "google-non-configure":
9 + "La connexion Google n'est pas encore activée sur ce serveur.",
10 + "etat-oauth-invalide":
11 + "La session Google a expiré — réessayez.",
12 + "echange-google-refuse": "Google a refusé l'échange — réessayez.",
13 + "id-token-absent": "Réponse Google incomplète — réessayez.",
14 + "id-token-invalide": "Réponse Google invalide — réessayez.",
15 + "profil-google-incomplet":
16 + "Votre compte Google n'a pas de courriel vérifié.",
17 + "apple-non-configure":
18 + "La connexion Apple n'est pas encore activée sur ce serveur.",
19 + "apple-refuse": "La connexion Apple a été annulée — réessayez.",
20 + "echange-apple-refuse": "Apple a refusé l'échange — réessayez.",
21 + "profil-apple-incomplet":
22 + "Votre compte Apple n'a pas de courriel vérifié.",
23 +};
24 +
25 +export default function AuthForm({
26 + next,
27 + error,
28 + via,
29 +}: {
30 + next: string;
31 + error?: string;
32 + via?: string;
33 +}) {
34 + const [mode, setMode] = useState<"login" | "register">("login");
35 + const [msg, setMsg] = useState<string | null>(
36 + error ? (ERRORS[error] ?? "Une erreur est survenue — réessayez.") : null,
37 + );
38 + const [busy, setBusy] = useState(false);
39 +
40 + async function submit(e: React.FormEvent<HTMLFormElement>) {
41 + e.preventDefault();
42 + setBusy(true);
43 + setMsg(null);
44 + const fd = new FormData(e.currentTarget);
45 + const body = Object.fromEntries(fd.entries());
46 + const res = await fetch(`/api/auth/${mode}`, {
47 + method: "POST",
48 + headers: { "Content-Type": "application/json" },
49 + body: JSON.stringify(body),
50 + });
51 + const data = await res.json().catch(() => ({}));
52 + if (res.ok) {
53 + window.location.href = next;
54 + } else {
55 + setMsg(data.error ?? "Une erreur est survenue — réessayez.");
56 + setBusy(false);
57 + }
58 + }
59 +
60 + return (
61 + <div className="gk-card p-6 sm:p-8">
62 + {via && (
63 + <p className="stat-chip mb-5 !text-[11px]">
64 + <span className="pulse-dot" aria-hidden="true" />
65 + Connexion demandée par {via} — vous y retournerez automatiquement
66 + </p>
67 + )}
68 +
69 + <a
70 + href={`/api/auth/google?next=${encodeURIComponent(next)}`}
71 + className="btn btn-google w-full"
72 + >
73 + {/* « G » officiel Google — couleurs de marque */}
74 + <svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
75 + <path
76 + fill="#EA4335"
77 + d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
78 + />
79 + <path
80 + fill="#4285F4"
81 + d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
82 + />
83 + <path
84 + fill="#FBBC05"
85 + d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
86 + />
87 + <path
88 + fill="#34A853"
89 + d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
90 + />
91 + </svg>
92 + Continuer avec Google
93 + </a>
94 +
95 + <a
96 + href={`/api/auth/apple?next=${encodeURIComponent(next)}`}
97 + className="btn btn-apple mt-3 w-full"
98 + >
99 + {/* Pomme Apple — blanche sur noir, style officiel */}
100 + <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
101 + <path
102 + fill="currentColor"
103 + d="M16.36 12.76c-.03-2.62 2.14-3.88 2.24-3.94-1.22-1.79-3.12-2.03-3.8-2.06-1.61-.16-3.15.95-3.97.95-.82 0-2.09-.93-3.43-.9-1.77.03-3.4 1.03-4.31 2.6-1.84 3.19-.47 7.9 1.32 10.49.88 1.27 1.92 2.69 3.29 2.64 1.32-.05 1.82-.85 3.42-.85 1.6 0 2.05.85 3.44.82 1.42-.02 2.32-1.29 3.19-2.56 1-1.46 1.41-2.87 1.44-2.95-.03-.01-2.76-1.06-2.83-4.24ZM13.75 4.99c.73-.88 1.22-2.11 1.08-3.33-1.05.04-2.31.7-3.06 1.58-.67.78-1.26 2.02-1.1 3.22 1.17.09 2.36-.6 3.08-1.47Z"
104 + />
105 + </svg>
106 + Continuer avec Apple
107 + </a>
108 +
109 + <div className="my-6 flex items-center gap-4">
110 + <span className="h-[1.5px] flex-1 bg-[rgba(20,24,20,0.18)]" />
111 + <span className="klabel">ou par courriel</span>
112 + <span className="h-[1.5px] flex-1 bg-[rgba(20,24,20,0.18)]" />
113 + </div>
114 +
115 + <div className="mb-5 grid grid-cols-2 gap-2" role="tablist">
116 + {(
117 + [
118 + ["login", "Se connecter"],
119 + ["register", "Créer un compte"],
120 + ] as const
121 + ).map(([m, label]) => (
122 + <button
123 + key={m}
124 + role="tab"
125 + aria-selected={mode === m}
126 + onClick={() => {
127 + setMode(m);
128 + setMsg(null);
129 + }}
130 + className={`btn ${mode === m ? "btn-lime" : "btn-ghost"} !min-h-[40px] !text-[13px]`}
131 + type="button"
132 + >
133 + {label}
134 + </button>
135 + ))}
136 + </div>
137 +
138 + <form onSubmit={submit} className="space-y-4">
139 + {mode === "register" && (
140 + <>
141 + <label className="block">
142 + <span className="klabel">Votre nom</span>
143 + <input
144 + name="name"
145 + type="text"
146 + required
147 + minLength={2}
148 + autoComplete="name"
149 + className="field mt-[6px]"
150 + placeholder="Prénom Nom"
151 + />
152 + </label>
153 + <fieldset>
154 + <legend className="klabel">Vous êtes…</legend>
155 + <div className="mt-[6px] space-y-2">
156 + {Object.entries(ROLES).map(([value, r]) => (
157 + <label
158 + key={value}
159 + className="flex cursor-pointer items-start gap-3 rounded-md border-[1.5px] border-[rgba(20,24,20,0.3)] bg-surface px-4 py-3 transition-colors has-[:checked]:border-ink has-[:checked]:bg-lime-soft has-[:checked]:shadow-[3px_3px_0_rgba(20,24,20,0.15)]"
160 + >
161 + <input
162 + type="radio"
163 + name="role"
164 + value={value}
165 + required
166 + className="mt-[3px] accent-[#1c5c41]"
167 + />
168 + <span>
169 + <span className="gk-display block text-[14px] font-bold">
170 + {r.label}
171 + </span>
172 + <span className="mt-[2px] block text-[12px] leading-snug text-ink-2">
173 + {r.desc}
174 + </span>
175 + </span>
176 + </label>
177 + ))}
178 + </div>
179 + <p className="gk-mono mt-2 text-[10.5px] text-ink-3">
180 + Ce statut sera affiché sur votre carte de membre.
181 + </p>
182 + </fieldset>
183 + </>
184 + )}
185 + <label className="block">
186 + <span className="klabel">Courriel</span>
187 + <input
188 + name="email"
189 + type="email"
190 + required
191 + autoComplete="email"
192 + className="field mt-[6px]"
193 + placeholder="vous@exemple.com"
194 + />
195 + </label>
196 + <label className="block">
197 + <span className="klabel">
198 + Mot de passe{mode === "register" ? " (8 caractères min.)" : ""}
199 + </span>
200 + <input
201 + name="password"
202 + type="password"
203 + required
204 + minLength={8}
205 + autoComplete={mode === "register" ? "new-password" : "current-password"}
206 + className="field mt-[6px]"
207 + placeholder="••••••••"
208 + />
209 + </label>
210 +
211 + {msg && (
212 + <p className="rounded-md border-[1.5px] border-[#b3423a] bg-[#fbe9e7] px-4 py-3 text-[13px] font-medium text-[#7c2f2a]">
213 + {msg}
214 + </p>
215 + )}
216 +
217 + <button type="submit" disabled={busy} className="btn btn-primary w-full">
218 + {busy
219 + ? "Un instant…"
220 + : mode === "login"
221 + ? "Se connecter"
222 + : "Créer mon KA ID"}
223 + </button>
224 + </form>
225 +
226 + <p className="mt-6 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-4 text-[12px] leading-relaxed text-ink-2">
227 + Un seul compte — le <strong className="gk-display">KA ID</strong> —
228 + pour toutes les plateformes du groupe. En continuant, vous acceptez
229 + les{" "}
230 + <a href="/conditions" className="underline underline-offset-4">
231 + conditions d&apos;utilisation
232 + </a>{" "}
233 + et la{" "}
234 + <a href="/confidentialite" className="underline underline-offset-4">
235 + politique de confidentialité
236 + </a>
237 + .
238 + </p>
239 + </div>
240 + );
241 +}
added src/app/connexion/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import { redirect } from "next/navigation";
4 +import { getSessionUser, safeNext } from "@/lib/auth";
5 +import { SSO_CLIENTS } from "@/lib/sso";
6 +import AuthForm from "./AuthForm";
7 +
8 +export const metadata: Metadata = {
9 + title: "KA ID — connexion | Groupe KA",
10 + description:
11 + "Un seul compte pour toutes les plateformes du Groupe KA : créez votre KA ID ou continuez avec Google.",
12 +};
13 +
14 +export default async function Connexion({
15 + searchParams,
16 +}: {
17 + searchParams: Promise<{ next?: string; error?: string; via?: string }>;
18 +}) {
19 + const sp = await searchParams;
20 + const next = safeNext(sp.next);
21 +
22 + // Déjà connecté (et pas de retour SSO en attente) → profil.
23 + if (!sp.error) {
24 + const user = await getSessionUser();
25 + if (user) redirect(next);
26 + }
27 +
28 + const viaLabel = sp.via ? (SSO_CLIENTS[sp.via]?.label ?? sp.via) : undefined;
29 +
30 + return (
31 + <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">
32 + <section className="pt-12 text-center sm:pt-14">
33 + <p className="kicker rise justify-center">
34 + KA ID · un compte, sept plateformes
35 + </p>
36 + <h1 className="gk-display rise mt-4 text-[clamp(30px,5vw,44px)] leading-[1] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
37 + Un seul <span className="hl">·Ka</span> pour tout
38 + </h1>
39 + <p className="rise mt-4 text-[14px] text-ink-2 [animation-delay:0.16s]">
40 + Votre KA ID vous suit sur Lou·Ka, Immo·Ka, Vrai-Prix, ValoPlex,
41 + Auto·Ka, Fabri·Ka et Food·Ka. Même profil partout — c&apos;est
42 + l&apos;agrégation, appliquée à vous-même.
43 + </p>
44 + </section>
45 + <div className="rise mt-8 [animation-delay:0.22s]">
46 + <AuthForm next={next} error={sp.error} via={viaLabel} />
47 + </div>
48 + </main>
49 + );
50 +}
added src/app/figures.tsx +828 −0
@@ -0,0 +1,828 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Figures & visualisations du Groupe KA — SVG/HTML dessinés sur mesure,
3 +// style « éditorial sharp ». Une mesure = une teinte ; identité portée par
4 +// les étiquettes et pastilles de marque, jamais par la couleur seule.
5 +
6 +/* ------------------------------------------------------------------ */
7 +/* Données partagées des figures */
8 +/* ------------------------------------------------------------------ */
9 +
10 +export const FIG_PLATFORMS: {
11 + name: string;
12 + accent: string;
13 + domain: string;
14 +}[] = [
15 + { name: "Lou·Ka", accent: "#d9f26b", domain: "lou-ka.com" },
16 + { name: "Immo·Ka", accent: "#e23744", domain: "immo-ka.com" },
17 + { name: "Vrai-Prix", accent: "#ff5148", domain: "vrai-prix.com" },
18 + { name: "ValoPlex", accent: "#ff9f45", domain: "valoplex.com" },
19 + { name: "Auto·Ka", accent: "#ff5a2a", domain: "auto-ka.com" },
20 + { name: "Fabri·Ka", accent: "#C4532E", domain: "fabri-ka.com" },
21 + { name: "Food·Ka", accent: "#d9f26b", domain: "food-ka.com" },
22 +];
23 +
24 +const ACCENT: Record<string, string> = Object.fromEntries(
25 + FIG_PLATFORMS.map((p) => [p.name, p.accent]),
26 +);
27 +
28 +function BrandChip({ name }: { name: string }) {
29 + return (
30 + <span
31 + aria-hidden="true"
32 + className="inline-flex h-[16px] w-[16px] flex-none items-center justify-center rounded-[4px] bg-ink"
33 + >
34 + <span
35 + className="h-[10px] w-[10px] rounded-[2.5px]"
36 + style={{ background: ACCENT[name] ?? "#d9f26b" }}
37 + />
38 + </span>
39 + );
40 +}
41 +
42 +function FigCaption({ lead, children }: { lead: string; children: React.ReactNode }) {
43 + return (
44 + <figcaption className="border-t-[1.5px] border-ink bg-surface-2 px-5 py-4 text-[13px] text-ink-2 sm:px-7">
45 + <span className="gk-mono font-bold text-green">{lead} — </span>
46 + {children}
47 + </figcaption>
48 + );
49 +}
50 +
51 +/* ------------------------------------------------------------------ */
52 +/* 1. Le sablier ·Ka : plein de sites → connecteurs → KA → 7 flèches */
53 +/* ------------------------------------------------------------------ */
54 +
55 +const FIG_SOURCES = [
56 + "Gestionnaires immobiliers",
57 + "Agences de courtage",
58 + "Rôles fonciers (MAMH)",
59 + "Registres de ventes",
60 + "Concessionnaires autos",
61 + "Marchands d'occasion",
62 + "Boutiques québécoises",
63 + "Sites de fabricants",
64 + "Bannières d'épicerie",
65 + "Circulaires & soldes",
66 +];
67 +
68 +export function AggregationFigure() {
69 + const W = 1060;
70 + const H = 620;
71 + const srcX = 196; // bord droit des chips sources
72 + const kaL = 462; // bord gauche de la boîte KA
73 + const kaR = 598; // bord droit de la boîte KA
74 + const kaY = H / 2;
75 + const dstX = 836; // bord gauche des chips plateformes
76 +
77 + const srcYs = FIG_SOURCES.map(
78 + (_, i) => 52 + (i * (H - 104)) / (FIG_SOURCES.length - 1),
79 + );
80 + const dstYs = FIG_PLATFORMS.map(
81 + (_, i) => 70 + (i * (H - 140)) / (FIG_PLATFORMS.length - 1),
82 + );
83 +
84 + return (
85 + <figure className="gk-card mt-7 overflow-hidden p-0">
86 + <div className="overflow-x-auto">
87 + <svg
88 + viewBox={`0 0 ${W} ${H}`}
89 + role="img"
90 + aria-label="Schéma de l'agrégation KA : des centaines de sites sources sont lus par des connecteurs, la donnée converge vers KA qui la redistribue vers les sept plateformes."
91 + className="block min-w-[760px]"
92 + >
93 + <defs>
94 + <marker
95 + id="ka-arrow"
96 + viewBox="0 0 10 10"
97 + refX="8"
98 + refY="5"
99 + markerWidth="7"
100 + markerHeight="7"
101 + orient="auto-start-reverse"
102 + >
103 + <path d="M0,0 L10,5 L0,10 z" fill="#141814" />
104 + </marker>
105 + </defs>
106 +
107 + {/* ---- flèches sources → KA (la moitié « plein de sites ») ---- */}
108 + {srcYs.map((y, i) => (
109 + <path
110 + key={`in-${i}`}
111 + d={`M ${srcX} ${y} C ${srcX + 130} ${y}, ${kaL - 120} ${kaY}, ${kaL - 8} ${kaY + (i - (FIG_SOURCES.length - 1) / 2) * 7}`}
112 + fill="none"
113 + stroke="#141814"
114 + strokeWidth="1.5"
115 + strokeOpacity="0.5"
116 + className="fig-flow"
117 + style={{ animationDelay: `${i * 0.13}s` }}
118 + />
119 + ))}
120 +
121 + {/* ---- flèches KA → plateformes (le split) ---- */}
122 + {dstYs.map((y, i) => (
123 + <path
124 + key={`out-${i}`}
125 + d={`M ${kaR + 4} ${kaY + (i - (FIG_PLATFORMS.length - 1) / 2) * 9} C ${kaR + 120} ${kaY}, ${dstX - 130} ${y}, ${dstX - 12} ${y}`}
126 + fill="none"
127 + stroke="#141814"
128 + strokeWidth="2"
129 + strokeOpacity="0.75"
130 + markerEnd="url(#ka-arrow)"
131 + className="fig-flow"
132 + style={{ animationDelay: `${0.4 + i * 0.11}s` }}
133 + />
134 + ))}
135 +
136 + {/* ---- chips sources (plein de sites) ---- */}
137 + {FIG_SOURCES.map((label, i) => (
138 + <g key={label}>
139 + <rect
140 + x="16"
141 + y={srcYs[i] - 15}
142 + width={srcX - 16}
143 + height="30"
144 + rx="6"
145 + fill="#ffffff"
146 + stroke="#141814"
147 + strokeWidth="1.5"
148 + />
149 + <circle cx="32" cy={srcYs[i]} r="3.5" fill="#1c5c41" />
150 + <text
151 + x="44"
152 + y={srcYs[i] + 3.5}
153 + fontFamily="var(--font-jetbrains), monospace"
154 + fontSize="10.5"
155 + fontWeight="700"
156 + fill="#141814"
157 + >
158 + {label}
159 + </text>
160 + </g>
161 + ))}
162 + <text
163 + x="16"
164 + y="24"
165 + fontFamily="var(--font-jetbrains), monospace"
166 + fontSize="11"
167 + fontWeight="700"
168 + letterSpacing="1.5"
169 + fill="#8b928c"
170 + >
171 + DES CENTAINES DE SITES
172 + </text>
173 +
174 + {/* ---- étiquette connecteurs sur le faisceau ---- */}
175 + <g transform={`translate(${(srcX + kaL) / 2 - 8} ${kaY - 116}) rotate(-4)`}>
176 + <rect
177 + x="-98"
178 + y="-24"
179 + width="196"
180 + height="48"
181 + rx="12"
182 + fill="#d9f26b"
183 + stroke="#141814"
184 + strokeWidth="1.5"
185 + />
186 + <text
187 + x="0"
188 + y="-4"
189 + textAnchor="middle"
190 + fontFamily="var(--font-jetbrains), monospace"
191 + fontSize="11"
192 + fontWeight="700"
193 + letterSpacing="1"
194 + fill="#141814"
195 + >
196 + 450+ CONNECTEURS
197 + </text>
198 + <text
199 + x="0"
200 + y="13"
201 + textAnchor="middle"
202 + fontFamily="var(--font-jetbrains), monospace"
203 + fontSize="8.5"
204 + fill="#2c3a2c"
205 + >
206 + lecture à la source · normalisation
207 + </text>
208 + </g>
209 +
210 + {/* ---- la boîte KA ---- */}
211 + <g transform={`translate(${(kaL + kaR) / 2} ${kaY}) rotate(-2)`}>
212 + <rect
213 + x={-(kaR - kaL) / 2 + 6}
214 + y="-64"
215 + width={kaR - kaL}
216 + height="140"
217 + rx="12"
218 + fill="rgba(20,24,20,0.18)"
219 + />
220 + <rect
221 + x={-(kaR - kaL) / 2}
222 + y="-70"
223 + width={kaR - kaL}
224 + height="140"
225 + rx="12"
226 + fill="#141814"
227 + />
228 + <text
229 + x="0"
230 + y="6"
231 + textAnchor="middle"
232 + fontFamily="var(--font-space-grotesk), sans-serif"
233 + fontSize="58"
234 + fontWeight="700"
235 + letterSpacing="-2"
236 + fill="#d9f26b"
237 + >
238 + KA
239 + </text>
240 + <text
241 + x="0"
242 + y="34"
243 + textAnchor="middle"
244 + fontFamily="var(--font-jetbrains), monospace"
245 + fontSize="10.5"
246 + fontWeight="700"
247 + letterSpacing="2"
248 + fill="rgba(245,243,238,0.75)"
249 + >
250 + = AGRÉGATION
251 + </text>
252 + <text
253 + x="0"
254 + y="51"
255 + textAnchor="middle"
256 + fontFamily="var(--font-jetbrains), monospace"
257 + fontSize="8"
258 + fill="rgba(245,243,238,0.55)"
259 + >
260 + dédoublonner · géocoder
261 + </text>
262 + </g>
263 +
264 + {/* ---- chips plateformes (le split ·Ka) ---- */}
265 + {FIG_PLATFORMS.map((p, i) => (
266 + <g key={p.name}>
267 + <rect
268 + x={dstX}
269 + y={dstYs[i] - 20}
270 + width={W - dstX - 16}
271 + height="40"
272 + rx="8"
273 + fill="#ffffff"
274 + stroke="#141814"
275 + strokeWidth="1.5"
276 + />
277 + <rect
278 + x={dstX + 12}
279 + y={dstYs[i] - 8}
280 + width="16"
281 + height="16"
282 + rx="4"
283 + fill="#141814"
284 + />
285 + <rect
286 + x={dstX + 15}
287 + y={dstYs[i] - 5}
288 + width="10"
289 + height="10"
290 + rx="2.5"
291 + fill={p.accent}
292 + />
293 + <text
294 + x={dstX + 38}
295 + y={dstYs[i] - 1}
296 + fontFamily="var(--font-space-grotesk), sans-serif"
297 + fontSize="14.5"
298 + fontWeight="700"
299 + fill="#141814"
300 + >
301 + {p.name}
302 + </text>
303 + <text
304 + x={dstX + 38}
305 + y={dstYs[i] + 13}
306 + fontFamily="var(--font-jetbrains), monospace"
307 + fontSize="9"
308 + fill="#8b928c"
309 + >
310 + {p.domain}
311 + </text>
312 + </g>
313 + ))}
314 + <text
315 + x={dstX}
316 + y="34"
317 + fontFamily="var(--font-jetbrains), monospace"
318 + fontSize="11"
319 + fontWeight="700"
320 + letterSpacing="1.5"
321 + fill="#8b928c"
322 + >
323 + SEPT PLATEFORMES
324 + </text>
325 + </svg>
326 + </div>
327 + <FigCaption lead="LE SABLIER ·KA">
328 + des centaines de sites éparpillés, lus à la source par des connecteurs
329 + dédiés ; tout converge vers KA, qui normalise, dédoublonne et
330 + redistribue vers sept plateformes. Une flèche entre, sept flèches
331 + sortent : c&apos;est ça, agréger.
332 + </FigCaption>
333 + </figure>
334 + );
335 +}
336 +
337 +/* ------------------------------------------------------------------ */
338 +/* 2. Le pipeline : de la page web à la donnée propre (vertical) */
339 +/* ------------------------------------------------------------------ */
340 +
341 +const PIPELINE_STEPS: { num: string; title: string; desc: string }[] = [
342 + {
343 + num: "01",
344 + title: "Site source",
345 + desc: "La page publique, telle que tout le monde la voit.",
346 + },
347 + {
348 + num: "02",
349 + title: "Connecteur",
350 + desc: "Un lecteur dédié par site — il connaît sa structure par cœur.",
351 + },
352 + {
353 + num: "03",
354 + title: "Normalisation",
355 + desc: "Mêmes champs, mêmes unités : un 3½ est un 3½ partout.",
356 + },
357 + {
358 + num: "04",
359 + title: "Dédoublonnage & géocodage",
360 + desc: "Une seule fiche par annonce réelle, posée sur la carte.",
361 + },
362 + {
363 + num: "05",
364 + title: "Publication",
365 + desc: "En ligne, datée, traçable jusqu'à la source originale.",
366 + },
367 +];
368 +
369 +export function PipelineFigure() {
370 + const W = 480;
371 + const rowH = 96;
372 + const boxW = 356;
373 + const H = PIPELINE_STEPS.length * rowH + 24;
374 +
375 + return (
376 + <figure className="gk-card overflow-hidden p-0">
377 + <div className="overflow-x-auto">
378 + <svg
379 + viewBox={`0 0 ${W} ${H}`}
380 + role="img"
381 + aria-label="Le pipeline de la donnée KA : site source, connecteur, normalisation, dédoublonnage et géocodage, publication — puis resynchronisation en boucle."
382 + className="block min-w-[420px]"
383 + >
384 + <defs>
385 + <marker
386 + id="pipe-arrow"
387 + viewBox="0 0 10 10"
388 + refX="8"
389 + refY="5"
390 + markerWidth="7"
391 + markerHeight="7"
392 + orient="auto-start-reverse"
393 + >
394 + <path d="M0,0 L10,5 L0,10 z" fill="#141814" />
395 + </marker>
396 + </defs>
397 +
398 + {/* connecteurs verticaux entre stations */}
399 + {PIPELINE_STEPS.slice(0, -1).map((_, i) => (
400 + <line
401 + key={`v-${i}`}
402 + x1={24 + boxW / 2}
403 + y1={20 + i * rowH + 64}
404 + x2={24 + boxW / 2}
405 + y2={20 + (i + 1) * rowH - 6}
406 + stroke="#141814"
407 + strokeWidth="2"
408 + markerEnd="url(#pipe-arrow)"
409 + className="fig-flow"
410 + style={{ animationDelay: `${i * 0.2}s` }}
411 + />
412 + ))}
413 +
414 + {/* boucle de resynchronisation : bas → haut, à droite */}
415 + <path
416 + d={`M ${24 + boxW} ${20 + (PIPELINE_STEPS.length - 1) * rowH + 32}
417 + C ${W - 26} ${20 + (PIPELINE_STEPS.length - 1) * rowH + 32},
418 + ${W - 26} ${20 + 32},
419 + ${24 + boxW + 10} ${20 + 32}`}
420 + fill="none"
421 + stroke="#1c5c41"
422 + strokeWidth="2"
423 + markerEnd="url(#pipe-arrow)"
424 + className="fig-flow"
425 + style={{ animationDelay: "0.5s" }}
426 + />
427 + <g
428 + transform={`translate(${W - 27} ${H / 2}) rotate(90)`}
429 + >
430 + <rect
431 + x="-78"
432 + y="-13"
433 + width="156"
434 + height="26"
435 + rx="13"
436 + fill="#d9f26b"
437 + stroke="#141814"
438 + strokeWidth="1.5"
439 + />
440 + <text
441 + x="0"
442 + y="4"
443 + textAnchor="middle"
444 + fontFamily="var(--font-jetbrains), monospace"
445 + fontSize="9.5"
446 + fontWeight="700"
447 + letterSpacing="1"
448 + fill="#141814"
449 + >
450 + RESYNC EN CONTINU
451 + </text>
452 + </g>
453 +
454 + {/* stations */}
455 + {PIPELINE_STEPS.map((s, i) => (
456 + <g key={s.num}>
457 + <rect
458 + x="24"
459 + y={20 + i * rowH}
460 + width={boxW}
461 + height="64"
462 + rx="10"
463 + fill={i === PIPELINE_STEPS.length - 1 ? "#141814" : "#ffffff"}
464 + stroke="#141814"
465 + strokeWidth="1.5"
466 + />
467 + <text
468 + x="42"
469 + y={20 + i * rowH + 27}
470 + fontFamily="var(--font-jetbrains), monospace"
471 + fontSize="11"
472 + fontWeight="700"
473 + fill={i === PIPELINE_STEPS.length - 1 ? "#d9f26b" : "#1c5c41"}
474 + >
475 + {s.num}
476 + </text>
477 + <text
478 + x="70"
479 + y={20 + i * rowH + 27}
480 + fontFamily="var(--font-space-grotesk), sans-serif"
481 + fontSize="14.5"
482 + fontWeight="700"
483 + fill={i === PIPELINE_STEPS.length - 1 ? "#f5f3ee" : "#141814"}
484 + >
485 + {s.title}
486 + </text>
487 + <text
488 + x="42"
489 + y={20 + i * rowH + 48}
490 + fontFamily="var(--font-inter), sans-serif"
491 + fontSize="11"
492 + fill={
493 + i === PIPELINE_STEPS.length - 1
494 + ? "rgba(245,243,238,0.7)"
495 + : "#4d5551"
496 + }
497 + >
498 + {s.desc}
499 + </text>
500 + </g>
501 + ))}
502 + </svg>
503 + </div>
504 + <FigCaption lead="LE PIPELINE">
505 + cinq stations, aucune main humaine. La boucle verte à droite est le
506 + secret : tout le pipeline se rejoue en continu, donc une annonce
507 + retirée à la source disparaît d&apos;elle-même.
508 + </FigCaption>
509 + </figure>
510 + );
511 +}
512 +
513 +/* ------------------------------------------------------------------ */
514 +/* 3. Le cycle ·Ka : la boucle jour et nuit */
515 +/* ------------------------------------------------------------------ */
516 +
517 +const CYCLE_STEPS = [
518 + { num: "01", label: "Lire les sources", angle: -90 },
519 + { num: "02", label: "Comparer à hier", angle: 0 },
520 + { num: "03", label: "Mettre à jour", angle: 90 },
521 + { num: "04", label: "Publier & tracer", angle: 180 },
522 +];
523 +
524 +export function CycleFigure() {
525 + const S = 480;
526 + const cx = S / 2;
527 + const cy = S / 2 + 6;
528 + const R = 150;
529 +
530 + return (
531 + <figure className="gk-card overflow-hidden p-0">
532 + <div className="flex justify-center">
533 + <svg
534 + viewBox={`0 0 ${S} ${S}`}
535 + role="img"
536 + aria-label="Le cycle KA : lire les sources, comparer à hier, mettre à jour, publier et tracer — en boucle, jour et nuit."
537 + className="block w-full max-w-[480px]"
538 + >
539 + <defs>
540 + <marker
541 + id="cyc-arrow"
542 + viewBox="0 0 10 10"
543 + refX="7"
544 + refY="5"
545 + markerWidth="6.5"
546 + markerHeight="6.5"
547 + orient="auto"
548 + >
549 + <path d="M0,0 L10,5 L0,10 z" fill="#141814" />
550 + </marker>
551 + </defs>
552 +
553 + {/* anneau pointillé qui tourne (le flux) */}
554 + <circle
555 + cx={cx}
556 + cy={cy}
557 + r={R}
558 + fill="none"
559 + stroke="#141814"
560 + strokeWidth="2"
561 + strokeOpacity="0.65"
562 + className="fig-flow"
563 + />
564 + {/* flèches de sens, aux diagonales */}
565 + {[-45, 45, 135, 225].map((a) => {
566 + const rad = (a * Math.PI) / 180;
567 + const x = cx + R * Math.cos(rad);
568 + const y = cy + R * Math.sin(rad);
569 + // tangente (sens horaire)
570 + const tx = -Math.sin(rad);
571 + const ty = Math.cos(rad);
572 + return (
573 + <line
574 + key={a}
575 + x1={x - tx}
576 + y1={y - ty}
577 + x2={x + tx * 2}
578 + y2={y + ty * 2}
579 + stroke="#141814"
580 + strokeWidth="2"
581 + markerEnd="url(#cyc-arrow)"
582 + />
583 + );
584 + })}
585 +
586 + {/* KA au centre */}
587 + <g transform={`translate(${cx} ${cy}) rotate(-2)`}>
588 + <rect x="-48" y="-42" width="96" height="88" rx="10" fill="rgba(20,24,20,0.18)" transform="translate(5 5)" />
589 + <rect x="-48" y="-42" width="96" height="88" rx="10" fill="#141814" />
590 + <text
591 + x="0"
592 + y="8"
593 + textAnchor="middle"
594 + fontFamily="var(--font-space-grotesk), sans-serif"
595 + fontSize="40"
596 + fontWeight="700"
597 + letterSpacing="-1.5"
598 + fill="#d9f26b"
599 + >
600 + KA
601 + </text>
602 + <text
603 + x="0"
604 + y="30"
605 + textAnchor="middle"
606 + fontFamily="var(--font-jetbrains), monospace"
607 + fontSize="8"
608 + letterSpacing="1.5"
609 + fill="rgba(245,243,238,0.65)"
610 + >
611 + JOUR ET NUIT
612 + </text>
613 + </g>
614 +
615 + {/* étapes sur l'anneau */}
616 + {CYCLE_STEPS.map((s) => {
617 + const rad = (s.angle * Math.PI) / 180;
618 + const x = cx + R * Math.cos(rad);
619 + const y = cy + R * Math.sin(rad);
620 + const w = 150;
621 + return (
622 + <g key={s.num} transform={`translate(${x} ${y})`}>
623 + <rect
624 + x={-w / 2}
625 + y="-19"
626 + width={w}
627 + height="38"
628 + rx="8"
629 + fill="#ffffff"
630 + stroke="#141814"
631 + strokeWidth="1.5"
632 + />
633 + <text
634 + x={-w / 2 + 12}
635 + y="4.5"
636 + fontFamily="var(--font-jetbrains), monospace"
637 + fontSize="10"
638 + fontWeight="700"
639 + fill="#1c5c41"
640 + >
641 + {s.num}
642 + </text>
643 + <text
644 + x={-w / 2 + 34}
645 + y="4.5"
646 + fontFamily="var(--font-space-grotesk), sans-serif"
647 + fontSize="12.5"
648 + fontWeight="700"
649 + fill="#141814"
650 + >
651 + {s.label}
652 + </text>
653 + </g>
654 + );
655 + })}
656 + </svg>
657 + </div>
658 + <FigCaption lead="LE CYCLE ·KA">
659 + quatre gestes, en boucle, sans intervention humaine. C&apos;est la
660 + définition maison de l&apos;agrégation : pas un instantané, un
661 + battement.
662 + </FigCaption>
663 + </figure>
664 + );
665 +}
666 +
667 +/* ------------------------------------------------------------------ */
668 +/* 4. Barres : ce qui est en ligne en ce moment (une mesure, une */
669 +/* teinte ; identité par étiquette + pastille de marque) */
670 +/* ------------------------------------------------------------------ */
671 +
672 +const INVENTORY: { name: string; value: number; unit: string }[] = [
673 + { name: "Fabri·Ka", value: 281000, unit: "produits québécois" },
674 + { name: "Immo·Ka", value: 54000, unit: "propriétés à vendre" },
675 + { name: "Food·Ka", value: 23700, unit: "prix d'épicerie" },
676 + { name: "Auto·Ka", value: 15900, unit: "véhicules d'occasion" },
677 + { name: "Lou·Ka", value: 7000, unit: "logements à louer" },
678 +];
679 +
680 +const fmt = (n: number) => n.toLocaleString("fr-CA").replace(/ /g, " ");
681 +
682 +export function InventoryBars() {
683 + const max = Math.max(...INVENTORY.map((d) => d.value));
684 + return (
685 + <figure className="gk-card overflow-hidden p-0">
686 + <div className="p-5 sm:p-7">
687 + <p className="klabel">Inventaire vivant · aujourd&apos;hui</p>
688 + <h3 className="gk-display mt-2 text-[17px] font-bold uppercase">
689 + Ce qui est en ligne en ce moment
690 + </h3>
691 + <div className="mt-6 space-y-5">
692 + {INVENTORY.map((d) => (
693 + <div key={d.name} title={`${d.name} — ${fmt(d.value)}+ ${d.unit}`}>
694 + <div className="flex items-baseline justify-between gap-3">
695 + <span className="flex items-center gap-2 text-[13px] font-bold">
696 + <BrandChip name={d.name} />
697 + <span className="gk-display">{d.name}</span>
698 + <span className="gk-mono text-[10px] font-medium text-ink-3">
699 + {d.unit}
700 + </span>
701 + </span>
702 + <span className="gk-mono text-[12.5px] font-bold text-ink">
703 + {fmt(d.value)}+
704 + </span>
705 + </div>
706 + <div className="mt-[6px] h-[14px] w-full rounded-r-[4px] bg-[rgba(20,24,20,0.06)]">
707 + <div
708 + className="bar-grow h-full rounded-r-[4px] bg-green"
709 + style={{ width: `${Math.max((d.value / max) * 100, 1.5)}%` }}
710 + />
711 + </div>
712 + </div>
713 + ))}
714 + </div>
715 + </div>
716 + <FigCaption lead="L'INVENTAIRE">
717 + chaque barre est vivante : elle grossit quand les connecteurs trouvent
718 + du neuf, elle maigrit quand une annonce disparaît à la source. Compté
719 + aujourd&apos;hui, pas promis.
720 + </FigCaption>
721 + </figure>
722 + );
723 +}
724 +
725 +/* ------------------------------------------------------------------ */
726 +/* 5. Matrice de connecteurs : un point = un connecteur */
727 +/* ------------------------------------------------------------------ */
728 +
729 +const CONNECTORS: { name: string; count: number; kind: string }[] = [
730 + { name: "Auto·Ka", count: 124, kind: "concessionnaires" },
731 + { name: "Lou·Ka", count: 108, kind: "gestionnaires" },
732 + { name: "Immo·Ka", count: 65, kind: "agences" },
733 + { name: "Food·Ka", count: 20, kind: "bannières" },
734 +];
735 +
736 +export function ConnectorMatrix() {
737 + return (
738 + <figure className="gk-card overflow-hidden p-0">
739 + <div className="p-5 sm:p-7">
740 + <p className="klabel">La flotte · un point = un connecteur</p>
741 + <h3 className="gk-display mt-2 text-[17px] font-bold uppercase">
742 + 317 connecteurs, dessinés un par un
743 + </h3>
744 + <div className="mt-6 space-y-6">
745 + {CONNECTORS.map((c) => (
746 + <div key={c.name} title={`${c.name} — ${c.count} connecteurs (${c.kind})`}>
747 + <div className="flex items-baseline justify-between gap-3">
748 + <span className="flex items-center gap-2 text-[13px] font-bold">
749 + <BrandChip name={c.name} />
750 + <span className="gk-display">{c.name}</span>
751 + <span className="gk-mono text-[10px] font-medium text-ink-3">
752 + {c.kind}
753 + </span>
754 + </span>
755 + <span className="gk-mono text-[12.5px] font-bold">
756 + {c.count}
757 + </span>
758 + </div>
759 + <div className="mt-[8px] flex max-w-[460px] flex-wrap gap-[3px]">
760 + {Array.from({ length: c.count }, (_, i) => (
761 + <span
762 + key={i}
763 + className="h-[7px] w-[7px] rounded-[2px] bg-green"
764 + />
765 + ))}
766 + </div>
767 + </div>
768 + ))}
769 + </div>
770 + </div>
771 + <FigCaption lead="LA FLOTTE">
772 + chaque point est un petit programme qui connaît un site par cœur et le
773 + relit sans arrêt. S&apos;y ajoutent les flux des 1 204 boutiques de
774 + Fabri·Ka et les données publiques qui alimentent Vrai-Prix et ValoPlex.
775 + </FigCaption>
776 + </figure>
777 + );
778 +}
779 +
780 +/* ------------------------------------------------------------------ */
781 +/* 6. Tuiles de couverture : les deux moteurs d'évaluation */
782 +/* ------------------------------------------------------------------ */
783 +
784 +const COVERAGE: {
785 + name: string;
786 + value: string;
787 + label: string;
788 + sub: string;
789 +}[] = [
790 + {
791 + name: "Vrai-Prix",
792 + value: "3 747 008",
793 + label: "propriétés couvertes",
794 + sub: "toutes les unités du rôle foncier du Québec, estimées",
795 + },
796 + {
797 + name: "ValoPlex",
798 + value: "393 867",
799 + label: "plex évalués",
800 + sub: "1 733 744 portes — l'unité d'analyse du plex",
801 + },
802 +];
803 +
804 +export function CoverageTiles() {
805 + return (
806 + <div className="flex h-full flex-col gap-5">
807 + {COVERAGE.map((c) => (
808 + <article key={c.name} className="gk-card gk-card-hover flex-1 p-5 sm:p-7">
809 + <p className="klabel flex items-center gap-2">
810 + <BrandChip name={c.name} />
811 + {c.name} · couverture
812 + </p>
813 + <p className="gk-display mt-3 text-[clamp(34px,4.5vw,48px)] leading-none font-bold tracking-[-0.03em]">
814 + {c.value}
815 + </p>
816 + <p className="gk-display mt-1 text-[14px] font-bold uppercase text-green">
817 + {c.label}
818 + </p>
819 + <p className="mt-2 text-[12.5px] text-ink-2">{c.sub}</p>
820 + </article>
821 + ))}
822 + <p className="gk-mono text-[11px] leading-relaxed text-ink-3">
823 + Couvrir n&apos;est pas agréger au rabais : chaque estimation garde ses
824 + comparables, ses ajustements en dollars et sa fourchette.
825 + </p>
826 + </div>
827 + );
828 +}
added src/app/globals.css +845 −0
@@ -0,0 +1,845 @@
1 +/*
2 + * Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + *
4 + * Groupe KA — système de design « éditorial sharp »
5 + * (identité partagée avec Lou-Ka, Vrai-Prix et ValoPlex)
6 + * Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
7 + * Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
8 + * Accent électrique lime #d9f26b sur encre verte profonde
9 + */
10 +
11 +@import "tailwindcss";
12 +
13 +:root {
14 + --paper: #f5f3ee;
15 + --surface: #ffffff;
16 + --surface-2: #faf9f5;
17 + --ink: #141814;
18 + --ink-2: #4d5551;
19 + --ink-3: #8b928c;
20 + --line: rgba(20, 24, 20, 0.14);
21 + --line-strong: rgba(20, 24, 20, 0.85);
22 + --green: #1c5c41;
23 + --green-deep: #123f2e;
24 + --lime: #d9f26b;
25 + --lime-soft: #f0f9d2;
26 + --amber: #e8a33d;
27 + --amber-soft: #fdf3e2;
28 + --danger: #b3423a;
29 + --danger-soft: #fbe9e7;
30 +
31 + --r-card: 10px;
32 + --r-ctl: 6px;
33 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
34 + --shadow-off: 6px 6px 0 var(--ink);
35 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
36 +}
37 +
38 +@theme inline {
39 + --color-paper: var(--paper);
40 + --color-surface: var(--surface);
41 + --color-surface-2: var(--surface-2);
42 + --color-ink: var(--ink);
43 + --color-ink-2: var(--ink-2);
44 + --color-ink-3: var(--ink-3);
45 + --color-green: var(--green);
46 + --color-green-deep: var(--green-deep);
47 + --color-lime: var(--lime);
48 + --color-lime-soft: var(--lime-soft);
49 + --color-amber: var(--amber);
50 + --color-amber-soft: var(--amber-soft);
51 + --color-danger: var(--danger);
52 + --font-display: var(--font-space-grotesk), system-ui, sans-serif;
53 + --font-body: var(--font-inter), system-ui, sans-serif;
54 + --font-mono: var(--font-jetbrains), ui-monospace, monospace;
55 +}
56 +
57 +html {
58 + scroll-behavior: smooth;
59 +}
60 +
61 +body {
62 + background: var(--paper);
63 + color: var(--ink);
64 + font-family: var(--font-body);
65 + font-size: 15px;
66 + line-height: 1.55;
67 + -webkit-font-smoothing: antialiased;
68 +}
69 +
70 +/* Grain de film pleine page — jamais de z-index sur body > * (position: relative
71 + seulement) pour ne pas casser les utilitaires z-* de Tailwind. */
72 +body::before {
73 + content: "";
74 + position: fixed;
75 + inset: 0;
76 + pointer-events: none;
77 + opacity: 0.35;
78 + z-index: 9999;
79 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
80 +}
81 +body > * {
82 + position: relative;
83 +}
84 +
85 +h1,
86 +h2,
87 +h3,
88 +h4 {
89 + font-family: var(--font-display);
90 + letter-spacing: -0.03em;
91 +}
92 +
93 +::selection {
94 + background: var(--lime);
95 + color: var(--ink);
96 +}
97 +
98 +:focus-visible {
99 + outline: 2px solid var(--green);
100 + outline-offset: 2px;
101 +}
102 +
103 +/* ---------- Micro-typographie ---------- */
104 +
105 +.kicker {
106 + display: inline-flex;
107 + align-items: center;
108 + gap: 10px;
109 + font-family: var(--font-mono);
110 + font-size: 11.5px;
111 + font-weight: 500;
112 + text-transform: uppercase;
113 + letter-spacing: 0.12em;
114 + color: var(--green);
115 +}
116 +.kicker::before {
117 + content: "";
118 + width: 22px;
119 + height: 2px;
120 + background: var(--green);
121 +}
122 +
123 +.klabel {
124 + font-family: var(--font-mono);
125 + font-size: 10px;
126 + font-weight: 700;
127 + text-transform: uppercase;
128 + letter-spacing: 0.1em;
129 + color: var(--ink-3);
130 +}
131 +
132 +.gk-display {
133 + font-family: var(--font-display);
134 +}
135 +.gk-mono {
136 + font-family: var(--font-mono);
137 +}
138 +
139 +/* Traitements de titre signature */
140 +.outline-txt {
141 + color: transparent;
142 + -webkit-text-stroke: 2px var(--ink);
143 +}
144 +.hl {
145 + display: inline-block;
146 + background: var(--lime);
147 + border-radius: 8px;
148 + padding: 0 10px 2px;
149 + transform: rotate(-1deg);
150 +}
151 +
152 +/* ---------- Cartes ---------- */
153 +
154 +.gk-card {
155 + background: var(--surface);
156 + border: 1.5px solid var(--ink);
157 + border-radius: var(--r-card);
158 + box-shadow: var(--shadow-off-soft);
159 +}
160 +.gk-card-hover {
161 + transition:
162 + transform 0.18s ease,
163 + box-shadow 0.18s ease;
164 +}
165 +.gk-card-hover:hover {
166 + transform: translate(-3px, -3px);
167 + box-shadow: var(--shadow-off);
168 +}
169 +
170 +/* ---------- Boutons ---------- */
171 +
172 +.btn {
173 + display: inline-flex;
174 + align-items: center;
175 + justify-content: center;
176 + gap: 8px;
177 + min-height: 44px;
178 + padding: 10px 20px;
179 + border: 1.5px solid var(--ink);
180 + border-radius: var(--r-ctl);
181 + font-family: var(--font-display);
182 + font-size: 14px;
183 + font-weight: 700;
184 + cursor: pointer;
185 + text-decoration: none;
186 + transition:
187 + background 0.15s ease,
188 + transform 0.1s ease,
189 + box-shadow 0.1s ease;
190 +}
191 +.btn:active {
192 + transform: translate(2px, 2px);
193 + box-shadow: none !important;
194 +}
195 +.btn-primary {
196 + background: var(--ink);
197 + color: var(--lime);
198 + box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.25);
199 +}
200 +.btn-primary:hover {
201 + background: var(--green-deep);
202 +}
203 +.btn-ghost {
204 + background: transparent;
205 + color: var(--ink);
206 +}
207 +.btn-ghost:hover {
208 + background: var(--lime);
209 +}
210 +.btn-lime {
211 + background: var(--lime);
212 + color: var(--ink);
213 + box-shadow: 4px 4px 0 rgba(217, 242, 107, 0.25);
214 +}
215 +.btn-lime:hover {
216 + background: var(--lime-soft);
217 +}
218 +
219 +/* Boutons OAuth (connexion) — couleurs officielles des marques,
220 + mais bordure encre + ombre dure pour rester dans le langage du site. */
221 +.btn-google {
222 + background: #ffffff;
223 + color: #1f1f1f;
224 + box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.25);
225 +}
226 +.btn-google:hover {
227 + background: #f4f4f1;
228 +}
229 +.btn-apple {
230 + background: #000000;
231 + color: #ffffff;
232 + box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.25);
233 +}
234 +.btn-apple:hover {
235 + background: #1d1d1f;
236 +}
237 +
238 +/* ---------- Champs de formulaire (KA ID) ---------- */
239 +
240 +.field {
241 + display: block;
242 + width: 100%;
243 + padding: 11px 14px;
244 + background: var(--surface);
245 + border: 1.5px solid var(--ink);
246 + border-radius: var(--r-ctl);
247 + font-family: var(--font-body);
248 + font-size: 14px;
249 + color: var(--ink);
250 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);
251 + transition: box-shadow 0.15s ease;
252 +}
253 +.field::placeholder {
254 + color: var(--ink-3);
255 +}
256 +.field:focus {
257 + outline: 2px solid var(--green);
258 + outline-offset: 1px;
259 + box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25);
260 +}
261 +
262 +/* ---------- Carte de membre Groupe KA (même gabarit que Lou·Ka) ---------- */
263 +
264 +.mc {
265 + position: relative;
266 + max-width: 520px;
267 + overflow: hidden;
268 + margin-top: 28px;
269 + background: var(--ink);
270 + color: var(--paper);
271 + border: 2px solid var(--ink);
272 + border-radius: 16px;
273 + padding: 24px 26px 0;
274 + box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18);
275 +}
276 +.mc-watermark {
277 + position: absolute;
278 + right: -18px;
279 + top: -34px;
280 + pointer-events: none;
281 + font-family: var(--font-display);
282 + font-weight: 700;
283 + font-size: 170px;
284 + letter-spacing: -0.06em;
285 + color: rgba(217, 242, 107, 0.07);
286 + transform: rotate(-8deg);
287 + line-height: 1;
288 +}
289 +.mc-head {
290 + display: flex;
291 + align-items: baseline;
292 + justify-content: space-between;
293 + gap: 12px;
294 + flex-wrap: wrap;
295 +}
296 +.mc-brand {
297 + font-family: var(--font-display);
298 + font-weight: 700;
299 + font-size: 24px;
300 + letter-spacing: -0.04em;
301 +}
302 +.mc-ka {
303 + background: var(--lime);
304 + color: var(--ink);
305 + padding: 1px 6px 3px;
306 + border-radius: 5px;
307 + margin-left: 3px;
308 + display: inline-block;
309 + transform: rotate(-2deg);
310 +}
311 +.mc-label {
312 + font-family: var(--font-mono);
313 + font-size: 10px;
314 + text-transform: uppercase;
315 + letter-spacing: 0.16em;
316 + color: rgba(245, 243, 238, 0.55);
317 +}
318 +.mc-id-row {
319 + margin: 26px 0 22px;
320 + display: flex;
321 + align-items: center;
322 + justify-content: space-between;
323 + gap: 16px;
324 +}
325 +.mc-id-block {
326 + display: flex;
327 + flex-direction: column;
328 + gap: 4px;
329 + min-width: 0;
330 +}
331 +.mc-qr {
332 + width: 78px;
333 + height: 78px;
334 + flex: none;
335 + border-radius: 10px;
336 + border: 2px solid var(--lime);
337 + background: #ffffff;
338 + padding: 5px;
339 +}
340 +.mc-id-label {
341 + font-family: var(--font-mono);
342 + font-size: 10px;
343 + text-transform: uppercase;
344 + letter-spacing: 0.2em;
345 + color: rgba(217, 242, 107, 0.65);
346 +}
347 +.mc-id {
348 + font-family: var(--font-mono);
349 + font-weight: 700;
350 + color: var(--lime);
351 + font-size: clamp(22px, 6vw, 34px);
352 + letter-spacing: 0.14em;
353 + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
354 +}
355 +.mc-foot {
356 + display: flex;
357 + align-items: flex-end;
358 + justify-content: space-between;
359 + gap: 14px;
360 + padding-bottom: 18px;
361 +}
362 +.mc-holder {
363 + display: flex;
364 + flex-direction: column;
365 + gap: 2px;
366 + min-width: 0;
367 +}
368 +.mc-holder-name {
369 + font-weight: 600;
370 + font-size: 15px;
371 + overflow: hidden;
372 + text-overflow: ellipsis;
373 + white-space: nowrap;
374 +}
375 +.mc-holder-role {
376 + display: inline-block;
377 + align-self: flex-start;
378 + margin: 3px 0 2px;
379 + padding: 2px 8px 3px;
380 + border-radius: 5px;
381 + background: var(--lime);
382 + color: var(--ink);
383 + font-family: var(--font-mono);
384 + font-size: 9.5px;
385 + font-weight: 700;
386 + letter-spacing: 0.12em;
387 + transform: rotate(-1deg);
388 +}
389 +.mc-holder-since {
390 + font-family: var(--font-mono);
391 + font-size: 10.5px;
392 + color: rgba(245, 243, 238, 0.5);
393 + letter-spacing: 0.06em;
394 +}
395 +.mc-avatar {
396 + width: 52px;
397 + height: 52px;
398 + border-radius: 999px;
399 + border: 2px solid var(--lime);
400 + flex: none;
401 + object-fit: cover;
402 +}
403 +.mc-avatar-empty {
404 + display: flex;
405 + align-items: center;
406 + justify-content: center;
407 + background: var(--lime);
408 + color: var(--ink);
409 + font-family: var(--font-display);
410 + font-weight: 700;
411 + font-size: 22px;
412 +}
413 +.mc-strip {
414 + display: flex;
415 + gap: 5px;
416 + margin: 0 -26px;
417 + padding: 9px 18px;
418 + background: rgba(217, 242, 107, 0.1);
419 + border-top: 1px solid rgba(217, 242, 107, 0.25);
420 + overflow: hidden;
421 +}
422 +.mc-strip i {
423 + display: block;
424 + width: 3px;
425 + border-radius: 1px;
426 + background: var(--lime);
427 + opacity: 0.7;
428 +}
429 +.mc-strip i:nth-child(3n) {
430 + height: 14px;
431 + opacity: 0.35;
432 +}
433 +.mc-strip i:nth-child(3n + 1) {
434 + height: 9px;
435 +}
436 +.mc-strip i:nth-child(3n + 2) {
437 + height: 17px;
438 + opacity: 0.9;
439 +}
440 +
441 +/* ---------- Carte physique imprimable (CR80 : 85,6 × 54 mm) ---------- */
442 +
443 +.pcard {
444 + position: relative;
445 + width: 85.6mm;
446 + height: 54mm;
447 + overflow: hidden;
448 + border-radius: 3.18mm; /* rayon normalisé ISO/IEC 7810 */
449 + outline: 0.3mm dashed rgba(20, 24, 20, 0.4); /* repère de découpe */
450 + outline-offset: 1mm;
451 + flex: none;
452 + -webkit-print-color-adjust: exact;
453 + print-color-adjust: exact;
454 +}
455 +.pcard-front {
456 + background: var(--ink);
457 + color: var(--paper);
458 + padding: 4.5mm 5mm;
459 + display: flex;
460 + flex-direction: column;
461 +}
462 +.pcard-watermark {
463 + position: absolute;
464 + right: -4mm;
465 + top: -8mm;
466 + pointer-events: none;
467 + font-family: var(--font-display);
468 + font-weight: 700;
469 + font-size: 34mm;
470 + letter-spacing: -0.06em;
471 + color: rgba(217, 242, 107, 0.08);
472 + transform: rotate(-8deg);
473 + line-height: 1;
474 +}
475 +.pcard-head {
476 + display: flex;
477 + align-items: baseline;
478 + justify-content: space-between;
479 + gap: 3mm;
480 +}
481 +.pcard-brand {
482 + font-family: var(--font-display);
483 + font-weight: 700;
484 + font-size: 5mm;
485 + letter-spacing: -0.03em;
486 +}
487 +.pcard-ka {
488 + display: inline-block;
489 + background: var(--lime);
490 + color: var(--ink);
491 + padding: 0 1.4mm 0.4mm;
492 + border-radius: 1.2mm;
493 + transform: rotate(-2deg);
494 +}
495 +.pcard-label {
496 + font-family: var(--font-mono);
497 + font-size: 1.9mm;
498 + letter-spacing: 0.16em;
499 + color: rgba(245, 243, 238, 0.55);
500 +}
501 +.pcard-id {
502 + margin-top: 5mm;
503 + display: flex;
504 + flex-direction: column;
505 + gap: 0.8mm;
506 +}
507 +.pcard-id-label {
508 + font-family: var(--font-mono);
509 + font-size: 1.9mm;
510 + letter-spacing: 0.2em;
511 + color: rgba(217, 242, 107, 0.65);
512 +}
513 +.pcard-id-value {
514 + font-family: var(--font-mono);
515 + font-weight: 700;
516 + font-size: 6.4mm;
517 + letter-spacing: 0.12em;
518 + color: var(--lime);
519 +}
520 +.pcard-foot {
521 + margin-top: auto;
522 + display: flex;
523 + align-items: flex-end;
524 + justify-content: space-between;
525 + gap: 3mm;
526 +}
527 +.pcard-holder {
528 + display: flex;
529 + flex-direction: column;
530 + gap: 0.7mm;
531 + min-width: 0;
532 +}
533 +.pcard-name {
534 + font-weight: 600;
535 + font-size: 3.4mm;
536 + white-space: nowrap;
537 + overflow: hidden;
538 + text-overflow: ellipsis;
539 +}
540 +.pcard-role {
541 + align-self: flex-start;
542 + padding: 0.4mm 1.6mm 0.6mm;
543 + border-radius: 1mm;
544 + background: var(--lime);
545 + color: var(--ink);
546 + font-family: var(--font-mono);
547 + font-weight: 700;
548 + font-size: 1.9mm;
549 + letter-spacing: 0.1em;
550 + transform: rotate(-1deg);
551 +}
552 +.pcard-since {
553 + font-family: var(--font-mono);
554 + font-size: 1.9mm;
555 + letter-spacing: 0.08em;
556 + color: rgba(245, 243, 238, 0.5);
557 +}
558 +.pcard-avatar {
559 + width: 11mm;
560 + height: 11mm;
561 + border-radius: 999px;
562 + border: 0.5mm solid var(--lime);
563 + object-fit: cover;
564 + flex: none;
565 +}
566 +.pcard-avatar-empty {
567 + display: flex;
568 + align-items: center;
569 + justify-content: center;
570 + background: var(--lime);
571 + color: var(--ink);
572 + font-family: var(--font-display);
573 + font-weight: 700;
574 + font-size: 5mm;
575 +}
576 +.pcard-back {
577 + background: #ffffff;
578 + border: 0.4mm solid var(--ink);
579 + display: flex;
580 + align-items: center;
581 + gap: 4mm;
582 + padding: 4.5mm 5mm;
583 +}
584 +.pcard-back-left {
585 + flex: none;
586 +}
587 +.pcard-qr {
588 + width: 30mm;
589 + height: 30mm;
590 + display: block;
591 +}
592 +.pcard-back-right {
593 + display: flex;
594 + flex-direction: column;
595 + gap: 1.6mm;
596 + min-width: 0;
597 +}
598 +.pcard-back-title {
599 + font-family: var(--font-display);
600 + font-weight: 700;
601 + font-size: 3.4mm;
602 + line-height: 1.15;
603 + text-transform: uppercase;
604 + letter-spacing: -0.01em;
605 +}
606 +.pcard-back-url {
607 + font-family: var(--font-mono);
608 + font-size: 2.2mm;
609 + color: var(--green);
610 + word-break: break-all;
611 +}
612 +.pcard-nfc {
613 + display: inline-flex;
614 + align-items: center;
615 + gap: 1.5mm;
616 + align-self: flex-start;
617 + padding: 1mm 2mm;
618 + border: 0.3mm dashed var(--ink);
619 + border-radius: 1.5mm;
620 + font-family: var(--font-mono);
621 + font-size: 1.9mm;
622 + letter-spacing: 0.06em;
623 + color: var(--ink-2);
624 +}
625 +.pcard-nfc-ico {
626 + font-weight: 700;
627 + color: var(--green);
628 + letter-spacing: -0.05em;
629 +}
630 +.pcard-back-foot {
631 + font-family: var(--font-mono);
632 + font-size: 1.7mm;
633 + line-height: 1.5;
634 + color: var(--ink-3);
635 +}
636 +
637 +@media print {
638 + .no-print,
639 + header,
640 + .ticker,
641 + footer,
642 + body::before {
643 + display: none !important;
644 + }
645 + body {
646 + background: #ffffff;
647 + }
648 + .print-page {
649 + max-width: none;
650 + padding: 0;
651 + }
652 + .print-cards {
653 + margin: 0;
654 + gap: 10mm;
655 + }
656 + .pcard {
657 + box-shadow: none;
658 + }
659 + @page {
660 + margin: 12mm;
661 + }
662 +}
663 +
664 +/* ---------- Pilules de stats ---------- */
665 +
666 +.stat-chip {
667 + display: inline-flex;
668 + align-items: center;
669 + gap: 8px;
670 + white-space: nowrap;
671 + padding: 8px 14px;
672 + background: var(--surface);
673 + border: 1.5px solid var(--ink);
674 + border-radius: 999px;
675 + font-family: var(--font-mono);
676 + font-size: 12px;
677 + font-weight: 700;
678 + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);
679 +}
680 +
681 +.pulse-dot {
682 + position: relative;
683 + width: 8px;
684 + height: 8px;
685 + border-radius: 999px;
686 + background: var(--green);
687 + flex: none;
688 +}
689 +.pulse-dot::after {
690 + content: "";
691 + position: absolute;
692 + inset: -4px;
693 + border-radius: 999px;
694 + border: 2px solid var(--lime);
695 + animation: pulse 2.4s ease-out infinite;
696 +}
697 +@keyframes pulse {
698 + 0% {
699 + transform: scale(0.5);
700 + opacity: 1;
701 + }
702 + 70%,
703 + 100% {
704 + transform: scale(1.4);
705 + opacity: 0;
706 + }
707 +}
708 +
709 +/* ---------- Ticker ---------- */
710 +
711 +.ticker {
712 + overflow: hidden;
713 + background: var(--ink);
714 + border-bottom: 1.5px solid var(--ink);
715 +}
716 +.ticker-inner {
717 + display: inline-flex;
718 + white-space: nowrap;
719 + padding: 8px 0;
720 + font-family: var(--font-mono);
721 + font-size: 11.5px;
722 + font-weight: 500;
723 + text-transform: uppercase;
724 + letter-spacing: 0.1em;
725 + color: var(--lime);
726 + animation: ticker 44s linear infinite;
727 +}
728 +.ticker-item::after {
729 + content: "◆";
730 + margin: 0 18px;
731 + opacity: 0.55;
732 + font-size: 8px;
733 + vertical-align: 2px;
734 +}
735 +@keyframes ticker {
736 + from {
737 + transform: translateX(0);
738 + }
739 + to {
740 + transform: translateX(-50%);
741 + }
742 +}
743 +
744 +/* ---------- Tableau éditorial ---------- */
745 +
746 +.src-wrap {
747 + border: 1.5px solid var(--ink);
748 + border-radius: var(--r-card);
749 + box-shadow: var(--shadow-off-soft);
750 + overflow-x: auto;
751 + background: var(--surface);
752 +}
753 +.src-table {
754 + width: 100%;
755 + border-collapse: collapse;
756 + font-size: 13.5px;
757 +}
758 +.src-table th {
759 + font-family: var(--font-mono);
760 + font-size: 10px;
761 + font-weight: 700;
762 + text-transform: uppercase;
763 + letter-spacing: 0.1em;
764 + text-align: left;
765 + color: var(--ink-3);
766 + background: var(--surface-2);
767 + padding: 10px 14px;
768 + border-bottom: 1.5px solid var(--ink);
769 +}
770 +.src-table td {
771 + padding: 12px 14px;
772 + border-bottom: 1px solid var(--line);
773 + vertical-align: top;
774 +}
775 +.src-table tr:last-child td {
776 + border-bottom: none;
777 +}
778 +.src-table tbody tr:hover {
779 + background: var(--surface-2);
780 +}
781 +
782 +/* ---------- Figure d'agrégation ---------- */
783 +
784 +.fig-flow {
785 + stroke-dasharray: 7 7;
786 + animation: figflow 1.1s linear infinite;
787 +}
788 +@keyframes figflow {
789 + to {
790 + stroke-dashoffset: -14;
791 + }
792 +}
793 +
794 +.bar-grow {
795 + transform-origin: left center;
796 + animation: growx 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) both;
797 +}
798 +@keyframes growx {
799 + from {
800 + transform: scaleX(0);
801 + }
802 + to {
803 + transform: scaleX(1);
804 + }
805 +}
806 +
807 +/* ---------- Animations d'entrée ---------- */
808 +
809 +.rise {
810 + opacity: 0;
811 + animation: rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
812 +}
813 +@keyframes rise {
814 + from {
815 + opacity: 0;
816 + transform: translateY(12px);
817 + }
818 + to {
819 + opacity: 1;
820 + transform: translateY(0);
821 + }
822 +}
823 +
824 +/* ---------- Accessibilité ---------- */
825 +
826 +@media (prefers-reduced-motion: reduce) {
827 + .ticker-inner,
828 + .pulse-dot::after,
829 + .fig-flow {
830 + animation: none;
831 + }
832 + .rise {
833 + animation: none;
834 + opacity: 1;
835 + }
836 + *,
837 + *::before,
838 + *::after {
839 + transition-duration: 0.01ms !important;
840 + animation-duration: 0.01ms !important;
841 + }
842 + html {
843 + scroll-behavior: auto;
844 + }
845 +}
added src/app/icon.svg +5 −0
@@ -0,0 +1,5 @@
1 +<!-- Auteur : Simon-Pierre Boucher — contact@spboucher.ai -->
2 +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
3 + <rect width="64" height="64" rx="12" fill="#141814"/>
4 + <text x="32" y="43" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="26" fill="#d9f26b" text-anchor="middle">KA</text>
5 +</svg>
added src/app/layout.tsx +268 −0
@@ -0,0 +1,268 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 +import UserButton from "./UserButton";
5 +import "./globals.css";
6 +
7 +const spaceGrotesk = Space_Grotesk({
8 + subsets: ["latin"],
9 + variable: "--font-space-grotesk",
10 +});
11 +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
12 +const jetbrains = JetBrains_Mono({
13 + subsets: ["latin"],
14 + variable: "--font-jetbrains",
15 +});
16 +
17 +export const metadata: Metadata = {
18 + metadataBase: new URL("https://www.groupe-ka.com"),
19 + title: "Groupe KA — holding d’agrégateurs automatisés, sans boîte noire",
20 + description:
21 + "Groupe KA est un holding québécois d’agrégateurs de produits et services entièrement automatisés : Lou·Ka (location), Immo·Ka (propriétés à vendre), Vrai-Prix (valeur résidentielle), ValoPlex (plex), Auto·Ka (voitures usagées), Fabri·Ka (produits québécois) et Food·Ka (épicerie). Sept plateformes, zéro saisie manuelle.",
22 + openGraph: {
23 + title: "Groupe KA — holding d’agrégateurs automatisés, sans boîte noire",
24 + description:
25 + "Ka, c’est l’agrégation : des centaines de connecteurs, sept plateformes — Lou·Ka, Immo·Ka, Vrai-Prix, ValoPlex, Auto·Ka, Fabri·Ka et Food·Ka.",
26 + url: "https://www.groupe-ka.com",
27 + siteName: "Groupe KA",
28 + locale: "fr_CA",
29 + type: "website",
30 + },
31 +};
32 +
33 +const NAV_ITEMS = [
34 + { href: "/#flux", label: "L’agrégation" },
35 + { href: "/#chiffres", label: "Chiffres" },
36 + { href: "/#produits", label: "Plateformes" },
37 + { href: "/#socle", label: "Le socle" },
38 + { href: "/#mission", label: "Mission" },
39 + { href: "/#contact", label: "Contact" },
40 +];
41 +
42 +const TICKER_ITEMS = [
43 + "·Ka = agréger — mille sites, un seul endroit",
44 + "Sept agrégateurs · une obsession : la transparence",
45 + "450+ connecteurs lisent les sources jour et nuit",
46 + "7 000+ logements à louer agrégés en continu",
47 + "54 000+ propriétés à vendre, lues à la source",
48 + "15 900+ voitures usagées chez 124 concessionnaires",
49 + "281 000+ produits québécois de 1 204 boutiques",
50 + "23 700+ prix d'épicerie suivis chez 20 bannières",
51 + "3 747 008 propriétés évaluées au Québec",
52 + "393 867 plex · 1 733 744 portes",
53 + "Zéro boîte noire — tout est automatisé, rien n'est inventé",
54 +];
55 +
56 +function Wordmark({ size = "header" }: { size?: "header" | "footer" }) {
57 + const base =
58 + size === "footer"
59 + ? "text-[30px] text-paper"
60 + : "text-[22px] text-ink sm:text-[26px]";
61 + return (
62 + <span
63 + className={`gk-display inline-flex items-baseline gap-[5px] font-bold tracking-[-0.04em] ${base}`}
64 + >
65 + Groupe
66 + {size === "footer" ? (
67 + <span className="text-lime">KA</span>
68 + ) : (
69 + <span className="inline-block -rotate-2 rounded-md bg-ink px-[7px] pb-[2px] text-lime transition-transform duration-200 group-hover:rotate-0">
70 + KA
71 + </span>
72 + )}
73 + </span>
74 + );
75 +}
76 +
77 +export default function RootLayout({
78 + children,
79 +}: Readonly<{ children: React.ReactNode }>) {
80 + return (
81 + <html lang="fr">
82 + <body
83 + className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} bg-paper text-ink`}
84 + >
85 + <header className="sticky top-0 z-[1100] border-b-2 border-ink bg-paper md:bg-[rgba(245,243,238,0.88)] md:backdrop-blur-xl">
86 + <div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
87 + <a href="#" className="group" aria-label="Groupe KA — accueil">
88 + <Wordmark />
89 + </a>
90 + <nav aria-label="Navigation principale" className="hidden lg:block">
91 + <ul className="flex items-center gap-1">
92 + {NAV_ITEMS.map((item) => (
93 + <li key={item.href}>
94 + <a
95 + href={item.href}
96 + className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-4 py-[7px] text-[13.5px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink"
97 + >
98 + {item.label}
99 + </a>
100 + </li>
101 + ))}
102 + <li className="ml-1">
103 + <UserButton />
104 + </li>
105 + <li>
106 + <a
107 + href="mailto:contact@groupe-ka.com"
108 + className="gk-display ml-1 inline-block rounded-full border-[1.5px] border-ink bg-ink px-4 py-[7px] text-[13.5px] font-bold text-lime transition-colors hover:bg-green-deep"
109 + >
110 + Écrivez-nous
111 + </a>
112 + </li>
113 + </ul>
114 + </nav>
115 + {/* Mobile : icône compte directement dans le header + menu 100 % CSS */}
116 + <div className="flex items-center gap-2 lg:hidden">
117 + <UserButton variant="icon" />
118 + <input
119 + type="checkbox"
120 + id="gk-menu"
121 + className="peer sr-only"
122 + aria-label="Ouvrir le menu"
123 + />
124 + <label
125 + htmlFor="gk-menu"
126 + className="gk-mono flex h-11 w-11 cursor-pointer items-center justify-center rounded-md border-[1.5px] border-ink text-[18px] shadow-[3px_3px_0_rgba(20,24,20,0.25)] select-none"
127 + >
128 +
129 + </label>
130 + <div className="fixed inset-0 z-[3000] hidden flex-col bg-paper peer-checked:flex">
131 + <div className="flex h-16 items-center justify-between border-b-2 border-ink px-4">
132 + <Wordmark />
133 + <label
134 + htmlFor="gk-menu"
135 + className="flex h-11 w-11 cursor-pointer items-center justify-center rounded-md border-[1.5px] border-ink text-[18px] shadow-[3px_3px_0_rgba(20,24,20,0.25)] select-none"
136 + >
137 +
138 + </label>
139 + </div>
140 + <nav aria-label="Menu mobile" className="px-6 pt-8">
141 + <ul>
142 + {NAV_ITEMS.map((item, i) => (
143 + <li
144 + key={item.href}
145 + className="border-b border-dashed border-[rgba(20,24,20,0.25)]"
146 + >
147 + <a
148 + href={item.href}
149 + className="gk-display flex items-baseline gap-4 py-5 text-[29px] font-bold tracking-[-0.03em] uppercase"
150 + >
151 + <span className="gk-mono text-[12px] font-bold text-green">
152 + 0{i + 1}
153 + </span>
154 + {item.label}
155 + </a>
156 + </li>
157 + ))}
158 + <li className="pt-6">
159 + <a
160 + href="mailto:contact@groupe-ka.com"
161 + className="btn btn-primary w-full"
162 + >
163 + contact@groupe-ka.com
164 + </a>
165 + </li>
166 + </ul>
167 + </nav>
168 + </div>
169 + </div>
170 + </div>
171 + </header>
172 +
173 + <div className="ticker" aria-hidden="true">
174 + <div className="ticker-inner">
175 + {[0, 1].map((dup) => (
176 + <span key={dup}>
177 + {TICKER_ITEMS.map((item) => (
178 + <span key={item} className="ticker-item">
179 + {item}
180 + </span>
181 + ))}
182 + </span>
183 + ))}
184 + </div>
185 + </div>
186 +
187 + {children}
188 +
189 + <footer id="contact" className="mt-16 bg-ink py-11 text-[13px] text-[rgba(245,243,238,0.75)]">
190 + <div className="mx-auto max-w-6xl px-4 sm:px-6">
191 + <Wordmark size="footer" />
192 + <p className="mt-4 max-w-2xl">
193 + Holding québécois d&apos;agrégateurs de produits et services
194 + entièrement automatisés. Des centaines de connecteurs lisent les
195 + sites à la source, normalisent la donnée et se resynchronisent
196 + seuls — logements, propriétés, autos, produits d&apos;ici,
197 + épicerie. Rien d&apos;inventé, tout est traçable.{" "}
198 + <span className="text-lime">
199 + ·Ka, c&apos;est le suffixe qui veut dire agréger.
200 + </span>
201 + </p>
202 + <ul className="gk-mono mt-7 flex flex-wrap gap-x-7 gap-y-3 text-[11px] font-bold tracking-[0.1em] uppercase">
203 + {[
204 + ["Lou·Ka", "https://www.lou-ka.com"],
205 + ["Immo·Ka", "https://www.immo-ka.com"],
206 + ["Vrai-Prix", "https://www.vrai-prix.com"],
207 + ["ValoPlex", "https://www.valoplex.com"],
208 + ["Auto·Ka", "https://www.auto-ka.com"],
209 + ["Fabri·Ka", "https://www.fabri-ka.com"],
210 + ["Food·Ka", "https://www.food-ka.com"],
211 + ].map(([label, href]) => (
212 + <li key={label}>
213 + <a
214 + href={href}
215 + target="_blank"
216 + rel="noopener noreferrer"
217 + className="text-[rgba(245,243,238,0.65)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
218 + >
219 + {label}
220 + </a>
221 + </li>
222 + ))}
223 + </ul>
224 + <div className="mt-8 grid gap-x-8 gap-y-4 border-t border-[rgba(245,243,238,0.15)] pt-7 sm:grid-cols-3">
225 + {[
226 + {
227 + email: "contact@groupe-ka.com",
228 + role: "Projets, partenariats & données",
229 + },
230 + {
231 + email: "info@groupe-ka.com",
232 + role: "Médias & questions générales",
233 + },
234 + {
235 + email: "admin@groupe-ka.com",
236 + role: "Légal, vie privée & Loi 25",
237 + },
238 + ].map((c) => (
239 + <p key={c.email} className="text-[12px]">
240 + <a
241 + href={`mailto:${c.email}`}
242 + className="gk-mono font-bold text-[rgba(245,243,238,0.85)] underline-offset-4 hover:text-lime hover:underline"
243 + >
244 + {c.email}
245 + </a>
246 + <span className="mt-1 block text-[11px] text-[rgba(245,243,238,0.5)]">
247 + {c.role}
248 + </span>
249 + </p>
250 + ))}
251 + </div>
252 + <p className="gk-mono mt-8 text-[11px] text-[rgba(245,243,238,0.45)]">
253 + © 2026 Groupe KA — Simon-Pierre Boucher ·{" "}
254 + <a href="/conditions" className="underline-offset-4 hover:text-lime hover:underline">
255 + Conditions d&apos;utilisation
256 + </a>{" "}
257 + ·{" "}
258 + <a href="/confidentialite" className="underline-offset-4 hover:text-lime hover:underline">
259 + Politique de confidentialité
260 + </a>{" "}
261 + · groupe-ka.com
262 + </p>
263 + </div>
264 + </footer>
265 + </body>
266 + </html>
267 + );
268 +}
added src/app/m/[kaId]/page.tsx +157 −0
@@ -0,0 +1,157 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Page d'identification d'un membre (cible du code QR de la carte) :
3 +// grande photo, identité complète, badge de vérification. Le courriel et
4 +// les coordonnées ne sont jamais exposés.
5 +import type { Metadata } from "next";
6 +import { db } from "@/lib/db";
7 +import { roleLabel } from "@/lib/roles";
8 +
9 +export const metadata: Metadata = {
10 + title: "Identification de membre | Groupe KA",
11 + description: "Identification d'un membre du Groupe KA par son KA-ID.",
12 + robots: { index: false },
13 +};
14 +
15 +export const dynamic = "force-dynamic";
16 +
17 +function fmtDate(iso: string | null): string {
18 + if (!iso) return "—";
19 + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
20 + if (Number.isNaN(d.getTime())) return "—";
21 + return d
22 + .toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" })
23 + .replace(/^1 /, "1ᵉʳ ");
24 +}
25 +
26 +export default async function IdentificationMembre({
27 + params,
28 +}: {
29 + params: Promise<{ kaId: string }>;
30 +}) {
31 + const { kaId } = await params;
32 + const clean = /^ka-\d{10}$/.test(kaId) ? kaId : null;
33 + const row = clean
34 + ? (db
35 + .prepare(
36 + "SELECT ka_id, name, avatar_url, google_sub, role, created_at, last_login FROM users WHERE ka_id = ?",
37 + )
38 + .get(clean) as
39 + | {
40 + ka_id: string;
41 + name: string;
42 + avatar_url: string | null;
43 + google_sub: string | null;
44 + role: string | null;
45 + created_at: string;
46 + last_login: string | null;
47 + }
48 + | undefined)
49 + : undefined;
50 +
51 + if (!row) {
52 + return (
53 + <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">
54 + <section className="pt-12 text-center sm:pt-14">
55 + <p className="kicker rise justify-center">Identification de membre</p>
56 + <h1 className="gk-display rise mt-4 text-[clamp(28px,5vw,40px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
57 + Identifiant <span className="outline-txt">introuvable</span>
58 + </h1>
59 + <p className="rise mt-4 text-[14px] text-ink-2 [animation-delay:0.16s]">
60 + Aucun membre ne porte cet identifiant. Vérifiez le code, ou{" "}
61 + <a href="/connexion" className="underline underline-offset-4">
62 + créez votre KA ID
63 + </a>
64 + .
65 + </p>
66 + </section>
67 + </main>
68 + );
69 + }
70 +
71 + const infos: [string, string][] = [
72 + ["Identifiant membre", row.ka_id],
73 + ...(roleLabel(row.role)
74 + ? ([["Statut", roleLabel(row.role)!]] as [string, string][])
75 + : []),
76 + ["Membre depuis", fmtDate(row.created_at)],
77 + ["Dernière connexion", fmtDate(row.last_login)],
78 + ["Compte", row.google_sub ? "Lié à Google" : "Courriel vérifié"],
79 + ["Valide sur", "Les 7 plateformes ·Ka"],
80 + ];
81 +
82 + return (
83 + <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">
84 + <section className="pt-10 text-center sm:pt-12">
85 + <p className="kicker rise justify-center">Identification de membre</p>
86 +
87 + {/* ——— La grande photo ——— */}
88 + <div className="rise mt-7 flex justify-center [animation-delay:0.08s]">
89 + {row.avatar_url ? (
90 + // eslint-disable-next-line @next/next/no-img-element
91 + <img
92 + src={row.avatar_url}
93 + alt={`Photo de ${row.name}`}
94 + width={176}
95 + height={176}
96 + referrerPolicy="no-referrer"
97 + className="h-44 w-44 rounded-full border-[3px] border-ink object-cover shadow-[8px_8px_0_rgba(20,24,20,0.18)]"
98 + />
99 + ) : (
100 + <span className="gk-display flex h-44 w-44 items-center justify-center rounded-full border-[3px] border-ink bg-lime text-[64px] font-bold shadow-[8px_8px_0_rgba(20,24,20,0.18)]">
101 + {row.name.charAt(0).toUpperCase()}
102 + </span>
103 + )}
104 + </div>
105 +
106 + <h1 className="gk-display rise mt-6 text-[clamp(28px,6vw,42px)] leading-[1.02] font-bold tracking-[-0.03em] [animation-delay:0.14s]">
107 + {row.name}
108 + </h1>
109 + <p className="rise mt-3 inline-flex items-center gap-2 rounded-full border-[1.5px] border-ink bg-lime px-4 py-[7px] text-[13px] font-bold [animation-delay:0.2s]">
110 + <span
111 + aria-hidden="true"
112 + className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-ink text-[11px] font-bold text-lime"
113 + >
114 +
115 + </span>
116 + Membre vérifié du Groupe KA
117 + </p>
118 +
119 + {/* ——— KA-ID ——— */}
120 + <div className="gk-card rise mt-7 !bg-ink p-6 text-paper [animation-delay:0.26s]">
121 + <p className="gk-mono text-[10px] tracking-[0.2em] uppercase text-[rgba(217,242,107,0.65)]">
122 + KA-ID
123 + </p>
124 + <p className="gk-mono mt-2 text-[clamp(22px,6vw,30px)] font-bold tracking-[0.14em] text-lime [text-shadow:0_0_24px_rgba(217,242,107,0.35)]">
125 + {row.ka_id}
126 + </p>
127 + </div>
128 +
129 + {/* ——— Toutes les infos ——— */}
130 + <div className="gk-card rise mt-5 p-0 text-left [animation-delay:0.32s]">
131 + {infos.map(([label, value], i) => (
132 + <div
133 + key={label}
134 + className={`flex items-baseline justify-between gap-4 px-5 py-[13px] ${i < infos.length - 1 ? "border-b border-dashed border-[rgba(20,24,20,0.18)]" : ""}`}
135 + >
136 + <span className="klabel flex-none">{label}</span>
137 + <span
138 + className={`text-right text-[13.5px] font-semibold ${label === "Identifiant membre" ? "gk-mono text-green" : ""}`}
139 + >
140 + {value}
141 + </span>
142 + </div>
143 + ))}
144 + </div>
145 +
146 + <p className="mt-5 text-[12.5px] text-ink-2">
147 + Cette page confirme l&apos;identité d&apos;un membre à partir du code
148 + QR de sa carte. Le courriel et les coordonnées ne sont jamais
149 + affichés.
150 + </p>
151 + <a href="/" className="btn btn-ghost mt-4">
152 + groupe-ka.com ↗
153 + </a>
154 + </section>
155 + </main>
156 + );
157 +}
added src/app/page.tsx +572 −0
@@ -0,0 +1,572 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { ReactNode } from "react";
3 +import {
4 + AggregationFigure,
5 + PipelineFigure,
6 + CycleFigure,
7 + InventoryBars,
8 + ConnectorMatrix,
9 + CoverageTiles,
10 +} from "./figures";
11 +
12 +type Product = {
13 + num: string;
14 + markLeft: string;
15 + markBoxed: string;
16 + accent: string; // couleur du logo (texte de la boîte)
17 + inkbox: string; // fond de la boîte du logo
18 + domain: string;
19 + url: string;
20 + tagline: string;
21 + description: string;
22 + chips: string[];
23 + features: string[];
24 +};
25 +
26 +const PRODUCTS: Product[] = [
27 + {
28 + num: "01",
29 + markLeft: "Lou",
30 + markBoxed: "Ka",
31 + accent: "#d9f26b",
32 + inkbox: "#141814",
33 + domain: "www.lou-ka.com",
34 + url: "https://www.lou-ka.com",
35 + tagline: "Tous les logements à louer du Québec. Un seul endroit.",
36 + description:
37 + "Agrégateur indépendant de logements locatifs : les annonces d'environ 74 gestionnaires immobiliers, normalisées, géolocalisées et synchronisées en continu. Une annonce retirée du site source disparaît du site — rien d'inventé, tout est traçable jusqu'à l'annonce originale.",
38 + chips: ["7 000+ annonces", "108 connecteurs actifs", "MàJ horaire"],
39 + features: [
40 + "Recherche unifiée : ville, secteur, taille (3½…), loyer, gestionnaire",
41 + "Vue carte interactive en plus de la grille de résultats",
42 + "Historique des baisses de prix et position face au marché",
43 + "Aucun traceur publicitaire, aucun témoin tiers",
44 + ],
45 + },
46 + {
47 + num: "02",
48 + markLeft: "Immo",
49 + markBoxed: "Ka",
50 + accent: "#e23744",
51 + inkbox: "#1a1214",
52 + domain: "www.immo-ka.com",
53 + url: "https://www.immo-ka.com",
54 + tagline: "Toutes les propriétés à vendre du Québec. Un seul endroit.",
55 + description:
56 + "L'inventaire des agences de courtage du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct et des dizaines d'autres — agrégé à la source, normalisé et géolocalisé. Chaque fiche renvoie à l'inscription originale du courtier ; une propriété vendue disparaît d'elle-même.",
57 + chips: ["54 000+ propriétés", "65 connecteurs", "2 370 villes"],
58 + features: [
59 + "Recherche unifiée : ville, type, prix, terrain, pièces",
60 + "Vue carte province entière, de Gatineau à Gaspé",
61 + "Détection automatique des ventes et retraits",
62 + "Lien direct vers l'inscription originale de l'agence",
63 + ],
64 + },
65 + {
66 + num: "03",
67 + markLeft: "Vrai",
68 + markBoxed: "Prix",
69 + accent: "#ff5148",
70 + inkbox: "#141814",
71 + domain: "www.vrai-prix.com",
72 + url: "https://www.vrai-prix.com",
73 + tagline: "La vraie valeur de votre propriété, en toute transparence.",
74 + description:
75 + "Estimation résidentielle pour tout le Québec, bâtie sur les rôles d'évaluation foncière du MAMH et 745 119 ventes réelles publiées de 2021 à 2026. Moteur hybride — modèle hédonique + comparables ajustés — dont chaque ajustement est affiché en dollars.",
76 + chips: ["3 747 008 propriétés", "745 119 ventes réelles", "MdAPE 11 %"],
77 + features: [
78 + "Fourchette honnête P10-P90 plutôt qu'un faux prix unique",
79 + "Indice de confiance A-D affiché sur une jauge, jamais caché",
80 + "Chaque comparable détaillé : marché, superficie, âge, poids",
81 + "Rapports PDF de marque, standard à format bancaire",
82 + ],
83 + },
84 + {
85 + num: "04",
86 + markLeft: "Valo",
87 + markBoxed: "Plex",
88 + accent: "#ff9f45",
89 + inkbox: "#141814",
90 + domain: "www.valoplex.com",
91 + url: "https://www.valoplex.com",
92 + tagline: "La vraie valeur de votre plex, porte par porte.",
93 + description:
94 + "Le moteur d'évaluation spécialisé pour les immeubles à revenus du Québec : entraîné exclusivement sur ~91 060 ventes réelles de plex, avec les variables qui comptent — portes, aire habitable par porte, chambres locatives, mixité commerciale.",
95 + chips: ["393 867 plex", "1 733 744 portes", "~337 G$ de parc"],
96 + features: [
97 + "Valeur totale et valeur par porte, l'unité d'analyse du plex",
98 + "Fourchette calibrée : 8 fois sur 10, le vrai prix tombe dedans",
99 + "« Pourquoi ce prix ? » — le calcul montré en entier",
100 + "Suivi de parc immobilier et rapports institutionnels",
101 + ],
102 + },
103 + {
104 + num: "05",
105 + markLeft: "Auto",
106 + markBoxed: "Ka",
107 + accent: "#ff5a2a",
108 + inkbox: "#17181c",
109 + domain: "www.auto-ka.com",
110 + url: "https://www.auto-ka.com",
111 + tagline: "Toutes les voitures usagées à vendre au Québec. Un seul endroit.",
112 + description:
113 + "L'inventaire de 124 concessionnaires et marchands d'occasion, lu directement sur leurs sites — jamais via les plateformes d'annonces. Photos complètes, équipements, VIN, kilométrage normalisés ; les baisses de prix et les ventes sont détectées automatiquement.",
114 + chips: ["15 900+ véhicules", "124 concessionnaires", "18 régions"],
115 + features: [
116 + "Filtres complets : marque, modèle, année, km, carburant, région",
117 + "Suivi des baisses de prix — les aubaines remontent d'elles-mêmes",
118 + "Fiches enrichies : galeries, équipements, moteur, Carfax",
119 + "Rapport PDF du marché de l'occasion, téléchargeable",
120 + ],
121 + },
122 + {
123 + num: "06",
124 + markLeft: "Fabri",
125 + markBoxed: "Ka",
126 + accent: "#C4532E",
127 + inkbox: "#1E1B16",
128 + domain: "www.fabri-ka.com",
129 + url: "https://www.fabri-ka.com",
130 + tagline: "Tous les produits québécois. Un seul endroit.",
131 + description:
132 + "La vitrine des fabricants et artisans d'ici : plus de 280 000 produits provenant de 1 200 boutiques en ligne québécoises, indexés par région et par catégorie. Chaque produit renvoie directement à la boutique du fabricant — acheter local n'a jamais été aussi simple.",
133 + chips: ["281 000+ produits", "1 204 boutiques", "17 régions"],
134 + features: [
135 + "Découverte par région, catégorie et mot-clé",
136 + "Lien direct vers la boutique du fabricant, sans intermédiaire",
137 + "Des Îles-de-la-Madeleine à l'Abitibi : tout le Québec",
138 + "Registre public de 3 200+ boutiques d'ici",
139 + ],
140 + },
141 + {
142 + num: "07",
143 + markLeft: "Food",
144 + markBoxed: "Ka",
145 + accent: "#d9f26b",
146 + inkbox: "#14231a",
147 + domain: "www.food-ka.com",
148 + url: "https://www.food-ka.com",
149 + tagline: "Tous les prix d'épicerie du Québec.",
150 + description:
151 + "Les produits et circulaires des grandes bannières d'épicerie — Metro, IGA, Maxi, Super C, Provigo et 15 autres — agrégés avec leurs prix, soldes et prix unitaires. Comparer son panier entre bannières devient un réflexe, pas une corvée.",
152 + chips: ["23 700+ produits", "20 bannières", "2 900+ soldes actifs"],
153 + features: [
154 + "Comparaison des prix entre bannières, produit par produit",
155 + "Prix unitaires normalisés ($/100 g, $/L) pour comparer vraiment",
156 + "Soldes et circulaires de la semaine en un seul flux",
157 + "Catégories d'épicerie complètes, du frais au garde-manger",
158 + ],
159 + },
160 +];
161 +
162 +const MANIFESTO = [
163 + {
164 + num: "01",
165 + title: "Zéro boîte noire",
166 + body: "Un score magique n'est pas une réponse. Chaque estimation montre ses comparables, ses ajustements en dollars et sa fourchette d'incertitude. Si le calcul ne peut pas être montré, il ne sort pas.",
167 + },
168 + {
169 + num: "02",
170 + title: "Données réelles",
171 + body: "Rôles d'évaluation foncière, ventes publiées, annonces vérifiées à la source. Aucun prix inventé : quand la donnée n'existe pas, on affiche qu'elle n'existe pas. Validé sur des ventes jamais vues, pas promis.",
172 + },
173 + {
174 + num: "03",
175 + title: "Indépendance",
176 + body: "Aucun courtier ni annonceur ne paie pour influencer un résultat. Pas de traceur publicitaire, pas de revente de données. Le français d'abord, le Québec comme terrain, la Loi 25 comme plancher.",
177 + },
178 +];
179 +
180 +const SOCLE_ROWS: [string, string, string, string][] = [
181 + [
182 + "Lou·Ka",
183 + "Location résidentielle",
184 + "108 connecteurs sources + synchronisation horaire",
185 + "www.lou-ka.com",
186 + ],
187 + [
188 + "Immo·Ka",
189 + "Propriétés à vendre",
190 + "65 connecteurs d'agences + détection des ventes",
191 + "www.immo-ka.com",
192 + ],
193 + [
194 + "Vrai-Prix",
195 + "Résidentiel — tout le Québec",
196 + "Hédonique LightGBM + comparables réels ajustés",
197 + "www.vrai-prix.com",
198 + ],
199 + [
200 + "ValoPlex",
201 + "Immeubles à revenus (plex)",
202 + "Hédonique monotone + ventes de plex comparables",
203 + "www.valoplex.com",
204 + ],
205 + [
206 + "Auto·Ka",
207 + "Voitures usagées",
208 + "125 connecteurs concessionnaires + suivi des prix",
209 + "www.auto-ka.com",
210 + ],
211 + [
212 + "Fabri·Ka",
213 + "Produits québécois",
214 + "1 204 boutiques en ligne indexées par région",
215 + "www.fabri-ka.com",
216 + ],
217 + [
218 + "Food·Ka",
219 + "Épicerie",
220 + "20 bannières — prix, soldes et prix unitaires",
221 + "www.food-ka.com",
222 + ],
223 +];
224 +
225 +function Section({
226 + id,
227 + kicker,
228 + title,
229 + children,
230 +}: {
231 + id?: string;
232 + kicker: string;
233 + title: ReactNode;
234 + children: ReactNode;
235 +}) {
236 + return (
237 + <section id={id} className="mt-16 scroll-mt-24 sm:mt-20">
238 + <p className="kicker">{kicker}</p>
239 + <h2 className="gk-display mt-2 text-[22px] font-bold tracking-[-0.03em] uppercase sm:text-[26px]">
240 + {title}
241 + </h2>
242 + {children}
243 + </section>
244 + );
245 +}
246 +
247 +/* ------------------------------------------------------------------ */
248 +/* Entrée de dictionnaire — l'allusion fondatrice : ·Ka = agréger. */
249 +/* ------------------------------------------------------------------ */
250 +
251 +function KaDefinition() {
252 + return (
253 + <aside className="gk-card gk-card-hover rise p-6 sm:p-7 [animation-delay:0.3s]">
254 + <p className="klabel">Le nom, expliqué</p>
255 + <p className="gk-display mt-3 text-[30px] font-bold tracking-[-0.03em]">
256 + ·Ka{" "}
257 + <span className="gk-mono align-middle text-[13px] font-medium text-ink-3">
258 + /ka/ suffixe
259 + </span>
260 + </p>
261 + <ol className="mt-4 space-y-3 text-[13.5px] leading-relaxed text-ink-2">
262 + <li className="flex gap-3">
263 + <span className="gk-mono font-bold text-green">1.</span>
264 + <span>
265 + <strong className="text-ink">Agréger</strong> — rassembler en un
266 + seul endroit ce qui était éparpillé sur des centaines de sites.
267 + </span>
268 + </li>
269 + <li className="flex gap-3">
270 + <span className="gk-mono font-bold text-green">2.</span>
271 + <span>
272 + Marque de fabrique du groupe : Lou·Ka loge, Immo·Ka vend, Auto·Ka
273 + roule, Fabri·Ka fabrique, Food·Ka nourrit.
274 + </span>
275 + </li>
276 + </ol>
277 + <p className="mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-4 text-[13px] italic text-ink-2">
278 + « Tout ce qui finit en <span className="gk-display font-bold not-italic">·Ka</span>{" "}
279 + agrège. »
280 + </p>
281 + </aside>
282 + );
283 +}
284 +
285 +function ProductCard({ p, index }: { p: Product; index: number }) {
286 + return (
287 + <article
288 + className="gk-card gk-card-hover rise p-5 sm:p-7"
289 + style={{ animationDelay: `${0.1 + index * 0.12}s` }}
290 + >
291 + <div className="flex flex-wrap items-baseline justify-between gap-3">
292 + <span className="gk-mono text-[12px] font-bold text-green">
293 + {p.num}
294 + </span>
295 + <span className="klabel">{p.domain}</span>
296 + </div>
297 + <h3 className="gk-display mt-4 inline-flex items-baseline gap-[5px] text-[32px] font-bold tracking-[-0.04em] sm:text-[38px]">
298 + {p.markLeft}
299 + <span
300 + className="inline-block -rotate-2 rounded-md px-[8px] pb-[3px]"
301 + style={{ background: p.inkbox, color: p.accent }}
302 + >
303 + {p.markBoxed}
304 + </span>
305 + </h3>
306 + <p className="gk-display mt-3 text-[17px] font-bold uppercase tracking-[-0.01em]">
307 + {p.tagline}
308 + </p>
309 + <p className="mt-3 text-[13.5px] leading-relaxed text-ink-2">
310 + {p.description}
311 + </p>
312 + <div className="mt-5 flex flex-wrap gap-2">
313 + {p.chips.map((chip) => (
314 + <span key={chip} className="stat-chip !text-[11px]">
315 + {chip}
316 + </span>
317 + ))}
318 + </div>
319 + <ul className="mt-5 space-y-2 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-5">
320 + {p.features.map((f) => (
321 + <li key={f} className="flex gap-3 text-[13.5px] text-ink-2">
322 + <span
323 + aria-hidden="true"
324 + className="mt-[7px] h-[5px] w-[5px] flex-none rotate-45 border border-ink bg-lime"
325 + />
326 + {f}
327 + </li>
328 + ))}
329 + </ul>
330 + <a
331 + href={p.url}
332 + target="_blank"
333 + rel="noopener noreferrer"
334 + className="btn btn-primary mt-6 w-full sm:w-auto"
335 + >
336 + Visiter {p.domain} ↗
337 + </a>
338 + </article>
339 + );
340 +}
341 +
342 +export default function Home() {
343 + return (
344 + <main className="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
345 + {/* ---------- Héros ---------- */}
346 + <section className="grid items-start gap-8 pt-12 sm:pt-14 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
347 + <div className="min-w-0">
348 + <p className="kicker rise">
349 + Holding d’agrégateurs automatisés · Québec
350 + </p>
351 + <h1 className="gk-display rise mt-4 max-w-4xl text-[clamp(34px,6.4vw,68px)] leading-[0.98] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
352 + Mille sites.{" "}
353 + <span className="outline-txt">Un seul</span>{" "}
354 + <span className="hl">·Ka.</span>
355 + </h1>
356 + <p className="rise mt-5 max-w-xl text-[16.5px] text-ink-2 [animation-delay:0.16s]">
357 + <strong className="text-ink">Ka, c&apos;est l&apos;agrégation.</strong>{" "}
358 + Groupe KA est un holding d&apos;agrégateurs entièrement
359 + automatisés : des centaines de connecteurs lisent les sites à la
360 + source, normalisent la donnée et se resynchronisent seuls, jour et
361 + nuit. Se loger, acheter, évaluer, rouler, consommer local, faire
362 + l&apos;épicerie — sept plateformes, zéro saisie manuelle, zéro
363 + boîte noire.
364 + </p>
365 + <div className="rise mt-7 flex gap-3 overflow-x-auto pb-2 [animation-delay:0.24s]">
366 + <span className="stat-chip">
367 + <span className="pulse-dot" aria-hidden="true" />7 agrégateurs en
368 + ligne
369 + </span>
370 + <span className="stat-chip">450+ connecteurs sources</span>
371 + <span className="stat-chip">22 M+ d&apos;observations immobilières</span>
372 + <span className="stat-chip">375 000+ annonces &amp; produits agrégés</span>
373 + <span className="stat-chip">100 % données à la source</span>
374 + </div>
375 + <div className="rise mt-6 flex flex-wrap gap-3 [animation-delay:0.3s]">
376 + <a href="#flux" className="btn btn-primary">
377 + Voir comment KA agrège ↓
378 + </a>
379 + <a href="#produits" className="btn btn-ghost">
380 + Les sept plateformes
381 + </a>
382 + </div>
383 + </div>
384 + <KaDefinition />
385 + </section>
386 +
387 + {/* ---------- La figure d'agrégation ---------- */}
388 + <Section
389 + id="flux"
390 + kicker="Le geste fondateur · agréger"
391 + title={
392 + <>
393 + Plein de sites. <span className="hl">Une seule flèche.</span>
394 + </>
395 + }
396 + >
397 + <p className="mt-3 max-w-xl text-[14.5px] text-ink-2">
398 + Chaque plateforme du groupe fonctionne sur le même schéma : un
399 + faisceau de connecteurs part vers des centaines de sites sources,
400 + ramène la donnée brute, et KA la transforme en un seul endroit
401 + propre. Voici la machine, à découvert.
402 + </p>
403 + <AggregationFigure />
404 + <div className="mt-6 grid items-start gap-6 lg:grid-cols-2">
405 + <PipelineFigure />
406 + <CycleFigure />
407 + </div>
408 + </Section>
409 +
410 + {/* ---------- Les chiffres ---------- */}
411 + <Section
412 + id="chiffres"
413 + kicker="La preuve par les nombres · rien de caché"
414 + title={
415 + <>
416 + Ce que ·Ka <span className="hl">agrège, compté</span>
417 + </>
418 + }
419 + >
420 + <p className="mt-3 max-w-xl text-[14.5px] text-ink-2">
421 + Un agrégateur se juge à ce qu&apos;il agrège. Voici l&apos;état des
422 + lieux, mesuré sur les plateformes elles-mêmes — inventaire vivant,
423 + flotte de connecteurs, couverture des moteurs d&apos;évaluation.
424 + </p>
425 + <div className="mt-7 grid items-stretch gap-6 lg:grid-cols-2">
426 + <InventoryBars />
427 + <ConnectorMatrix />
428 + </div>
429 + <div className="mt-6 grid items-start gap-6 lg:grid-cols-2">
430 + <div className="gk-card !bg-ink p-6 text-paper sm:p-7">
431 + <p className="kicker !text-lime">L&apos;équation ·Ka</p>
432 + <p className="gk-display mt-4 text-[clamp(26px,3.5vw,38px)] leading-[1.05] font-bold tracking-[-0.03em] uppercase">
433 + 1 000+ sites lus
434 + <br />
435 + <span className="text-lime">→ 450+ connecteurs</span>
436 + <br />→ 7 plateformes
437 + </p>
438 + <p className="mt-4 max-w-md text-[13.5px] text-[rgba(245,243,238,0.75)]">
439 + La donnée entre éparpillée, elle ressort rangée. Entre les deux,
440 + aucune main humaine — c&apos;est le suffixe ·Ka qui travaille.
441 + </p>
442 + </div>
443 + <CoverageTiles />
444 + </div>
445 + </Section>
446 +
447 + {/* ---------- Produits ---------- */}
448 + <Section
449 + id="produits"
450 + kicker="Sept agrégateurs · un écosystème"
451 + title={
452 + <>
453 + Ce que nous <span className="hl">agrégeons</span>
454 + </>
455 + }
456 + >
457 + <p className="mt-3 max-w-xl text-[14.5px] text-ink-2">
458 + Chaque agrégateur attaque un angle mort du quotidien québécois : se
459 + loger, acheter, évaluer, rouler, consommer local, faire
460 + l&apos;épicerie. Aucune saisie manuelle — les plateformes se
461 + maintiennent elles-mêmes, annonce par annonce, prix par prix.
462 + </p>
463 + <div className="mt-7 grid gap-5 lg:grid-cols-3">
464 + {PRODUCTS.map((p, i) => (
465 + <ProductCard key={p.num} p={p} index={i} />
466 + ))}
467 + </div>
468 + </Section>
469 +
470 + {/* ---------- Socle commun ---------- */}
471 + <Section
472 + id="socle"
473 + kicker="Une seule fondation"
474 + title="Le socle commun"
475 + >
476 + <p className="mt-3 max-w-xl text-[14.5px] text-ink-2">
477 + Les sept plateformes partagent la même colonne vertébrale : données
478 + publiques et annonces lues directement à la source, connecteurs
479 + dédiés synchronisés en continu, moteurs statistiques validés, et une
480 + identité visuelle unique — bordures encre, lime électrique, calcul à
481 + découvert.
482 + </p>
483 + <div className="src-wrap mt-7">
484 + <table className="src-table">
485 + <thead>
486 + <tr>
487 + <th>Agrégateur</th>
488 + <th>Marché</th>
489 + <th>Moteur</th>
490 + <th>Adresse</th>
491 + </tr>
492 + </thead>
493 + <tbody>
494 + {SOCLE_ROWS.map(([produit, marche, moteur, adresse]) => (
495 + <tr key={produit}>
496 + <td className="gk-display font-bold whitespace-nowrap">
497 + {produit}
498 + </td>
499 + <td>{marche}</td>
500 + <td>{moteur}</td>
501 + <td className="gk-mono text-[12px] whitespace-nowrap">
502 + <a
503 + href={`https://${adresse}`}
504 + target="_blank"
505 + rel="noopener noreferrer"
506 + className="underline decoration-[rgba(20,24,20,0.3)] underline-offset-4 hover:decoration-ink"
507 + >
508 + {adresse}
509 + </a>
510 + </td>
511 + </tr>
512 + ))}
513 + </tbody>
514 + </table>
515 + </div>
516 + </Section>
517 +
518 + {/* ---------- Manifeste ---------- */}
519 + <Section id="mission" kicker="Notre manifeste" title="Trois principes, aucune exception">
520 + <div className="mt-7 grid gap-4 sm:grid-cols-3">
521 + {MANIFESTO.map((m) => (
522 + <article key={m.num} className="gk-card gk-card-hover p-5 sm:p-6">
523 + <span className="gk-mono text-[12px] font-bold text-green">
524 + {m.num}
525 + </span>
526 + <h3 className="gk-display mt-3 text-[17px] font-bold uppercase">
527 + {m.title}
528 + </h3>
529 + <p className="mt-3 text-[13.5px] leading-relaxed text-ink-2">
530 + {m.body}
531 + </p>
532 + </article>
533 + ))}
534 + </div>
535 + </Section>
536 +
537 + {/* ---------- Appel à l'action ---------- */}
538 + <section className="mt-16 sm:mt-20">
539 + <div className="gk-card overflow-hidden !bg-ink p-7 text-paper sm:p-10">
540 + <p className="kicker !text-lime">Un projet · une question</p>
541 + <h2 className="gk-display mt-3 max-w-2xl text-[clamp(24px,4vw,40px)] leading-[1.02] font-bold tracking-[-0.03em] uppercase">
542 + Quelque chose à <span className="text-lime">agréger</span> ?{" "}
543 + Parlons-en.
544 + </h2>
545 + <p className="mt-4 max-w-xl text-[14.5px] text-[rgba(245,243,238,0.75)]">
546 + Partenariats, données, médias ou simple curiosité méthodologique —
547 + de vraies personnes au bout. C&apos;est notre spécialité : tout
548 + ramener à un seul endroit.
549 + </p>
550 + <div className="mt-7 flex flex-wrap gap-3">
551 + <a
552 + href="mailto:contact@groupe-ka.com"
553 + className="btn btn-lime !border-lime"
554 + >
555 + contact@groupe-ka.com
556 + </a>
557 + <a
558 + href="mailto:info@groupe-ka.com"
559 + className="btn !border-lime !bg-transparent !text-lime hover:!bg-[rgba(217,242,107,0.12)]"
560 + >
561 + info@groupe-ka.com
562 + </a>
563 + </div>
564 + <p className="gk-mono mt-5 text-[11px] text-[rgba(245,243,238,0.55)]">
565 + contact@ — projets &amp; partenariats · info@ — médias &amp;
566 + questions générales · admin@ — légal &amp; vie privée
567 + </p>
568 + </div>
569 + </section>
570 + </main>
571 + );
572 +}
added src/app/sso/authorize/route.ts +65 −0
@@ -0,0 +1,65 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// « Se connecter avec KA » — endpoint SSO du hub.
3 +// Une plateforme du groupe (ex. Lou·Ka) envoie l'utilisateur ici ;
4 +// s'il a une session KA ID (créée par courriel ou via Google), on lui
5 +// remet un jeton signé (HS256, secret partagé avec la plateforme) qui
6 +// transporte son profil, puis on le renvoie vers la plateforme.
7 +import { NextRequest, NextResponse } from "next/server";
8 +import { SignJWT } from "jose";
9 +import { getSessionUser, BASE_URL } from "@/lib/auth";
10 +import { SSO_CLIENTS } from "@/lib/sso";
11 +import { ensureKaId } from "@/lib/db";
12 +
13 +export async function GET(req: NextRequest) {
14 + const clientId = req.nextUrl.searchParams.get("client_id") ?? "";
15 + const redirectUri = req.nextUrl.searchParams.get("redirect_uri") ?? "";
16 + const state = req.nextUrl.searchParams.get("state") ?? "";
17 +
18 + const client = SSO_CLIENTS[clientId];
19 + if (!client)
20 + return NextResponse.json(
21 + { error: `client_id inconnu : ${clientId}` },
22 + { status: 400 },
23 + );
24 + if (!client.redirectPrefixes.some((p) => redirectUri.startsWith(p)))
25 + return NextResponse.json(
26 + { error: "redirect_uri non autorisé pour ce client." },
27 + { status: 400 },
28 + );
29 + const secret = process.env[client.secretEnv];
30 + if (!secret)
31 + return NextResponse.json(
32 + { error: `Secret SSO non configuré pour ${clientId}.` },
33 + { status: 500 },
34 + );
35 +
36 + const user = await getSessionUser();
37 + if (!user) {
38 + // Pas de session hub → page de connexion, puis retour ici.
39 + const back = req.nextUrl.pathname + "?" + req.nextUrl.searchParams.toString();
40 + return NextResponse.redirect(
41 + new URL(`/connexion?next=${encodeURIComponent(back)}&via=${clientId}`, BASE_URL),
42 + );
43 + }
44 +
45 + const token = await new SignJWT({
46 + sub: String(user.id),
47 + ka_id: ensureKaId(user.id),
48 + email: user.email,
49 + name: user.name,
50 + picture: user.avatarUrl,
51 + provider: user.hasGoogle ? "google" : user.hasApple ? "apple" : "password",
52 + ka_role: user.role,
53 + })
54 + .setProtectedHeader({ alg: "HS256" })
55 + .setIssuer(BASE_URL)
56 + .setAudience(clientId)
57 + .setIssuedAt()
58 + .setExpirationTime("5m")
59 + .sign(new TextEncoder().encode(secret));
60 +
61 + const dest = new URL(redirectUri);
62 + dest.searchParams.set("ka_token", token);
63 + if (state) dest.searchParams.set("state", state);
64 + return NextResponse.redirect(dest);
65 +}
added src/app/u/[kaId]/page.tsx +189 −0
@@ -0,0 +1,189 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Profil public d'un membre (opt-in) — groupe-ka.com/u/{ka_id}.
3 +// Affiche nom, photo, statut, bio, emploi, ville, âge, site et réseaux —
4 +// JAMAIS le courriel ni le téléphone. 404 indistinct si privé/inexistant.
5 +import type { Metadata } from "next";
6 +import {
7 + db,
8 + parseSocials,
9 + ageFromBirthDate,
10 + type UserRow,
11 +} from "@/lib/db";
12 +import { roleCard } from "@/lib/roles";
13 +
14 +export const metadata: Metadata = {
15 + title: "Profil de membre | Groupe KA",
16 + description: "Profil public d'un membre du Groupe KA.",
17 +};
18 +
19 +export const dynamic = "force-dynamic";
20 +
21 +function fmtDate(iso: string | null): string {
22 + if (!iso) return "—";
23 + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
24 + if (Number.isNaN(d.getTime())) return "—";
25 + return d
26 + .toLocaleDateString("fr-CA", { month: "long", year: "numeric" });
27 +}
28 +
29 +const SOCIAL_LABELS: Record<string, string> = {
30 + instagram: "Instagram",
31 + facebook: "Facebook",
32 + x: "X",
33 + linkedin: "LinkedIn",
34 + tiktok: "TikTok",
35 + youtube: "YouTube",
36 +};
37 +
38 +function socialHref(key: string, value: string): string {
39 + if (/^https?:\/\//i.test(value)) return value;
40 + const handle = value.replace(/^@/, "");
41 + const base: Record<string, string> = {
42 + instagram: "https://instagram.com/",
43 + facebook: "https://facebook.com/",
44 + x: "https://x.com/",
45 + linkedin: "https://www.linkedin.com/in/",
46 + tiktok: "https://www.tiktok.com/@",
47 + youtube: "https://www.youtube.com/",
48 + };
49 + return (base[key] ?? "https://") + handle;
50 +}
51 +
52 +export default async function ProfilPublic({
53 + params,
54 +}: {
55 + params: Promise<{ kaId: string }>;
56 +}) {
57 + const { kaId } = await params;
58 + const clean = /^ka-\d{10}$/.test(kaId) ? kaId : null;
59 + const row = clean
60 + ? (db.prepare("SELECT * FROM users WHERE ka_id = ?").get(clean) as
61 + | UserRow
62 + | undefined)
63 + : undefined;
64 +
65 + // privé ou inexistant : indistinguable, volontairement
66 + if (!row || !row.public) {
67 + return (
68 + <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">
69 + <section className="pt-12 text-center sm:pt-14">
70 + <p className="kicker rise justify-center">Profil de membre</p>
71 + <h1 className="gk-display rise mt-4 text-[clamp(28px,5vw,40px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">
72 + Profil <span className="outline-txt">introuvable</span>
73 + </h1>
74 + <p className="rise mt-4 text-[14px] text-ink-2 [animation-delay:0.16s]">
75 + Ce profil n&apos;existe pas ou n&apos;est pas public.
76 + </p>
77 + </section>
78 + </main>
79 + );
80 + }
81 +
82 + const socials = parseSocials(row.socials);
83 + const age = ageFromBirthDate(row.birth_date);
84 + const facts: [string, string][] = [
85 + ...(row.job_title
86 + ? ([["Emploi", row.company ? `${row.job_title} · ${row.company}` : row.job_title]] as [string, string][])
87 + : row.company
88 + ? ([["Entreprise", row.company]] as [string, string][])
89 + : []),
90 + ...(row.city ? ([["Ville", row.city]] as [string, string][]) : []),
91 + ...(age !== null ? ([["Âge", `${age} ans`]] as [string, string][]) : []),
92 + ["Membre depuis", fmtDate(row.created_at)],
93 + ["KA-ID", row.ka_id ?? "—"],
94 + ];
95 +
96 + return (
97 + <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">
98 + <section className="pt-10 text-center sm:pt-12">
99 + <p className="kicker rise justify-center">
100 + Profil de membre · Groupe KA
101 + </p>
102 +
103 + <div className="rise mt-7 flex justify-center [animation-delay:0.08s]">
104 + {row.avatar_url ? (
105 + // eslint-disable-next-line @next/next/no-img-element
106 + <img
107 + src={row.avatar_url}
108 + alt={`Photo de ${row.name}`}
109 + width={176}
110 + height={176}
111 + referrerPolicy="no-referrer"
112 + className="h-44 w-44 rounded-full border-[3px] border-ink object-cover shadow-[8px_8px_0_rgba(20,24,20,0.18)]"
113 + />
114 + ) : (
115 + <span className="gk-display flex h-44 w-44 items-center justify-center rounded-full border-[3px] border-ink bg-lime text-[64px] font-bold shadow-[8px_8px_0_rgba(20,24,20,0.18)]">
116 + {row.name.charAt(0).toUpperCase()}
117 + </span>
118 + )}
119 + </div>
120 +
121 + <h1 className="gk-display rise mt-6 text-[clamp(28px,6vw,42px)] leading-[1.02] font-bold tracking-[-0.03em] [animation-delay:0.14s]">
122 + {row.name}
123 + </h1>
124 + {roleCard(row.role) && (
125 + <p className="rise mt-3 [animation-delay:0.18s]">
126 + <span className="gk-mono inline-block -rotate-1 rounded-[5px] bg-ink px-3 py-[4px] text-[10.5px] font-bold tracking-[0.12em] text-lime">
127 + {roleCard(row.role)}
128 + </span>
129 + </p>
130 + )}
131 + {row.bio && (
132 + <p className="rise mx-auto mt-4 max-w-sm text-[14.5px] leading-relaxed text-ink-2 [animation-delay:0.22s]">
133 + {row.bio}
134 + </p>
135 + )}
136 +
137 + <div className="gk-card rise mt-7 p-0 text-left [animation-delay:0.26s]">
138 + {facts.map(([label, value], i) => (
139 + <div
140 + key={label}
141 + className={`flex items-baseline justify-between gap-4 px-5 py-[13px] ${i < facts.length - 1 ? "border-b border-dashed border-[rgba(20,24,20,0.18)]" : ""}`}
142 + >
143 + <span className="klabel flex-none">{label}</span>
144 + <span
145 + className={`text-right text-[13.5px] font-semibold ${label === "KA-ID" ? "gk-mono text-green" : ""}`}
146 + >
147 + {value}
148 + </span>
149 + </div>
150 + ))}
151 + </div>
152 +
153 + {(row.website || Object.keys(socials).length > 0) && (
154 + <div className="rise mt-5 flex flex-wrap justify-center gap-2 [animation-delay:0.32s]">
155 + {row.website && (
156 + <a
157 + href={row.website}
158 + target="_blank"
159 + rel="noopener noreferrer nofollow"
160 + className="stat-chip !text-[11.5px] hover:bg-lime-soft"
161 + >
162 + 🌐 Site web
163 + </a>
164 + )}
165 + {Object.entries(socials).map(([k, v]) => (
166 + <a
167 + key={k}
168 + href={socialHref(k, v)}
169 + target="_blank"
170 + rel="noopener noreferrer nofollow"
171 + className="stat-chip !text-[11.5px] hover:bg-lime-soft"
172 + >
173 + {SOCIAL_LABELS[k] ?? k}
174 + </a>
175 + ))}
176 + </div>
177 + )}
178 +
179 + <p className="mt-6 text-[12px] text-ink-2">
180 + Profil publié volontairement par ce membre —{" "}
181 + <a href={`/m/${row.ka_id}`} className="underline underline-offset-4">
182 + vérifier son KA-ID
183 + </a>
184 + . Courriel et téléphone jamais affichés.
185 + </p>
186 + </section>
187 + </main>
188 + );
189 +}
added src/lib/auth.ts +105 −0
@@ -0,0 +1,105 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA ID — sessions (JWT HS256 en témoin HttpOnly) + hachage scrypt.
3 +import { SignJWT, jwtVerify } from "jose";
4 +import { cookies } from "next/headers";
5 +import crypto from "crypto";
6 +import { findUserById, type UserRow } from "./db";
7 +
8 +export const SESSION_COOKIE = "ka_session";
9 +export const BASE_URL = process.env.BASE_URL ?? "https://www.groupe-ka.com";
10 +
11 +function secret(): Uint8Array {
12 + const s = process.env.KA_AUTH_SECRET;
13 + if (!s) {
14 + if (process.env.NODE_ENV === "production")
15 + throw new Error("KA_AUTH_SECRET manquant");
16 + return new TextEncoder().encode("ka-dev-secret-non-production");
17 + }
18 + return new TextEncoder().encode(s);
19 +}
20 +
21 +/* ---------- mots de passe (scrypt natif, zéro dépendance) ---------- */
22 +
23 +export function hashPassword(password: string): string {
24 + const salt = crypto.randomBytes(16).toString("hex");
25 + const hash = crypto.scryptSync(password, salt, 64).toString("hex");
26 + return `${salt}:${hash}`;
27 +}
28 +
29 +export function verifyPassword(password: string, stored: string): boolean {
30 + const [salt, hash] = stored.split(":");
31 + if (!salt || !hash) return false;
32 + const test = crypto.scryptSync(password, salt, 64);
33 + return crypto.timingSafeEqual(test, Buffer.from(hash, "hex"));
34 +}
35 +
36 +/* ---------- sessions ---------- */
37 +
38 +export type SessionUser = {
39 + id: number;
40 + email: string;
41 + name: string;
42 + avatarUrl: string | null;
43 + hasGoogle: boolean;
44 + hasApple: boolean;
45 + hasPassword: boolean;
46 + kaId: string | null;
47 + createdAt: string;
48 + lastLogin: string | null;
49 + role: string | null;
50 +};
51 +
52 +export async function mintSessionToken(userId: number): Promise<string> {
53 + return await new SignJWT({ uid: userId })
54 + .setProtectedHeader({ alg: "HS256" })
55 + .setIssuedAt()
56 + .setIssuer(BASE_URL)
57 + .setExpirationTime("30d")
58 + .sign(secret());
59 +}
60 +
61 +export const sessionCookieOptions = {
62 + httpOnly: true,
63 + secure: process.env.NODE_ENV === "production",
64 + sameSite: "lax" as const,
65 + path: "/",
66 + maxAge: 60 * 60 * 24 * 30,
67 +};
68 +
69 +export async function getSessionUser(): Promise<SessionUser | null> {
70 + const jar = await cookies();
71 + const token = jar.get(SESSION_COOKIE)?.value;
72 + if (!token) return null;
73 + try {
74 + const { payload } = await jwtVerify(token, secret(), {
75 + issuer: BASE_URL,
76 + });
77 + const row = findUserById(Number(payload.uid)) as UserRow | undefined;
78 + if (!row) return null;
79 + return {
80 + id: row.id,
81 + email: row.email,
82 + name: row.name,
83 + avatarUrl: row.avatar_url,
84 + hasGoogle: !!row.google_sub,
85 + hasApple: !!row.apple_sub,
86 + hasPassword: !!row.password_hash,
87 + kaId: row.ka_id,
88 + createdAt: row.created_at,
89 + lastLogin: row.last_login,
90 + role: row.role,
91 + };
92 + } catch {
93 + return null;
94 + }
95 +}
96 +
97 +/* ---------- redirections sûres ---------- */
98 +
99 +// N'accepte que les chemins internes (« /compte ») — jamais d'URL absolue,
100 +// sauf un retour SSO interne déjà validé par /sso/authorize.
101 +export function safeNext(next: string | null | undefined): string {
102 + if (!next) return "/compte";
103 + if (next.startsWith("/") && !next.startsWith("//")) return next;
104 + return "/compte";
105 +}
added src/lib/db.ts +294 −0
@@ -0,0 +1,294 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA ID — base d'utilisateurs partagée du groupe (SQLite WAL).
3 +import Database from "better-sqlite3";
4 +import path from "path";
5 +import fs from "fs";
6 +
7 +function open() {
8 + const dbPath =
9 + process.env.KA_DB_PATH ?? path.join(process.cwd(), "data", "ka-id.db");
10 + fs.mkdirSync(path.dirname(dbPath), { recursive: true });
11 + const db = new Database(dbPath);
12 + db.pragma("journal_mode = WAL");
13 + db.exec(`
14 + CREATE TABLE IF NOT EXISTS users (
15 + id INTEGER PRIMARY KEY AUTOINCREMENT,
16 + email TEXT UNIQUE NOT NULL,
17 + name TEXT NOT NULL,
18 + avatar_url TEXT,
19 + password_hash TEXT,
20 + google_sub TEXT UNIQUE,
21 + created_at TEXT NOT NULL DEFAULT (datetime('now'))
22 + );
23 + `);
24 + // KA ID — identifiant membre du groupe (« ka- » + 10 chiffres), créé ICI,
25 + // au hub : les plateformes (Lou·Ka…) le reçoivent via le jeton SSO.
26 + try {
27 + db.exec("ALTER TABLE users ADD COLUMN ka_id TEXT");
28 + } catch {
29 + /* colonne déjà présente */
30 + }
31 + try {
32 + db.exec("ALTER TABLE users ADD COLUMN last_login TEXT");
33 + } catch {
34 + /* colonne déjà présente */
35 + }
36 + // Sign in with Apple (2026-08-13) — même rôle que google_sub.
37 + try {
38 + db.exec("ALTER TABLE users ADD COLUMN apple_sub TEXT");
39 + } catch {
40 + /* colonne déjà présente */
41 + }
42 + db.exec(
43 + "CREATE UNIQUE INDEX IF NOT EXISTS users_apple_sub ON users(apple_sub)",
44 + );
45 + try {
46 + db.exec("ALTER TABLE users ADD COLUMN role TEXT");
47 + } catch {
48 + /* colonne déjà présente */
49 + }
50 + // Profil enrichi (2026-08-13) — saisi sur le hub, LA source de vérité
51 + // affichée par toutes les plateformes du groupe.
52 + for (const col of [
53 + "bio TEXT",
54 + "city TEXT",
55 + "phone TEXT",
56 + "website TEXT",
57 + "job_title TEXT",
58 + "company TEXT",
59 + "birth_date TEXT",
60 + "socials TEXT",
61 + "public INTEGER DEFAULT 0",
62 + ]) {
63 + try {
64 + db.exec(`ALTER TABLE users ADD COLUMN ${col}`);
65 + } catch {
66 + /* colonne déjà présente */
67 + }
68 + }
69 + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id)");
70 + // rattrapage : tout compte existant sans KA ID en reçoit un
71 + const missing = db
72 + .prepare("SELECT id FROM users WHERE ka_id IS NULL")
73 + .all() as { id: number }[];
74 + for (const row of missing) assignKaId(db, row.id);
75 + // Favoris unifiés « Mon univers Ka » (2026-08-13) — le hub est LE magasin
76 + // central : les plateformes poussent/lisent via /api/sso/favorites.
77 + db.exec(`
78 + CREATE TABLE IF NOT EXISTS favorites (
79 + id INTEGER PRIMARY KEY AUTOINCREMENT,
80 + user_id INTEGER NOT NULL,
81 + app TEXT NOT NULL,
82 + item_id TEXT NOT NULL,
83 + title TEXT NOT NULL,
84 + subtitle TEXT,
85 + price_label TEXT,
86 + image_url TEXT,
87 + url TEXT,
88 + meta TEXT,
89 + created_at TEXT NOT NULL DEFAULT (datetime('now')),
90 + updated_at TEXT NOT NULL DEFAULT (datetime('now')),
91 + UNIQUE(user_id, app, item_id)
92 + );
93 + CREATE INDEX IF NOT EXISTS favorites_user ON favorites(user_id, app);
94 + `);
95 + return db;
96 +}
97 +
98 +function randomKaId(): string {
99 + let digits = "";
100 + for (let i = 0; i < 10; i++)
101 + digits += Math.floor(Math.random() * 10).toString();
102 + return `ka-${digits}`;
103 +}
104 +
105 +function assignKaId(d: Database.Database, id: number): string {
106 + for (;;) {
107 + const kid = randomKaId();
108 + try {
109 + d.prepare("UPDATE users SET ka_id = ? WHERE id = ?").run(kid, id);
110 + return kid;
111 + } catch {
112 + /* collision (1 chance sur 10 milliards) : on retente */
113 + }
114 + }
115 +}
116 +
117 +/** KA ID d'un compte — l'attribue s'il manque (source de vérité du groupe). */
118 +export function ensureKaId(id: number): string {
119 + const row = db.prepare("SELECT ka_id FROM users WHERE id = ?").get(id) as
120 + | { ka_id: string | null }
121 + | undefined;
122 + if (row?.ka_id) return row.ka_id;
123 + return assignKaId(db, id);
124 +}
125 +
126 +// Singleton (survit au rechargement à chaud en dev).
127 +const g = globalThis as unknown as { __kaDb?: Database.Database };
128 +export const db: Database.Database = g.__kaDb ?? (g.__kaDb = open());
129 +
130 +export type UserRow = {
131 + id: number;
132 + email: string;
133 + name: string;
134 + avatar_url: string | null;
135 + password_hash: string | null;
136 + google_sub: string | null;
137 + apple_sub: string | null;
138 + ka_id: string | null;
139 + created_at: string;
140 + last_login: string | null;
141 + role: string | null;
142 + bio: string | null;
143 + city: string | null;
144 + phone: string | null;
145 + website: string | null;
146 + job_title: string | null;
147 + company: string | null;
148 + birth_date: string | null;
149 + socials: string | null; // JSON {instagram, facebook, x, linkedin, tiktok, youtube}
150 + public: number;
151 +};
152 +
153 +export const SOCIAL_KEYS = [
154 + "instagram",
155 + "facebook",
156 + "x",
157 + "linkedin",
158 + "tiktok",
159 + "youtube",
160 +] as const;
161 +
162 +export function parseSocials(raw: string | null): Record<string, string> {
163 + try {
164 + const obj = JSON.parse(raw ?? "{}") as Record<string, unknown>;
165 + const out: Record<string, string> = {};
166 + for (const k of SOCIAL_KEYS)
167 + if (typeof obj[k] === "string" && obj[k]) out[k] = obj[k] as string;
168 + return out;
169 + } catch {
170 + return {};
171 + }
172 +}
173 +
174 +export function ageFromBirthDate(birth: string | null): number | null {
175 + if (!birth || !/^\d{4}-\d{2}-\d{2}$/.test(birth)) return null;
176 + const b = new Date(birth + "T00:00:00Z");
177 + if (Number.isNaN(b.getTime())) return null;
178 + const now = new Date();
179 + let age = now.getUTCFullYear() - b.getUTCFullYear();
180 + const m = now.getUTCMonth() - b.getUTCMonth();
181 + if (m < 0 || (m === 0 && now.getUTCDate() < b.getUTCDate())) age--;
182 + return age >= 0 && age < 130 ? age : null;
183 +}
184 +
185 +/** Horodate la connexion (appelé à chaque login/register/SSO). */
186 +export function touchLastLogin(id: number): void {
187 + db.prepare(
188 + "UPDATE users SET last_login = datetime('now') WHERE id = ?",
189 + ).run(id);
190 +}
191 +
192 +export function findUserByEmail(email: string): UserRow | undefined {
193 + return db
194 + .prepare("SELECT * FROM users WHERE email = ?")
195 + .get(email.toLowerCase()) as UserRow | undefined;
196 +}
197 +
198 +export function findUserById(id: number): UserRow | undefined {
199 + return db.prepare("SELECT * FROM users WHERE id = ?").get(id) as
200 + | UserRow
201 + | undefined;
202 +}
203 +
204 +export function findUserByGoogleSub(sub: string): UserRow | undefined {
205 + return db.prepare("SELECT * FROM users WHERE google_sub = ?").get(sub) as
206 + | UserRow
207 + | undefined;
208 +}
209 +
210 +export function findUserByAppleSub(sub: string): UserRow | undefined {
211 + return db.prepare("SELECT * FROM users WHERE apple_sub = ?").get(sub) as
212 + | UserRow
213 + | undefined;
214 +}
215 +
216 +export function findUserByKaId(kaId: string): UserRow | undefined {
217 + return db.prepare("SELECT * FROM users WHERE ka_id = ?").get(kaId) as
218 + | UserRow
219 + | undefined;
220 +}
221 +
222 +/* ---------- Favoris unifiés ---------- */
223 +
224 +export type FavoriteRow = {
225 + id: number;
226 + user_id: number;
227 + app: string;
228 + item_id: string;
229 + title: string;
230 + subtitle: string | null;
231 + price_label: string | null;
232 + image_url: string | null;
233 + url: string | null;
234 + meta: string | null;
235 + created_at: string;
236 + updated_at: string;
237 +};
238 +
239 +export function upsertFavorite(
240 + userId: number,
241 + app: string,
242 + item: {
243 + item_id: string;
244 + title: string;
245 + subtitle?: string;
246 + price_label?: string;
247 + image_url?: string;
248 + url?: string;
249 + meta?: unknown;
250 + },
251 +): void {
252 + db.prepare(
253 + `INSERT INTO favorites (user_id, app, item_id, title, subtitle,
254 + price_label, image_url, url, meta)
255 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
256 + ON CONFLICT(user_id, app, item_id) DO UPDATE SET
257 + title = excluded.title, subtitle = excluded.subtitle,
258 + price_label = excluded.price_label, image_url = excluded.image_url,
259 + url = excluded.url, meta = excluded.meta,
260 + updated_at = datetime('now')`,
261 + ).run(
262 + userId,
263 + app,
264 + item.item_id,
265 + item.title,
266 + item.subtitle ?? null,
267 + item.price_label ?? null,
268 + item.image_url ?? null,
269 + item.url ?? null,
270 + item.meta != null ? JSON.stringify(item.meta) : null,
271 + );
272 +}
273 +
274 +export function removeFavorite(
275 + userId: number,
276 + app: string,
277 + itemId: string,
278 +): void {
279 + db.prepare(
280 + "DELETE FROM favorites WHERE user_id = ? AND app = ? AND item_id = ?",
281 + ).run(userId, app, itemId);
282 +}
283 +
284 +export function favoritesOf(userId: number, app?: string): FavoriteRow[] {
285 + return (
286 + app
287 + ? db.prepare(
288 + "SELECT * FROM favorites WHERE user_id = ? AND app = ? ORDER BY created_at DESC",
289 + ).all(userId, app)
290 + : db.prepare(
291 + "SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC",
292 + ).all(userId)
293 + ) as FavoriteRow[];
294 +}
added src/lib/roles.ts +30 −0
@@ -0,0 +1,30 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Types de compte KA ID — choisis à la création, affichés sur la carte.
3 +export const ROLES: Record<
4 + string,
5 + { label: string; card: string; desc: string }
6 +> = {
7 + membre: {
8 + label: "Utilisateur des plateformes",
9 + card: "MEMBRE · UTILISATEUR",
10 + desc: "Je cherche, je compare, j'explore — logement, propriété, auto, épicerie…",
11 + },
12 + fournisseur: {
13 + label: "Fournisseur / partenaire",
14 + card: "FOURNISSEUR · PARTENAIRE",
15 + desc: "Gestionnaire immobilier, courtier, concessionnaire, boutique, bannière…",
16 + },
17 + equipe: {
18 + label: "Équipe Groupe KA",
19 + card: "ÉQUIPE · GROUPE KA",
20 + desc: "Je travaille sur les plateformes du groupe.",
21 + },
22 +};
23 +
24 +export function roleLabel(role: string | null): string | null {
25 + return role && ROLES[role] ? ROLES[role].label : null;
26 +}
27 +
28 +export function roleCard(role: string | null): string | null {
29 + return role && ROLES[role] ? ROLES[role].card : null;
30 +}
added src/lib/sso.ts +76 −0
@@ -0,0 +1,76 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import crypto from "crypto";
3 +// KA ID — registre des plateformes autorisées à déléguer leur connexion
4 +// au hub (« Se connecter avec KA »). Chaque client a son secret partagé
5 +// (env) et une liste blanche de redirect_uri.
6 +export const SSO_CLIENTS: Record<
7 + string,
8 + { label: string; redirectPrefixes: string[]; secretEnv: string }
9 +> = {
10 + "lou-ka": {
11 + label: "Lou·Ka",
12 + redirectPrefixes: ["https://www.lou-ka.com/", "http://localhost:8095/"],
13 + secretEnv: "KA_SSO_SECRET_LOU_KA",
14 + },
15 + "immo-ka": {
16 + label: "Immo·Ka",
17 + redirectPrefixes: ["https://www.immo-ka.com/"],
18 + secretEnv: "KA_SSO_SECRET_IMMO_KA",
19 + },
20 + "vrai-prix": {
21 + label: "Vrai-Prix",
22 + redirectPrefixes: ["https://www.vrai-prix.com/"],
23 + secretEnv: "KA_SSO_SECRET_VRAI_PRIX",
24 + },
25 + valoplex: {
26 + label: "ValoPlex",
27 + redirectPrefixes: ["https://www.valoplex.com/"],
28 + secretEnv: "KA_SSO_SECRET_VALOPLEX",
29 + },
30 + "auto-ka": {
31 + label: "Auto·Ka",
32 + redirectPrefixes: ["https://www.auto-ka.com/"],
33 + secretEnv: "KA_SSO_SECRET_AUTO_KA",
34 + },
35 + "fabri-ka": {
36 + label: "Fabri·Ka",
37 + redirectPrefixes: ["https://www.fabri-ka.com/"],
38 + secretEnv: "KA_SSO_SECRET_FABRI_KA",
39 + },
40 + "food-ka": {
41 + label: "Food·Ka",
42 + redirectPrefixes: ["https://www.food-ka.com/"],
43 + secretEnv: "KA_SSO_SECRET_FOOD_KA",
44 + },
45 + "ora-ka": {
46 + label: "Ora·Ka",
47 + redirectPrefixes: ["https://www.ora-ka.com/"],
48 + secretEnv: "KA_SSO_SECRET_ORA_KA",
49 + },
50 +};
51 +
52 +/**
53 + * Vérifie une requête serveur-à-serveur d'une plateforme :
54 + * sig = HMAC-SHA256(secret_client, `${client_id}.${ka_id}.${ts}`), ts ±5 min.
55 + * Retourne le client si valide, sinon null.
56 + */
57 +export function verifyClientSig(
58 + clientId: string,
59 + kaId: string,
60 + ts: string,
61 + sig: string,
62 +): { label: string } | null {
63 + const client = SSO_CLIENTS[clientId];
64 + const secret = client ? process.env[client.secretEnv] : undefined;
65 + if (!client || !secret) return null;
66 + if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
67 + return null;
68 + const expected = crypto
69 + .createHmac("sha256", secret)
70 + .update(`${clientId}.${kaId}.${ts}`)
71 + .digest("hex");
72 + const a = Buffer.from(sig, "hex");
73 + const b = Buffer.from(expected, "hex");
74 + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
75 + return client;
76 +}
added tsconfig.json +27 −0
@@ -0,0 +1,27 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2017",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noEmit": true,
9 + "esModuleInterop": true,
10 + "module": "esnext",
11 + "moduleResolution": "bundler",
12 + "resolveJsonModule": true,
13 + "isolatedModules": true,
14 + "jsx": "react-jsx",
15 + "incremental": true,
16 + "plugins": [{ "name": "next" }],
17 + "paths": { "@/*": ["./src/*"] }
18 + },
19 + "include": [
20 + "next-env.d.ts",
21 + "**/*.ts",
22 + "**/*.tsx",
23 + ".next/types/**/*.ts",
24 + ".next/dev/types/**/*.ts"
25 + ],
26 + "exclude": ["node_modules"]
27 +}
28