@learncard/email-templates 1.0.6 → 1.2.0

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 (41) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/previews/credential-updated-scenarios.d.ts +9 -0
  5. package/dist/previews/credential-updated-scenarios.d.ts.map +1 -0
  6. package/dist/previews/credential-updated-scenarios.js +7 -0
  7. package/dist/previews/credential-updated-scenarios.js.map +1 -0
  8. package/dist/previews/recovery-key-scenarios.d.ts.map +1 -1
  9. package/dist/previews/recovery-key-scenarios.js +1 -1
  10. package/dist/previews/recovery-key-scenarios.js.map +1 -1
  11. package/dist/render.d.ts +16 -0
  12. package/dist/render.d.ts.map +1 -1
  13. package/dist/render.js +16 -1
  14. package/dist/render.js.map +1 -1
  15. package/dist/templates/account-sign-in-changed.d.ts +11 -0
  16. package/dist/templates/account-sign-in-changed.d.ts.map +1 -0
  17. package/dist/templates/account-sign-in-changed.js +70 -0
  18. package/dist/templates/account-sign-in-changed.js.map +1 -0
  19. package/dist/templates/credential-updated.d.ts +33 -0
  20. package/dist/templates/credential-updated.d.ts.map +1 -0
  21. package/dist/templates/credential-updated.js +104 -0
  22. package/dist/templates/credential-updated.js.map +1 -0
  23. package/dist/templates/index.d.ts +7 -3
  24. package/dist/templates/index.d.ts.map +1 -1
  25. package/dist/templates/index.js +5 -3
  26. package/dist/templates/index.js.map +1 -1
  27. package/dist/templates/recovery-key.d.ts +1 -0
  28. package/dist/templates/recovery-key.d.ts.map +1 -1
  29. package/dist/templates/recovery-key.js +11 -3
  30. package/dist/templates/recovery-key.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/i18n.test.ts +9 -2
  33. package/src/__tests__/render.test.ts +151 -26
  34. package/src/index.ts +2 -0
  35. package/src/previews/credential-updated-scenarios.tsx +61 -0
  36. package/src/previews/recovery-key-scenarios.tsx +3 -0
  37. package/src/render.ts +39 -0
  38. package/src/templates/account-sign-in-changed.tsx +113 -0
  39. package/src/templates/credential-updated.tsx +309 -0
  40. package/src/templates/index.ts +17 -3
  41. package/src/templates/recovery-key.tsx +26 -1
