@brightweblabs/ui 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/email/invitation.test.ts +102 -0
- package/src/email/invitation.ts +217 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brightweblabs/ui",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.3",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./chart": "./src/components/chart.tsx",
|
|
32
32
|
"./checkbox": "./src/components/checkbox.tsx",
|
|
33
33
|
"./dropdown-menu": "./src/components/dropdown-menu.tsx",
|
|
34
|
+
"./email/invitation": "./src/email/invitation.ts",
|
|
34
35
|
"./empty-state": "./src/components/empty-state.tsx",
|
|
35
36
|
"./field": "./src/components/field.tsx",
|
|
36
37
|
"./input": "./src/components/input.tsx",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildInvitationEmail,
|
|
6
|
+
emailBrandNameFromSender,
|
|
7
|
+
transactionalEmailPaletteFromEnv,
|
|
8
|
+
} from "./invitation";
|
|
9
|
+
|
|
10
|
+
test("invitation email creates an accessible branded HTML and text pair", () => {
|
|
11
|
+
const email = buildInvitationEmail({
|
|
12
|
+
brandName: "BeGreen Consulting",
|
|
13
|
+
logoUrl: "https://portal.example/brand/logo.png?variant=email&theme=dark",
|
|
14
|
+
preheader: "Tem um convite novo.",
|
|
15
|
+
eyebrow: "Convite para o portal",
|
|
16
|
+
title: "O seu acesso começa aqui",
|
|
17
|
+
introduction: "Foi convidado para colaborar.",
|
|
18
|
+
details: [
|
|
19
|
+
{ label: "Organização", value: "Verde & Filhos <Lda>" },
|
|
20
|
+
{ label: "Perfil", value: "Membro" },
|
|
21
|
+
],
|
|
22
|
+
actionLabel: "Aceitar convite",
|
|
23
|
+
actionUrl: "https://portal.example/invite/id?from=email&safe=true",
|
|
24
|
+
expiresLabel: "24 de agosto de 2026",
|
|
25
|
+
recipientEmail: "ana@example.com",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.match(email.html, /role="presentation"/);
|
|
29
|
+
assert.match(email.html, /BeGreen Consulting/);
|
|
30
|
+
assert.match(email.html, /logo\.png\?variant=email&theme=dark/);
|
|
31
|
+
assert.match(email.html, /alt="BeGreen Consulting"/);
|
|
32
|
+
assert.match(email.html, /Aceitar convite/);
|
|
33
|
+
assert.match(email.html, /Verde & Filhos <Lda>/);
|
|
34
|
+
assert.match(email.html, /from=email&safe=true/);
|
|
35
|
+
assert.doesNotMatch(email.html, /Verde & Filhos <Lda>/);
|
|
36
|
+
assert.match(email.text, /Organização: Verde & Filhos <Lda>/);
|
|
37
|
+
assert.match(email.text, /24 de agosto de 2026/);
|
|
38
|
+
assert.match(email.html, /Este convite foi enviado para ana@example\.com/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("transactional email can use non-invitation recipient wording", () => {
|
|
42
|
+
const email = buildInvitationEmail({
|
|
43
|
+
brandName: "BeGreen Consulting",
|
|
44
|
+
preheader: "A sua conta foi atualizada.",
|
|
45
|
+
eyebrow: "Aviso de segurança",
|
|
46
|
+
title: "Alteração concluída",
|
|
47
|
+
introduction: "A operação foi concluída.",
|
|
48
|
+
details: [{ label: "Estado", value: "Concluído" }],
|
|
49
|
+
recipientEmail: "ana@example.com",
|
|
50
|
+
recipientLabel: "Esta mensagem foi enviada para",
|
|
51
|
+
closingNote: "Se não reconhece esta mensagem, pode ignorá-la em segurança.",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.match(email.html, /Esta mensagem foi enviada para ana@example\.com/);
|
|
55
|
+
assert.match(email.text, /Esta mensagem foi enviada para ana@example\.com/);
|
|
56
|
+
assert.doesNotMatch(email.html, /convite/i);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("email brand name follows the configured sender display name", () => {
|
|
60
|
+
assert.equal(emailBrandNameFromSender("BeGreen Consulting <portal@begreen.com.pt>"), "BeGreen Consulting");
|
|
61
|
+
assert.equal(emailBrandNameFromSender("portal@begreen.com.pt"), "Portal");
|
|
62
|
+
assert.equal(emailBrandNameFromSender("portal@begreen.com.pt", "Área de cliente"), "Área de cliente");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("transactional email supports security notices without an action", () => {
|
|
66
|
+
const email = buildInvitationEmail({
|
|
67
|
+
brandName: "BeGreen Consulting",
|
|
68
|
+
preheader: "A sua conta foi atualizada.",
|
|
69
|
+
eyebrow: "Aviso de segurança",
|
|
70
|
+
title: "Alteração concluída",
|
|
71
|
+
introduction: "A operação foi concluída.",
|
|
72
|
+
details: [{ label: "Estado", value: "Concluído" }],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
assert.doesNotMatch(email.html, /Se o botão não funcionar/);
|
|
76
|
+
assert.doesNotMatch(email.html, /href="null"/);
|
|
77
|
+
assert.match(email.text, /Estado: Concluído/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("transactional email applies a project palette and rejects unsafe colors", () => {
|
|
81
|
+
const palette = transactionalEmailPaletteFromEnv({
|
|
82
|
+
EMAIL_BRAND_COLOR_HEADER: "#0A151A",
|
|
83
|
+
EMAIL_BRAND_COLOR_ACCENT: "#5bc5f2",
|
|
84
|
+
EMAIL_BRAND_COLOR_BUTTON: "red;display:none",
|
|
85
|
+
});
|
|
86
|
+
const email = buildInvitationEmail({
|
|
87
|
+
brandName: "MQ Consulting",
|
|
88
|
+
preheader: "Tem um convite novo.",
|
|
89
|
+
eyebrow: "Convite",
|
|
90
|
+
title: "Bem-vindo",
|
|
91
|
+
introduction: "Entre no portal.",
|
|
92
|
+
details: [{ label: "Perfil", value: "Membro" }],
|
|
93
|
+
actionLabel: "Entrar",
|
|
94
|
+
actionUrl: "https://portal.example/invite",
|
|
95
|
+
palette,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.match(email.html, /background:#0A151A/);
|
|
99
|
+
assert.match(email.html, /color:#5bc5f2/);
|
|
100
|
+
assert.match(email.html, /bgcolor="#125b48"/);
|
|
101
|
+
assert.doesNotMatch(email.html, /red;display:none/);
|
|
102
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export type TransactionalEmailDetail = {
|
|
2
|
+
label: string;
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type TransactionalEmailPalette = {
|
|
7
|
+
canvas: string;
|
|
8
|
+
surface: string;
|
|
9
|
+
header: string;
|
|
10
|
+
headerText: string;
|
|
11
|
+
headerMutedText: string;
|
|
12
|
+
accent: string;
|
|
13
|
+
text: string;
|
|
14
|
+
mutedText: string;
|
|
15
|
+
border: string;
|
|
16
|
+
panel: string;
|
|
17
|
+
button: string;
|
|
18
|
+
buttonText: string;
|
|
19
|
+
warning: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_TRANSACTIONAL_EMAIL_PALETTE: TransactionalEmailPalette = {
|
|
23
|
+
canvas: "#f4f0e6",
|
|
24
|
+
surface: "#ffffff",
|
|
25
|
+
header: "#0c4f3f",
|
|
26
|
+
headerText: "#fdf7eb",
|
|
27
|
+
headerMutedText: "#d9e7df",
|
|
28
|
+
accent: "#bce889",
|
|
29
|
+
text: "#10251f",
|
|
30
|
+
mutedText: "#607168",
|
|
31
|
+
border: "#dce6d2",
|
|
32
|
+
panel: "#f5f7ef",
|
|
33
|
+
button: "#125b48",
|
|
34
|
+
buttonText: "#ffffff",
|
|
35
|
+
warning: "#d99a20",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const TRANSACTIONAL_EMAIL_PALETTE_ENV = {
|
|
39
|
+
canvas: "EMAIL_BRAND_COLOR_CANVAS",
|
|
40
|
+
surface: "EMAIL_BRAND_COLOR_SURFACE",
|
|
41
|
+
header: "EMAIL_BRAND_COLOR_HEADER",
|
|
42
|
+
headerText: "EMAIL_BRAND_COLOR_HEADER_TEXT",
|
|
43
|
+
headerMutedText: "EMAIL_BRAND_COLOR_HEADER_MUTED_TEXT",
|
|
44
|
+
accent: "EMAIL_BRAND_COLOR_ACCENT",
|
|
45
|
+
text: "EMAIL_BRAND_COLOR_TEXT",
|
|
46
|
+
mutedText: "EMAIL_BRAND_COLOR_MUTED_TEXT",
|
|
47
|
+
border: "EMAIL_BRAND_COLOR_BORDER",
|
|
48
|
+
panel: "EMAIL_BRAND_COLOR_PANEL",
|
|
49
|
+
button: "EMAIL_BRAND_COLOR_BUTTON",
|
|
50
|
+
buttonText: "EMAIL_BRAND_COLOR_BUTTON_TEXT",
|
|
51
|
+
warning: "EMAIL_BRAND_COLOR_WARNING",
|
|
52
|
+
} as const satisfies Record<keyof TransactionalEmailPalette, string>;
|
|
53
|
+
|
|
54
|
+
function validHexColor(value: string | null | undefined): value is string {
|
|
55
|
+
return Boolean(value?.match(/^#[0-9a-f]{6}$/i));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveTransactionalEmailPalette(
|
|
59
|
+
palette?: Partial<TransactionalEmailPalette>,
|
|
60
|
+
): TransactionalEmailPalette {
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
Object.entries(DEFAULT_TRANSACTIONAL_EMAIL_PALETTE).map(([key, fallback]) => {
|
|
63
|
+
const configured = palette?.[key as keyof TransactionalEmailPalette];
|
|
64
|
+
return [key, validHexColor(configured) ? configured : fallback];
|
|
65
|
+
}),
|
|
66
|
+
) as TransactionalEmailPalette;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function transactionalEmailPaletteFromEnv(
|
|
70
|
+
env: Record<string, string | undefined>,
|
|
71
|
+
): Partial<TransactionalEmailPalette> {
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(TRANSACTIONAL_EMAIL_PALETTE_ENV).flatMap(([key, envName]) => {
|
|
74
|
+
const value = env[envName]?.trim();
|
|
75
|
+
return validHexColor(value) ? [[key, value]] : [];
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type TransactionalEmailContent = {
|
|
81
|
+
brandName: string;
|
|
82
|
+
logoUrl?: string | null;
|
|
83
|
+
logoAlt?: string;
|
|
84
|
+
preheader: string;
|
|
85
|
+
eyebrow: string;
|
|
86
|
+
title: string;
|
|
87
|
+
introduction: string;
|
|
88
|
+
details: TransactionalEmailDetail[];
|
|
89
|
+
actionLabel?: string;
|
|
90
|
+
actionUrl?: string;
|
|
91
|
+
expiresLabel?: string | null;
|
|
92
|
+
recipientEmail?: string;
|
|
93
|
+
recipientLabel?: string;
|
|
94
|
+
closingNote?: string;
|
|
95
|
+
palette?: Partial<TransactionalEmailPalette>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type TransactionalEmail = {
|
|
99
|
+
html: string;
|
|
100
|
+
text: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type InvitationEmailDetail = TransactionalEmailDetail;
|
|
104
|
+
export type InvitationEmailContent = TransactionalEmailContent;
|
|
105
|
+
export type InvitationEmail = TransactionalEmail;
|
|
106
|
+
|
|
107
|
+
function escapeHtml(value: string): string {
|
|
108
|
+
return value
|
|
109
|
+
.replace(/&/g, "&")
|
|
110
|
+
.replace(/</g, "<")
|
|
111
|
+
.replace(/>/g, ">")
|
|
112
|
+
.replace(/"/g, """)
|
|
113
|
+
.replace(/'/g, "'");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function emailBrandNameFromSender(sender: string, fallback = "Portal"): string {
|
|
117
|
+
const displayName = sender.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim();
|
|
118
|
+
return displayName || fallback;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildTransactionalEmail(content: TransactionalEmailContent): TransactionalEmail {
|
|
122
|
+
const palette = resolveTransactionalEmailPalette(content.palette);
|
|
123
|
+
const brandName = escapeHtml(content.brandName);
|
|
124
|
+
const logoUrl = content.logoUrl ? escapeHtml(content.logoUrl) : null;
|
|
125
|
+
const logoAlt = escapeHtml(content.logoAlt ?? content.brandName);
|
|
126
|
+
const preheader = escapeHtml(content.preheader);
|
|
127
|
+
const eyebrow = escapeHtml(content.eyebrow);
|
|
128
|
+
const title = escapeHtml(content.title);
|
|
129
|
+
const introduction = escapeHtml(content.introduction);
|
|
130
|
+
const actionLabel = content.actionLabel ? escapeHtml(content.actionLabel) : null;
|
|
131
|
+
const actionUrl = content.actionUrl ? escapeHtml(content.actionUrl) : null;
|
|
132
|
+
const hasAction = Boolean(actionLabel && actionUrl);
|
|
133
|
+
const expiresLabel = content.expiresLabel ? escapeHtml(content.expiresLabel) : null;
|
|
134
|
+
const recipientEmail = content.recipientEmail ? escapeHtml(content.recipientEmail) : null;
|
|
135
|
+
const recipientLabel = escapeHtml(content.recipientLabel ?? "Este convite foi enviado para");
|
|
136
|
+
const closingNote = escapeHtml(content.closingNote ?? "Se não reconhece este convite, pode ignorar esta mensagem em segurança.");
|
|
137
|
+
const detailRows = content.details.map((detail, index) => `
|
|
138
|
+
<tr>
|
|
139
|
+
<td style="padding:${index === 0 ? "0 0 18px" : "18px 0"};${index > 0 ? `border-top:1px solid ${palette.border};` : ""}">
|
|
140
|
+
<p style="margin:0 0 5px;color:${palette.mutedText};font-size:11px;font-weight:700;line-height:1.4;letter-spacing:.08em;text-transform:uppercase;">${escapeHtml(detail.label)}</p>
|
|
141
|
+
<p style="margin:0;color:${palette.text};font-size:16px;font-weight:700;line-height:1.45;">${escapeHtml(detail.value)}</p>
|
|
142
|
+
</td>
|
|
143
|
+
</tr>`).join("");
|
|
144
|
+
|
|
145
|
+
const html = `<!doctype html>
|
|
146
|
+
<html lang="pt">
|
|
147
|
+
<head>
|
|
148
|
+
<meta charset="utf-8">
|
|
149
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
150
|
+
<meta name="color-scheme" content="light">
|
|
151
|
+
<meta name="supported-color-schemes" content="light">
|
|
152
|
+
<title>${title}</title>
|
|
153
|
+
</head>
|
|
154
|
+
<body style="margin:0;padding:0;background:${palette.canvas};color:${palette.text};font-family:Arial,Helvetica,sans-serif;-webkit-text-size-adjust:100%;">
|
|
155
|
+
<div style="display:none;max-height:0;max-width:0;overflow:hidden;opacity:0;color:transparent;">${preheader}͏ ‌ ͏ ‌ </div>
|
|
156
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:${palette.canvas};">
|
|
157
|
+
<tr>
|
|
158
|
+
<td align="center" style="padding:36px 14px;">
|
|
159
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;background:${palette.surface};border:1px solid ${palette.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 48px rgba(16,37,31,.10);">
|
|
160
|
+
<tr>
|
|
161
|
+
<td style="padding:30px 34px 34px;background:${palette.header};">
|
|
162
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
163
|
+
<tr>
|
|
164
|
+
<td style="padding:0 0 42px;">
|
|
165
|
+
${logoUrl
|
|
166
|
+
? `<img src="${logoUrl}" width="164" alt="${logoAlt}" style="display:block;width:164px;max-width:100%;height:auto;border:0;color:${palette.headerText};font-size:13px;font-weight:700;">`
|
|
167
|
+
: `<span style="display:inline-block;width:11px;height:11px;margin-right:9px;border-radius:50%;background:${palette.accent};vertical-align:1px;"></span><span style="color:${palette.headerText};font-size:13px;font-weight:800;letter-spacing:.04em;">${brandName}</span>`}
|
|
168
|
+
</td>
|
|
169
|
+
</tr>
|
|
170
|
+
<tr>
|
|
171
|
+
<td>
|
|
172
|
+
<p style="margin:0 0 13px;color:${palette.accent};font-size:11px;font-weight:800;line-height:1.4;letter-spacing:.1em;text-transform:uppercase;">${eyebrow}</p>
|
|
173
|
+
<h1 style="margin:0 0 17px;color:${palette.headerText};font-family:Georgia,'Times New Roman',serif;font-size:34px;font-weight:400;line-height:1.13;letter-spacing:-.02em;">${title}</h1>
|
|
174
|
+
<p style="margin:0;max-width:480px;color:${palette.headerMutedText};font-size:16px;line-height:1.65;">${introduction}</p>
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
</table>
|
|
178
|
+
</td>
|
|
179
|
+
</tr>
|
|
180
|
+
<tr>
|
|
181
|
+
<td style="padding:30px 34px 34px;">
|
|
182
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin:0 0 26px;background:${palette.panel};border:1px solid ${palette.border};border-radius:14px;">
|
|
183
|
+
<tr>
|
|
184
|
+
<td style="padding:20px 22px 2px;">
|
|
185
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
|
186
|
+
${detailRows}
|
|
187
|
+
</table>
|
|
188
|
+
</td>
|
|
189
|
+
</tr>
|
|
190
|
+
</table>
|
|
191
|
+
${hasAction ? `<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
|
192
|
+
<tr>
|
|
193
|
+
<td bgcolor="${palette.button}" style="border-radius:999px;">
|
|
194
|
+
<a href="${actionUrl}" style="display:inline-block;padding:14px 24px;color:${palette.buttonText};font-size:15px;font-weight:800;line-height:1.2;text-decoration:none;border-radius:999px;">${actionLabel} →</a>
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
</table>` : ""}
|
|
198
|
+
${expiresLabel ? `<p style="margin:22px 0 0;color:${palette.mutedText};font-size:13px;line-height:1.55;"><span style="color:${palette.warning};">●</span> Este convite é válido até <strong style="color:${palette.text};">${expiresLabel}</strong>.</p>` : ""}
|
|
199
|
+
${hasAction ? `<p style="margin:25px 0 0;padding-top:22px;border-top:1px solid ${palette.border};color:${palette.mutedText};font-size:12px;line-height:1.65;">Se o botão não funcionar, copie e cole este endereço no navegador:<br><a href="${actionUrl}" style="color:${palette.button};text-decoration:underline;word-break:break-all;">${actionUrl}</a></p>` : ""}
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
</table>
|
|
203
|
+
<p style="margin:18px auto 0;max-width:540px;color:${palette.mutedText};font-size:11px;line-height:1.6;text-align:center;">${closingNote}${recipientEmail ? `<br>${recipientLabel} ${recipientEmail}.` : ""}</p>
|
|
204
|
+
</td>
|
|
205
|
+
</tr>
|
|
206
|
+
</table>
|
|
207
|
+
</body>
|
|
208
|
+
</html>`;
|
|
209
|
+
|
|
210
|
+
const detailText = content.details.map((detail) => `${detail.label}: ${detail.value}`).join("\n");
|
|
211
|
+
const actionText = hasAction ? `\n\n${content.actionLabel}:\n${content.actionUrl}` : "";
|
|
212
|
+
const text = `${content.brandName}\n\n${content.title}\n\n${content.introduction}\n\n${detailText}${actionText}${content.expiresLabel ? `\n\nEste convite é válido até ${content.expiresLabel}.` : ""}\n\n${content.closingNote ?? "Se não reconhece este convite, pode ignorar esta mensagem em segurança."}${content.recipientEmail ? `\n${content.recipientLabel ?? "Este convite foi enviado para"} ${content.recipientEmail}.` : ""}`;
|
|
213
|
+
|
|
214
|
+
return { html, text };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const buildInvitationEmail = buildTransactionalEmail;
|