@learncard/email-templates 1.0.3 → 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.
Files changed (97) hide show
  1. package/dist/components/Layout.d.ts +2 -0
  2. package/dist/components/Layout.d.ts.map +1 -1
  3. package/dist/components/Layout.js +6 -1
  4. package/dist/components/Layout.js.map +1 -1
  5. package/dist/components/LinkFallback.d.ts +2 -0
  6. package/dist/components/LinkFallback.d.ts.map +1 -1
  7. package/dist/components/LinkFallback.js +5 -1
  8. package/dist/components/LinkFallback.js.map +1 -1
  9. package/dist/i18n.d.ts +52 -0
  10. package/dist/i18n.d.ts.map +1 -0
  11. package/dist/i18n.js +77 -0
  12. package/dist/i18n.js.map +1 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/render.d.ts +3 -1
  18. package/dist/render.d.ts.map +1 -1
  19. package/dist/render.js +33 -30
  20. package/dist/render.js.map +1 -1
  21. package/dist/sms.d.ts +6 -3
  22. package/dist/sms.d.ts.map +1 -1
  23. package/dist/sms.js +43 -7
  24. package/dist/sms.js.map +1 -1
  25. package/dist/templates/account-approved.d.ts +3 -1
  26. package/dist/templates/account-approved.d.ts.map +1 -1
  27. package/dist/templates/account-approved.js +55 -5
  28. package/dist/templates/account-approved.js.map +1 -1
  29. package/dist/templates/credential-awaiting-guardian.d.ts +3 -1
  30. package/dist/templates/credential-awaiting-guardian.d.ts.map +1 -1
  31. package/dist/templates/credential-awaiting-guardian.js +54 -4
  32. package/dist/templates/credential-awaiting-guardian.js.map +1 -1
  33. package/dist/templates/email-verification.d.ts +3 -1
  34. package/dist/templates/email-verification.d.ts.map +1 -1
  35. package/dist/templates/email-verification.js +57 -4
  36. package/dist/templates/email-verification.js.map +1 -1
  37. package/dist/templates/endorsement-request.d.ts +3 -1
  38. package/dist/templates/endorsement-request.d.ts.map +1 -1
  39. package/dist/templates/endorsement-request.js +53 -5
  40. package/dist/templates/endorsement-request.js.map +1 -1
  41. package/dist/templates/guardian-approval.d.ts +3 -1
  42. package/dist/templates/guardian-approval.d.ts.map +1 -1
  43. package/dist/templates/guardian-approval.js +55 -5
  44. package/dist/templates/guardian-approval.js.map +1 -1
  45. package/dist/templates/guardian-approved-claim.d.ts +3 -1
  46. package/dist/templates/guardian-approved-claim.d.ts.map +1 -1
  47. package/dist/templates/guardian-approved-claim.js +50 -4
  48. package/dist/templates/guardian-approved-claim.js.map +1 -1
  49. package/dist/templates/guardian-credential-approval.d.ts +3 -1
  50. package/dist/templates/guardian-credential-approval.d.ts.map +1 -1
  51. package/dist/templates/guardian-credential-approval.js +50 -4
  52. package/dist/templates/guardian-credential-approval.js.map +1 -1
  53. package/dist/templates/guardian-email-otp.d.ts +3 -1
  54. package/dist/templates/guardian-email-otp.d.ts.map +1 -1
  55. package/dist/templates/guardian-email-otp.js +55 -5
  56. package/dist/templates/guardian-email-otp.js.map +1 -1
  57. package/dist/templates/guardian-rejected-credential.d.ts +3 -1
  58. package/dist/templates/guardian-rejected-credential.d.ts.map +1 -1
  59. package/dist/templates/guardian-rejected-credential.js +45 -3
  60. package/dist/templates/guardian-rejected-credential.js.map +1 -1
  61. package/dist/templates/inbox-claim.d.ts +3 -1
  62. package/dist/templates/inbox-claim.d.ts.map +1 -1
  63. package/dist/templates/inbox-claim.js +63 -11
  64. package/dist/templates/inbox-claim.js.map +1 -1
  65. package/dist/templates/recovery-key.d.ts +3 -1
  66. package/dist/templates/recovery-key.d.ts.map +1 -1
  67. package/dist/templates/recovery-key.js +59 -3
  68. package/dist/templates/recovery-key.js.map +1 -1
  69. package/dist/templates/verification-code.d.ts +3 -1
  70. package/dist/templates/verification-code.d.ts.map +1 -1
  71. package/dist/templates/verification-code.js +153 -17
  72. package/dist/templates/verification-code.js.map +1 -1
  73. package/dist/tenant-registry.d.ts.map +1 -1
  74. package/dist/tenant-registry.js +17 -0
  75. package/dist/tenant-registry.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/__tests__/i18n.test.ts +329 -0
  78. package/src/__tests__/tenant-registry.test.ts +24 -0
  79. package/src/components/Layout.tsx +69 -60
  80. package/src/components/LinkFallback.tsx +19 -11
  81. package/src/i18n.ts +110 -0
  82. package/src/index.ts +15 -2
  83. package/src/render.ts +47 -28
  84. package/src/sms.ts +61 -6
  85. package/src/templates/account-approved.tsx +82 -29
  86. package/src/templates/credential-awaiting-guardian.tsx +113 -19
  87. package/src/templates/email-verification.tsx +87 -16
  88. package/src/templates/endorsement-request.tsx +142 -17
  89. package/src/templates/guardian-approval.tsx +106 -16
  90. package/src/templates/guardian-approved-claim.tsx +137 -17
  91. package/src/templates/guardian-credential-approval.tsx +112 -17
  92. package/src/templates/guardian-email-otp.tsx +103 -25
  93. package/src/templates/guardian-rejected-credential.tsx +137 -15
  94. package/src/templates/inbox-claim.tsx +180 -29
  95. package/src/templates/recovery-key.tsx +126 -30
  96. package/src/templates/verification-code.tsx +204 -38
  97. package/src/tenant-registry.ts +24 -2
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 { SmsTemplateId, SmsTemplateDataMap, SmsInboxClaimData, SmsVerificationData } from './sms';
44
+ export type {
45
+ SmsTemplateId,
46
+ SmsTemplateDataMap,
47
+ SmsInboxClaimData,
48
+ SmsVerificationData,
49
+ } from './sms';
41
50
 