@@ -0,0 +1,309 @@
1
+ /**
2
+ * CredentialUpdated — privacy-minimal notice that an issuer published a new
3
+ * version of a credential.
4
+ *
5
+ * Used by: brain-service credential-refresh publication (templateId:
6
+ * 'credential-updated').
7
+ *
8
+ * Deliberately minimal context: the issuer display name and the credential
9
+ * display title are the only dynamic values. No grades, subject/recipient
10
+ * details, evidence, body, or issuer-authored update summary is rendered. The
11
+ * CTA points at the recipient's own notifications page (never a claim URL and
12
+ * never a bearer token), so a forwarded email cannot be used to access the
13
+ * credential.
14
+ */
15
+
16
+ import { Text, Section } from '@react-email/components';
17
+ import * as React from 'react';
18
+
19
+ import type { TenantBranding } from '../branding';
20
+ import { DEFAULT_BRANDING } from '../branding';
21
+ import { Layout } from '../components/Layout';
22
+ import { EmailButton } from '../components/EmailButton';
23
+ import { IssuerLogo } from '../components/IssuerLogo';
24
+ import { LinkFallback } from '../components/LinkFallback';
25
+ import type { NotificationLocale } from '../i18n';
26
+ import { resolveCatalogLocale, interpolate, SHARED } from '../i18n';
27
+
28
+ export interface CredentialUpdatedProps {
29
+ branding: TenantBranding;
30
+ issuer?: {
31
+ name?: string;
32
+ logoUrl?: string;
33
+ };
34
+ /** Bounded credential display title. Omitted when unavailable. */
35
+ credential?: {
36
+ name?: string;
37
+ };
38
+ /** Recipient locale (BCP-47). Defaults to English. */
39
+ locale?: string;
40
+ }
41
+
42
+ const ldquo = '\u201C';
43
+ const rdquo = '\u201D';
44
+
45
+ type Strings = {
46
+ preview: string;
47
+ heading: string;
48
+ body: (issuerName: string | undefined, credentialName: string | undefined) => React.ReactNode;
49
+ signIn: string;
50
+ button: string;
51
+ sincerely: string;
52
+ subject: string;
53
+ };
54
+
55
+ const EN: Strings = {
56
+ preview: 'Your credential has been updated',
57
+ heading: 'Your credential was updated',
58
+ body: (issuerName, credentialName) =>
59
+ issuerName ? (
60
+ credentialName ? (
61
+ <>
62
+ <strong>{issuerName}</strong> updated your{' '}
63
+ <strong>
64
+ {ldquo}
65
+ {credentialName}
66
+ {rdquo}
67
+ </strong>{' '}
68
+ certificate.
69
+ </>
70
+ ) : (
71
+ <>
72
+ <strong>{issuerName}</strong> updated one of your credentials.
73
+ </>
74
+ )
75
+ ) : credentialName ? (
76
+ <>
77
+ Your{' '}
78
+ <strong>
79
+ {ldquo}
80
+ {credentialName}
81
+ {rdquo}
82
+ </strong>{' '}
83
+ certificate was updated.
84
+ </>
85
+ ) : (
86
+ <>One of your credentials was updated.</>
87
+ ),
88
+ signIn: 'Sign in to view the latest version.',
89
+ button: 'View Updates',
90
+ sincerely: 'Sincerely,',
91
+ subject: 'Your credential was updated',
92
+ };
93
+
94
+ const ES: Strings = {
95
+ preview: 'Tu credencial se ha actualizado',
96
+ heading: 'Tu credencial se actualizó',
97
+ body: (issuerName, credentialName) =>
98
+ issuerName ? (
99
+ credentialName ? (
100
+ <>
101
+ <strong>{issuerName}</strong> actualizó tu certificado{' '}
102
+ <strong>
103
+ {ldquo}
104
+ {credentialName}
105
+ {rdquo}
106
+ </strong>
107
+ .
108
+ </>
109
+ ) : (
110
+ <>
111
+ <strong>{issuerName}</strong> actualizó una de tus credenciales.
112
+ </>
113
+ )
114
+ ) : credentialName ? (
115
+ <>
116
+ Tu certificado{' '}
117
+ <strong>
118
+ {ldquo}
119
+ {credentialName}
120
+ {rdquo}
121
+ </strong>{' '}
122
+ se actualizó.
123
+ </>
124
+ ) : (
125
+ <>Una de tus credenciales se actualizó.</>
126
+ ),
127
+ signIn: 'Inicia sesión para ver la última versión.',
128
+ button: 'Ver actualizaciones',
129
+ sincerely: 'Atentamente,',
130
+ subject: 'Tu credencial se actualizó',
131
+ };
132
+
133
+ const FR: Strings = {
134
+ preview: 'Votre titre a été mis à jour',
135
+ heading: 'Votre titre a été mis à jour',
136
+ body: (issuerName, credentialName) =>
137
+ issuerName ? (
138
+ credentialName ? (
139
+ <>
140
+ <strong>{issuerName}</strong> a mis à jour votre certificat{' '}
141
+ <strong>
142
+ {ldquo}
143
+ {credentialName}
144
+ {rdquo}
145
+ </strong>
146
+ .
147
+ </>
148
+ ) : (
149
+ <>
150
+ <strong>{issuerName}</strong> a mis à jour l&apos;un de vos titres.
151
+ </>
152
+ )
153
+ ) : credentialName ? (
154
+ <>
155
+ Votre certificat{' '}
156
+ <strong>
157
+ {ldquo}
158
+ {credentialName}
159
+ {rdquo}
160
+ </strong>{' '}
161
+ a été mis à jour.
162
+ </>
163
+ ) : (
164
+ <>L&apos;un de vos titres a été mis à jour.</>
165
+ ),
166
+ signIn: 'Connectez-vous pour voir la dernière version.',
167
+ button: 'Voir les mises à jour',
168
+ sincerely: 'Cordialement,',
169
+ subject: 'Votre titre a été mis à jour',
170
+ };
171
+
172
+ const AR: Strings = {
173
+ preview: 'تم تحديث شهادتك',
174
+ heading: 'تم تحديث شهادتك',
175
+ body: (issuerName, credentialName) =>
176
+ issuerName ? (
177
+ credentialName ? (
178
+ <>
179
+ قام <strong>{issuerName}</strong> بتحديث شهادتك{' '}
180
+ <strong>
181
+ {ldquo}
182
+ {credentialName}
183
+ {rdquo}
184
+ </strong>
185
+ .
186
+ </>
187
+ ) : (
188
+ <>
189
+ قام <strong>{issuerName}</strong> بتحديث إحدى شهاداتك.
190
+ </>
191
+ )
192
+ ) : credentialName ? (
193
+ <>
194
+ تم تحديث شهادتك{' '}
195
+ <strong>
196
+ {ldquo}
197
+ {credentialName}
198
+ {rdquo}
199
+ </strong>
200
+ .
201
+ </>
202
+ ) : (
203
+ <>تم تحديث إحدى شهاداتك.</>
204
+ ),
205
+ signIn: 'سجّل الدخول لعرض أحدث إصدار.',
206
+ button: 'عرض التحديثات',
207
+ sincerely: 'مع التحيات،',
208
+ subject: 'تم تحديث شهادتك',
209
+ };
210
+
211
+ const STRINGS: Record<NotificationLocale, Strings> = { en: EN, es: ES, fr: FR, ar: AR };
212
+
213
+ /** Notifications page of the configured app (never a claim or token URL). */
214
+ const notificationsUrl = (branding: TenantBranding): string =>
215
+ `${branding.appUrl.replace(/\/+$/, '')}/notifications`;
216
+
217
+ export const CredentialUpdated: React.FC<CredentialUpdatedProps> = ({
218
+ branding,
219
+ issuer,
220
+ credential,
221
+ locale,
222
+ }) => {
223
+ const s = STRINGS[resolveCatalogLocale(locale)];
224
+ const issuerName = issuer?.name;
225
+ const credentialName = credential?.name;
226
+ const updatesUrl = notificationsUrl(branding);
227
+
228
+ return (
229
+ <Layout
230
+ branding={branding}
231
+ locale={locale}
232
+ preview={s.preview}
233
+ showHeaderLogo={!issuer?.logoUrl}
234
+ >
235
+ <IssuerLogo
236
+ logoUrl={issuer?.logoUrl}
237
+ alt={issuerName ? `${issuerName} logo` : undefined}
238
+ />
239
+
240
+ <Text style={heading}>{s.heading}</Text>
241
+
242
+ <Text style={paragraph}>{s.body(issuerName, credentialName)}</Text>
243
+
244
+ <Text style={paragraph}>{s.signIn}</Text>
245
+
246
+ <Section style={buttonWrapper}>
247
+ <EmailButton href={updatesUrl} branding={branding}>
248
+ {s.button}
249
+ </EmailButton>
250
+ </Section>
251
+
252
+ <Text style={signOff}>
253
+ {s.sincerely}
254
+ <br />
255
+ {interpolate(SHARED[resolveCatalogLocale(locale)].teamSignature, {
256
+ brandName: branding.brandName,
257
+ })}
258
+ </Text>
259
+
260
+ <LinkFallback href={updatesUrl} locale={locale} />
261
+ </Layout>
262
+ );
263
+ };
264
+
265
+ export const getCredentialUpdatedSubject = (_branding: TenantBranding, locale?: string): string =>
266
+ STRINGS[resolveCatalogLocale(locale)].subject;
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Styles
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const heading: React.CSSProperties = {
273
+ fontSize: 24,
274
+ fontWeight: 600,
275
+ color: '#111827',
276
+ margin: '0 0 24px',
277
+ };
278
+
279
+ const paragraph: React.CSSProperties = {
280
+ fontSize: 16,
281
+ color: '#374151',
282
+ lineHeight: '24px',
283
+ margin: '0 0 24px',
284
+ };
285
+
286
+ const buttonWrapper: React.CSSProperties = {
287
+ margin: '0 0 24px',
288
+ };
289
+
290
+ const signOff: React.CSSProperties = {
291
+ fontSize: 14,
292
+ color: '#374151',
293
+ lineHeight: '20px',
294
+ margin: '24px 0 0',
295
+ };
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Preview
299
+ // ---------------------------------------------------------------------------
300
+
301
+ export default function Preview() {
302
+ return (
303
+ <CredentialUpdated
304
+ branding={DEFAULT_BRANDING}
305
+ issuer={{ name: 'Inbox Demo School' }}
306
+ credential={{ name: 'Introduction to Biology' }}
307
+ />
308
+ );
309
+ }
@@ -16,20 +16,34 @@ export type { RecoveryKeyProps } from './recovery-key';
16
16
  export { EndorsementRequest, getEndorsementRequestSubject } from './endorsement-request';
