SPB Git forge

spb/groupe-ka

Public

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

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%
11.4 KB · 234 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KA ID — courriels transactionnels via Resend (domaine groupe-ka.com).3// API REST directe (fetch), zéro dépendance — même philosophie que scrypt.4// Gabarits « éditorial sharp » : encre #141814, lime #d9f26b, mono uppercase,5// carte à bordure encre + ombre décalée dure — l'identité exacte du site,6// en tables HTML compatibles clients courriel (Gmail, Apple Mail, Outlook).78const RESEND_ENDPOINT = "https://api.resend.com/emails";910export const MAIL_FROM =11  process.env.RESEND_FROM ?? "KA ID — Groupe Ka <ka-id@groupe-ka.com>";1213export async function sendEmail(opts: {14  to: string | string[];15  subject: string;16  html: string;17  text: string;18  /** expéditeur personnalisé (boîtes admin @groupe-ka.com) — défaut : MAIL_FROM */19  from?: string;20  replyTo?: string;21  cc?: string[];22}): Promise<{ id: string | null }> {23  const key = process.env.RESEND_API_KEY;24  if (!key) throw new Error("RESEND_API_KEY manquant");25  const res = await fetch(RESEND_ENDPOINT, {26    method: "POST",27    headers: {28      Authorization: `Bearer ${key}`,29      "Content-Type": "application/json",30    },31    body: JSON.stringify({32      from: opts.from ?? MAIL_FROM,33      to: Array.isArray(opts.to) ? opts.to : [opts.to],34      ...(opts.cc?.length ? { cc: opts.cc } : {}),35      ...(opts.replyTo ? { reply_to: opts.replyTo } : {}),36      subject: opts.subject,37      html: opts.html,38      text: opts.text,39    }),40  });41  if (!res.ok) {42    const detail = await res.text().catch(() => "");43    throw new Error(`Resend ${res.status} : ${detail.slice(0, 300)}`);44  }45  const data = (await res.json().catch(() => ({}))) as { id?: string };46  return { id: data.id ?? null };47}4849/* ---------- système de design courriel (identité Groupe Ka) ---------- */5051const INK = "#141814";52const INK_2 = "#4d5551";53const INK_3 = "#8b928c";54const PAPER = "#f5f3ee";55const LIME = "#d9f26b";56const GREEN = "#1c5c41";5758const F_DISPLAY = "'Space Grotesk',Arial,'Helvetica Neue',sans-serif";59const F_BODY = "'Inter',Helvetica,Arial,sans-serif";60const F_MONO = "'JetBrains Mono','SFMono-Regular',Menlo,Consolas,'Courier New',monospace";6162function layout(opts: {63  kicker: string;64  title: string;65  preheader: string;66  inner: string;67}): string {68  return `<!doctype html>69<html lang="fr">70<head>71  <meta charset="utf-8">72  <meta name="viewport" content="width=device-width, initial-scale=1">73  <meta name="color-scheme" content="light">74  <meta name="supported-color-schemes" content="light">75  <title>${opts.title.replace(/<[^>]+>/g, " ")}</title>76</head>77<body style="margin:0;padding:0;background:${PAPER};">78  <div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${opts.preheader}&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>79  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${PAPER};">80    <tr>81      <td align="center" style="padding:40px 16px 36px;">82        <table role="presentation" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;">8384          <!-- manchette : marque + étiquette mono -->85          <tr>86            <td style="padding:0 2px 16px;">87              <table role="presentation" width="100%" cellpadding="0" cellspacing="0">88                <tr>89                  <td style="font-family:${F_DISPLAY};font-weight:700;font-size:22px;letter-spacing:-0.03em;color:${INK};white-space:nowrap;">Groupe&nbsp;<span style="display:inline-block;background:${LIME};color:${INK};padding:0 7px 3px;border-radius:6px;">Ka</span></td>90                  <td align="right" style="font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:${INK_3};">${opts.kicker}</td>91                </tr>92              </table>93            </td>94          </tr>9596          <!-- carte : bordure encre + ombre décalée dure (signature du site) -->97          <tr>98            <td style="background:#ffffff;border:2px solid ${INK};border-right-width:8px;border-bottom-width:8px;border-radius:12px;padding:30px 28px 24px;">99              <table role="presentation" width="100%" cellpadding="0" cellspacing="0">100                <tr>101                  <td style="font-family:${F_MONO};font-size:11px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${GREEN};"><span style="display:inline-block;width:22px;height:2px;background:${GREEN};vertical-align:middle;">&nbsp;</span>&nbsp;&nbsp;KA&nbsp;ID</td>102                </tr>103                <tr>104                  <td style="padding:12px 0 0;font-family:${F_DISPLAY};font-weight:700;font-size:27px;line-height:1.08;letter-spacing:-0.03em;color:${INK};">${opts.title}</td>105                </tr>106                <tr><td style="padding:14px 0 0;">${opts.inner}</td></tr>107                <tr>108                  <td style="padding:24px 0 0;">109                    <table role="presentation" width="100%" cellpadding="0" cellspacing="0">110                      <tr><td style="border-top:1.5px dashed rgba(20,24,20,0.25);font-size:0;line-height:0;">&nbsp;</td></tr>111                    </table>112                  </td>113                </tr>114                <tr>115                  <td style="padding:8px 0 0;font-family:${F_MONO};font-size:10.5px;line-height:1.7;color:${INK_3};">Vous n'êtes pas à l'origine de cette demande&nbsp;? Ignorez ce courriel — rien ne se passera sans lui.</td>116                </tr>117              </table>118            </td>119          </tr>120121          <!-- pied : écosystème -->122          <tr>123            <td style="padding:20px 2px 0;font-family:${F_MONO};font-size:9.5px;font-weight:700;line-height:1.9;letter-spacing:0.1em;text-transform:uppercase;color:${INK_3};">124              Un seul compte · tout l'écosystème<br>125              <a href="https://www.groupe-ka.com" style="color:${GREEN};text-decoration:none;">www.groupe-ka.com</a>126              &nbsp;—&nbsp;Lou·Ka&nbsp;· Immo·Ka&nbsp;· Vrai-Prix&nbsp;· Auto·Ka&nbsp;· Food·Ka&nbsp;· Sorti·Ka&nbsp;· Job·Ka&nbsp;…127            </td>128          </tr>129130        </table>131      </td>132    </tr>133  </table>134</body>135</html>`;136}137138function paragraph(html: string): string {139  return `<p style="margin:0;font-family:${F_BODY};font-size:14.5px;line-height:1.65;color:${INK_2};">${html}</p>`;140}141142/** Bouton pilule signature : encre pleine, texte lime (btn-primary du site). */143function button(url: string, label: string): string {144  return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:22px 0 10px;">145    <tr>146      <td style="background:${INK};border-radius:999px;mso-padding-alt:14px 30px;">147        <a href="${url}" style="display:inline-block;padding:14px 30px;font-family:${F_DISPLAY};font-weight:700;font-size:15px;letter-spacing:-0.01em;color:${LIME};text-decoration:none;border-radius:999px;">${label}&nbsp;&nbsp;&rarr;</a>148      </td>149    </tr>150  </table>151  <p style="margin:0;font-family:${F_MONO};font-size:10px;line-height:1.7;color:${INK_3};word-break:break-all;">Le bouton ne répond pas&nbsp;? Copiez ce lien&nbsp;:<br><a href="${url}" style="color:${GREEN};text-decoration:underline;">${url}</a></p>`;152}153154/** Pastille d'info mono (durée de validité, usage unique…). */155function metaChips(items: string[]): string {156  return `<p style="margin:16px 0 0;font-family:${F_MONO};font-size:9.5px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${INK_3};">${items.join("&nbsp;&nbsp;·&nbsp;&nbsp;")}</p>`;157}158159/* ---------- gabarits ---------- */160161export function verificationEmail(name: string, url: string) {162  const inner = `163    ${paragraph(164      `Bonjour <strong style="color:${INK};">${name}</strong>,<br>165       votre KA ID vient d'être créé — un seul compte pour toutes les166       plateformes du Groupe Ka. Dernière étape&nbsp;: confirmer que cette167       adresse est bien la vôtre. Vous serez connecté automatiquement.`,168    )}169    ${button(url, "Confirmer mon adresse")}170    ${metaChips(["Valable 24 heures", "Usage unique"])}`;171  return {172    subject: "Confirmez votre adresse — KA ID",173    html: layout({174      kicker: "KA ID · Confirmation",175      title: "Une adresse à confirmer,<br>un écosystème à ouvrir",176      preheader: "Confirmez votre adresse pour activer votre KA ID — lien valable 24 heures.",177      inner,178    }),179    text: `Bonjour ${name},\n\nVotre KA ID vient d'être créé. Confirmez votre adresse pour l'activer (lien valable 24 h, usage unique) :\n${url}\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.\n\nGroupe Ka — www.groupe-ka.com`,180  };181}182183export function magicLinkEmail(name: string, url: string) {184  const inner = `185    ${paragraph(186      `Bonjour <strong style="color:${INK};">${name}</strong>,<br>187       vous avez demandé à vous connecter sans mot de passe. Un clic sur le188       bouton et vous êtes chez vous — sur toutes les plateformes du groupe.`,189    )}190    ${button(url, "Me connecter")}191    ${metaChips(["Valable 15 minutes", "Usage unique"])}`;192  return {193    subject: "Votre lien de connexion — KA ID",194    html: layout({195      kicker: "KA ID · Connexion",196      title: "Votre lien de connexion,<br>sans mot de passe",197      preheader: "Un clic et vous êtes connecté — lien valable 15 minutes, usage unique.",198      inner,199    }),200    text: `Bonjour ${name},\n\nVotre lien de connexion KA ID (valable 15 minutes, usage unique) :\n${url}\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.\n\nGroupe Ka — www.groupe-ka.com`,201  };202}203204export function loginCodeEmail(name: string, code: string) {205  // Panneau encre/lime — même langage que la carte de membre KA ID.206  const codePanel = `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:22px 0 0;">207    <tr>208      <td align="center" style="background:${INK};border-radius:12px;padding:26px 18px 20px;">209        <p style="margin:0 0 12px;font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.26em;text-transform:uppercase;color:rgba(217,242,107,0.65);">Code de connexion</p>210        <p style="margin:0;font-family:${F_MONO};font-weight:700;font-size:42px;line-height:1;letter-spacing:0.28em;color:${LIME};">${code}</p>211        <p style="margin:16px 0 0;font-family:${F_MONO};font-size:9px;font-weight:700;letter-spacing:0.2em;text-transform:uppercase;color:rgba(245,243,238,0.45);">Valable 10 minutes&nbsp;&nbsp;·&nbsp;&nbsp;Usage unique</p>212      </td>213    </tr>214  </table>`;215  const inner = `216    ${paragraph(217      `Bonjour <strong style="color:${INK};">${name}</strong>,<br>218       votre mot de passe est bon — il ne manque que ce code pour ouvrir la219       session. Saisissez-le sur la page de connexion&nbsp;:`,220    )}221    ${codePanel}222    <p style="margin:14px 0 0;font-family:${F_BODY};font-size:12.5px;line-height:1.6;color:${INK_2};">Ce n'était pas vous&nbsp;? Quelqu'un connaît alors votre mot de passe&nbsp;— <a href="https://www.groupe-ka.com/compte" style="color:${GREEN};">changez-le dès maintenant</a>.</p>`;223  return {224    subject: `${code} — votre code de connexion KA ID`,225    html: layout({226      kicker: "KA ID · Vérification",227      title: "Votre code de connexion",228      preheader: `Votre code : ${code} — il expire dans 10 minutes.`,229      inner,230    }),231    text: `Bonjour ${name},\n\nVotre code de connexion KA ID (expire dans 10 minutes, usage unique) : ${code}\n\nSi vous n'êtes pas à l'origine de cette connexion, changez votre mot de passe sur www.groupe-ka.com/compte.\n\nGroupe Ka — www.groupe-ka.com`,232  };233}234