feat: initial commit
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 | +[](https://www.groupe-ka.com) | |
| 10 | +[](https://nextjs.org) | |
| 11 | +[](https://react.dev) | |
| 12 | +[](https://www.typescriptlang.org) | |
| 13 | +[](https://tailwindcss.com) | |
| 14 | +[](https://sqlite.org) | |
| 15 | + | |
| 16 | +[](#-authentification) | |
| 17 | +[](#-authentification) | |
| 18 | +[-d9f26b?style=flat-square)](#-authentification) | |
| 19 | +[](#-authentification) | |
| 20 | +[](#-fonctionnalités) | |
| 21 | + | |
| 22 | +[](#-sso--un-compte-sept-plateformes) | |
| 23 | +[](#-routes) | |
| 24 | +[](#-routes) | |
| 25 | +[](#-métriques) | |
| 26 | +[](#-déploiement) | |
| 27 | +[](#-auteur) | |
| 28 | + | |
| 29 | +</div> | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +## 📸 Aperçu | |
| 34 | + | |
| 35 | +| Accueil | Connexion (KA ID) | | |
| 36 | +|:---:|:---:| | |
| 37 | +|  |  | | |
| 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'é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'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'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'ici (Fabri·Ka) ou une aubaine | |
| 87 | + d'épicerie (Food·Ka). | |
| 88 | + </p> | |
| 89 | + </div> | |
| 90 | + ) : ( | |
| 91 | + <div className="mt-4 space-y-6"> | |
| 92 | + {[...byApp.entries()].map(([app, items]) => ( | |
| 93 | + <div key={app}> | |
| 94 | + <p className="klabel flex items-center gap-2"> | |
| 95 | + <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'écosystème Groupe KA — il vous | |
| 380 | + suit sur chaque plateforme. Le hub ne conserve que ce profil : rien | |
| 381 | + d'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'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'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'agrégateurs automatisés. Il présente les | |
| 18 | + plateformes du groupe et n'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'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'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'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'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'entraver le fonctionnement du site, d'y | |
| 63 | + accéder par des moyens automatisés abusifs, ou de présenter les | |
| 64 | + données du groupe comme les siennes. L'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'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'incertitude | |
| 82 | + affichées ; elles ne remplacent pas l'évaluation d'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'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'est le même principe | |
| 20 | + d'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'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'évaluation, ventes publiées). Lorsque | |
| 45 | + ces sources contiennent des renseignements à caractère personnel | |
| 46 | + (ex. coordonnées d'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'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 & 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'accès à l'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'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'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'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'est | |
| 42 | + l'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'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'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'est la | |
| 660 | + définition maison de l'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'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'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'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'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'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'ici, | |
| 197 | + épicerie. Rien d'inventé, tout est traçable.{" "} | |
| 198 | + <span className="text-lime"> | |
| 199 | + ·Ka, c'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'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'identité d'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'est l'agrégation.</strong>{" "} | |
| 358 | + Groupe KA est un holding d'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'é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'observations immobilières</span> | |
| 372 | + <span className="stat-chip">375 000+ annonces & 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'il agrège. Voici l'état des | |
| 422 | + lieux, mesuré sur les plateformes elles-mêmes — inventaire vivant, | |
| 423 | + flotte de connecteurs, couverture des moteurs d'é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'é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'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'é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'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 & partenariats · info@ — médias & | |
| 566 | + questions générales · admin@ — légal & 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'existe pas ou n'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 | ||