17
17
  export type { EndorsementRequestProps } from './endorsement-request';
18
18
 
19
- export { CredentialAwaitingGuardian, getCredentialAwaitingGuardianSubject } from './credential-awaiting-guardian';
19
+ export {
20
+ CredentialAwaitingGuardian,
21
+ getCredentialAwaitingGuardianSubject,
22
+ } from './credential-awaiting-guardian';
20
23
  export type { CredentialAwaitingGuardianProps } from './credential-awaiting-guardian';
21
24
 
22
25
  export { GuardianApprovedClaim, getGuardianApprovedClaimSubject } from './guardian-approved-claim';
23
26
  export type { GuardianApprovedClaimProps } from './guardian-approved-claim';
24
27
 
25
- export { GuardianCredentialApproval, getGuardianCredentialApprovalSubject } from './guardian-credential-approval';
28
+ export {
29
+ GuardianCredentialApproval,
30
+ getGuardianCredentialApprovalSubject,
31
+ } from './guardian-credential-approval';
26
32
  export type { GuardianCredentialApprovalProps } from './guardian-credential-approval';
27
33
 
28
34
  export { GuardianEmailOtp, getGuardianEmailOtpSubject } from './guardian-email-otp';
29
35
  export type { GuardianEmailOtpProps } from './guardian-email-otp';
