@learncard/email-templates 1.0.4 → 1.0.5
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/dist/components/Layout.d.ts +2 -0
- package/dist/components/Layout.d.ts.map +1 -1
- package/dist/components/Layout.js +6 -1
- package/dist/components/Layout.js.map +1 -1
- package/dist/components/LinkFallback.d.ts +2 -0
- package/dist/components/LinkFallback.d.ts.map +1 -1
- package/dist/components/LinkFallback.js +5 -1
- package/dist/components/LinkFallback.js.map +1 -1
- package/dist/i18n.d.ts +52 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +77 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/render.d.ts +3 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +33 -30
- package/dist/render.js.map +1 -1
- package/dist/sms.d.ts +6 -3
- package/dist/sms.d.ts.map +1 -1
- package/dist/sms.js +43 -7
- package/dist/sms.js.map +1 -1
- package/dist/templates/account-approved.d.ts +3 -1
- package/dist/templates/account-approved.d.ts.map +1 -1
- package/dist/templates/account-approved.js +55 -5
- package/dist/templates/account-approved.js.map +1 -1
- package/dist/templates/credential-awaiting-guardian.d.ts +3 -1
- package/dist/templates/credential-awaiting-guardian.d.ts.map +1 -1
- package/dist/templates/credential-awaiting-guardian.js +54 -4
- package/dist/templates/credential-awaiting-guardian.js.map +1 -1
- package/dist/templates/email-verification.d.ts +3 -1
- package/dist/templates/email-verification.d.ts.map +1 -1
- package/dist/templates/email-verification.js +57 -4
- package/dist/templates/email-verification.js.map +1 -1
- package/dist/templates/endorsement-request.d.ts +3 -1
- package/dist/templates/endorsement-request.d.ts.map +1 -1
- package/dist/templates/endorsement-request.js +53 -5
- package/dist/templates/endorsement-request.js.map +1 -1
- package/dist/templates/guardian-approval.d.ts +3 -1
- package/dist/templates/guardian-approval.d.ts.map +1 -1
- package/dist/templates/guardian-approval.js +55 -5
- package/dist/templates/guardian-approval.js.map +1 -1
- package/dist/templates/guardian-approved-claim.d.ts +3 -1
- package/dist/templates/guardian-approved-claim.d.ts.map +1 -1
- package/dist/templates/guardian-approved-claim.js +50 -4
- package/dist/templates/guardian-approved-claim.js.map +1 -1
- package/dist/templates/guardian-credential-approval.d.ts +3 -1
- package/dist/templates/guardian-credential-approval.d.ts.map +1 -1
- package/dist/templates/guardian-credential-approval.js +50 -4
- package/dist/templates/guardian-credential-approval.js.map +1 -1
- package/dist/templates/guardian-email-otp.d.ts +3 -1
- package/dist/templates/guardian-email-otp.d.ts.map +1 -1
- package/dist/templates/guardian-email-otp.js +55 -5
- package/dist/templates/guardian-email-otp.js.map +1 -1
- package/dist/templates/guardian-rejected-credential.d.ts +3 -1
- package/dist/templates/guardian-rejected-credential.d.ts.map +1 -1
- package/dist/templates/guardian-rejected-credential.js +45 -3
- package/dist/templates/guardian-rejected-credential.js.map +1 -1
- package/dist/templates/inbox-claim.d.ts +3 -1
- package/dist/templates/inbox-claim.d.ts.map +1 -1
- package/dist/templates/inbox-claim.js +63 -11
- package/dist/templates/inbox-claim.js.map +1 -1
- package/dist/templates/recovery-key.d.ts +3 -1
- package/dist/templates/recovery-key.d.ts.map +1 -1
- package/dist/templates/recovery-key.js +59 -3
- package/dist/templates/recovery-key.js.map +1 -1
- package/dist/templates/verification-code.d.ts +3 -1
- package/dist/templates/verification-code.d.ts.map +1 -1
- package/dist/templates/verification-code.js +153 -17
- package/dist/templates/verification-code.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/i18n.test.ts +329 -0
- package/src/components/Layout.tsx +69 -60
- package/src/components/LinkFallback.tsx +19 -11
- package/src/i18n.ts +110 -0
- package/src/index.ts +15 -2
- package/src/render.ts +47 -28
- package/src/sms.ts +61 -6
- package/src/templates/account-approved.tsx +82 -29
- package/src/templates/credential-awaiting-guardian.tsx +113 -19
- package/src/templates/email-verification.tsx +87 -16
- package/src/templates/endorsement-request.tsx +142 -17
- package/src/templates/guardian-approval.tsx +106 -16
- package/src/templates/guardian-approved-claim.tsx +137 -17
- package/src/templates/guardian-credential-approval.tsx +112 -17
- package/src/templates/guardian-email-otp.tsx +103 -25
- package/src/templates/guardian-rejected-credential.tsx +137 -15
- package/src/templates/inbox-claim.tsx +180 -29
- package/src/templates/recovery-key.tsx +126 -30
- package/src/templates/verification-code.tsx +204 -38
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n tests for @learncard/email-templates (LC-1902, workstream C1).
|
|
3
|
+
*
|
|
4
|
+
* Asserts:
|
|
5
|
+
* 1. Every template renders for every locale (en/es/fr/ar) without throwing.
|
|
6
|
+
* 2. A sampled es/fr/ar subject + body differs from the EN output.
|
|
7
|
+
* 3. EN output is unchanged: rendering with `locale === 'en'` or `locale === undefined`
|
|
8
|
+
* is byte-identical (the correctness gate — no behavior change for EN).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { renderEmail } from '../render';
|
|
14
|
+
import { renderSms } from '../sms';
|
|
15
|
+
import { DEFAULT_BRANDING } from '../branding';
|
|
16
|
+
import { resolveCatalogLocale, htmlDir } from '../i18n';
|
|
17
|
+
import type { NotificationLocale } from '../i18n';
|
|
18
|
+
import type { TemplateId, TemplateDataMap } from '../render';
|
|
19
|
+
|
|
20
|
+
const LOCALES: NotificationLocale[] = ['en', 'es', 'fr', 'ar'];
|
|
21
|
+
|
|
22
|
+
// Minimal valid data for every template ID.
|
|
23
|
+
const FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
24
|
+
'embed-email-verification': {
|
|
25
|
+
verificationCode: '123456',
|
|
26
|
+
verificationEmail: 'test@example.com',
|
|
27
|
+
},
|
|
28
|
+
'contact-method-verification': { verificationToken: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' },
|
|
29
|
+
'login-verification-code': { verificationCode: '654321' },
|
|
30
|
+
'contact-method-verification-1': { verificationCode: '111222' },
|
|
31
|
+
'recovery-email-code': { verificationCode: '999888', verificationEmail: 'recover@example.com' },
|
|
32
|
+
'recovery-email-verification': { verificationCode: '777666' },
|
|
33
|
+
'inbox-claim': {
|
|
34
|
+
claimUrl: 'https://learncard.app/claim/abc123',
|
|
35
|
+
credential: { name: 'Test Badge', type: 'badge' },
|
|
36
|
+
issuer: { name: 'Acme Corp' },
|
|
37
|
+
recipient: { name: 'Jane Doe' },
|
|
38
|
+
},
|
|
39
|
+
'universal-inbox-claim': {
|
|
40
|
+
claimUrl: 'https://learncard.app/claim/abc123',
|
|
41
|
+
credential: { name: 'Test Badge', type: 'badge' },
|
|
42
|
+
issuer: { name: 'Acme Corp' },
|
|
43
|
+
},
|
|
44
|
+
'guardian-approval': {
|
|
45
|
+
approvalUrl: 'https://learncard.app/approve?token=abc',
|
|
46
|
+
approvalToken: 'abc',
|
|
47
|
+
requester: { displayName: 'Alex Student', profileId: 'p1' },
|
|
48
|
+
guardian: { email: 'parent@example.com' },
|
|
49
|
+
},
|
|
50
|
+
'account-approved': { user: { displayName: 'New User' } },
|
|
51
|
+
'account-approved-email': { user: { displayName: 'New User' } },
|
|
52
|
+
'recovery-key': { recoveryKey: 'mango-delta-fox-echo-bravo-seven-lima-niner' },
|
|
53
|
+
'recovery-key-backup': { recoveryKey: 'mango-delta-fox-echo-bravo-seven-lima-niner' },
|
|
54
|
+
'endorsement-request': {
|
|
55
|
+
shareLink: 'https://learncard.app/share/xyz',
|
|
56
|
+
recipient: { name: 'Dr. Emily Chen' },
|
|
57
|
+
issuer: { name: 'John Doe' },
|
|
58
|
+
credential: { name: 'Teaching Certificate' },
|
|
59
|
+
message: 'Please endorse my skills!',
|
|
60
|
+
},
|
|
61
|
+
'universal-inbox-claim-1': {
|
|
62
|
+
shareLink: 'https://learncard.app/share/xyz',
|
|
63
|
+
recipient: { name: 'Dr. Emily Chen' },
|
|
64
|
+
issuer: { name: 'John Doe' },
|
|
65
|
+
},
|
|
66
|
+
'credential-awaiting-guardian': {
|
|
67
|
+
issuer: { name: 'Springfield School District' },
|
|
68
|
+
credential: { name: 'Perfect Attendance Award' },
|
|
69
|
+
recipient: { email: 'student@example.com' },
|
|
70
|
+
},
|
|
71
|
+
'guardian-approved-claim': {
|
|
72
|
+
issuer: { name: 'Springfield School District' },
|
|
73
|
+
credential: { name: 'Perfect Attendance Award' },
|
|
74
|
+
},
|
|
75
|
+
'guardian-credential-approval': {
|
|
76
|
+
approvalUrl: 'https://learncard.app/guardian/approve?token=abc',
|
|
77
|
+
approvalToken: 'abc',
|
|
78
|
+
issuer: { name: 'Springfield School District' },
|
|
79
|
+
credential: { name: 'Perfect Attendance Award' },
|
|
80
|
+
recipient: { email: 'student@example.com' },
|
|
81
|
+
},
|
|
82
|
+
'guardian-email-otp': { verificationCode: '445566' },
|
|
83
|
+
'guardian-rejected-credential': {
|
|
84
|
+
issuer: { name: 'Springfield School District' },
|
|
85
|
+
credential: { name: 'Perfect Attendance Award' },
|
|
86
|
+
recipient: { email: 'student@example.com' },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const ALL_TEMPLATE_IDS = Object.keys(FIXTURES) as TemplateId[];
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// 1. Every template renders for every locale without throwing
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('i18n — every template renders in every locale', () => {
|
|
97
|
+
for (const templateId of ALL_TEMPLATE_IDS) {
|
|
98
|
+
for (const locale of LOCALES) {
|
|
99
|
+
it(`${templateId} (${locale}) renders`, async () => {
|
|
100
|
+
const result = await renderEmail(
|
|
101
|
+
templateId,
|
|
102
|
+
DEFAULT_BRANDING,
|
|
103
|
+
FIXTURES[templateId],
|
|
104
|
+
locale
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(result.html).toContain('<!DOCTYPE');
|
|
108
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
109
|
+
expect(result.subject.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// 2. es/fr/ar subject + body differ from en
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe('i18n — non-EN locales localize content', () => {
|
|
120
|
+
// Use a spread of representative templates; subjects are the cleanest signal.
|
|
121
|
+
const SAMPLES: TemplateId[] = [
|
|
122
|
+
'inbox-claim',
|
|
123
|
+
'account-approved',
|
|
124
|
+
'recovery-key',
|
|
125
|
+
'guardian-credential-approval',
|
|
126
|
+
'guardian-email-otp',
|
|
127
|
+
'credential-awaiting-guardian',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const templateId of SAMPLES) {
|
|
131
|
+
it(`${templateId} subject + body differ per locale`, async () => {
|
|
132
|
+
const en = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'en');
|
|
133
|
+
const subjects = new Set<string>([en.subject]);
|
|
134
|
+
|
|
135
|
+
for (const locale of ['es', 'fr', 'ar'] as NotificationLocale[]) {
|
|
136
|
+
const localized = await renderEmail(
|
|
137
|
+
templateId,
|
|
138
|
+
DEFAULT_BRANDING,
|
|
139
|
+
FIXTURES[templateId],
|
|
140
|
+
locale
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Subject should differ from EN (localized).
|
|
144
|
+
expect(localized.subject).not.toBe(en.subject);
|
|
145
|
+
// Body should differ from EN (the passport/sign-off copy localizes).
|
|
146
|
+
expect(localized.html).not.toBe(en.html);
|
|
147
|
+
// …but still render the same brand name.
|
|
148
|
+
expect(localized.html).toContain(DEFAULT_BRANDING.brandName);
|
|
149
|
+
// Subjects are unique across locales (no accidental aliasing).
|
|
150
|
+
expect(subjects.has(localized.subject)).toBe(false);
|
|
151
|
+
subjects.add(localized.subject);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it('renders localized footer disclaimer per locale (shared chrome)', async () => {
|
|
157
|
+
const en = await renderEmail(
|
|
158
|
+
'inbox-claim',
|
|
159
|
+
DEFAULT_BRANDING,
|
|
160
|
+
FIXTURES['inbox-claim'],
|
|
161
|
+
'en'
|
|
162
|
+
);
|
|
163
|
+
const es = await renderEmail(
|
|
164
|
+
'inbox-claim',
|
|
165
|
+
DEFAULT_BRANDING,
|
|
166
|
+
FIXTURES['inbox-claim'],
|
|
167
|
+
'es'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// EN chrome present in EN, absent in ES.
|
|
171
|
+
expect(en.text).toContain('You received this email because');
|
|
172
|
+
expect(es.text).not.toContain('You received this email because');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('localizes the "The {brand} Team" sign-off across all templates', async () => {
|
|
176
|
+
for (const templateId of ALL_TEMPLATE_IDS) {
|
|
177
|
+
const en = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'en');
|
|
178
|
+
const es = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'es');
|
|
179
|
+
|
|
180
|
+
expect(en.text).toContain(`The ${DEFAULT_BRANDING.brandName} Team`);
|
|
181
|
+
expect(es.text).toContain(`El equipo de ${DEFAULT_BRANDING.brandName}`);
|
|
182
|
+
expect(es.text).not.toContain(`The ${DEFAULT_BRANDING.brandName} Team`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('localizes the verification-code preview sub-title (not just the subject)', async () => {
|
|
187
|
+
const en = await renderEmail(
|
|
188
|
+
'login-verification-code',
|
|
189
|
+
DEFAULT_BRANDING,
|
|
190
|
+
FIXTURES['login-verification-code'],
|
|
191
|
+
'en'
|
|
192
|
+
);
|
|
193
|
+
const es = await renderEmail(
|
|
194
|
+
'login-verification-code',
|
|
195
|
+
DEFAULT_BRANDING,
|
|
196
|
+
FIXTURES['login-verification-code'],
|
|
197
|
+
'es'
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Preview text renders into the hidden <Preview> block in the HTML.
|
|
201
|
+
expect(en.html).toContain('Your code:');
|
|
202
|
+
expect(es.html).toContain('Tu código:');
|
|
203
|
+
expect(es.html).not.toContain('Your code:');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 3. EN output is unchanged (correctness gate)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
describe('i18n — EN is unchanged', () => {
|
|
212
|
+
it('locale undefined === locale "en" (byte-identical html/text/subject)', async () => {
|
|
213
|
+
for (const templateId of ALL_TEMPLATE_IDS) {
|
|
214
|
+
const noLocale = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId]);
|
|
215
|
+
const explicitEn = await renderEmail(
|
|
216
|
+
templateId,
|
|
217
|
+
DEFAULT_BRANDING,
|
|
218
|
+
FIXTURES[templateId],
|
|
219
|
+
'en'
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(noLocale.subject).toBe(explicitEn.subject);
|
|
223
|
+
expect(noLocale.html).toBe(explicitEn.html);
|
|
224
|
+
expect(noLocale.text).toBe(explicitEn.text);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('unknown locale falls back to EN (no throw, EN subject)', async () => {
|
|
229
|
+
const templateId: TemplateId = 'inbox-claim';
|
|
230
|
+
const en = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'en');
|
|
231
|
+
const unknown = await renderEmail(
|
|
232
|
+
templateId,
|
|
233
|
+
DEFAULT_BRANDING,
|
|
234
|
+
FIXTURES[templateId],
|
|
235
|
+
'xx-XX'
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(unknown.subject).toBe(en.subject);
|
|
239
|
+
expect(unknown.html).toBe(en.html);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// resolveCatalogLocale
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
describe('resolveCatalogLocale', () => {
|
|
248
|
+
it('returns the supported base locale for BCP-47 input', () => {
|
|
249
|
+
expect(resolveCatalogLocale('en')).toBe('en');
|
|
250
|
+
expect(resolveCatalogLocale('en-US')).toBe('en');
|
|
251
|
+
expect(resolveCatalogLocale('es-MX')).toBe('es');
|
|
252
|
+
expect(resolveCatalogLocale('fr-CA')).toBe('fr');
|
|
253
|
+
expect(resolveCatalogLocale('ar')).toBe('ar');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('falls back to en for unsupported / missing input (never throws)', () => {
|
|
257
|
+
expect(resolveCatalogLocale('de')).toBe('en');
|
|
258
|
+
expect(resolveCatalogLocale('zh-CN')).toBe('en');
|
|
259
|
+
expect(resolveCatalogLocale(undefined)).toBe('en');
|
|
260
|
+
expect(resolveCatalogLocale(null)).toBe('en');
|
|
261
|
+
expect(resolveCatalogLocale('')).toBe('en');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// RTL direction
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe('htmlDir', () => {
|
|
270
|
+
it('returns rtl for Arabic (incl. BCP-47), ltr otherwise', () => {
|
|
271
|
+
expect(htmlDir('ar')).toBe('rtl');
|
|
272
|
+
expect(htmlDir('ar-EG')).toBe('rtl');
|
|
273
|
+
expect(htmlDir('en')).toBe('ltr');
|
|
274
|
+
expect(htmlDir('es')).toBe('ltr');
|
|
275
|
+
expect(htmlDir('fr')).toBe('ltr');
|
|
276
|
+
expect(htmlDir(undefined)).toBe('ltr');
|
|
277
|
+
expect(htmlDir('xx-XX')).toBe('ltr');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('i18n — rendered <Html dir> reflects locale', () => {
|
|
282
|
+
it.each(ALL_TEMPLATE_IDS)('%s renders dir="rtl" for Arabic', async templateId => {
|
|
283
|
+
const ar = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'ar');
|
|
284
|
+
expect(ar.html).toContain('dir="rtl"');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it.each(ALL_TEMPLATE_IDS)('%s renders dir="ltr" (not rtl) for English', async templateId => {
|
|
288
|
+
const en = await renderEmail(templateId, DEFAULT_BRANDING, FIXTURES[templateId], 'en');
|
|
289
|
+
expect(en.html).toContain('dir="ltr"');
|
|
290
|
+
expect(en.html).not.toContain('dir="rtl"');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// SMS i18n
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe('i18n — renderSms localizes', () => {
|
|
299
|
+
const inboxData = {
|
|
300
|
+
claimUrl: 'https://learncard.app/claim/abc',
|
|
301
|
+
recipient: { name: 'Jane' },
|
|
302
|
+
issuer: { name: 'Acme' },
|
|
303
|
+
credential: { name: 'Test Badge', type: 'badge' },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
it('inbox-claim SMS differs per locale and brand name is interpolated', () => {
|
|
307
|
+
const en = renderSms('inbox-claim', DEFAULT_BRANDING, inboxData, 'en');
|
|
308
|
+
const es = renderSms('inbox-claim', DEFAULT_BRANDING, inboxData, 'es');
|
|
309
|
+
|
|
310
|
+
expect(en).toContain('Claim it here');
|
|
311
|
+
expect(es).toContain('Reclámalo aquí');
|
|
312
|
+
expect(es).not.toBe(en);
|
|
313
|
+
// Both carry the dynamic claim URL.
|
|
314
|
+
expect(en).toContain('https://learncard.app/claim/abc');
|
|
315
|
+
expect(es).toContain('https://learncard.app/claim/abc');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('verification SMS localizes and is unchanged for EN vs undefined', () => {
|
|
319
|
+
const data = { verificationToken: '999222' };
|
|
320
|
+
const en = renderSms('contact-method-verification', DEFAULT_BRANDING, data, 'en');
|
|
321
|
+
const undefinedLocale = renderSms('contact-method-verification', DEFAULT_BRANDING, data);
|
|
322
|
+
const es = renderSms('contact-method-verification', DEFAULT_BRANDING, data, 'es');
|
|
323
|
+
|
|
324
|
+
expect(en).toBe(undefinedLocale);
|
|
325
|
+
expect(en).toContain('Your LearnCard verification code is:');
|
|
326
|
+
expect(es).toContain('Tu código de verificación de LearnCard es:');
|
|
327
|
+
expect(en).toContain('999222');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import * as React from 'react';
|
|
20
20
|
|
|
21
21
|
import type { TenantBranding } from '../branding';
|
|
22
|
+
import { SHARED, htmlLang, htmlDir, interpolate, resolveCatalogLocale } from '../i18n';
|
|
22
23
|
|
|
23
24
|
interface LayoutProps {
|
|
24
25
|
branding: TenantBranding;
|
|
@@ -27,69 +28,77 @@ interface LayoutProps {
|
|
|
27
28
|
/** Show the brand icon at the top of the card. Defaults to true.
|
|
28
29
|
* Set to false when the template already has an IssuerLogo. */
|
|
29
30
|
showHeaderLogo?: boolean;
|
|
31
|
+
/** Recipient locale (BCP-47). Defaults to English. */
|
|
32
|
+
locale?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
export const Layout: React.FC<LayoutProps> = ({
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{/*
|
|
53
|
-
{
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
35
|
+
export const Layout: React.FC<LayoutProps> = ({
|
|
36
|
+
branding,
|
|
37
|
+
preview,
|
|
38
|
+
children,
|
|
39
|
+
showHeaderLogo = true,
|
|
40
|
+
locale,
|
|
41
|
+
}) => {
|
|
42
|
+
const chrome = SHARED[resolveCatalogLocale(locale)];
|
|
43
|
+
const disclaimerText = interpolate(chrome.footerDisclaimer, { brandName: branding.brandName });
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Html lang={htmlLang(locale)} dir={htmlDir(locale)}>
|
|
47
|
+
<Head />
|
|
48
|
+
|
|
49
|
+
<Preview>{preview}</Preview>
|
|
50
|
+
|
|
51
|
+
<Body style={body}>
|
|
52
|
+
{/* Top spacer — Gmail strips <body> padding, so use an inline div */}
|
|
53
|
+
<div style={topSpacer}> </div>
|
|
54
|
+
|
|
55
|
+
{/* Wordmark — sits above the white card like the Postmark layout */}
|
|
56
|
+
<Container style={wordmarkContainer}>
|
|
57
|
+
<Text style={wordmark}>
|
|
58
|
+
<Link href={branding.websiteUrl} style={wordmarkLink}>
|
|
59
|
+
{branding.brandName.toUpperCase()}
|
|
60
|
+
</Link>
|
|
61
|
+
</Text>
|
|
62
|
+
</Container>
|
|
63
|
+
|
|
64
|
+
<Container style={container}>
|
|
65
|
+
{/* Optional brand icon */}
|
|
66
|
+
{showHeaderLogo && (
|
|
67
|
+
<Section style={headerLogo}>
|
|
68
|
+
<Img
|
|
69
|
+
src={branding.logoUrl}
|
|
70
|
+
alt={branding.logoAlt}
|
|
71
|
+
width={80}
|
|
72
|
+
height={80}
|
|
73
|
+
style={logoImg}
|
|
74
|
+
/>
|
|
75
|
+
</Section>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{/* Content */}
|
|
79
|
+
<Section style={showHeaderLogo ? contentAfterLogo : content}>
|
|
80
|
+
{children}
|
|
62
81
|
</Section>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{/*
|
|
66
|
-
<
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<Text style={footerLinks}>
|
|
84
|
-
<Link href={`mailto:${branding.supportEmail}`} style={footerLink}>
|
|
85
|
-
Contact Support
|
|
86
|
-
</Link>
|
|
87
|
-
</Text>
|
|
88
|
-
</Container>
|
|
89
|
-
</Body>
|
|
90
|
-
</Html>
|
|
91
|
-
);
|
|
92
|
-
|
|
82
|
+
</Container>
|
|
83
|
+
|
|
84
|
+
{/* Footer — sits below the white card in the gray area */}
|
|
85
|
+
<Container style={footerContainer}>
|
|
86
|
+
<Text style={footerDisclaimer}>{disclaimerText}</Text>
|
|
87
|
+
|
|
88
|
+
<Text style={footerCopyright}>
|
|
89
|
+
{branding.copyrightHolder} © {new Date().getFullYear()}
|
|
90
|
+
</Text>
|
|
91
|
+
|
|
92
|
+
<Text style={footerLinks}>
|
|
93
|
+
<Link href={`mailto:${branding.supportEmail}`} style={footerLink}>
|
|
94
|
+
{chrome.contactSupport}
|
|
95
|
+
</Link>
|
|
96
|
+
</Text>
|
|
97
|
+
</Container>
|
|
98
|
+
</Body>
|
|
99
|
+
</Html>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
93
102
|
// ---------------------------------------------------------------------------
|
|
94
103
|
// Styles (inline for email compatibility)
|
|
95
104
|
// ---------------------------------------------------------------------------
|
|
@@ -7,23 +7,31 @@
|
|
|
7
7
|
import { Text, Link, Hr } from '@react-email/components';
|
|
8
8
|
import * as React from 'react';
|
|
9
9
|
|
|
10
|
+
import { SHARED, resolveCatalogLocale } from '../i18n';
|
|
11
|
+
|
|
10
12
|
interface LinkFallbackProps {
|
|
11
13
|
href: string;
|
|
14
|
+
/** Recipient locale (BCP-47). Defaults to English. */
|
|
15
|
+
locale?: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
export const LinkFallback: React.FC<LinkFallbackProps> = ({ href }) =>
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
export const LinkFallback: React.FC<LinkFallbackProps> = ({ href, locale }) => {
|
|
19
|
+
const chrome = SHARED[resolveCatalogLocale(locale)];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<Hr style={divider} />
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
If you have trouble with the button above, copy and paste this link into your browser:
|
|
20
|
-
</Text>
|
|
25
|
+
<Text style={hint}>{chrome.linkFallbackHint}</Text>
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
<Text style={linkText}>
|
|
28
|
+
<Link href={href} style={link}>
|
|
29
|
+
{href}
|
|
30
|
+
</Link>
|
|
31
|
+
</Text>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
27
35
|
|
|
28
36
|
const divider: React.CSSProperties = {
|
|
29
37
|
borderColor: '#e5e7eb',
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n foundation for @learncard/email-templates (LC-1902, workstream C1).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the dependency-free per-locale dictionary pattern used by the C2
|
|
5
|
+
* notification catalog (`brain-service/src/helpers/notificationMessages.ts`).
|
|
6
|
+
*
|
|
7
|
+
* This module owns:
|
|
8
|
+
* - `NotificationLocale` — the union of supported locales (en/es/fr/ar).
|
|
9
|
+
* - `resolveCatalogLocale()` — narrow a runtime BCP-47 string to a supported
|
|
10
|
+
* locale, falling back to `'en'`. Never throws.
|
|
11
|
+
* - `SHARED` — strings shared across templates via the `Layout` /
|
|
12
|
+
* `LinkFallback` chrome (footer disclaimer, "Contact Support", the
|
|
13
|
+
* link-fallback hint). Keeps the shared chrome DRY.
|
|
14
|
+
*
|
|
15
|
+
* Brand values (`brandName`, `appUrl`, `copyrightHolder`, …) are intentionally
|
|
16
|
+
* NOT in this catalog — they stay interpolated from `TenantBranding`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Locales supported by the email/SMS catalogs. */
|
|
20
|
+
export type NotificationLocale = 'en' | 'es' | 'fr' | 'ar';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LOCALE: NotificationLocale = 'en';
|
|
23
|
+
|
|
24
|
+
const SUPPORTED_LOCALES = new Set<NotificationLocale>(['en', 'es', 'fr', 'ar']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Narrow an arbitrary runtime locale string (BCP-47, e.g. `es`, `es-MX`) down
|
|
28
|
+
* to one of the supported catalog locales. Falls back to `'en'`.
|
|
29
|
+
*
|
|
30
|
+
* Accepts `string | undefined` so callers can pass through optional values
|
|
31
|
+
* (e.g. a missing recipient locale) without a guard.
|
|
32
|
+
*/
|
|
33
|
+
export const resolveCatalogLocale = (locale?: string | null): NotificationLocale => {
|
|
34
|
+
if (typeof locale !== 'string' || locale.length === 0) return DEFAULT_LOCALE;
|
|
35
|
+
const base = locale.toLowerCase().split('-')[0] as NotificationLocale;
|
|
36
|
+
return SUPPORTED_LOCALES.has(base) ? base : DEFAULT_LOCALE;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map a supported locale to the value for the top-level `<Html lang>`
|
|
41
|
+
* attribute. BCP-47 base tag is sufficient for email clients / RTL handling.
|
|
42
|
+
*/
|
|
43
|
+
export const htmlLang = (locale?: string | null): string => resolveCatalogLocale(locale);
|
|
44
|
+
|
|
45
|
+
/** Locales that render right-to-left. */
|
|
46
|
+
const RTL_LOCALES = new Set<NotificationLocale>(['ar']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Text direction for the top-level `<Html dir>` attribute. Arabic renders
|
|
50
|
+
* right-to-left; everything else left-to-right. Email clients need an explicit
|
|
51
|
+
* `dir="rtl"` to flip alignment, punctuation, and layout for RTL languages.
|
|
52
|
+
*/
|
|
53
|
+
export const htmlDir = (locale?: string | null): 'ltr' | 'rtl' =>
|
|
54
|
+
RTL_LOCALES.has(resolveCatalogLocale(locale)) ? 'rtl' : 'ltr';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Shared chrome strings (Layout footer, LinkFallback hint)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface SharedChromeStrings {
|
|
61
|
+
/** Footer disclaimer shown on every email (uses {brandName}). */
|
|
62
|
+
footerDisclaimer: string;
|
|
63
|
+
/** "Contact Support" link text in the footer. */
|
|
64
|
+
contactSupport: string;
|
|
65
|
+
/** Hint above the copy/paste link fallback. */
|
|
66
|
+
linkFallbackHint: string;
|
|
67
|
+
/** Sign-off line at the end of every email body (uses {brandName}). */
|
|
68
|
+
teamSignature: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const SHARED: Record<NotificationLocale, SharedChromeStrings> = {
|
|
72
|
+
en: {
|
|
73
|
+
footerDisclaimer:
|
|
74
|
+
'You received this email because someone requested to add this email to their {brandName} account. Please ignore this email if this was not you.',
|
|
75
|
+
contactSupport: 'Contact Support',
|
|
76
|
+
linkFallbackHint:
|
|
77
|
+
'If you have trouble with the button above, copy and paste this link into your browser:',
|
|
78
|
+
teamSignature: 'The {brandName} Team',
|
|
79
|
+
},
|
|
80
|
+
es: {
|
|
81
|
+
footerDisclaimer:
|
|
82
|
+
'Recibiste este correo porque alguien solicitó añadir este correo a su cuenta de {brandName}. Ignora este mensaje si no fuiste tú.',
|
|
83
|
+
contactSupport: 'Contactar con soporte',
|
|
84
|
+
linkFallbackHint:
|
|
85
|
+
'Si tienes problemas con el botón anterior, copia y pega este enlace en tu navegador:',
|
|
86
|
+
teamSignature: 'El equipo de {brandName}',
|
|
87
|
+
},
|
|
88
|
+
fr: {
|
|
89
|
+
footerDisclaimer:
|
|
90
|
+
'Vous avez reçu cet e-mail car quelqu\u2019un a demandé à ajouter cette adresse e-mail à son compte {brandName}. Veuillez ignorer cet e-mail si vous n\u2019êtes pas à l\u2019origine de cette demande.',
|
|
91
|
+
contactSupport: 'Contacter le support',
|
|
92
|
+
linkFallbackHint:
|
|
93
|
+
'Si le bouton ci-dessus ne fonctionne pas, copiez et collez ce lien dans votre navigateur :',
|
|
94
|
+
teamSignature: 'L’équipe {brandName}',
|
|
95
|
+
},
|
|
96
|
+
ar: {
|
|
97
|
+
footerDisclaimer:
|
|
98
|
+
'لقد تلقيت هذه الرسالة لأن أحدهم طلب إضافة هذا البريد إلى حسابه في {brandName}. يرجى تجاهل هذه الرسالة إذا لم تكن أنت.',
|
|
99
|
+
contactSupport: 'تواصل مع الدعم',
|
|
100
|
+
linkFallbackHint: 'إذا واجهت مشكلة مع الزر أعلاه، انسخ هذا الرابط والصقه في متصفحك:',
|
|
101
|
+
teamSignature: 'فريق {brandName}',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Replace `{var}` placeholders in `template` with values from `params`. */
|
|
106
|
+
export const interpolate = (template: string, params: Record<string, string | undefined>): string =>
|
|
107
|
+
template.replace(/\{(\w+)\}/g, (match, key: string) => {
|
|
108
|
+
const value = params[key];
|
|
109
|
+
return value === undefined ? match : value;
|
|
110
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
export { resolveBranding, DEFAULT_BRANDING } from './branding';
|
|
18
18
|
export type { TenantBranding } from './branding';
|
|
19
19
|
|
|
20
|
+
// i18n (LC-1902)
|
|
21
|
+
export { resolveCatalogLocale } from './i18n';
|
|
22
|
+
export type { NotificationLocale } from './i18n';
|
|
23
|
+
|
|
20
24
|
// Email rendering
|
|
21
25
|
export { renderEmail } from './render';
|
|
22
26
|
export type { RenderedEmail, TemplateId, TemplateDataMap } from './render';
|
|
@@ -37,10 +41,19 @@ export type {
|
|
|
37
41
|
|
|
38
42
|
// SMS rendering
|
|
39
43
|
export { renderSms } from './sms';
|
|
40
|
-
export type {
|
|
44
|
+
export type {
|
|
45
|
+
SmsTemplateId,
|
|
46
|
+
SmsTemplateDataMap,
|
|
47
|
+
SmsInboxClaimData,
|
|
48
|
+
SmsVerificationData,
|
|
49
|
+
} from './sms';
|
|
41
50
|
|
|
42
51
|
// Server-side tenant resolution
|
|
43
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
resolveTenantFromRequest,
|
|
54
|
+
registerOriginMapping,
|
|
55
|
+
registerTenantBranding,
|
|
56
|
+
} from './tenant-registry';
|
|
44
57
|
export type { ResolvedTenant, RequestHeaders } from './tenant-registry';
|
|
45
58
|
|
|
46
59
|
// Template components are intentionally NOT re-exported from the barrel.
|