42
51
  // Server-side tenant resolution
43
- export { resolveTenantFromRequest, registerOriginMapping, registerTenantBranding } from './tenant-registry';
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.
package/src/render.ts CHANGED
@@ -239,10 +239,12 @@ export async function renderEmail<T extends TemplateId>(
239
239
  templateId: T,
240
240
  branding: Partial<TenantBranding> | TenantBranding,
241
241
  data: TemplateDataMap[T],
242
+ /** Recipient locale (BCP-47). Defaults to English. */
243
+ locale?: string
242
244
  ): Promise<RenderedEmail> {
243
245
  const resolved = resolveBranding(branding);
244
246
 
245
- const { element, subject } = buildElement(templateId, resolved, data);
247
+ const { element, subject } = buildElement(templateId, resolved, data, locale);
246
248
 
247
249
  const html = await render(element);
248
250
  const text = await render(element, { plainText: true });
@@ -258,6 +260,7 @@ function buildVerificationElement(
258
260
  branding: TenantBranding,
259
261
  data: VerificationCodeData,
260
262
  variant: VerificationCodeVariant,
263
+ locale?: string
261
264
  ): { element: React.ReactElement; subject: string } {
262
265
  return {
263
266
  element: React.createElement(VerificationCode, {
@@ -265,8 +268,9 @@ function buildVerificationElement(
265
268
  verificationCode: data.verificationCode,
266
269
  verificationEmail: data.verificationEmail,
267
270
  variant,
271
+ locale,
268
272
  }),
269
- subject: getVerificationCodeSubject(branding, variant),
273
+ subject: getVerificationCodeSubject(branding, variant, locale),
270
274
  };
271
275
  }
272
276
 
@@ -274,48 +278,63 @@ function buildElement(
274
278
  templateId: TemplateId,
275
279
  branding: TenantBranding,
276
280
  data: TemplateDataMap[TemplateId],
281
+ locale?: string
277
282
  ): { element: React.ReactElement; subject: string } {
278
-
279
283
  switch (templateId) {
280
284
  case 'embed-email-verification':
281
- return buildVerificationElement(branding, data as VerificationCodeData, 'embed-verification');
285
+ return buildVerificationElement(
286
+ branding,
287
+ data as VerificationCodeData,
288
+ 'embed-verification',
289
+ locale
290
+ );
282
291
 
283
292
  case 'contact-method-verification': {
284
293
  const d = data as EmailVerificationData;
285
- const props: EmailVerificationProps = { branding, ...d };
294
+ const props: EmailVerificationProps = { branding, ...d, locale };
286
295
 
287
296
  return {
288
297
  element: React.createElement(EmailVerification, props),
289
- subject: getEmailVerificationSubject(branding),
298
+ subject: getEmailVerificationSubject(branding, locale),
290
299
  };
291
300
  }
292
301
 
293
302
  case 'login-verification-code':
294
303
  case 'contact-method-verification-1':
295
- return buildVerificationElement(branding, data as VerificationCodeData, 'login');
304
+ return buildVerificationElement(
305
+ branding,
306
+ data as VerificationCodeData,
307
+ 'login',
308
+ locale
309
+ );
296
310
 
297
311
  case 'recovery-email-code':
298
312
  case 'recovery-email-verification':
299
- return buildVerificationElement(branding, data as VerificationCodeData, 'recovery-email');
313
+ return buildVerificationElement(
314
+ branding,
315
+ data as VerificationCodeData,
316
+ 'recovery-email',
317
+ locale
318
+ );
300
319
 
301
320
  case 'inbox-claim':
302
321
  case 'universal-inbox-claim': {
303
322
  const d = data as InboxClaimData;
304
- const props: InboxClaimProps = { branding, ...d };
323
+ const props: InboxClaimProps = { branding, ...d, locale };
305
324
 
306
325
  return {
307
326
  element: React.createElement(InboxClaim, props),
308
- subject: getInboxClaimSubject(branding, props),
327
+ subject: getInboxClaimSubject(branding, props, locale),
309
328
  };
310
329
  }
311
330
 
312
331
  case 'guardian-approval': {
313
332
  const d = data as GuardianApprovalData;
314
- const props: GuardianApprovalProps = { branding, ...d };
333
+ const props: GuardianApprovalProps = { branding, ...d, locale };
315
334
 
316
335
  return {
317
336
  element: React.createElement(GuardianApproval, props),
318
- subject: getGuardianApprovalSubject(branding, props),
337
+ subject: getGuardianApprovalSubject(branding, props, locale),
319
338
  };
320
339
  }
321
340
 
@@ -324,80 +343,80 @@ function buildElement(
324
343
  const d = data as AccountApprovedData;
325
344
 
326
345
  return {
327
- element: React.createElement(AccountApproved, { branding, ...d }),
328
- subject: getAccountApprovedSubject(branding),
346
+ element: React.createElement(AccountApproved, { branding, ...d, locale }),
347
+ subject: getAccountApprovedSubject(branding, locale),
329
348
  };
330
349
  }
331
350
 
332
351
  case 'recovery-key':
333
352
  case 'recovery-key-backup': {
334
353
  const d = data as RecoveryKeyData;
335
- const props: RecoveryKeyProps = { branding, ...d };
354
+ const props: RecoveryKeyProps = { branding, ...d, locale };
336
355
 
337
356
  return {
338
357
  element: React.createElement(RecoveryKey, props),
339
- subject: getRecoveryKeySubject(branding),
358
+ subject: getRecoveryKeySubject(branding, locale),
340
359
  };
341
360
  }
342
361
 
343
362
  case 'endorsement-request':
344
363
  case 'universal-inbox-claim-1': {
345
364
  const d = data as EndorsementRequestData;
346
- const props: EndorsementRequestProps = { branding, ...d };
365
+ const props: EndorsementRequestProps = { branding, ...d, locale };
347
366
 
348
367
  return {
349
368
  element: React.createElement(EndorsementRequest, props),
350
- subject: getEndorsementRequestSubject(branding, props),
369
+ subject: getEndorsementRequestSubject(branding, props, locale),
351
370
  };
352
371
  }
353
372
 
354
373
  case 'credential-awaiting-guardian': {
355
374
  const d = data as CredentialAwaitingGuardianData;
356
- const props: CredentialAwaitingGuardianProps = { branding, ...d };
375
+ const props: CredentialAwaitingGuardianProps = { branding, ...d, locale };
357
376
 
358
377
  return {
359
378
  element: React.createElement(CredentialAwaitingGuardian, props),
360
- subject: getCredentialAwaitingGuardianSubject(branding),
379
+ subject: getCredentialAwaitingGuardianSubject(branding, locale),
361
380
  };
362
381
  }
363
382
 
364
383
  case 'guardian-approved-claim': {
365
384
  const d = data as GuardianApprovedClaimData;
366
- const props: GuardianApprovedClaimProps = { branding, ...d };
385
+ const props: GuardianApprovedClaimProps = { branding, ...d, locale };
367
386
 
368
387
  return {
369
388
  element: React.createElement(GuardianApprovedClaim, props),
370
- subject: getGuardianApprovedClaimSubject(branding),
389
+ subject: getGuardianApprovedClaimSubject(branding, locale),
371
390
  };
372
391
  }
373
392
 
374
393
  case 'guardian-credential-approval': {
375
394
  const d = data as GuardianCredentialApprovalData;
376
- const props: GuardianCredentialApprovalProps = { branding, ...d };
395
+ const props: GuardianCredentialApprovalProps = { branding, ...d, locale };
377
396
 
378
397
  return {
379
398
  element: React.createElement(GuardianCredentialApproval, props),
380
- subject: getGuardianCredentialApprovalSubject(branding),
399
+ subject: getGuardianCredentialApprovalSubject(branding, locale),
381
400
  };
382
401
  }
383
402
 
384
403
  case 'guardian-email-otp': {
385
404
  const d = data as GuardianEmailOtpData;
386
- const props: GuardianEmailOtpProps = { branding, ...d };
405
+ const props: GuardianEmailOtpProps = { branding, ...d, locale };
387
406
 
388
407
  return {
389
408
  element: React.createElement(GuardianEmailOtp, props),
390
- subject: getGuardianEmailOtpSubject(branding),
409
+ subject: getGuardianEmailOtpSubject(branding, locale),
391
410
  };
392
411
  }
393
412
 
394
413
  case 'guardian-rejected-credential': {
395
414
  const d = data as GuardianRejectedCredentialData;
396
- const props: GuardianRejectedCredentialProps = { branding, ...d };
415
+ const props: GuardianRejectedCredentialProps = { branding, ...d, locale };
397
416
 
398
417
  return {
399
418
  element: React.createElement(GuardianRejectedCredential, props),
400
- subject: getGuardianRejectedCredentialSubject(branding),
419
+ subject: getGuardianRejectedCredentialSubject(branding, locale),
401
420
  };
402
421
  }
403
422
 
package/src/sms.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * SMS template rendering — tenant-aware replacement for sms.helpers.ts.
3
3
  *
4
- * Replaces the hardcoded "LearnCard" in SMS text with the tenant's brand name.
4
+ * Replaces the hardcoded "LearnCard" in SMS text with the tenant's brand name
5
+ * and renders localized SMS bodies (LC-1902, workstream C1).
5
6
  */
6
7
 
7
8
  import type { TenantBranding } from './branding';
8
9
  import { resolveBranding } from './branding';
10
+ import type { NotificationLocale } from './i18n';
11
+ import { resolveCatalogLocale } from './i18n';
9
12
 
10
13
  // ---------------------------------------------------------------------------
11
14
  // SMS template data shapes
@@ -29,12 +32,59 @@ export interface SmsTemplateDataMap {
29
32
  'contact-method-verification': SmsVerificationData;
30
33
  }
31
34
 
35
+ // ---------------------------------------------------------------------------
36
+ // Per-locale SMS copy. EN values byte-match the originals in sms.helpers.ts.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const INBOX_CLAIM: Record<
40
+ NotificationLocale,
41
+ {
42
+ greetingName: (name: string) => string;
43
+ body: (credType: string, credName: string, issuerName: string | undefined) => string;
44
+ cta: string;
45
+ }
46
+ > = {
47
+ en: {
48
+ greetingName: name => `Hello, ${name}! `,
49
+ body: (credType, credName, issuerName) =>
50
+ `You have a new ${credType}, "${credName}"${issuerName ? ` from ${issuerName}.` : '.'}`,
51
+ cta: 'Claim it here:',
52
+ },
53
+ es: {
54
+ greetingName: name => `¡Hola, ${name}! `,
55
+ body: (credType, credName, issuerName) =>
56
+ `Tienes un nuevo ${credType}, "${credName}"${issuerName ? ` de ${issuerName}.` : '.'}`,
57
+ cta: 'Reclámalo aquí:',
58
+ },
59
+ fr: {
60
+ greetingName: name => `Bonjour ${name} ! `,
61
+ body: (credType, credName, issuerName) =>
62
+ `Vous avez un nouveau ${credType}, « ${credName} »${
63
+ issuerName ? ` de ${issuerName}.` : '.'
64
+ }`,
65
+ cta: 'Réclamez-le ici :',
66
+ },
67
+ ar: {
68
+ greetingName: name => `مرحبًا ${name}! `,
69
+ body: (credType, credName, issuerName) =>
70
+ `لديك ${credType} جديد، "${credName}"${issuerName ? ` من ${issuerName}.` : '.'}`,
71
+ cta: 'طالب به هنا:',
72
+ },
73
+ };
74
+
75
+ const VERIFICATION: Record<NotificationLocale, (brandName: string) => string> = {
76
+ en: b => `Your ${b} verification code is:`,
77
+ es: b => `Tu código de verificación de ${b} es:`,
78
+ fr: b => `Votre code de vérification ${b} est :`,
79
+ ar: b => `رمز التحقق الخاص بك في ${b} هو:`,
80
+ };
81
+
32
82
  // ---------------------------------------------------------------------------
33
83
  // renderSms()
34
84
  // ---------------------------------------------------------------------------
35
85
 
36
86
  /**
37
- * Render a branded SMS body for the given template.
87
+ * Render a branded, localized SMS body for the given template.
38
88
  *
39
89
  * @returns The SMS body string, or `undefined` if the templateId is unknown.
40
90
  */
@@ -42,24 +92,29 @@ export function renderSms<T extends SmsTemplateId>(
42
92
  templateId: T,
43
93
  branding: Partial<TenantBranding> | TenantBranding,
44
94
  data: SmsTemplateDataMap[T],
95
+ /** Recipient locale (BCP-47). Defaults to English. */
96
+ locale?: string
45
97
  ): string | undefined {
46
98
  const resolved = resolveBranding(branding);
99
+ const loc = resolveCatalogLocale(locale);
47
100
 
48
101
  switch (templateId) {
49
102
  case 'inbox-claim': {
50
103
  const d = data as SmsInboxClaimData;
51
- const greeting = d.recipient?.name ? `Hello, ${d.recipient.name}! ` : '';
104
+ const s = INBOX_CLAIM[loc];
105
+ const greeting = d.recipient?.name ? s.greetingName(d.recipient.name) : '';
52
106
  const credType = d.credential?.type ?? 'credential';
53
107
  const credName = d.credential?.name ?? 'Unnamed Credential';
54
- const issuerPart = d.issuer?.name ? ` from ${d.issuer.name}.` : '.';
55
108
 
56
- return `${greeting}You have a new ${credType}, "${credName}"${issuerPart} Claim it here: ${d.claimUrl}`;
109
+ return `${greeting}${s.body(credType, credName, d.issuer?.name)} ${s.cta} ${
110
+ d.claimUrl
111
+ }`;
57
112
  }
58
113
 
59
114
  case 'contact-method-verification': {
60
115
  const d = data as SmsVerificationData;
61
116
 
62
- return `Your ${resolved.brandName} verification code is: ${d.verificationToken}`;
117
+ return `${VERIFICATION[loc](resolved.brandName)} ${d.verificationToken}`;
63
118
  }
64
119
 
65
120
  default:
@@ -11,56 +11,114 @@ import type { TenantBranding } from '../branding';
11
11
  import { DEFAULT_BRANDING } from '../branding';
12
12
  import { Layout } from '../components/Layout';
13
13
  import { EmailButton } from '../components/EmailButton';
14
+ import type { NotificationLocale } from '../i18n';
15
+ import { resolveCatalogLocale, interpolate, SHARED } from '../i18n';
14
16
 
15
17
  export interface AccountApprovedProps {
16
18
  branding: TenantBranding;
17
19
  user?: {
18
20
  displayName?: string;
19
21
  };
22
+ /** Recipient locale (BCP-47). Defaults to English. */
23
+ locale?: string;
20
24
  }
21
25
 
22
- export const AccountApproved: React.FC<AccountApprovedProps> = ({
23
- branding,
24
- user,
25
- }) => {
26
+ const STRINGS: Record<
27
+ NotificationLocale,
28
+ {
29
+ preview: (brandName: string) => string;
30
+ heading: (brandName: string) => string;
31
+ greeting: (name: string) => string;
32
+ body1: (brandName: string) => string;
33
+ body2: string;
34
+ ctaInstruction: string;
35
+ button: (brandName: string) => string;
36
+ thankYou: string;
37
+ subject: (brandName: string) => string;
38
+ }
39
+ > = {
40
+ en: {
41
+ preview: b => `Your ${b} account has been approved!`,
42
+ heading: b => `Your ${b} account has been approved!`,
43
+ greeting: name => `Hello ${name},`,
44
+ body1: b => `Your ${b} account has been approved by your guardian/parent.`,
45
+ body2: '{brandName} is a private, digital passport for learning and work. It lets users securely collect and share their verified skills and achievements online.',
46
+ ctaInstruction: 'Please click the button below to login to your account.',
47
+ button: b => `Login to ${b} \u2192`,
48
+ thankYou: 'Thank you,',
49
+ subject: b => `Your ${b} account has been approved`,
50
+ },
51
+ es: {
52
+ preview: b => `¡Tu cuenta de ${b} ha sido aprobada!`,
53
+ heading: b => `¡Tu cuenta de ${b} ha sido aprobada!`,
54
+ greeting: name => `Hola ${name},`,
55
+ body1: b => `Tu cuenta de ${b} ha sido aprobada por tu tutor o progenitor.`,
56
+ body2: '{brandName} es un pasaporte digital y privado para el aprendizaje y el trabajo. Permite a los usuarios recopilar y compartir de forma segura sus habilidades y logros verificados en línea.',
57
+ ctaInstruction: 'Haz clic en el botón siguiente para iniciar sesión en tu cuenta.',
58
+ button: b => `Iniciar sesión en ${b} \u2192`,
59
+ thankYou: 'Gracias,',
60
+ subject: b => `Tu cuenta de ${b} ha sido aprobada`,
61
+ },
62
+ fr: {
63
+ preview: b => `Votre compte ${b} a été approuvé !`,
64
+ heading: b => `Votre compte ${b} a été approuvé !`,
65
+ greeting: name => `Bonjour ${name},`,
66
+ body1: b => `Votre compte ${b} a été approuvé par votre tuteur ou parent.`,
67
+ body2: '{brandName} est un passeport numérique privé pour l\u2019apprentissage et le travail. Il permet aux utilisateurs de collecter et de partager en toute sécurité leurs compétences et réalisations vérifiées en ligne.',
68
+ ctaInstruction:
69
+ 'Veuillez cliquer sur le bouton ci-dessous pour vous connecter à votre compte.',
70
+ button: b => `Se connecter à ${b} \u2192`,
71
+ thankYou: 'Merci,',
72
+ subject: b => `Votre compte ${b} a été approuvé`,
73
+ },
74
+ ar: {
75
+ preview: b => `تمت الموافقة على حسابك في ${b}!`,
76
+ heading: b => `تمت الموافقة على حسابك في ${b}!`,
77
+ greeting: name => `مرحبًا ${name}،`,
78
+ body1: b => `تمت الموافقة على حسابك في ${b} من قبل ولي أمرك.`,
79
+ body2: '{brandName} هو جواز سفر رقمي خاص للتعلّم والعمل. يتيح للمستخدمين جمع ومشاركة مهاراتهم وإنجازاتهم الموثّقة بأمان عبر الإنترنت.',
80
+ ctaInstruction: 'يرجى النقر على الزر أدناه لتسجيل الدخول إلى حسابك.',
81
+ button: b => `تسجيل الدخول إلى ${b} ←`,
82
+ thankYou: 'شكرًا،',
83
+ subject: b => `تمت الموافقة على حسابك في ${b}`,
84
+ },
85
+ };
86
+
87
+ export const AccountApproved: React.FC<AccountApprovedProps> = ({ branding, user, locale }) => {
88
+ const s = STRINGS[resolveCatalogLocale(locale)];
26
89
  const name = user?.displayName ?? 'there';
27
90
 
28
91
  return (
29
- <Layout branding={branding} preview={`Your ${branding.brandName} account has been approved!`}>
30
- <Text style={heading}>Your {branding.brandName} account has been approved!</Text>
92
+ <Layout branding={branding} locale={locale} preview={s.preview(branding.brandName)}>
93
+ <Text style={heading}>{s.heading(branding.brandName)}</Text>
31
94
 
32
- <Text style={paragraph}>
33
- Hello {name},
34
- </Text>
95
+ <Text style={paragraph}>{s.greeting(name)}</Text>
35
96
 
36
- <Text style={paragraph}>
37
- Your {branding.brandName} account has been approved by your guardian/parent.
38
- </Text>
97
+ <Text style={paragraph}>{s.body1(branding.brandName)}</Text>
39
98
 
40
- <Text style={paragraph}>
41
- {branding.brandName} is a private, digital passport for learning and work.
42
- It lets users securely collect and share their verified skills and achievements online.
43
- </Text>
99
+ <Text style={paragraph}>{interpolate(s.body2, { brandName: branding.brandName })}</Text>
44
100
 
45
- <Text style={paragraph}>
46
- Please click the button below to login to your account.
47
- </Text>
101
+ <Text style={paragraph}>{s.ctaInstruction}</Text>
48
102
 
49
103
  <Section style={buttonWrapper}>
50
104
  <EmailButton href={branding.appUrl} branding={branding}>
51
- Login to {branding.brandName} &rarr;
105
+ {s.button(branding.brandName)}
52
106
  </EmailButton>
53
107
  </Section>
54
108
 
55
109
  <Text style={signOff}>
56
- Thank you,<br />The {branding.brandName} Team
110
+ {s.thankYou}
111
+ <br />
112
+ {interpolate(SHARED[resolveCatalogLocale(locale)].teamSignature, {
113
+ brandName: branding.brandName,
114
+ })}
57
115
  </Text>
58
116
  </Layout>
59
117
  );
60
118
  };
61
119
 
62
- export const getAccountApprovedSubject = (branding: TenantBranding): string =>
63
- `Your ${branding.brandName} account has been approved`;
120
+ export const getAccountApprovedSubject = (branding: TenantBranding, locale?: string): string =>
121
+ STRINGS[resolveCatalogLocale(locale)].subject(branding.brandName);
64
122
 
65
123
  // ---------------------------------------------------------------------------
66
124
  // Styles
@@ -96,10 +154,5 @@ const signOff: React.CSSProperties = {
96
154
  // ---------------------------------------------------------------------------
97
155
 
98
156
  export default function Preview() {
99
- return (
100
- <AccountApproved
101
- branding={DEFAULT_BRANDING}
102
- user={{ displayName: 'Jane Doe' }}
103
- />
104
- );
157
+ return <AccountApproved branding={DEFAULT_BRANDING} user={{ displayName: 'Jane Doe' }} />;
105
158
  }
@@ -12,6 +12,8 @@ import type { TenantBranding } from '../branding';
12
12
  import { DEFAULT_BRANDING } from '../branding';
13
13
  import { Layout } from '../components/Layout';
14
14
  import { IssuerLogo } from '../components/IssuerLogo';
15
+ import type { NotificationLocale } from '../i18n';
16
+ import { resolveCatalogLocale, interpolate, SHARED } from '../i18n';
15
17
 
16
18
  export interface CredentialAwaitingGuardianProps {
17
19
  branding: TenantBranding;
@@ -25,45 +27,136 @@ export interface CredentialAwaitingGuardianProps {
25
27
  recipient?: {
26
28
  email?: string;
27
29
  };
30
+ /** Recipient locale (BCP-47). Defaults to English. */
31
+ locale?: string;
28
32
  }
29
33
 
34
+ const STRINGS: Record<
35
+ NotificationLocale,
36
+ {
37
+ preview: (credentialName: string) => string;
38
+ heading: string;
39
+ greeting: string;
40
+ body1: (issuerName: string, credentialName: string) => React.ReactNode;
41
+ body2: (brandName: string) => string;
42
+ statusBadge: string;
43
+ muted: string;
44
+ sincerely: string;
45
+ subject: string;
46
+ }
47
+ > = {
48
+ en: {
49
+ preview: c => `Your credential "${c}" is pending guardian approval`,
50
+ heading: 'Pending guardian approval',
51
+ greeting: 'Hi there,',
52
+ body1: (issuerName, credentialName) => (
53
+ <>
54
+ <strong>{issuerName}</strong> has sent you the credential{' '}
55
+ <strong>&ldquo;{credentialName}&rdquo;</strong>.
56
+ </>
57
+ ),
58
+ body2: b =>
59
+ `Before you can claim it, your guardian needs to review and approve it first. We\u2019ve already sent them a notification \u2014 once they approve, you\u2019ll receive a follow-up email and the credential will appear in your ${b} account.`,
60
+ statusBadge: 'Awaiting Guardian Approval',
61
+ muted: 'No action is needed from you right now.',
62
+ sincerely: 'Sincerely,',
63
+ subject: 'Your Credential Is Pending Guardian Approval',
64
+ },
65
+ es: {
66
+ preview: c => `Tu credencial "${c}" está pendiente de aprobación del tutor`,
67
+ heading: 'Pendiente de aprobación del tutor',
68
+ greeting: 'Hola,',
69
+ body1: (issuerName, credentialName) => (
70
+ <>
71
+ <strong>{issuerName}</strong> te ha enviado la credencial{' '}
72
+ <strong>&ldquo;{credentialName}&rdquo;</strong>.
73
+ </>
74
+ ),
75
+ body2: b =>
76
+ `Antes de poder reclamarla, tu tutor debe revisarla y aprobarla primero. Ya le hemos enviado una notificación \u2014 cuando la apruebe, recibirás un correo de seguimiento y la credencial aparecerá en tu cuenta de ${b}.`,
77
+ statusBadge: 'Pendiente de aprobación del tutor',
78
+ muted: 'No necesitas realizar ninguna acción en este momento.',
79
+ sincerely: 'Atentamente,',
80
+ subject: 'Tu credencial está pendiente de aprobación del tutor',
81
+ },
82
+ fr: {
83
+ preview: c => `Votre titre « ${c} » est en attente d\u2019approbation du tuteur`,
84
+ heading: 'En attente d\u2019approbation du tuteur',
85
+ greeting: 'Bonjour,',
86
+ body1: (issuerName, credentialName) => (
87
+ <>
88
+ <strong>{issuerName}</strong> vous a envoyé le titre{' '}
89
+ <strong>&laquo;&nbsp;{credentialName}&nbsp;&raquo;</strong>.
90
+ </>
91
+ ),
92
+ body2: b =>
93
+ `Avant de pouvoir le réclamer, votre tuteur doit d\u2019abord le vérifier et l\u2019approuver. Nous lui avons déjà envoyé une notification \u2014 une fois qu\u2019il aura approuvé, vous recevrez un e-mail de suivi et le titre apparaîtra dans votre compte ${b}.`,
94
+ statusBadge: 'En attente d\u2019approbation du tuteur',
95
+ muted: 'Aucune action n\u2019est requise de votre part pour le moment.',
96
+ sincerely: 'Cordialement,',
97
+ subject: 'Votre titre est en attente d\u2019approbation du tuteur',
98
+ },
99
+ ar: {
100
+ preview: c => `شهادتك "${c}" بانتظار موافقة ولي الأمر`,
101
+ heading: 'بانتظار موافقة ولي الأمر',
102
+ greeting: 'مرحبًا،',
103
+ body1: (issuerName, credentialName) => (
104
+ <>
105
+ أرسل لك <strong>{issuerName}</strong> الشهادة{' '}
106
+ <strong>&laquo;{credentialName}&raquo;</strong>.
107
+ </>
108
+ ),
109
+ body2: b =>
110
+ `قبل أن تتمكن من المطالبة بها، يجب على ولي أمرك مراجعتها والموافقة عليها أولاً. لقد أرسلنا إليه إشعارًا بالفعل \u2014 بمجرد موافقته، ستتلقى رسالة متابعة وستظهر الشهادة في حسابك على ${b}.`,
111
+ statusBadge: 'بانتظار موافقة ولي الأمر',
112
+ muted: 'لا حاجة لأي إجراء منك في الوقت الحالي.',
113
+ sincerely: 'مع التحيات،',
114
+ subject: 'شهادتك بانتظار موافقة ولي الأمر',
115
+ },
116
+ };
117
+
30
118
  export const CredentialAwaitingGuardian: React.FC<CredentialAwaitingGuardianProps> = ({
31
119
  branding,
32
120
  issuer,
33
121
  credential,
122
+ locale,
34
123
  }) => {
124
+ const s = STRINGS[resolveCatalogLocale(locale)];
35
125
  const issuerName = issuer?.name ?? 'An issuer';
36
126
  const credentialName = credential?.name ?? 'a credential';
37
127
 
38
128
  return (
39
- <Layout branding={branding} preview={`Your credential "${credentialName}" is pending guardian approval`} showHeaderLogo={false}>
40
- <IssuerLogo logoUrl={issuer?.logoUrl} alt={issuerName ? `${issuerName} logo` : undefined} />
129
+ <Layout
130
+ branding={branding}
131
+ locale={locale}
132
+ preview={s.preview(credentialName)}
133
+ showHeaderLogo={false}
134
+ >
135
+ <IssuerLogo
136
+ logoUrl={issuer?.logoUrl}
137
+ alt={issuerName ? `${issuerName} logo` : undefined}
138
+ />
41
139
 
42
- <Text style={heading}>Pending guardian approval</Text>
140
+ <Text style={heading}>{s.heading}</Text>
43
141
 
44
- <Text style={paragraph}>Hi there,</Text>
142
+ <Text style={paragraph}>{s.greeting}</Text>
45
143
 
46
- <Text style={paragraph}>
47
- <strong>{issuerName}</strong> has sent you the credential{' '}
48
- <strong>&ldquo;{credentialName}&rdquo;</strong>.
49
- </Text>
144
+ <Text style={paragraph}>{s.body1(issuerName, credentialName)}</Text>
50
145
 
51
- <Text style={paragraph}>
52
- Before you can claim it, your guardian needs to review and approve it first.
53
- We&apos;ve already sent them a notification — once they approve, you&apos;ll receive
54
- a follow-up email and the credential will appear in your {branding.brandName} account.
55
- </Text>
146
+ <Text style={paragraph}>{s.body2(branding.brandName)}</Text>
56
147
 
57
148
  <Section style={statusBadge}>
58
- <Text style={statusText}>Awaiting Guardian Approval</Text>
149
+ <Text style={statusText}>{s.statusBadge}</Text>
59
150
  </Section>
60
151
 
61
- <Text style={muted}>
62
- No action is needed from you right now.
63
- </Text>
152
+ <Text style={muted}>{s.muted}</Text>
64
153
 
65
154
  <Text style={signOff}>
66
- Sincerely,<br />The {branding.brandName} Team
155
+ {s.sincerely}
156
+ <br />
157
+ {interpolate(SHARED[resolveCatalogLocale(locale)].teamSignature, {
158
+ brandName: branding.brandName,
159
+ })}
67
160
  </Text>
68
161
  </Layout>
69
162
  );
@@ -71,7 +164,8 @@ export const CredentialAwaitingGuardian: React.FC<CredentialAwaitingGuardianProp
71
164
 
72
165
  export const getCredentialAwaitingGuardianSubject = (
73
166
  _branding: TenantBranding,
74
- ): string => 'Your Credential Is Pending Guardian Approval';
167
+ locale?: string
168
+ ): string => STRINGS[resolveCatalogLocale(locale)].subject;
75
169
 
76
170
  // ---------------------------------------------------------------------------
77
171
  // Styles