30
36
 
31
- export { GuardianRejectedCredential, getGuardianRejectedCredentialSubject } from './guardian-rejected-credential';
37
+ export {
38
+ GuardianRejectedCredential,
39
+ getGuardianRejectedCredentialSubject,
40
+ } from './guardian-rejected-credential';
32
41
  export type { GuardianRejectedCredentialProps } from './guardian-rejected-credential';
33
42
 
34
43
  export { EmailVerification, getEmailVerificationSubject } from './email-verification';
35
44
  export type { EmailVerificationProps } from './email-verification';
45
+
46
+ export { CredentialUpdated, getCredentialUpdatedSubject } from './credential-updated';
47
+ export type { CredentialUpdatedProps } from './credential-updated';
48
+ export { AccountSignInChanged, getAccountSignInChangedSubject } from './account-sign-in-changed';
49
+ export type { AccountSignInChangedProps } from './account-sign-in-changed';
@@ -17,6 +17,7 @@ import { resolveCatalogLocale, interpolate, SHARED } from '../i18n';
17
17
  export interface RecoveryKeyProps {
18
18
  branding: TenantBranding;
19
19
  recoveryKey: string;
20
+ confirmationCode: string;
20
21
  /** Recipient locale (BCP-47). Defaults to English. */
21
22
  locale?: string;
22
23
  }
@@ -29,6 +30,8 @@ const STRINGS: Record<
29
30
  greeting: string;
30
31
  body: (brandName: string) => string;
31
32
  codeLabel: string;
33
+ confirmationCodeLabel: string;
34
+ confirmationInstruction: string;
32
35
  recoverInstruction: () => React.ReactNode;
33
36
  notRequested: string;
34
37
  passport: string;
@@ -43,6 +46,9 @@ const STRINGS: Record<
43
46
  body: b =>
44
47
  `Keep this email safe. You can use the recovery key below to regain access to your ${b} account if you lose your device.`,
45
48
  codeLabel: 'Recovery Key \u2014 do NOT share this with anyone',
