Design Groupe KA (éditorial sharp, accent azur) + SSO KA ID
TÂCHE DESIGN — harmonisation sur le design system commun ka-ui : - apps/web/ka/ : tokens.css (socle canonique), ecosystem.json, KaFooter (footer commun encre + flux du bot en enfant), GroupeKaBadge — vendorés depuis le repo ka-ui (ex-frontend/src/ka déplacé au bon endroit). - globals.css rethémé : papier #f5f3ee, encre #141814, accent AZUR (#1c7ed6 / #e7f2fd / #14508f, texte blanc sur accent), bordures encre 1,5 px + ombres décalées ; mode sombre retiré (thème éditorial unique). - layout.tsx : tokens.css chargé en premier, badge « Un service Groupe KA » cliquable vers le hub, bouton compte KA au header, KaFooter commun (11 sites sœurs, 3 courriels + rôles, liens légaux du hub, © année), title « Trouve-Ka — Un service Groupe KA », themeColor papier. - Nouvelle page /contact : courriels contact@/info@/admin@groupe-ka.com, avertissement agrégateur, création de compte KA → groupe-ka.com/connexion. TÂCHE SSO KA ID — connexion déléguée au hub groupe-ka.com : - lib/ka-auth.ts (node:crypto pur, modèle vrai-prix) : state signé local, vérification JWT HS256 ka_token (aud=trouve-ka, KA_SSO_SECRET), session cookie httpOnly tk_ka_session 30 jours. - Routes /api/auth/ka/login, /api/auth/ka/callback, /api/auth/me, /api/auth/logout (servies par Next avant le rewrite vers FastAPI). - next.config.mjs : chargement du .env racine du monorepo (KA_SSO_SECRET, KA_HUB_URL) sans dépendance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 changed files +1,013 −271
added
apps/web/app/api/auth/ka/callback/route.ts
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Retour SSO KA ID : vérifie le state signé localement puis le ka_token du hub | |
| 3 | +// (JWT HS256 — signature KA_SSO_SECRET, alg, iss, aud, exp), pose la session | |
| 4 | +// locale (cookie httpOnly tk_ka_session signé HMAC-SHA256, 30 jours) et | |
| 5 | +// redirige vers la destination demandée (ou l'accueil). | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { | |
| 8 | + KA_BASE_URL, | |
| 9 | + SESSION_COOKIE, | |
| 10 | + SESSION_DAYS, | |
| 11 | + sessionFromClaims, | |
| 12 | + verifyKaToken, | |
| 13 | + verifyState, | |
| 14 | +} from "@/lib/ka-auth"; | |
| 15 | + | |
| 16 | +export function GET(req: NextRequest) { | |
| 17 | + const q = req.nextUrl.searchParams; | |
| 18 | + const state = verifyState(q.get("state") ?? ""); | |
| 19 | + if (!state) { | |
| 20 | + return NextResponse.json( | |
| 21 | + { error: "state invalide ou expiré — recommencez la connexion" }, | |
| 22 | + { status: 400 }, | |
| 23 | + ); | |
| 24 | + } | |
| 25 | + const claims = verifyKaToken(q.get("ka_token") ?? ""); | |
| 26 | + if (!claims) { | |
| 27 | + return NextResponse.json( | |
| 28 | + { error: "jeton KA invalide ou expiré — recommencez la connexion" }, | |
| 29 | + { status: 401 }, | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + const made = sessionFromClaims(claims); | |
| 33 | + if (!made) { | |
| 34 | + return NextResponse.json( | |
| 35 | + { error: "jeton KA incomplet (ka_id manquant)" }, | |
| 36 | + { status: 401 }, | |
| 37 | + ); | |
| 38 | + } | |
| 39 | + const res = NextResponse.redirect(new URL(state.next, KA_BASE_URL)); | |
| 40 | + res.cookies.set(SESSION_COOKIE, made.value, { | |
| 41 | + httpOnly: true, | |
| 42 | + secure: KA_BASE_URL.startsWith("https"), | |
| 43 | + sameSite: "lax", | |
| 44 | + path: "/", | |
| 45 | + maxAge: SESSION_DAYS * 86400, | |
| 46 | + }); | |
| 47 | + return res; | |
| 48 | +} | |
added
apps/web/app/api/auth/ka/login/route.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Départ SSO « Se connecter avec KA » : redirige vers le hub d'identité du | |
| 3 | +// Groupe KA (groupe-ka.com) avec un state opaque signé localement (le state | |
| 4 | +// transporte aussi la destination `next` de retour — chemin local seulement). | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { CLIENT_ID, KA_BASE_URL, KA_HUB_URL, newState, sanitizeNext } from "@/lib/ka-auth"; | |
| 7 | + | |
| 8 | +export function GET(req: NextRequest) { | |
| 9 | + const next = sanitizeNext(req.nextUrl.searchParams.get("next")); | |
| 10 | + const state = newState(next); | |
| 11 | + if (!state || !process.env.KA_SSO_SECRET) { | |
| 12 | + return NextResponse.json( | |
| 13 | + { error: "SSO KA ID non configuré (KA_SSO_SECRET manquant)" }, | |
| 14 | + { status: 503 }, | |
| 15 | + ); | |
| 16 | + } | |
| 17 | + const url = new URL(`${KA_HUB_URL}/sso/authorize`); | |
| 18 | + url.searchParams.set("client_id", CLIENT_ID); | |
| 19 | + url.searchParams.set("redirect_uri", `${KA_BASE_URL}/api/auth/ka/callback`); | |
| 20 | + url.searchParams.set("state", state); | |
| 21 | + return NextResponse.redirect(url); | |
| 22 | +} | |
added
apps/web/app/api/auth/logout/route.ts
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Déconnexion : efface le cookie de session local (la session est sans | |
| 3 | +// stockage serveur — supprimer le cookie suffit). POST seulement. | |
| 4 | +import { NextResponse } from "next/server"; | |
| 5 | +import { KA_BASE_URL, SESSION_COOKIE } from "@/lib/ka-auth"; | |
| 6 | + | |
| 7 | +export function POST() { | |
| 8 | + const res = NextResponse.json({ ok: true }); | |
| 9 | + res.cookies.set(SESSION_COOKIE, "", { | |
| 10 | + httpOnly: true, | |
| 11 | + secure: KA_BASE_URL.startsWith("https"), | |
| 12 | + sameSite: "lax", | |
| 13 | + path: "/", | |
| 14 | + maxAge: 0, | |
| 15 | + }); | |
| 16 | + return res; | |
| 17 | +} | |
added
apps/web/app/api/auth/me/route.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Session courante : { user } si le cookie tk_ka_session est valide, sinon | |
| 3 | +// { user: null } — consommé par le bouton compte de l'en-tête. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 6 | + | |
| 7 | +export function GET(req: NextRequest) { | |
| 8 | + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 9 | + if (!s) return NextResponse.json({ user: null }); | |
| 10 | + return NextResponse.json({ | |
| 11 | + user: { | |
| 12 | + ka_id: s.ka_id, | |
| 13 | + email: s.email, | |
| 14 | + name: s.name, | |
| 15 | + picture: s.picture, | |
| 16 | + provider: s.provider ?? "ka-id", | |
| 17 | + }, | |
| 18 | + }); | |
| 19 | +} | |
added
apps/web/app/contact/page.tsx
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — page /contact (coordonnées communes Groupe KA) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Les coordonnées viennent de ka/ecosystem.json (source canonique ka-ui) : | |
| 7 | + * 3 courriels + rôles, avertissement agrégateur, liens légaux du hub et | |
| 8 | + * création de compte KA ID sur groupe-ka.com/connexion. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import type { Metadata } from "next"; | |
| 12 | +import eco from "@/ka/ecosystem.json"; | |
| 13 | + | |
| 14 | +export const metadata: Metadata = { | |
| 15 | + title: "Contact", | |
| 16 | + description: | |
| 17 | + "Joindre Groupe KA — projets et partenariats, médias, légal et vie privée (Loi 25). Trouve-KA est un service Groupe KA.", | |
| 18 | +}; | |
| 19 | + | |
| 20 | +export default function ContactPage() { | |
| 21 | + return ( | |
| 22 | + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-12 sm:px-6"> | |
| 23 | + <p className="kicker">Un service Groupe KA</p> | |
| 24 | + <h1 className="mt-3 text-4xl font-bold sm:text-5xl"> | |
| 25 | + Nous <span className="hl">joindre</span> | |
| 26 | + </h1> | |
| 27 | + <p className="mt-4 max-w-xl text-[15px] text-ink-2"> | |
| 28 | + Trouve-KA — le moteur de recherche du web québécois — est un service de{" "} | |
| 29 | + <a | |
| 30 | + href={eco.hub.url} | |
| 31 | + target="_blank" | |
| 32 | + rel="noopener noreferrer" | |
| 33 | + className="font-bold text-ink underline underline-offset-4 hover:decoration-[var(--accent)]" | |
| 34 | + > | |
| 35 | + {eco.org.name} | |
| 36 | + </a> | |
| 37 | + . {eco.org.tagline} | |
| 38 | + </p> | |
| 39 | + | |
| 40 | + <div className="mt-8 grid gap-4 sm:grid-cols-1"> | |
| 41 | + {eco.contacts.map((c) => ( | |
| 42 | + <div key={c.email} className="gk-card flex flex-col gap-1 p-5"> | |
| 43 | + <span className="klabel">{c.role}</span> | |
| 44 | + <a | |
| 45 | + href={`mailto:${c.email}`} | |
| 46 | + className="gk-mono text-[15px] font-bold text-ink underline-offset-4 hover:text-[var(--accent-deep)] hover:underline" | |
| 47 | + > | |
| 48 | + {c.email} | |
| 49 | + </a> | |
| 50 | + </div> | |
| 51 | + ))} | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + <p className="mt-8 border-l-2 border-[var(--accent)] pl-4 text-[14px] text-ink-2"> | |
| 55 | + <b className="text-ink">{eco.org.disclaimer}</b> | |
| 56 | + </p> | |
| 57 | + | |
| 58 | + <div className="gk-card mt-8 p-5"> | |
| 59 | + <span className="klabel">Compte KA ID</span> | |
| 60 | + <p className="mt-2 text-[14px] text-ink-2"> | |
| 61 | + Un seul compte pour tout l'écosystème ·Ka. La création de compte se | |
| 62 | + fait sur le hub : | |
| 63 | + </p> | |
| 64 | + <a | |
| 65 | + href={`${eco.hub.url}${eco.hub.loginPath}`} | |
| 66 | + target="_blank" | |
| 67 | + rel="noopener noreferrer" | |
| 68 | + className="btn btn-primary mt-4" | |
| 69 | + > | |
| 70 | + Créer un compte KA | |
| 71 | + </a> | |
| 72 | + </div> | |
| 73 | + | |
| 74 | + <p className="gk-mono mt-10 text-[11px] uppercase tracking-[0.08em] text-ink-3"> | |
| 75 | + {eco.legal.map((l, i) => ( | |
| 76 | + <span key={l.href}> | |
| 77 | + {i > 0 ? " · " : ""} | |
| 78 | + <a | |
| 79 | + href={l.href} | |
| 80 | + target="_blank" | |
| 81 | + rel="noopener noreferrer" | |
| 82 | + className="underline-offset-4 hover:text-ink hover:underline" | |
| 83 | + > | |
| 84 | + {l.label} | |
| 85 | + </a> | |
| 86 | + </span> | |
| 87 | + ))} | |
| 88 | + </p> | |
| 89 | + </main> | |
| 90 | + ); | |
| 91 | +} | |
modified
apps/web/app/globals.css
+75 −110
@@ -3,9 +3,14 @@ | ||
| 3 | 3 | * Author: Simon-Pierre Boucher |
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | 5 | * |
| 6 | + * Le socle canonique (palette papier/encre, composants partagés) vient de | |
| 7 | + * ka/tokens.css, chargé AVANT ce fichier dans app/layout.tsx. Ici : l'accent | |
| 8 | + * AZUR attribué à trouve-ka, le pont vers les polices next/font et les | |
| 9 | + * composants propres au moteur de recherche. | |
| 10 | + * | |
| 6 | 11 | * Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono |
| 7 | − * Signature : bordures encre + ombres décalées (néo-brutalisme raffiné) | |
| 8 | − * Accent bleu pâle marine #b9cfee sur encre | |
| 12 | + * Signature : papier #f5f3ee, encre #141814, bordures encre + ombres décalées | |
| 13 | + * Accent trouve-ka : AZUR #1c7ed6 | |
| 9 | 14 | */ |
| 10 | 15 | |
| 11 | 16 | @tailwind base; |
@@ -13,85 +18,56 @@ | ||
| 13 | 18 | @tailwind utilities; |
| 14 | 19 | |
| 15 | 20 | :root { |
| 16 | − /* Thème clair « Lou·Ka » : blanc, encre navy, accent orange, liens bleus, | |
| 17 | − coins doux et ombres diffuses (fini les ombres décalées en clair). */ | |
| 18 | − --paper: #ffffff; | |
| 21 | + /* ---- Palette fixe Groupe KA (tokens.css fait foi) ---- */ | |
| 22 | + --paper: #f5f3ee; | |
| 19 | 23 | --surface: #ffffff; |
| 20 | − --surface-2: #f4f5f8; | |
| 21 | − --ink: #0b1330; | |
| 22 | − --ink-2: #3e4560; | |
| 23 | − --ink-3: #8a90a5; | |
| 24 | − --line: rgba(11, 19, 48, 0.1); | |
| 25 | − --line-strong: rgba(11, 19, 48, 0.16); | |
| 26 | − --edge: rgba(11, 19, 48, 0.14); | |
| 27 | − --green: #2258ff; | |
| 28 | − --green-deep: #0b1330; | |
| 29 | − --lime: #ff6a00; | |
| 30 | − --lime-soft: #fff1e6; | |
| 31 | − --blue-soft: #e9efff; | |
| 32 | − --amber: #ff6a00; | |
| 33 | − --amber-soft: #fff1e6; | |
| 34 | − --danger: #d64545; | |
| 35 | − --danger-soft: #fdecec; | |
| 36 | − | |
| 37 | − --paper-glass: rgba(255, 255, 255, 0.88); | |
| 38 | − | |
| 39 | − --r-card: 16px; | |
| 40 | − --r-ctl: 10px; | |
| 41 | − --shadow-flat: 0 1px 3px rgba(11, 19, 48, 0.06); | |
| 42 | − --shadow-off: 0 16px 40px rgba(11, 19, 48, 0.14); | |
| 43 | − --shadow-off-soft: 0 8px 24px rgba(11, 19, 48, 0.08); | |
| 44 | − --shadow-btn: 0 4px 14px rgba(11, 19, 48, 0.18); | |
| 45 | − --shadow-field: 0 2px 10px rgba(11, 19, 48, 0.06); | |
| 24 | + --surface-2: #faf9f5; | |
| 25 | + --ink: #141814; | |
| 26 | + --ink-2: #4d5551; | |
| 27 | + --ink-3: #8b928c; | |
| 28 | + --line: rgba(20, 24, 20, 0.14); | |
| 29 | + --line-strong: rgba(20, 24, 20, 0.28); | |
| 30 | + --edge: rgba(20, 24, 20, 0.85); | |
| 31 | + --green: #1c5c41; | |
| 32 | + --green-deep: #123f2e; | |
| 33 | + | |
| 34 | + /* ---- ACCENT trouve-ka : AZUR ---- */ | |
| 35 | + --accent: #1c7ed6; | |
| 36 | + --accent-soft: #e7f2fd; | |
| 37 | + --accent-deep: #14508f; | |
| 38 | + --on-accent: #ffffff; | |
| 39 | + | |
| 40 | + /* alias historiques du site (l'accent s'appelait --lime) */ | |
| 41 | + --lime: var(--accent); | |
| 42 | + --lime-soft: var(--accent-soft); | |
| 43 | + --blue-soft: var(--accent-soft); | |
| 44 | + --amber: #e8a33d; | |
| 45 | + --amber-soft: #fdf3e2; | |
| 46 | + --danger: #b3423a; | |
| 47 | + --danger-soft: #fbe9e7; | |
| 48 | + | |
| 49 | + --paper-glass: rgba(245, 243, 238, 0.88); | |
| 50 | + | |
| 51 | + /* ---- Géométrie sharp + ombres décalées ---- */ | |
| 52 | + --r-card: 10px; | |
| 53 | + --r-ctl: 6px; | |
| 54 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 55 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 56 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 57 | + --shadow-btn: 4px 4px 0 rgba(20, 24, 20, 0.18); | |
| 58 | + --shadow-field: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 46 | 59 | |
| 47 | 60 | --btn-primary-bg: var(--ink); |
| 48 | − --btn-primary-fg: #ffffff; | |
| 49 | − --btn-primary-hover: #22315e; | |
| 61 | + --btn-primary-fg: var(--accent); | |
| 62 | + --btn-primary-hover: var(--green-deep); | |
| 50 | 63 | |
| 64 | + /* ---- Pont next/font → tokens ---- */ | |
| 51 | 65 | --font-display: var(--font-space-grotesk), system-ui, sans-serif; |
| 52 | 66 | --font-serif: var(--font-fraunces), Georgia, serif; |
| 53 | 67 | --font-body: var(--font-inter), system-ui, sans-serif; |
| 54 | 68 | --font-mono: var(--font-jetbrains), ui-monospace, monospace; |
| 55 | 69 | } |
| 56 | 70 | |
| 57 | −/* ---------- Mode sombre — navy profond, mêmes accents ---------- */ | |
| 58 | −[data-theme="dark"] { | |
| 59 | − --paper: #0a0f22; | |
| 60 | − --surface: #121834; | |
| 61 | − --surface-2: #0e142c; | |
| 62 | − --ink: #edf0f7; | |
| 63 | − --ink-2: #b3bacf; | |
| 64 | − --ink-3: #7b83a0; | |
| 65 | − --line: rgba(237, 240, 247, 0.12); | |
| 66 | − --line-strong: rgba(237, 240, 247, 0.24); | |
| 67 | − --edge: rgba(237, 240, 247, 0.16); | |
| 68 | − --green: #7da2ff; | |
| 69 | − --green-deep: #0a0f22; | |
| 70 | − --lime: #ff7d1f; | |
| 71 | − --lime-soft: #3a2408; | |
| 72 | − --blue-soft: #16224a; | |
| 73 | − --amber: #ff7d1f; | |
| 74 | − --amber-soft: #3a2408; | |
| 75 | − --danger: #e57373; | |
| 76 | − --danger-soft: #3a1414; | |
| 77 | − | |
| 78 | − --paper-glass: rgba(10, 15, 34, 0.86); | |
| 79 | − | |
| 80 | − --shadow-flat: 0 1px 3px rgba(0, 0, 0, 0.45); | |
| 81 | − --shadow-off: 0 16px 40px rgba(0, 0, 0, 0.5); | |
| 82 | − --shadow-off-soft: 0 8px 24px rgba(0, 0, 0, 0.4); | |
| 83 | − --shadow-btn: 0 4px 14px rgba(0, 0, 0, 0.5); | |
| 84 | − --shadow-field: 0 2px 10px rgba(0, 0, 0, 0.4); | |
| 85 | − | |
| 86 | − /* Bouton primaire inversé : accent orange, texte sombre */ | |
| 87 | − --btn-primary-bg: var(--lime); | |
| 88 | − --btn-primary-fg: #0a0f22; | |
| 89 | − --btn-primary-hover: #ff9346; | |
| 90 | −} | |
| 91 | −[data-theme="dark"] body::before { | |
| 92 | − opacity: 0.22; | |
| 93 | −} | |
| 94 | − | |
| 95 | 71 | @layer base { |
| 96 | 72 | html { |
| 97 | 73 | -webkit-text-size-adjust: 100%; |
@@ -107,21 +83,6 @@ | ||
| 107 | 83 | -webkit-font-smoothing: antialiased; |
| 108 | 84 | } |
| 109 | 85 | |
| 110 | − /* Grain de film pleine page — jamais de z-index sur body > * (position: | |
| 111 | − relative seulement) pour ne pas casser les utilitaires z-* de Tailwind. */ | |
| 112 | − body::before { | |
| 113 | − content: ""; | |
| 114 | − position: fixed; | |
| 115 | − inset: 0; | |
| 116 | − pointer-events: none; | |
| 117 | − opacity: 0.35; | |
| 118 | − z-index: 9999; | |
| 119 | − 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"); | |
| 120 | − } | |
| 121 | − body > * { | |
| 122 | − position: relative; | |
| 123 | − } | |
| 124 | − | |
| 125 | 86 | h1, |
| 126 | 87 | h2, |
| 127 | 88 | h3, |
@@ -131,8 +92,8 @@ | ||
| 131 | 92 | } |
| 132 | 93 | |
| 133 | 94 | ::selection { |
| 134 | − background: var(--lime); | |
| 135 | − color: var(--ink); | |
| 95 | + background: var(--accent); | |
| 96 | + color: var(--on-accent); | |
| 136 | 97 | } |
| 137 | 98 | |
| 138 | 99 | :focus-visible { |
@@ -185,7 +146,8 @@ | ||
| 185 | 146 | } |
| 186 | 147 | .hl { |
| 187 | 148 | display: inline-block; |
| 188 | − background: var(--lime); | |
| 149 | + background: var(--accent); | |
| 150 | + color: var(--on-accent); | |
| 189 | 151 | border-radius: 8px; |
| 190 | 152 | padding: 0 10px 2px; |
| 191 | 153 | transform: rotate(-1deg); |
@@ -195,7 +157,7 @@ | ||
| 195 | 157 | |
| 196 | 158 | .gk-card { |
| 197 | 159 | background: var(--surface); |
| 198 | − border: 1px solid var(--edge); | |
| 160 | + border: 1.5px solid var(--edge); | |
| 199 | 161 | border-radius: var(--r-card); |
| 200 | 162 | box-shadow: var(--shadow-off-soft); |
| 201 | 163 | } |
@@ -218,7 +180,7 @@ | ||
| 218 | 180 | gap: 8px; |
| 219 | 181 | min-height: 44px; |
| 220 | 182 | padding: 10px 20px; |
| 221 | − border: 1px solid var(--edge); | |
| 183 | + border: 1.5px solid var(--edge); | |
| 222 | 184 | border-radius: var(--r-ctl); |
| 223 | 185 | font-family: var(--font-display); |
| 224 | 186 | font-size: 14px; |
@@ -227,6 +189,7 @@ | ||
| 227 | 189 | text-decoration: none; |
| 228 | 190 | transition: |
| 229 | 191 | background 0.15s ease, |
| 192 | + color 0.15s ease, | |
| 230 | 193 | transform 0.1s ease, |
| 231 | 194 | box-shadow 0.1s ease; |
| 232 | 195 | } |
@@ -247,7 +210,7 @@ | ||
| 247 | 210 | .btn-search { |
| 248 | 211 | font-family: var(--font-display); |
| 249 | 212 | font-weight: 700; |
| 250 | − border: 1px solid var(--edge); | |
| 213 | + border: 1.5px solid var(--edge); | |
| 251 | 214 | border-radius: 5px; |
| 252 | 215 | background: var(--btn-primary-bg); |
| 253 | 216 | color: var(--btn-primary-fg); |
@@ -257,11 +220,11 @@ | ||
| 257 | 220 | background: var(--btn-primary-hover); |
| 258 | 221 | } |
| 259 | 222 | |
| 260 | − /* ---------- Pastilles de filtre (thème-aware) ---------- */ | |
| 223 | + /* ---------- Pastilles de filtre ---------- */ | |
| 261 | 224 | .pill { |
| 262 | 225 | font-family: var(--font-mono); |
| 263 | 226 | white-space: nowrap; |
| 264 | − border: 1px solid var(--edge); | |
| 227 | + border: 1.5px solid var(--edge); | |
| 265 | 228 | border-radius: 999px; |
| 266 | 229 | padding: 6px 14px; |
| 267 | 230 | font-size: 11px; |
@@ -278,7 +241,7 @@ | ||
| 278 | 241 | box-shadow 0.15s ease; |
| 279 | 242 | } |
| 280 | 243 | .pill:hover { |
| 281 | − background: var(--lime-soft); | |
| 244 | + background: var(--accent-soft); | |
| 282 | 245 | color: var(--ink); |
| 283 | 246 | transform: translateY(-1px); |
| 284 | 247 | box-shadow: var(--shadow-field); |
@@ -305,7 +268,7 @@ | ||
| 305 | 268 | z-index: 50; |
| 306 | 269 | overflow: hidden; |
| 307 | 270 | background: var(--surface); |
| 308 | − border: 1px solid var(--edge); | |
| 271 | + border: 1.5px solid var(--edge); | |
| 309 | 272 | border-radius: var(--r-card); |
| 310 | 273 | box-shadow: var(--shadow-off-soft); |
| 311 | 274 | animation: rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both; |
@@ -323,7 +286,7 @@ | ||
| 323 | 286 | } |
| 324 | 287 | .suggest-item[aria-selected="true"], |
| 325 | 288 | .suggest-item:hover { |
| 326 | − background: var(--lime-soft); | |
| 289 | + background: var(--accent-soft); | |
| 327 | 290 | color: var(--ink); |
| 328 | 291 | } |
| 329 | 292 | .suggest-item mark { |
@@ -357,7 +320,7 @@ | ||
| 357 | 320 | display: inline-flex; |
| 358 | 321 | align-items: center; |
| 359 | 322 | gap: 7px; |
| 360 | − border: 1px solid var(--edge); | |
| 323 | + border: 1.5px solid var(--edge); | |
| 361 | 324 | border-radius: 999px; |
| 362 | 325 | padding: 7px 15px; |
| 363 | 326 | font-size: 13px; |
@@ -370,7 +333,7 @@ | ||
| 370 | 333 | transform 0.15s ease; |
| 371 | 334 | } |
| 372 | 335 | .chip-link:hover { |
| 373 | − background: var(--lime-soft); | |
| 336 | + background: var(--accent-soft); | |
| 374 | 337 | color: var(--ink); |
| 375 | 338 | transform: translateY(-1px); |
| 376 | 339 | } |
@@ -379,15 +342,17 @@ | ||
| 379 | 342 | color: var(--ink); |
| 380 | 343 | } |
| 381 | 344 | .btn-ghost:hover { |
| 382 | − background: var(--lime); | |
| 345 | + background: var(--accent); | |
| 346 | + color: var(--on-accent); | |
| 383 | 347 | } |
| 384 | 348 | .btn-lime { |
| 385 | − background: var(--lime); | |
| 386 | − color: var(--ink); | |
| 349 | + background: var(--accent); | |
| 350 | + color: var(--on-accent); | |
| 387 | 351 | box-shadow: var(--shadow-btn); |
| 388 | 352 | } |
| 389 | 353 | .btn-lime:hover { |
| 390 | − background: var(--lime-soft); | |
| 354 | + background: var(--accent-deep); | |
| 355 | + color: var(--on-accent); | |
| 391 | 356 | } |
| 392 | 357 | |
| 393 | 358 | /* ---------- Champs de formulaire ---------- */ |
@@ -397,7 +362,7 @@ | ||
| 397 | 362 | width: 100%; |
| 398 | 363 | padding: 11px 14px; |
| 399 | 364 | background: var(--surface); |
| 400 | − border: 1px solid var(--edge); | |
| 365 | + border: 1.5px solid var(--edge); | |
| 401 | 366 | border-radius: var(--r-ctl); |
| 402 | 367 | font-family: var(--font-body); |
| 403 | 368 | font-size: 14px; |
@@ -409,9 +374,9 @@ | ||
| 409 | 374 | color: var(--ink-3); |
| 410 | 375 | } |
| 411 | 376 | .field:focus { |
| 412 | − outline: 2px solid var(--green); | |
| 413 | − outline-offset: 1px; | |
| 414 | − box-shadow: 0 0 0 3px var(--blue-soft); | |
| 377 | + outline: none; | |
| 378 | + border-color: var(--ink); | |
| 379 | + box-shadow: 3px 3px 0 var(--accent); | |
| 415 | 380 | } |
| 416 | 381 | |
| 417 | 382 | /* ---------- Pilules de stats ---------- */ |
@@ -423,7 +388,7 @@ | ||
| 423 | 388 | white-space: nowrap; |
| 424 | 389 | padding: 8px 14px; |
| 425 | 390 | background: var(--surface); |
| 426 | − border: 1px solid var(--edge); | |
| 391 | + border: 1.5px solid var(--edge); | |
| 427 | 392 | border-radius: 999px; |
| 428 | 393 | font-family: var(--font-mono); |
| 429 | 394 | font-size: 12px; |
@@ -444,14 +409,14 @@ | ||
| 444 | 409 | position: absolute; |
| 445 | 410 | inset: -4px; |
| 446 | 411 | border-radius: 999px; |
| 447 | − border: 2px solid var(--lime); | |
| 412 | + border: 2px solid var(--accent); | |
| 448 | 413 | animation: pulse 2.4s ease-out infinite; |
| 449 | 414 | } |
| 450 | 415 | |
| 451 | 416 | /* ---------- Tableau éditorial ---------- */ |
| 452 | 417 | |
| 453 | 418 | .src-wrap { |
| 454 | − border: 1px solid var(--edge); | |
| 419 | + border: 1.5px solid var(--edge); | |
| 455 | 420 | border-radius: var(--r-card); |
| 456 | 421 | box-shadow: var(--shadow-off-soft); |
| 457 | 422 | overflow-x: auto; |
modified
apps/web/app/layout.tsx
+44 −100
@@ -2,6 +2,11 @@ | ||
| 2 | 2 | * Trouve-KA — layout racine de l'application web |
| 3 | 3 | * Author: Simon-Pierre Boucher |
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | + * | |
| 6 | + * Design « éditorial sharp » Groupe KA : ka/tokens.css (socle canonique) | |
| 7 | + * chargé en premier, puis globals.css (accent AZUR + composants du moteur). | |
| 8 | + * Header : wordmark + badge « Un service Groupe KA » + connexion KA ID. | |
| 9 | + * Footer : KaFooter commun (ecosystem.json) + flux temps réel du bot. | |
| 5 | 10 | */ |
| 6 | 11 | |
| 7 | 12 | import type { Metadata, Viewport } from "next"; |
@@ -9,7 +14,10 @@ import { Fraunces, Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google | ||
| 9 | 14 | import Link from "next/link"; |
| 10 | 15 | import { Wordmark } from "@/components/wordmark"; |
| 11 | 16 | import { LiveCrawl } from "@/components/live-crawl"; |
| 12 | −import { ThemeToggle } from "@/components/theme-toggle"; | |
| 17 | +import { AuthButton } from "@/components/auth-button"; | |
| 18 | +import GroupeKaBadge from "@/ka/GroupeKaBadge"; | |
| 19 | +import KaFooter from "@/ka/KaFooter"; | |
| 20 | +import "@/ka/tokens.css"; | |
| 13 | 21 | import "./globals.css"; |
| 14 | 22 | |
| 15 | 23 | const spaceGrotesk = Space_Grotesk({ |
@@ -27,49 +35,43 @@ const fraunces = Fraunces({ | ||
| 27 | 35 | variable: "--font-fraunces", |
| 28 | 36 | }); |
| 29 | 37 | |
| 30 | −/** Applique le thème avant le premier rendu (aucun flash clair/sombre). */ | |
| 31 | −const THEME_INIT = `(function(){try{var t=localStorage.getItem("tk-theme");if(!t)t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;}catch(e){}})();`; | |
| 32 | − | |
| 33 | 38 | export const metadata: Metadata = { |
| 34 | 39 | metadataBase: new URL(process.env.PUBLIC_URL ?? "https://www.trouve-ka.com"), |
| 35 | 40 | title: { |
| 36 | − default: "Trouve-KA — Cherche le Québec", | |
| 41 | + default: "Trouve-Ka — Un service Groupe KA", | |
| 37 | 42 | template: "%s — Trouve-KA", |
| 38 | 43 | }, |
| 39 | 44 | description: |
| 40 | − "Moteur de recherche indépendant du web québécois. Trouve-KA découvre, indexe et classe les sites du Québec — en continu.", | |
| 45 | + "Moteur de recherche indépendant du web québécois. Trouve-KA découvre, indexe et classe les sites du Québec — en continu. Un service Groupe KA.", | |
| 41 | 46 | }; |
| 42 | 47 | |
| 43 | 48 | export const viewport: Viewport = { |
| 44 | − themeColor: "#0b1330", | |
| 49 | + themeColor: "#f5f3ee", | |
| 45 | 50 | width: "device-width", |
| 46 | 51 | initialScale: 1, |
| 47 | 52 | }; |
| 48 | 53 | |
| 49 | −const FOOTER_LINKS = [ | |
| 50 | − { href: "/trouveka-bot", label: "À propos du robot" }, | |
| 54 | +const NAV_LINKS = [ | |
| 51 | 55 | { href: "/status", label: "État du moteur" }, |
| 52 | 56 | { href: "/soumettre", label: "Soumettre un site" }, |
| 57 | + { href: "/contact", label: "Contact" }, | |
| 53 | 58 | ] as const; |
| 54 | 59 | |
| 55 | −const SISTER_SITES = [ | |
| 56 | − ["Groupe·KA", "https://www.groupe-ka.com"], | |
| 57 | − ["Lou·Ka", "https://www.lou-ka.com"], | |
| 58 | − ["Immo·Ka", "https://www.immo-ka.com"], | |
| 59 | − ["Auto·Ka", "https://www.auto-ka.com"], | |
| 60 | − ["Fabri·Ka", "https://www.fabri-ka.com"], | |
| 61 | − ["Food·Ka", "https://www.food-ka.com"], | |
| 62 | −] as const; | |
| 60 | +const LOCAL_FOOTER_LINKS = [ | |
| 61 | + { href: "/trouveka-bot", label: "À propos du robot" }, | |
| 62 | + { href: "/status", label: "État du moteur" }, | |
| 63 | + { href: "/soumettre", label: "Soumettre un site" }, | |
| 64 | + { href: "/contact", label: "Contact" }, | |
| 65 | +]; | |
| 63 | 66 | |
| 64 | 67 | export default function RootLayout({ |
| 65 | 68 | children, |
| 66 | 69 | }: Readonly<{ children: React.ReactNode }>) { |
| 67 | 70 | return ( |
| 68 | − <html lang="fr" suppressHydrationWarning> | |
| 71 | + <html lang="fr"> | |
| 69 | 72 | <body |
| 70 | 73 | className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} ${fraunces.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`} |
| 71 | 74 | > |
| 72 | − <script dangerouslySetInnerHTML={{ __html: THEME_INIT }} /> | |
| 73 | 75 | <a |
| 74 | 76 | href="#contenu" |
| 75 | 77 | className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-ctl focus:border-[1.5px] focus:border-ink focus:bg-ink focus:px-4 focus:py-2 focus:font-display focus:text-sm focus:font-bold focus:text-lime" |
@@ -77,99 +79,41 @@ export default function RootLayout({ | ||
| 77 | 79 | Aller au contenu principal |
| 78 | 80 | </a> |
| 79 | 81 | |
| 80 | − <header className="sticky top-0 z-40 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl"> | |
| 81 | − <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6"> | |
| 82 | − <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil"> | |
| 83 | − <Wordmark /> | |
| 84 | − </Link> | |
| 82 | + <header className="sticky top-0 z-40 border-b-[1.5px] border-[var(--line-strong)] bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl"> | |
| 83 | + <div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4 sm:px-6"> | |
| 84 | + <div className="flex min-w-0 items-center gap-3"> | |
| 85 | + <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil"> | |
| 86 | + <Wordmark /> | |
| 87 | + </Link> | |
| 88 | + <span className="hidden sm:inline-flex"> | |
| 89 | + <GroupeKaBadge /> | |
| 90 | + </span> | |
| 91 | + </div> | |
| 85 | 92 | <nav aria-label="Navigation principale"> |
| 86 | 93 | <ul className="flex items-center gap-1"> |
| 87 | − <li> | |
| 88 | − <Link | |
| 89 | − href="/status" | |
| 90 | − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 91 | − > | |
| 92 | − État du moteur | |
| 93 | − </Link> | |
| 94 | − </li> | |
| 95 | − <li> | |
| 96 | − <Link | |
| 97 | − href="/soumettre" | |
| 98 | − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 99 | − > | |
| 100 | − Soumettre un site | |
| 101 | − </Link> | |
| 102 | − </li> | |
| 103 | − <li> | |
| 104 | − <ThemeToggle /> | |
| 105 | − </li> | |
| 106 | − </ul> | |
| 107 | − </nav> | |
| 108 | − </div> | |
| 109 | − </header> | |
| 110 | − | |
| 111 | − <div className="flex flex-1 flex-col">{children}</div> | |
| 112 | − | |
| 113 | − <footer className="border-t border-[#0b1330] bg-[#0b1330] py-9 text-[13px] text-[rgba(237,240,247,0.75)]"> | |
| 114 | − <div className="mx-auto max-w-6xl px-4 sm:px-6"> | |
| 115 | − <div className="flex flex-wrap items-baseline justify-between gap-x-8 gap-y-3"> | |
| 116 | − <Wordmark variant="footer" /> | |
| 117 | − <p className="gk-display text-[15px] font-bold text-[rgba(237,240,247,0.85)]"> | |
| 118 | − Un moteur de recherche par{" "} | |
| 119 | − <a | |
| 120 | − href="https://www.groupe-ka.com" | |
| 121 | − target="_blank" | |
| 122 | − rel="noopener noreferrer" | |
| 123 | − className="whitespace-nowrap underline-offset-4 hover:underline hover:decoration-lime" | |
| 124 | − > | |
| 125 | − <span className="text-white">Groupe</span>{" "} | |
| 126 | − <span className="text-lime">KA</span> | |
| 127 | − </a> | |
| 128 | − </p> | |
| 129 | − </div> | |
| 130 | − <p className="gk-display mt-3 text-[15px] font-bold text-lime"> | |
| 131 | − Cherche le Québec. | |
| 132 | − </p> | |
| 133 | − <LiveCrawl /> | |
| 134 | − <nav aria-label="Liens de pied de page" className="mt-5"> | |
| 135 | − <ul className="flex flex-wrap gap-x-6 gap-y-2"> | |
| 136 | − {FOOTER_LINKS.map((link) => ( | |
| 137 | − <li key={link.href}> | |
| 94 | + {NAV_LINKS.map((link) => ( | |
| 95 | + <li key={link.href} className="hidden md:block"> | |
| 138 | 96 | <Link |
| 139 | 97 | href={link.href} |
| 140 | − className="rounded-sm text-[rgba(237,240,247,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 98 | + className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 141 | 99 | > |
| 142 | 100 | {link.label} |
| 143 | 101 | </Link> |
| 144 | 102 | </li> |
| 145 | 103 | ))} |
| 104 | + <li> | |
| 105 | + <AuthButton /> | |
| 106 | + </li> | |
| 146 | 107 | </ul> |
| 147 | 108 | </nav> |
| 148 | − <ul className="gk-mono mt-6 flex flex-wrap gap-x-6 gap-y-2 text-[11px] font-bold uppercase tracking-[0.1em]"> | |
| 149 | − {SISTER_SITES.map(([label, href]) => ( | |
| 150 | − <li key={label}> | |
| 151 | − <a | |
| 152 | − href={href} | |
| 153 | − target="_blank" | |
| 154 | − rel="noopener noreferrer" | |
| 155 | − className="text-[rgba(237,240,247,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime" | |
| 156 | − > | |
| 157 | − {label} | |
| 158 | − </a> | |
| 159 | − </li> | |
| 160 | − ))} | |
| 161 | − </ul> | |
| 162 | − <p className="gk-mono mt-7 border-t border-[rgba(237,240,247,0.15)] pt-5 text-[11px] text-[rgba(237,240,247,0.45)]"> | |
| 163 | − © 2026 Trouve-KA — Simon-Pierre Boucher ·{" "} | |
| 164 | − <a | |
| 165 | − href="mailto:contact@spboucher.ai" | |
| 166 | − className="underline-offset-4 hover:text-lime hover:underline" | |
| 167 | − > | |
| 168 | − contact@spboucher.ai | |
| 169 | − </a> | |
| 170 | − </p> | |
| 171 | 109 | </div> |
| 172 | − </footer> | |
| 110 | + </header> | |
| 111 | + | |
| 112 | + <div className="flex flex-1 flex-col">{children}</div> | |
| 113 | + | |
| 114 | + <KaFooter siteId="trouve-ka" localLegal={LOCAL_FOOTER_LINKS}> | |
| 115 | + <LiveCrawl /> | |
| 116 | + </KaFooter> | |
| 173 | 117 | </body> |
| 174 | 118 | </html> |
| 175 | 119 | ); |
added
apps/web/components/auth-button.tsx
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — bouton compte KA ID (header) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * « Se connecter avec KA » : délègue l'identité au hub groupe-ka.com via | |
| 7 | + * /api/auth/ka/login (SSO KA ID). Une fois connecté : nom + déconnexion. | |
| 8 | + * La création de compte se fait TOUJOURS sur le hub (groupe-ka.com/connexion). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useEffect, useState } from "react"; | |
| 14 | +import { usePathname } from "next/navigation"; | |
| 15 | + | |
| 16 | +type KaUser = { | |
| 17 | + ka_id: string; | |
| 18 | + email: string; | |
| 19 | + name: string; | |
| 20 | + picture: string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export function AuthButton() { | |
| 24 | + const [user, setUser] = useState<KaUser | null>(null); | |
| 25 | + const [ready, setReady] = useState(false); | |
| 26 | + const pathname = usePathname(); | |
| 27 | + | |
| 28 | + useEffect(() => { | |
| 29 | + let active = true; | |
| 30 | + fetch("/api/auth/me", { cache: "no-store" }) | |
| 31 | + .then((r) => (r.ok ? r.json() : { user: null })) | |
| 32 | + .then((d: { user: KaUser | null }) => { | |
| 33 | + if (active) setUser(d.user); | |
| 34 | + }) | |
| 35 | + .catch(() => {}) | |
| 36 | + .finally(() => { | |
| 37 | + if (active) setReady(true); | |
| 38 | + }); | |
| 39 | + return () => { | |
| 40 | + active = false; | |
| 41 | + }; | |
| 42 | + }, []); | |
| 43 | + | |
| 44 | + const logout = async () => { | |
| 45 | + try { | |
| 46 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 47 | + } catch { | |
| 48 | + /* réseau indisponible : on recharge quand même */ | |
| 49 | + } | |
| 50 | + location.reload(); | |
| 51 | + }; | |
| 52 | + | |
| 53 | + if (!ready) { | |
| 54 | + return <span className="inline-block h-[44px] w-[150px]" aria-hidden="true" />; | |
| 55 | + } | |
| 56 | + | |
| 57 | + if (user) { | |
| 58 | + return ( | |
| 59 | + <span className="flex items-center gap-2"> | |
| 60 | + <span | |
| 61 | + className="gk-display hidden max-w-[140px] truncate text-[13px] font-bold text-ink sm:inline" | |
| 62 | + title={user.email || user.ka_id} | |
| 63 | + > | |
| 64 | + {user.name || "Membre KA"} | |
| 65 | + </span> | |
| 66 | + <button | |
| 67 | + type="button" | |
| 68 | + onClick={logout} | |
| 69 | + className="gk-display inline-flex min-h-[44px] items-center rounded-full border-[1.5px] border-ink bg-surface px-4 text-[13px] font-bold text-ink transition-colors hover:bg-[var(--accent-soft)]" | |
| 70 | + > | |
| 71 | + Se déconnecter | |
| 72 | + </button> | |
| 73 | + </span> | |
| 74 | + ); | |
| 75 | + } | |
| 76 | + | |
| 77 | + return ( | |
| 78 | + <a | |
| 79 | + href={`/api/auth/ka/login?next=${encodeURIComponent(pathname || "/")}`} | |
| 80 | + className="gk-display inline-flex min-h-[44px] items-center whitespace-nowrap rounded-full border-[1.5px] border-ink bg-[var(--btn-primary-bg)] px-4 text-[13px] font-bold text-[var(--btn-primary-fg)] transition-colors hover:bg-[var(--btn-primary-hover)]" | |
| 81 | + > | |
| 82 | + Se connecter avec KA | |
| 83 | + </a> | |
| 84 | + ); | |
| 85 | +} | |
deleted
apps/web/components/theme-toggle.tsx
+0 −59
@@ -1,59 +0,0 @@ | ||
| 1 | −/** | |
| 2 | − * Trouve-KA — bascule clair/sombre (persistée, défaut système) | |
| 3 | − * Author: Simon-Pierre Boucher | |
| 4 | − * Contact: contact@spboucher.ai | |
| 5 | − */ | |
| 6 | − | |
| 7 | −"use client"; | |
| 8 | − | |
| 9 | −import { useEffect, useState } from "react"; | |
| 10 | − | |
| 11 | −type Theme = "light" | "dark"; | |
| 12 | − | |
| 13 | −function applyTheme(theme: Theme) { | |
| 14 | − document.documentElement.dataset.theme = theme; | |
| 15 | − try { | |
| 16 | − localStorage.setItem("tk-theme", theme); | |
| 17 | − } catch { | |
| 18 | − /* stockage indisponible (navigation privée) : le choix vaut pour la session */ | |
| 19 | − } | |
| 20 | −} | |
| 21 | − | |
| 22 | −export function ThemeToggle() { | |
| 23 | − // null jusqu'au montage : le SSR ne connaît pas le thème → pas d'hydration mismatch | |
| 24 | − const [theme, setTheme] = useState<Theme | null>(null); | |
| 25 | − | |
| 26 | − useEffect(() => { | |
| 27 | − setTheme( | |
| 28 | − (document.documentElement.dataset.theme as Theme) === "dark" | |
| 29 | − ? "dark" | |
| 30 | − : "light", | |
| 31 | − ); | |
| 32 | − }, []); | |
| 33 | − | |
| 34 | − const next: Theme = theme === "dark" ? "light" : "dark"; | |
| 35 | − | |
| 36 | − return ( | |
| 37 | − <button | |
| 38 | − type="button" | |
| 39 | − aria-label={theme === "dark" ? "Passer au mode clair" : "Passer au mode sombre"} | |
| 40 | − title={theme === "dark" ? "Mode clair" : "Mode sombre"} | |
| 41 | − onClick={() => { | |
| 42 | − setTheme(next); | |
| 43 | − applyTheme(next); | |
| 44 | − }} | |
| 45 | − className="inline-flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-transparent text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 46 | − > | |
| 47 | − {theme === "dark" ? ( | |
| 48 | − <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-4 w-4"> | |
| 49 | − <circle cx="12" cy="12" r="4" /> | |
| 50 | − <path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /> | |
| 51 | − </svg> | |
| 52 | − ) : ( | |
| 53 | − <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4"> | |
| 54 | − <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /> | |
| 55 | − </svg> | |
| 56 | − )} | |
| 57 | − </button> | |
| 58 | − ); | |
| 59 | −} | |
added
apps/web/ka/GroupeKaBadge.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable | |
| 3 | +// vers le hub. À placer dans le header de chaque site (à côté du logo de la | |
| 4 | +// marque). Variante dark pour les surfaces encre. Zone de protection : le | |
| 5 | +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge. | |
| 6 | +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) { | |
| 7 | + return ( | |
| 8 | + <a | |
| 9 | + className={`gk-badge${dark ? " gk-badge--dark" : ""}`} | |
| 10 | + href="https://www.groupe-ka.com" | |
| 11 | + target="_blank" | |
| 12 | + rel="noopener noreferrer" | |
| 13 | + aria-label="Un service Groupe KA — visiter le portail de l'écosystème" | |
| 14 | + > | |
| 15 | + Un service | |
| 16 | + <b> | |
| 17 | + Groupe<span className="ka">KA</span> | |
| 18 | + </b> | |
| 19 | + </a> | |
| 20 | + ); | |
| 21 | +} | |
added
apps/web/ka/KaFooter.tsx
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), vendoré ici | |
| 3 | +// sous apps/web/ka/. Consomme ecosystem.json : toute modification des infos de | |
| 4 | +// contact se fait dans ka-ui/ecosystem.json puis se resynchronise sur les 12 | |
| 5 | +// sites (voir ka-ui/README.md). | |
| 6 | +// Extension locale trouve-ka : prop `children` (rendue après l'avertissement) | |
| 7 | +// pour accueillir le flux temps réel du bot (<LiveCrawl />). | |
| 8 | +import eco from "./ecosystem.json"; | |
| 9 | + | |
| 10 | +type LocalLink = { label: string; href: string }; | |
| 11 | + | |
| 12 | +export default function KaFooter({ | |
| 13 | + siteId, | |
| 14 | + localLegal = [], | |
| 15 | + children, | |
| 16 | +}: { | |
| 17 | + /** id du site courant dans ecosystem.json (ex. "trouve-ka") */ | |
| 18 | + siteId: string; | |
| 19 | + /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */ | |
| 20 | + localLegal?: LocalLink[]; | |
| 21 | + /** contenu propre au site, rendu après l'avertissement (ex. flux du bot) */ | |
| 22 | + children?: React.ReactNode; | |
| 23 | +}) { | |
| 24 | + const year = new Date().getFullYear(); | |
| 25 | + const site = eco.sites.find((s) => s.id === siteId); | |
| 26 | + const others = eco.sites.filter((s) => s.id !== siteId); | |
| 27 | + return ( | |
| 28 | + <footer className="ka-footer" id="contact"> | |
| 29 | + <div className="container"> | |
| 30 | + <a | |
| 31 | + className="wordmark" | |
| 32 | + href={eco.hub.url} | |
| 33 | + target={siteId === "groupe-ka" ? undefined : "_blank"} | |
| 34 | + rel="noopener noreferrer" | |
| 35 | + aria-label="Groupe KA — le portail de l'écosystème" | |
| 36 | + > | |
| 37 | + Groupe <span className="ka">KA</span> | |
| 38 | + </a> | |
| 39 | + <p className="desc"> | |
| 40 | + {eco.org.tagline} Des centaines de connecteurs lisent les sites à la | |
| 41 | + source, normalisent la donnée et se resynchronisent seuls.{" "} | |
| 42 | + {site ? ( | |
| 43 | + <> | |
| 44 | + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "} | |
| 45 | + {site.tagline} — est un service Groupe KA. | |
| 46 | + </> | |
| 47 | + ) : null} | |
| 48 | + </p> | |
| 49 | + <p className="notice"> | |
| 50 | + <b>{eco.org.disclaimer}</b> | |
| 51 | + </p> | |
| 52 | + {children} | |
| 53 | + <ul className="sites"> | |
| 54 | + {others.map((s) => ( | |
| 55 | + <li key={s.id}> | |
| 56 | + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer"> | |
| 57 | + {s.wordmark} | |
| 58 | + </a> | |
| 59 | + </li> | |
| 60 | + ))} | |
| 61 | + {eco.extraFooterLinks.map((l) => ( | |
| 62 | + <li key={l.href}> | |
| 63 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 64 | + {l.label} | |
| 65 | + </a> | |
| 66 | + </li> | |
| 67 | + ))} | |
| 68 | + </ul> | |
| 69 | + <div className="contacts"> | |
| 70 | + {eco.contacts.map((c) => ( | |
| 71 | + <p key={c.email} style={{ margin: 0 }}> | |
| 72 | + <a href={`mailto:${c.email}`}>{c.email}</a> | |
| 73 | + <span>{c.role}</span> | |
| 74 | + </p> | |
| 75 | + ))} | |
| 76 | + </div> | |
| 77 | + <p className="legal"> | |
| 78 | + © {year} {eco.org.copyrightHolder} | |
| 79 | + {localLegal.map((l) => ( | |
| 80 | + <span key={l.href}> | |
| 81 | + {" · "} | |
| 82 | + <a href={l.href}>{l.label}</a> | |
| 83 | + </span> | |
| 84 | + ))} | |
| 85 | + {eco.legal.map((l) => ( | |
| 86 | + <span key={l.href}> | |
| 87 | + {" · "} | |
| 88 | + <a href={l.href} target="_blank" rel="noopener noreferrer"> | |
| 89 | + {l.label} | |
| 90 | + </a> | |
| 91 | + </span> | |
| 92 | + ))} | |
| 93 | + </p> | |
| 94 | + </div> | |
| 95 | + </footer> | |
| 96 | + ); | |
| 97 | +} | |
added
apps/web/ka/ecosystem.json
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +{ | |
| 2 | + "org": { | |
| 3 | + "name": "Groupe KA", | |
| 4 | + "legalName": "Groupe KA — Simon-Pierre Boucher", | |
| 5 | + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.", | |
| 6 | + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.", | |
| 7 | + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher" | |
| 8 | + }, | |
| 9 | + "hub": { | |
| 10 | + "url": "https://www.groupe-ka.com", | |
| 11 | + "loginPath": "/connexion", | |
| 12 | + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login." | |
| 13 | + }, | |
| 14 | + "contacts": [ | |
| 15 | + { "email": "contact@groupe-ka.com", "role": "Projets, partenariats & données" }, | |
| 16 | + { "email": "info@groupe-ka.com", "role": "Médias & questions générales" }, | |
| 17 | + { "email": "admin@groupe-ka.com", "role": "Légal, vie privée & Loi 25" } | |
| 18 | + ], | |
| 19 | + "legal": [ | |
| 20 | + { "label": "Conditions d'utilisation", "href": "https://www.groupe-ka.com/conditions" }, | |
| 21 | + { "label": "Politique de confidentialité", "href": "https://www.groupe-ka.com/confidentialite" }, | |
| 22 | + { "label": "Protection des renseignements personnels (Loi 25)", "href": "https://www.groupe-ka.com/loi-25" }, | |
| 23 | + { "label": "Transparence des robots d'indexation", "href": "https://www.groupe-ka.com/bots" } | |
| 24 | + ], | |
| 25 | + "sites": [ | |
| 26 | + { "id": "groupe-ka", "wordmark": "Groupe KA", "domain": "www.groupe-ka.com", "accent": "#d9f26b", "accentSoft": "#f0f9d2", "accentDeep": "#123f2e", "onAccent": "#141814", "tagline": "Le portail de l'écosystème ·Ka" }, | |
| 27 | + { "id": "trouve-ka", "wordmark": "Trouve·Ka", "domain": "www.trouve-ka.com", "accent": "#1c7ed6", "accentSoft": "#e7f2fd", "accentDeep": "#14508f", "onAccent": "#ffffff", "tagline": "Le moteur de recherche du web québécois" }, | |
| 28 | + { "id": "lou-ka", "wordmark": "Lou·Ka", "domain": "www.lou-ka.com", "accent": "#ff6a00", "accentSoft": "#fff1e6", "accentDeep": "#cc5500", "onAccent": "#ffffff", "tagline": "Tous les logements à louer" }, | |
| 29 | + { "id": "immo-ka", "wordmark": "Immo·Ka", "domain": "www.immo-ka.com", "accent": "#e23744", "accentSoft": "#fbe0e2", "accentDeep": "#a8232e", "onAccent": "#ffffff", "tagline": "Toutes les propriétés à vendre" }, | |
| 30 | + { "id": "vrai-prix", "wordmark": "Vrai-Prix", "domain": "www.vrai-prix.com", "accent": "#ff5148", "accentSoft": "#ffe3e0", "accentDeep": "#9e2a25", "onAccent": "#ffffff", "tagline": "La valeur réelle de chaque propriété" }, | |
| 31 | + { "id": "auto-ka", "wordmark": "Auto·Ka", "domain": "www.auto-ka.com", "accent": "#ff5a2a", "accentSoft": "#ffe8de", "accentDeep": "#cc3f16", "onAccent": "#ffffff", "tagline": "Les voitures usagées du Québec" }, | |
| 32 | + { "id": "fabri-ka", "wordmark": "Fabri·Ka", "domain": "www.fabri-ka.com", "accent": "#c4532e", "accentSoft": "#f7e3da", "accentDeep": "#a94525", "onAccent": "#ffffff", "tagline": "Les produits fabriqués au Québec" }, | |
| 33 | + { "id": "food-ka", "wordmark": "Food·Ka", "domain": "www.food-ka.com", "accent": "#1f9d55", "accentSoft": "#e2f5ea", "accentDeep": "#157a40", "onAccent": "#ffffff", "tagline": "Les prix d'épicerie, suivis à la source" }, | |
| 34 | + { "id": "resto-ka", "wordmark": "Resto·Ka", "domain": "www.resto-ka.com", "accent": "#f08c00", "accentSoft": "#fdeed7", "accentDeep": "#b96a00", "onAccent": "#141814", "tagline": "Chaque resto, chaque plat, chaque prix" }, | |
| 35 | + { "id": "sorti-ka", "wordmark": "Sorti·Ka", "domain": "www.sorti-ka.com", "accent": "#d6336c", "accentSoft": "#fbe0eb", "accentDeep": "#a12551", "onAccent": "#ffffff", "tagline": "Toutes les sorties, dans les 17 régions" }, | |
| 36 | + { "id": "crea-ka", "wordmark": "Créa·Ka", "domain": "www.crea-ka.com", "accent": "#7048e8", "accentSoft": "#ece5fc", "accentDeep": "#5433b8", "onAccent": "#ffffff", "tagline": "Les créateurs d'ici, tous leurs liens" }, | |
| 37 | + { "id": "api-ka", "wordmark": "API·Ka", "domain": "www.api-ka.com", "accent": "#3b5bdb", "accentSoft": "#e4eafb", "accentDeep": "#2b44a8", "onAccent": "#ffffff", "tagline": "La donnée de l'écosystème, par API" } | |
| 38 | + ], | |
| 39 | + "extraFooterLinks": [ | |
| 40 | + { "label": "ValoPlex", "href": "https://www.valoplex.com" }, | |
| 41 | + { "label": "Ka2", "href": "https://www.ka2.bot" }, | |
| 42 | + { "label": "Ka4", "href": "https://www.ka4.bot" }, | |
| 43 | + { "label": "Ka6", "href": "https://www.ka6.bot" } | |
| 44 | + ] | |
| 45 | +} | |
added
apps/web/ka/tokens.css
+229 −0
@@ -0,0 +1,229 @@ | ||
| 1 | +/* ----------------------------------------------------------------------------- | |
| 2 | + Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit) | |
| 4 | + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle | |
| 5 | + de composants partagés par les 12 plateformes. Chaque site importe ce | |
| 6 | + fichier PUIS surcharge uniquement son accent (voir accents.css). | |
| 7 | + | |
| 8 | + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono | |
| 9 | + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site | |
| 10 | + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film, | |
| 11 | + surlignés d'accent inclinés (néo-brutalisme raffiné) | |
| 12 | + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first) | |
| 13 | + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp) | |
| 14 | +----------------------------------------------------------------------------- */ | |
| 15 | + | |
| 16 | +:root { | |
| 17 | + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */ | |
| 18 | + --paper: #f5f3ee; | |
| 19 | + --surface: #ffffff; | |
| 20 | + --surface-2: #faf9f5; | |
| 21 | + --ink: #141814; | |
| 22 | + --ink-2: #4d5551; | |
| 23 | + --ink-3: #8b928c; | |
| 24 | + --line: rgba(20, 24, 20, 0.14); | |
| 25 | + --line-strong: rgba(20, 24, 20, 0.85); | |
| 26 | + --green: #1c5c41; | |
| 27 | + --green-deep: #123f2e; | |
| 28 | + --amber: #e8a33d; | |
| 29 | + --amber-soft: #fdf3e2; | |
| 30 | + --danger: #b3423a; | |
| 31 | + --danger-soft: #fbe9e7; | |
| 32 | + | |
| 33 | + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */ | |
| 34 | + --accent: #d9f26b; /* défaut : lime Groupe KA */ | |
| 35 | + --accent-soft: #f0f9d2; | |
| 36 | + --accent-deep: #123f2e; | |
| 37 | + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */ | |
| 38 | + | |
| 39 | + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */ | |
| 40 | + --lime: var(--accent); | |
| 41 | + --lime-soft: var(--accent-soft); | |
| 42 | + | |
| 43 | + /* ---- Géométrie sharp ---- */ | |
| 44 | + --r-card: 10px; | |
| 45 | + --r-ctl: 6px; | |
| 46 | + --r-pill: 999px; | |
| 47 | + | |
| 48 | + /* ---- Ombres décalées — la signature du groupe ---- */ | |
| 49 | + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 50 | + --shadow-off: 6px 6px 0 var(--ink); | |
| 51 | + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 52 | + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18); | |
| 53 | + | |
| 54 | + /* ---- Typographie ---- */ | |
| 55 | + --font-display: "Space Grotesk", system-ui, sans-serif; | |
| 56 | + --font-body: "Inter", system-ui, sans-serif; | |
| 57 | + --font-mono: "JetBrains Mono", ui-monospace, monospace; | |
| 58 | + | |
| 59 | + /* Échelle fluide (mobile-first, clamp) */ | |
| 60 | + --fs-h1: clamp(30px, 3.2vw + 18px, 54px); | |
| 61 | + --fs-h2: clamp(23px, 1.8vw + 14px, 34px); | |
| 62 | + --fs-h3: clamp(17px, 0.9vw + 12px, 22px); | |
| 63 | + --fs-body: 15px; | |
| 64 | + --fs-small: 13px; | |
| 65 | + | |
| 66 | + /* Espacements */ | |
| 67 | + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; | |
| 68 | + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px; | |
| 69 | + | |
| 70 | + /* Cible tactile minimale */ | |
| 71 | + --touch: 44px; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/* ---- Socle ---- */ | |
| 75 | +* { box-sizing: border-box; } | |
| 76 | +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 77 | +body { | |
| 78 | + margin: 0; | |
| 79 | + background: var(--paper); | |
| 80 | + color: var(--ink); | |
| 81 | + font-family: var(--font-body); | |
| 82 | + font-size: var(--fs-body); | |
| 83 | + line-height: 1.55; | |
| 84 | + -webkit-font-smoothing: antialiased; | |
| 85 | + padding-bottom: env(safe-area-inset-bottom); | |
| 86 | + overflow-x: clip; /* jamais de débordement horizontal */ | |
| 87 | +} | |
| 88 | +img, svg, video { max-width: 100%; height: auto; display: block; } | |
| 89 | +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; } | |
| 90 | +h1 { font-size: var(--fs-h1); line-height: 1.06; } | |
| 91 | +h2 { font-size: var(--fs-h2); line-height: 1.15; } | |
| 92 | +h3 { font-size: var(--fs-h3); line-height: 1.25; } | |
| 93 | +a { color: inherit; } | |
| 94 | +::selection { background: var(--accent); color: var(--on-accent); } | |
| 95 | +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } | |
| 96 | + | |
| 97 | +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position: | |
| 98 | + relative seulement), sinon les utilitaires z-* sont écrasés. */ | |
| 99 | +body::before { | |
| 100 | + content: ""; | |
| 101 | + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999; | |
| 102 | + 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"); | |
| 103 | +} | |
| 104 | +body > * { position: relative; } | |
| 105 | + | |
| 106 | +/* ---- Conteneur commun ---- */ | |
| 107 | +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); } | |
| 108 | +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } } | |
| 109 | + | |
| 110 | +/* ---- Micro-typographie signature ---- */ | |
| 111 | +.kicker { | |
| 112 | + display: inline-flex; align-items: center; gap: 10px; | |
| 113 | + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; | |
| 114 | + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green); | |
| 115 | +} | |
| 116 | +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); } | |
| 117 | +.klabel { | |
| 118 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 119 | + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); | |
| 120 | +} | |
| 121 | +.hl { | |
| 122 | + display: inline-block; background: var(--accent); color: var(--on-accent); | |
| 123 | + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg); | |
| 124 | +} | |
| 125 | +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); } | |
| 126 | + | |
| 127 | +/* ---- Boutons (cible ≥ 44 px) ---- */ | |
| 128 | +.btn { | |
| 129 | + display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| 130 | + min-height: var(--touch); padding: 10px 18px; | |
| 131 | + font-family: var(--font-display); font-weight: 700; font-size: 14px; | |
| 132 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 133 | + background: var(--surface); color: var(--ink); | |
| 134 | + cursor: pointer; text-decoration: none; | |
| 135 | + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s; | |
| 136 | +} | |
| 137 | +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); } | |
| 138 | +.btn:active { transform: translate(0, 0); box-shadow: none; } | |
| 139 | +.btn:disabled { opacity: 0.45; pointer-events: none; } | |
| 140 | +.btn-primary { background: var(--ink); color: var(--accent); } | |
| 141 | +.btn-primary:hover { background: var(--accent-deep); } | |
| 142 | +.btn-accent { background: var(--accent); color: var(--on-accent); } | |
| 143 | +.btn-ghost { background: transparent; border-color: var(--line); } | |
| 144 | +.btn-ghost:hover { border-color: var(--ink); } | |
| 145 | + | |
| 146 | +/* ---- Cartes ---- */ | |
| 147 | +.card { | |
| 148 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 149 | + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); | |
| 150 | + overflow: hidden; | |
| 151 | +} | |
| 152 | +.card-hover { transition: transform 0.15s, box-shadow 0.15s; } | |
| 153 | +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); } | |
| 154 | + | |
| 155 | +/* ---- Chips / badges ---- */ | |
| 156 | +.chip { | |
| 157 | + display: inline-flex; align-items: center; gap: 6px; | |
| 158 | + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700; | |
| 159 | + text-transform: uppercase; letter-spacing: 0.06em; | |
| 160 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 161 | + background: var(--surface); color: var(--ink); | |
| 162 | +} | |
| 163 | +.chip-accent { background: var(--accent); color: var(--on-accent); } | |
| 164 | +.chip-soft { background: var(--accent-soft); border-color: var(--line); } | |
| 165 | + | |
| 166 | +/* ---- Champs ---- */ | |
| 167 | +.input, .select, .textarea { | |
| 168 | + width: 100%; min-height: var(--touch); padding: 10px 14px; | |
| 169 | + font: inherit; color: var(--ink); background: var(--surface); | |
| 170 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | |
| 171 | +} | |
| 172 | +.input:focus, .select:focus, .textarea:focus { | |
| 173 | + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink); | |
| 174 | +} | |
| 175 | +.input::placeholder { color: var(--ink-3); } | |
| 176 | + | |
| 177 | +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */ | |
| 178 | +.gk-badge { | |
| 179 | + display: inline-flex; align-items: center; gap: 7px; | |
| 180 | + min-height: 30px; padding: 3px 10px 4px; | |
| 181 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 182 | + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none; | |
| 183 | + border: 1.5px solid var(--ink); border-radius: var(--r-pill); | |
| 184 | + background: var(--surface); color: var(--ink-2); white-space: nowrap; | |
| 185 | +} | |
| 186 | +.gk-badge b { | |
| 187 | + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em; | |
| 188 | + text-transform: none; color: var(--ink); | |
| 189 | +} | |
| 190 | +.gk-badge b .ka { | |
| 191 | + display: inline-block; background: var(--ink); color: var(--accent); | |
| 192 | + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg); | |
| 193 | +} | |
| 194 | +.gk-badge:hover .ka { transform: rotate(0); } | |
| 195 | +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); } | |
| 196 | +.gk-badge--dark b { color: var(--paper); } | |
| 197 | + | |
| 198 | +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */ | |
| 199 | +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); } | |
| 200 | +.ka-footer a { text-decoration: none; } | |
| 201 | +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; } | |
| 202 | +.ka-footer .wordmark .ka { color: var(--accent); } | |
| 203 | +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); } | |
| 204 | +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); } | |
| 205 | +.ka-footer .notice b { color: var(--paper); } | |
| 206 | +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } | |
| 207 | +.ka-footer .sites a { color: rgba(245,243,238,0.65); } | |
| 208 | +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 209 | +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; } | |
| 210 | +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } } | |
| 211 | +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); } | |
| 212 | +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 213 | +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); } | |
| 214 | +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); } | |
| 215 | +.ka-footer .legal a { color: inherit; } | |
| 216 | +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; } | |
| 217 | + | |
| 218 | +/* ---- Tableaux → cartes empilées sous 768 px ---- */ | |
| 219 | +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| 220 | +@media (max-width: 767px) { | |
| 221 | + .tbl-stack thead { display: none; } | |
| 222 | + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); } | |
| 223 | + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; } | |
| 224 | + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/* ---- Utilitaires responsive ---- */ | |
| 228 | +.only-mobile { display: initial; } .only-desktop { display: none; } | |
| 229 | +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } } | |
added
apps/web/lib/ka-auth.ts
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ----------------------------------------------------------------------------- | |
| 3 | +// KA ID — SSO du Groupe KA (hub d'identité : groupe-ka.com). | |
| 4 | +// « Se connecter avec KA » : l'utilisateur est envoyé au hub, qui le renvoie | |
| 5 | +// ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET et | |
| 6 | +// transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur | |
| 7 | +// toutes les plateformes du groupe). Aucune dépendance : node:crypto seulement. | |
| 8 | +// · state / session locale : base64url(JSON) + "." + HMAC-SHA256 hex, | |
| 9 | +// signés avec une clé LOCALE (KA_AUTH_SECRET, sinon dérivée de | |
| 10 | +// KA_SSO_SECRET — jamais le secret partagé tel quel). | |
| 11 | +// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET | |
| 12 | +// (signature, alg, iss, aud, exp). | |
| 13 | +// Modèle de référence : vrai-prix/src/lib/ka-auth.ts. | |
| 14 | +// ----------------------------------------------------------------------------- | |
| 15 | +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; | |
| 16 | + | |
| 17 | +export const SESSION_COOKIE = "tk_ka_session"; | |
| 18 | +export const SESSION_DAYS = 30; | |
| 19 | +export const CLIENT_ID = "trouve-ka"; | |
| 20 | + | |
| 21 | +export const KA_HUB_URL = ( | |
| 22 | + process.env.KA_HUB_URL || "https://www.groupe-ka.com" | |
| 23 | +).replace(/\/+$/, ""); | |
| 24 | + | |
| 25 | +/** URL publique de l'app (redirect_uri — le hub n'accepte que trouve-ka.com). */ | |
| 26 | +export const KA_BASE_URL = ( | |
| 27 | + process.env.KA_BASE_URL || | |
| 28 | + process.env.PUBLIC_URL || | |
| 29 | + "https://www.trouve-ka.com" | |
| 30 | +).replace(/\/+$/, ""); | |
| 31 | + | |
| 32 | +/** Session locale posée en cookie httpOnly après un retour SSO réussi. */ | |
| 33 | +export type KaSession = { | |
| 34 | + ka_id: string; | |
| 35 | + email: string; | |
| 36 | + name: string; | |
| 37 | + picture: string; | |
| 38 | + provider?: string; | |
| 39 | + exp: number; // epoch (secondes) | |
| 40 | +}; | |
| 41 | + | |
| 42 | +const now = () => Math.floor(Date.now() / 1000); | |
| 43 | + | |
| 44 | +/** Clé LOCALE de signature (state + session). Dérivée de KA_SSO_SECRET si | |
| 45 | + * KA_AUTH_SECRET n'est pas défini — jamais le secret partagé directement. */ | |
| 46 | +function authSecret(): Buffer | null { | |
| 47 | + const local = process.env.KA_AUTH_SECRET; | |
| 48 | + if (local) return Buffer.from(local, "utf8"); | |
| 49 | + const shared = process.env.KA_SSO_SECRET; | |
| 50 | + if (!shared) return null; | |
| 51 | + return createHash("sha256").update(`trouve-ka-local|${shared}`).digest(); | |
| 52 | +} | |
| 53 | + | |
| 54 | +function ssoSecret(): Buffer | null { | |
| 55 | + const s = process.env.KA_SSO_SECRET; | |
| 56 | + return s ? Buffer.from(s, "utf8") : null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */ | |
| 60 | +function safeEqual(a: Buffer, b: Buffer): boolean { | |
| 61 | + return a.length === b.length && timingSafeEqual(a, b); | |
| 62 | +} | |
| 63 | + | |
| 64 | +// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------ | |
| 65 | + | |
| 66 | +export function signLocal(payload: Record<string, unknown>): string | null { | |
| 67 | + const secret = authSecret(); | |
| 68 | + if (!secret) return null; | |
| 69 | + const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); | |
| 70 | + const sig = createHmac("sha256", secret).update(raw).digest("hex"); | |
| 71 | + return `${raw}.${sig}`; | |
| 72 | +} | |
| 73 | + | |
| 74 | +export function verifyLocal(token: string): Record<string, unknown> | null { | |
| 75 | + const secret = authSecret(); | |
| 76 | + if (!secret || !token) return null; | |
| 77 | + try { | |
| 78 | + const dot = token.lastIndexOf("."); | |
| 79 | + if (dot < 0) return null; | |
| 80 | + const raw = token.slice(0, dot); | |
| 81 | + const sig = token.slice(dot + 1); | |
| 82 | + const expected = createHmac("sha256", secret).update(raw).digest("hex"); | |
| 83 | + if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) { | |
| 84 | + return null; | |
| 85 | + } | |
| 86 | + const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); | |
| 87 | + if (typeof payload !== "object" || payload === null) return null; | |
| 88 | + if (typeof payload.exp !== "number" || payload.exp < now()) return null; | |
| 89 | + return payload as Record<string, unknown>; | |
| 90 | + } catch { | |
| 91 | + return null; | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +// -- state SSO (anti-forgerie, transporte aussi la destination `next`) -------- | |
| 96 | + | |
| 97 | +/** State opaque signé localement — expire en 10 minutes. */ | |
| 98 | +export function newState(next: string): string | null { | |
| 99 | + return signLocal({ | |
| 100 | + n: randomBytes(12).toString("base64url"), | |
| 101 | + next, | |
| 102 | + exp: now() + 600, | |
| 103 | + }); | |
| 104 | +} | |
| 105 | + | |
| 106 | +/** Vérifie le state et rend la destination `next` (chemin local sûr). */ | |
| 107 | +export function verifyState(state: string): { next: string } | null { | |
| 108 | + const p = verifyLocal(state); | |
| 109 | + if (!p) return null; | |
| 110 | + const next = typeof p.next === "string" ? p.next : "/"; | |
| 111 | + return { next: sanitizeNext(next) }; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */ | |
| 115 | +export function sanitizeNext(next: string | null | undefined): string { | |
| 116 | + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/"; | |
| 117 | + return next; | |
| 118 | +} | |
| 119 | + | |
| 120 | +// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) --------------- | |
| 121 | + | |
| 122 | +export function verifyKaToken(token: string): Record<string, unknown> | null { | |
| 123 | + const secret = ssoSecret(); | |
| 124 | + if (!secret || !token) return null; | |
| 125 | + try { | |
| 126 | + const parts = token.split("."); | |
| 127 | + if (parts.length !== 3) return null; | |
| 128 | + const [h64, p64, s64] = parts; | |
| 129 | + const expected = createHmac("sha256", secret) | |
| 130 | + .update(`${h64}.${p64}`) | |
| 131 | + .digest(); | |
| 132 | + if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null; | |
| 133 | + | |
| 134 | + const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8")); | |
| 135 | + if (header?.alg !== "HS256") return null; | |
| 136 | + | |
| 137 | + const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8")); | |
| 138 | + if (claims?.iss !== KA_HUB_URL) return null; | |
| 139 | + const aud = claims?.aud; | |
| 140 | + const audOk = | |
| 141 | + aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID)); | |
| 142 | + if (!audOk) return null; | |
| 143 | + if (typeof claims?.exp !== "number" || claims.exp < now()) return null; | |
| 144 | + return claims as Record<string, unknown>; | |
| 145 | + } catch { | |
| 146 | + return null; | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +// -- session locale ------------------------------------------------------------ | |
| 151 | + | |
| 152 | +/** Construit le cookie de session (30 jours) depuis les claims du hub. */ | |
| 153 | +export function sessionFromClaims( | |
| 154 | + claims: Record<string, unknown>, | |
| 155 | +): { value: string; session: KaSession } | null { | |
| 156 | + const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : ""; | |
| 157 | + if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis | |
| 158 | + const email = | |
| 159 | + typeof claims.email === "string" ? claims.email.toLowerCase() : ""; | |
| 160 | + const session: KaSession = { | |
| 161 | + ka_id: kaId, | |
| 162 | + email, | |
| 163 | + name: | |
| 164 | + typeof claims.name === "string" && claims.name | |
| 165 | + ? claims.name | |
| 166 | + : email.split("@")[0] || "membre", | |
| 167 | + picture: typeof claims.picture === "string" ? claims.picture : "", | |
| 168 | + provider: typeof claims.provider === "string" ? claims.provider : undefined, | |
| 169 | + exp: now() + SESSION_DAYS * 86400, | |
| 170 | + }; | |
| 171 | + const value = signLocal({ ...session }); | |
| 172 | + return value ? { value, session } : null; | |
| 173 | +} | |
| 174 | + | |
| 175 | +/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */ | |
| 176 | +export function readSession(cookieValue: string | undefined): KaSession | null { | |
| 177 | + if (!cookieValue) return null; | |
| 178 | + const p = verifyLocal(cookieValue); | |
| 179 | + if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null; | |
| 180 | + return { | |
| 181 | + ka_id: p.ka_id, | |
| 182 | + email: typeof p.email === "string" ? p.email : "", | |
| 183 | + name: typeof p.name === "string" ? p.name : "", | |
| 184 | + picture: typeof p.picture === "string" ? p.picture : "", | |
| 185 | + provider: typeof p.provider === "string" ? p.provider : undefined, | |
| 186 | + exp: p.exp as number, | |
| 187 | + }; | |
| 188 | +} | |
modified
apps/web/next.config.mjs
+32 −2
@@ -4,10 +4,40 @@ | ||
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | +import { readFileSync } from "node:fs"; | |
| 8 | +import { dirname, join } from "node:path"; | |
| 9 | +import { fileURLToPath } from "node:url"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Charge le .env RACINE du monorepo (~/trouve-ka/.env : KA_SSO_SECRET, | |
| 13 | + * KA_HUB_URL, API_URL…) sans dépendance : pm2 lance `pnpm start` dans | |
| 14 | + * apps/web et Next ne lit que les .env locaux — on complète process.env | |
| 15 | + * sans jamais écraser une variable déjà définie. | |
| 16 | + */ | |
| 17 | +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); | |
| 18 | +try { | |
| 19 | + const raw = readFileSync(join(repoRoot, ".env"), "utf8"); | |
| 20 | + for (const line of raw.split("\n")) { | |
| 21 | + const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); | |
| 22 | + if (!m) continue; | |
| 23 | + let v = m[2].trim(); | |
| 24 | + if ( | |
| 25 | + (v.startsWith('"') && v.endsWith('"')) || | |
| 26 | + (v.startsWith("'") && v.endsWith("'")) | |
| 27 | + ) { | |
| 28 | + v = v.slice(1, -1); | |
| 29 | + } | |
| 30 | + if (process.env[m[1]] === undefined) process.env[m[1]] = v; | |
| 31 | + } | |
| 32 | +} catch { | |
| 33 | + /* pas de .env racine (dev laptop) : les variables viennent de l'environnement */ | |
| 34 | +} | |
| 35 | + | |
| 7 | 36 | /** |
| 8 | 37 | * Le client fetch toujours des chemins relatifs `/api/...` : Next.js proxifie |
| 9 | − * vers le backend (API_URL). Ainsi tout fonctionne à l'identique en local et | |
| 10 | − * derrière le tunnel ngrok (www.trouve-ka.com), sans URL absolues côté client. | |
| 38 | + * vers le backend (API_URL). Les routes /api/auth/* sont servies par Next | |
| 39 | + * lui-même (SSO KA ID) — les routes du système de fichiers passent AVANT les | |
| 40 | + * rewrites « afterFiles ». Tout le reste va au FastAPI. | |
| 11 | 41 | */ |
| 12 | 42 | const API_URL = process.env.API_URL || "http://localhost:8080"; |
| 13 | 43 | |
| 14 | 44 | |