49
+ confirmationCodeLabel: 'Confirmation code',
50
+ confirmationInstruction:
51
+ 'Enter this code in the app to confirm that you received your recovery key.',
46
52
  recoverInstruction: () => (
47
53
  <>
48
54
  To recover your account, choose <strong>&ldquo;Recover via Email&rdquo;</strong> in
@@ -62,6 +68,9 @@ const STRINGS: Record<
62
68
  body: b =>
63
69
  `Guarda este correo en un lugar seguro. Puedes usar la clave de recuperación siguiente para recuperar el acceso a tu cuenta de ${b} si pierdes tu dispositivo.`,
64
70
  codeLabel: 'Clave de recuperación \u2014 NO la compartas con nadie',
71
+ confirmationCodeLabel: 'Código de confirmación',
72
+ confirmationInstruction:
73
+ 'Introduce este código en la aplicación para confirmar que recibiste tu clave de recuperación.',
65
74
  recoverInstruction: () => (
66
75
  <>
67
76
  Para recuperar tu cuenta, elige <strong>&ldquo;Recuperar por correo&rdquo;</strong>{' '}
@@ -81,6 +90,9 @@ const STRINGS: Record<
81
90
  body: b =>
82
91
  `Conservez cet e-mail en sécurité. Vous pouvez utiliser la clé de récupération ci-dessous pour retrouver l\u2019accès à votre compte ${b} si vous perdez votre appareil.`,
83
92
  codeLabel: 'Clé de récupération \u2014 NE la partagez avec PERSONNE',
93
+ confirmationCodeLabel: 'Code de confirmation',
94
+ confirmationInstruction:
95
+ 'Saisissez ce code dans l’application pour confirmer que vous avez reçu votre clé de récupération.',
84
96
  recoverInstruction: () => (
85
97
  <>
86
98
  Pour récupérer votre compte, choisissez{' '}
@@ -103,6 +115,9 @@ const STRINGS: Record<
103
115
  body: b =>
104
116
  `احتفظ بهذا البريد في مكان آمن. يمكنك استخدام مفتاح الاستعادة أدناه لاستعادة الوصول إلى حسابك في ${b} إذا فقدت جهازك.`,
105
117
  codeLabel: 'مفتاح الاستعادة \u2014 لا تشاركه مع أي شخص إطلاقًا',
118
+ confirmationCodeLabel: 'رمز التأكيد',
119
+ confirmationInstruction:
120
+ 'أدخل هذا الرمز في التطبيق لتأكيد استلام مفتاح الاستعادة الخاص بك.',
106
121
  recoverInstruction: () => (
107
122
  <>
108
123
  لاستعادة حسابك، اختر <strong>&laquo;الاستعادة عبر البريد&raquo;</strong> في التطبيق
@@ -117,7 +132,12 @@ const STRINGS: Record<
117
132
  },
118
133
  };
119
134
 
120
- export const RecoveryKey: React.FC<RecoveryKeyProps> = ({ branding, recoveryKey, locale }) => {
135
+ export const RecoveryKey: React.FC<RecoveryKeyProps> = ({
136
+ branding,
137
+ recoveryKey,
138
+ confirmationCode,
139
+ locale,
140
+ }) => {
121
141
  const s = STRINGS[resolveCatalogLocale(locale)];
122
142
 
123
143
  return (
@@ -130,6 +150,10 @@ export const RecoveryKey: React.FC<RecoveryKeyProps> = ({ branding, recoveryKey,
130
150
 
131
151
  <CodeBlock code={recoveryKey} label={s.codeLabel} variant="key" />
132
152
 
153
+ <CodeBlock code={confirmationCode} label={s.confirmationCodeLabel} variant="code" />
154
+
155
+ <Text style={paragraph}>{s.confirmationInstruction}</Text>
156
+
133
157
  <Text style={paragraph}>{s.recoverInstruction()}</Text>
134
158
 
135
159
  <Text style={muted}>{s.notRequested}</Text>
@@ -193,6 +217,7 @@ export default function Preview() {
193
217
  <RecoveryKey
194
218
  branding={DEFAULT_BRANDING}
195
219
  recoveryKey="mango-delta-fox-echo-bravo-seven-lima-niner"
220
+ confirmationCode="123456"
196
221
  />
197
222
  );
198
223
  }