@learncard/email-templates 1.2.1 → 1.3.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.
- package/dist/components/Layout.d.ts +1 -0
- package/dist/components/Layout.d.ts.map +1 -1
- package/dist/components/Layout.js +2 -2
- package/dist/components/Layout.js.map +1 -1
- package/dist/i18n.d.ts +15 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +31 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/previews/account-secured-scenarios.d.ts +6 -0
- package/dist/previews/account-secured-scenarios.d.ts.map +1 -0
- package/dist/previews/account-secured-scenarios.js +7 -0
- package/dist/previews/account-secured-scenarios.js.map +1 -0
- package/dist/previews/escrow-pin-sign-in-scenarios.d.ts +6 -0
- package/dist/previews/escrow-pin-sign-in-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-pin-sign-in-scenarios.js +7 -0
- package/dist/previews/escrow-pin-sign-in-scenarios.js.map +1 -0
- package/dist/render.d.ts +20 -9
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +39 -1
- package/dist/render.js.map +1 -1
- package/dist/templates/account-secured.d.ts +27 -0
- package/dist/templates/account-secured.d.ts.map +1 -0
- package/dist/templates/account-secured.js +118 -0
- package/dist/templates/account-secured.js.map +1 -0
- package/dist/templates/announcement.d.ts +13 -0
- package/dist/templates/announcement.d.ts.map +1 -0
- package/dist/templates/announcement.js +34 -0
- package/dist/templates/announcement.js.map +1 -0
- package/dist/templates/escrow-pin-sign-in.d.ts +24 -0
- package/dist/templates/escrow-pin-sign-in.d.ts.map +1 -0
- package/dist/templates/escrow-pin-sign-in.js +96 -0
- package/dist/templates/escrow-pin-sign-in.js.map +1 -0
- package/dist/templates/index.d.ts +22 -38
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +21 -14
- package/dist/templates/index.js.map +1 -1
- package/dist/tenant-registry.d.ts +2 -2
- package/dist/tenant-registry.d.ts.map +1 -1
- package/dist/tenant-registry.js +2 -3
- package/dist/tenant-registry.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/i18n.test.ts +39 -1
- package/src/__tests__/render.test.ts +154 -0
- package/src/__tests__/tenant-registry.test.ts +2 -2
- package/src/components/Layout.tsx +4 -1
- package/src/i18n.ts +33 -0
- package/src/index.ts +4 -0
- package/src/previews/account-secured-scenarios.tsx +41 -0
- package/src/previews/escrow-pin-sign-in-scenarios.tsx +52 -0
- package/src/render.ts +73 -0
- package/src/templates/account-secured.tsx +236 -0
- package/src/templates/announcement.tsx +100 -0
- package/src/templates/escrow-pin-sign-in.tsx +197 -0
- package/src/templates/index.ts +97 -35
- package/src/tenant-registry.ts +3 -4
|
@@ -28,6 +28,13 @@ const VETPASS_BRANDING: Partial<TenantBranding> = {
|
|
|
28
28
|
* produce output without throwing.
|
|
29
29
|
*/
|
|
30
30
|
const TEMPLATE_FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
31
|
+
announcement: {
|
|
32
|
+
title: 'New learning opportunities',
|
|
33
|
+
body: 'Explore the latest programs.',
|
|
34
|
+
ctaLabel: 'Explore',
|
|
35
|
+
ctaUrl: 'https://learncard.app/m/programs?utm_source=email',
|
|
36
|
+
unsubscribeUrl: 'https://learncard.app/unsubscribe?token=example',
|
|
37
|
+
},
|
|
31
38
|
'embed-email-verification': {
|
|
32
39
|
verificationCode: '123456',
|
|
33
40
|
verificationEmail: 'test@example.com',
|
|
@@ -166,6 +173,13 @@ const TEMPLATE_FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
166
173
|
releaseAfter: 'October 2, 2026, 3:00 PM',
|
|
167
174
|
cancelUrl: 'https://learncard.app/recovery/cancel?token=abc123',
|
|
168
175
|
},
|
|
176
|
+
'escrow-pin-sign-in': {
|
|
177
|
+
signedInAt: 'October 2, 2026, 3:00 PM',
|
|
178
|
+
secureUrl: 'https://learncard.app/account/secure',
|
|
179
|
+
},
|
|
180
|
+
'account-secured': {
|
|
181
|
+
securedAt: 'October 2, 2026, 3:00 PM',
|
|
182
|
+
},
|
|
169
183
|
};
|
|
170
184
|
|
|
171
185
|
const ALL_TEMPLATE_IDS = Object.keys(TEMPLATE_FIXTURES) as TemplateId[];
|
|
@@ -552,6 +566,114 @@ describe('renderEmail — escrow hold lifecycle', () => {
|
|
|
552
566
|
expect(html).not.toContain('undefined');
|
|
553
567
|
});
|
|
554
568
|
|
|
569
|
+
it('escrow-pin-sign-in includes the sign-in date and the secure-account link', async () => {
|
|
570
|
+
const data = TEMPLATE_FIXTURES['escrow-pin-sign-in'];
|
|
571
|
+
const { html, text, subject } = await renderEmail(
|
|
572
|
+
'escrow-pin-sign-in',
|
|
573
|
+
DEFAULT_BRANDING,
|
|
574
|
+
data
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
expect(html).toContain(data.signedInAt);
|
|
578
|
+
expect(html).toContain(data.secureUrl);
|
|
579
|
+
expect(text).toContain('Not you? Secure your account.');
|
|
580
|
+
expect(text).toContain('Secure my account');
|
|
581
|
+
expect(subject).toBe('New device signed in with your PIN');
|
|
582
|
+
assertNoJargon(html);
|
|
583
|
+
assertNoJargon(text);
|
|
584
|
+
assertNoJargon(subject);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('escrow-pin-sign-in formats the ISO timestamp lca-api sends, per locale', async () => {
|
|
588
|
+
const iso = '2026-11-02T15:04:05.123Z';
|
|
589
|
+
const en = await renderEmail('escrow-pin-sign-in', DEFAULT_BRANDING, {
|
|
590
|
+
signedInAt: iso,
|
|
591
|
+
secureUrl: 'https://learncard.app/account/secure',
|
|
592
|
+
});
|
|
593
|
+
const fr = await renderEmail(
|
|
594
|
+
'escrow-pin-sign-in',
|
|
595
|
+
DEFAULT_BRANDING,
|
|
596
|
+
{ signedInAt: iso, secureUrl: 'https://learncard.app/account/secure' },
|
|
597
|
+
'fr'
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
expect(en.text).not.toContain(iso);
|
|
601
|
+
expect(en.text).toContain('November 2, 2026');
|
|
602
|
+
expect(en.text).toContain('UTC');
|
|
603
|
+
expect(fr.text).toContain('2 novembre 2026');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('escrow-pin-sign-in carries tenant branding', async () => {
|
|
607
|
+
const { html } = await renderEmail('escrow-pin-sign-in', VETPASS_BRANDING, {
|
|
608
|
+
signedInAt: 'October 2, 2026, 3:00 PM',
|
|
609
|
+
secureUrl: 'https://vetpass.app/account/secure',
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
expect(html).toContain('VetPass');
|
|
613
|
+
expect(html).toContain('https://vetpass.app/account/secure');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('account-secured works for an owner who did not do it (no secure link, support first)', async () => {
|
|
617
|
+
const data = TEMPLATE_FIXTURES['account-secured'];
|
|
618
|
+
const { html, text, subject } = await renderEmail(
|
|
619
|
+
'account-secured',
|
|
620
|
+
DEFAULT_BRANDING,
|
|
621
|
+
data
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
expect(html).toContain(data.securedAt);
|
|
625
|
+
expect(subject).toBe('Other devices were signed out of your account');
|
|
626
|
+
// The owner's login may be gone, so the notice must not depend on the in-app action.
|
|
627
|
+
expect(html).not.toContain('/account/secure');
|
|
628
|
+
expect(text).toContain('your own sign-in may have been removed');
|
|
629
|
+
// The revoke reset every recovery method and removed automatic recovery, so the
|
|
630
|
+
// lost-login path is not offered as the way back; support is.
|
|
631
|
+
expect(text).toContain('reset its recovery methods');
|
|
632
|
+
expect(text).not.toContain('I lost access to my school email or login');
|
|
633
|
+
expect(text).toContain(`Contact ${DEFAULT_BRANDING.supportEmail} right away`);
|
|
634
|
+
expect(html).toContain(`href="${DEFAULT_BRANDING.appUrl}"`);
|
|
635
|
+
assertNoJargon(html);
|
|
636
|
+
assertNoJargon(text);
|
|
637
|
+
assertNoJargon(subject);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('account-secured formats the ISO timestamp lca-api sends, per locale', async () => {
|
|
641
|
+
const iso = '2026-11-02T15:04:05.123Z';
|
|
642
|
+
const en = await renderEmail('account-secured', DEFAULT_BRANDING, { securedAt: iso });
|
|
643
|
+
const es = await renderEmail('account-secured', DEFAULT_BRANDING, { securedAt: iso }, 'es');
|
|
644
|
+
|
|
645
|
+
expect(en.text).not.toContain(iso);
|
|
646
|
+
expect(en.text).toContain('November 2, 2026');
|
|
647
|
+
expect(en.text).toContain('UTC');
|
|
648
|
+
expect(es.text).toContain('2 de noviembre de 2026');
|
|
649
|
+
expect(es.subject).not.toBe(en.subject);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('account-secured carries tenant branding and the tenant support address', async () => {
|
|
653
|
+
const { html, text } = await renderEmail('account-secured', VETPASS_BRANDING, {
|
|
654
|
+
securedAt: 'October 2, 2026, 3:00 PM',
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
expect(html).toContain('VetPass');
|
|
658
|
+
expect(text).toContain(`Contact ${VETPASS_BRANDING.supportEmail} right away`);
|
|
659
|
+
expect(html).toContain(`href="${VETPASS_BRANDING.appUrl}"`);
|
|
660
|
+
expect(text).not.toContain(DEFAULT_BRANDING.supportEmail);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('account-secured points to the organization when the tenant has no support address', async () => {
|
|
664
|
+
const { text } = await renderEmail(
|
|
665
|
+
'account-secured',
|
|
666
|
+
{ ...DEFAULT_BRANDING, supportEmail: '' },
|
|
667
|
+
TEMPLATE_FIXTURES['account-secured']
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
expect(text).not.toContain('Contact right away');
|
|
671
|
+
expect(text).toContain(
|
|
672
|
+
`Contact the organization that gave you access to ${DEFAULT_BRANDING.brandName} right away`
|
|
673
|
+
);
|
|
674
|
+
expect(text).not.toContain('I lost access to my school email or login');
|
|
675
|
+
});
|
|
676
|
+
|
|
555
677
|
it('all escrow hold templates include the "This wasn\'t me" cancel CTA when a cancel URL is present', async () => {
|
|
556
678
|
const started = await renderEmail(
|
|
557
679
|
'escrow-hold-started',
|
|
@@ -746,3 +868,35 @@ describe('renderEmail — edge cases', () => {
|
|
|
746
868
|
expect(html).toContain('no-recipient-token');
|
|
747
869
|
});
|
|
748
870
|
});
|
|
871
|
+
|
|
872
|
+
describe('announcement', () => {
|
|
873
|
+
it('omits the button and fallback in HTML and text when no campaign destination exists', async () => {
|
|
874
|
+
const { ctaUrl, ...content } = TEMPLATE_FIXTURES.announcement;
|
|
875
|
+
const data = { ...content, ctaLabel: 'Unique campaign action' };
|
|
876
|
+
const { html, text } = await renderEmail('announcement', VETPASS_BRANDING, data);
|
|
877
|
+
for (const output of [html, text]) {
|
|
878
|
+
expect(output).not.toContain(ctaUrl);
|
|
879
|
+
expect(output).not.toContain(data.ctaLabel);
|
|
880
|
+
expect(output).not.toContain('/m/');
|
|
881
|
+
expect(output).toContain(data.body);
|
|
882
|
+
expect(output).toContain(data.unsubscribeUrl);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('renders escaped authored copy, CTA and unsubscribe in HTML and text', async () => {
|
|
887
|
+
const data = {
|
|
888
|
+
...TEMPLATE_FIXTURES.announcement,
|
|
889
|
+
body: '<script>alert(1)</script>\nSecond line',
|
|
890
|
+
};
|
|
891
|
+
const { html, text, subject } = await renderEmail('announcement', VETPASS_BRANDING, data);
|
|
892
|
+
expect(subject).toBe(data.title);
|
|
893
|
+
expect(html).toContain('VetPass');
|
|
894
|
+
expect(html).not.toContain('<script>alert');
|
|
895
|
+
expect(html).toContain('<script>');
|
|
896
|
+
for (const output of [html, text]) {
|
|
897
|
+
expect(output).toContain(data.ctaUrl);
|
|
898
|
+
expect(output).toContain(data.unsubscribeUrl);
|
|
899
|
+
expect(output).toContain('Second line');
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
});
|
|
@@ -70,8 +70,8 @@ describe('resolveTenantFromRequest', () => {
|
|
|
70
70
|
expect(result.resolvedVia).toBe('origin');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it('resolves vetpass
|
|
74
|
-
const headers: RequestHeaders = { origin: 'https://
|
|
73
|
+
it('resolves an unlisted vetpass subdomain via its parent domain', () => {
|
|
74
|
+
const headers: RequestHeaders = { origin: 'https://preview.vetpass.app' };
|
|
75
75
|
const result = resolveTenantFromRequest(headers);
|
|
76
76
|
|
|
77
77
|
expect(result.id).toBe('vetpass');
|
|
@@ -24,6 +24,7 @@ import { SHARED, htmlLang, htmlDir, interpolate, resolveCatalogLocale } from '..
|
|
|
24
24
|
interface LayoutProps {
|
|
25
25
|
branding: TenantBranding;
|
|
26
26
|
preview: string;
|
|
27
|
+
disclaimer?: string;
|
|
27
28
|
children: React.ReactNode;
|
|
28
29
|
/** Show the brand icon at the top of the card. Defaults to true.
|
|
29
30
|
* Set to false when the template already has an IssuerLogo. */
|
|
@@ -35,12 +36,14 @@ interface LayoutProps {
|
|
|
35
36
|
export const Layout: React.FC<LayoutProps> = ({
|
|
36
37
|
branding,
|
|
37
38
|
preview,
|
|
39
|
+
disclaimer,
|
|
38
40
|
children,
|
|
39
41
|
showHeaderLogo = true,
|
|
40
42
|
locale,
|
|
41
43
|
}) => {
|
|
42
44
|
const chrome = SHARED[resolveCatalogLocale(locale)];
|
|
43
|
-
const disclaimerText =
|
|
45
|
+
const disclaimerText =
|
|
46
|
+
disclaimer ?? interpolate(chrome.footerDisclaimer, { brandName: branding.brandName });
|
|
44
47
|
|
|
45
48
|
return (
|
|
46
49
|
<Html lang={htmlLang(locale)} dir={htmlDir(locale)}>
|
package/src/i18n.ts
CHANGED
|
@@ -108,3 +108,36 @@ export const interpolate = (template: string, params: Record<string, string | un
|
|
|
108
108
|
const value = params[key];
|
|
109
109
|
return value === undefined ? match : value;
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `{ locale }` when set, `{}` otherwise — spread into props whose `locale` is optional
|
|
114
|
+
* so an absent locale is omitted rather than passed as `undefined`
|
|
115
|
+
* (safe under `exactOptionalPropertyTypes`).
|
|
116
|
+
*/
|
|
117
|
+
export const localeProp = (locale: string | null | undefined): { locale?: string } =>
|
|
118
|
+
locale === undefined || locale === null ? {} : { locale };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* lca-api passes ISO timestamps for security notices; show them as a readable date in
|
|
122
|
+
* the recipient's language. The recipient's time zone is unknown, so the time is shown
|
|
123
|
+
* in UTC and labelled as such. Anything that is not an ISO timestamp (previews,
|
|
124
|
+
* pre-formatted text) is shown as is.
|
|
125
|
+
*/
|
|
126
|
+
export const formatNoticeTimestamp = (value: string, locale: NotificationLocale): string => {
|
|
127
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) return value;
|
|
128
|
+
|
|
129
|
+
const date = new Date(value);
|
|
130
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return new Intl.DateTimeFormat(locale, {
|
|
134
|
+
dateStyle: 'long',
|
|
135
|
+
timeStyle: 'short',
|
|
136
|
+
timeZone: 'UTC',
|
|
137
|
+
})
|
|
138
|
+
.format(date)
|
|
139
|
+
.concat(' UTC');
|
|
140
|
+
} catch {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,8 @@ export type {
|
|
|
44
44
|
EscrowHoldReleasedData,
|
|
45
45
|
EscrowHoldCancelledData,
|
|
46
46
|
EscrowPinLockedData,
|
|
47
|
+
EscrowPinSignInData,
|
|
48
|
+
AccountSecuredData,
|
|
47
49
|
} from './render';
|
|
48
50
|
export type { EscrowHoldCancelledReason } from './templates/escrow-hold-cancelled';
|
|
49
51
|
|
|
@@ -69,3 +71,5 @@ export type { ResolvedTenant, RequestHeaders } from './tenant-registry';
|
|
|
69
71
|
// configure `jsx` in their tsconfig. Use renderEmail() / renderSms() instead.
|
|
70
72
|
// For direct component access (testing / preview), import from
|
|
71
73
|
// '@learncard/email-templates/src/templates' explicitly.
|
|
74
|
+
|
|
75
|
+
export type { AnnouncementData } from './render';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account-secured — all preview scenarios stacked.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { AccountSecured } from '../templates/account-secured';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function AccountSecuredScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="Default (LearnCard)" />
|
|
14
|
+
<AccountSecured branding={LEARNCARD_BRANDING} securedAt="October 2, 2026, 3:00 PM" />
|
|
15
|
+
|
|
16
|
+
<ScenarioDivider label="Spanish" />
|
|
17
|
+
<AccountSecured
|
|
18
|
+
branding={LEARNCARD_BRANDING}
|
|
19
|
+
securedAt="2 de octubre de 2026, 3:00 p.m."
|
|
20
|
+
locale="es"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<ScenarioDivider label="French" />
|
|
24
|
+
<AccountSecured
|
|
25
|
+
branding={LEARNCARD_BRANDING}
|
|
26
|
+
securedAt="2 octobre 2026, 15h00"
|
|
27
|
+
locale="fr"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
31
|
+
<AccountSecured
|
|
32
|
+
branding={LEARNCARD_BRANDING}
|
|
33
|
+
securedAt="2 أكتوبر 2026، 3:00 م"
|
|
34
|
+
locale="ar"
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
38
|
+
<AccountSecured branding={VETPASS_BRANDING} securedAt="October 2, 2026, 3:00 PM" />
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escrow-pin-sign-in — all preview scenarios stacked.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { EscrowPinSignIn } from '../templates/escrow-pin-sign-in';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function EscrowPinSignInScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="Default (LearnCard)" />
|
|
14
|
+
<EscrowPinSignIn
|
|
15
|
+
branding={LEARNCARD_BRANDING}
|
|
16
|
+
signedInAt="October 2, 2026, 3:00 PM"
|
|
17
|
+
secureUrl="https://learncard.app/account/secure"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<ScenarioDivider label="Spanish" />
|
|
21
|
+
<EscrowPinSignIn
|
|
22
|
+
branding={LEARNCARD_BRANDING}
|
|
23
|
+
signedInAt="2 de octubre de 2026, 3:00 p.m."
|
|
24
|
+
secureUrl="https://learncard.app/account/secure"
|
|
25
|
+
locale="es"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<ScenarioDivider label="French" />
|
|
29
|
+
<EscrowPinSignIn
|
|
30
|
+
branding={LEARNCARD_BRANDING}
|
|
31
|
+
signedInAt="2 octobre 2026, 15h00"
|
|
32
|
+
secureUrl="https://learncard.app/account/secure"
|
|
33
|
+
locale="fr"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
37
|
+
<EscrowPinSignIn
|
|
38
|
+
branding={LEARNCARD_BRANDING}
|
|
39
|
+
signedInAt="2 أكتوبر 2026، 3:00 م"
|
|
40
|
+
secureUrl="https://learncard.app/account/secure"
|
|
41
|
+
locale="ar"
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
45
|
+
<EscrowPinSignIn
|
|
46
|
+
branding={VETPASS_BRANDING}
|
|
47
|
+
signedInAt="October 2, 2026, 3:00 PM"
|
|
48
|
+
secureUrl="https://vetpass.app/account/secure"
|
|
49
|
+
/>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Announcement, getAnnouncementSubject } from './templates/announcement';
|
|
1
2
|
/**
|
|
2
3
|
* render.ts — the main rendering entry point.
|
|
3
4
|
*
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
getEscrowHoldCancelledSubject,
|
|
54
55
|
EscrowPinLocked,
|
|
55
56
|
getEscrowPinLockedSubject,
|
|
57
|
+
EscrowPinSignIn,
|
|
58
|
+
getEscrowPinSignInSubject,
|
|
59
|
+
AccountSecured,
|
|
60
|
+
getAccountSecuredSubject,
|
|
56
61
|
} from './templates';
|
|
57
62
|
|
|
58
63
|
import type {
|
|
@@ -75,7 +80,10 @@ import type {
|
|
|
75
80
|
EscrowHoldCancelledProps,
|
|
76
81
|
EscrowHoldCancelledReason,
|
|
77
82
|
EscrowPinLockedProps,
|
|
83
|
+
EscrowPinSignInProps,
|
|
84
|
+
AccountSecuredProps,
|
|
78
85
|
} from './templates';
|
|
86
|
+
import { localeProp } from './i18n';
|
|
79
87
|
|
|
80
88
|
// ---------------------------------------------------------------------------
|
|
81
89
|
// Public types
|
|
@@ -94,7 +102,17 @@ export interface RenderedEmail {
|
|
|
94
102
|
* This provides full type safety: `renderEmail('inbox-claim', branding, data)` will
|
|
95
103
|
* require `data` to match `InboxClaimData`.
|
|
96
104
|
*/
|
|
105
|
+
export interface AnnouncementData {
|
|
106
|
+
title: string;
|
|
107
|
+
body: string;
|
|
108
|
+
heroImageUrl?: string;
|
|
109
|
+
ctaLabel: string;
|
|
110
|
+
ctaUrl?: string;
|
|
111
|
+
unsubscribeUrl: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
export interface TemplateDataMap {
|
|
115
|
+
announcement: AnnouncementData;
|
|
98
116
|
// -- Verification code variants ------------------------------------------
|
|
99
117
|
|
|
100
118
|
/** brain-service: contact-methods.ts, embed flow */
|
|
@@ -190,6 +208,12 @@ export interface TemplateDataMap {
|
|
|
190
208
|
|
|
191
209
|
/** lca-api: recovery PIN locked after too many failed attempts */
|
|
192
210
|
'escrow-pin-locked': EscrowPinLockedData;
|
|
211
|
+
|
|
212
|
+
/** lca-api: a new device signed in with the recovery PIN — links to "Secure my account" */
|
|
213
|
+
'escrow-pin-sign-in': EscrowPinSignInData;
|
|
214
|
+
|
|
215
|
+
/** lca-api: "Secure my account" signed out every other device and login */
|
|
216
|
+
'account-secured': AccountSecuredData;
|
|
193
217
|
}
|
|
194
218
|
|
|
195
219
|
export type TemplateId = keyof TemplateDataMap;
|
|
@@ -304,6 +328,15 @@ export interface EscrowPinLockedData {
|
|
|
304
328
|
cancelUrl?: string;
|
|
305
329
|
}
|
|
306
330
|
|
|
331
|
+
export interface EscrowPinSignInData {
|
|
332
|
+
signedInAt: string;
|
|
333
|
+
secureUrl: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface AccountSecuredData {
|
|
337
|
+
securedAt: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
307
340
|
// ---------------------------------------------------------------------------
|
|
308
341
|
// renderEmail()
|
|
309
342
|
// ---------------------------------------------------------------------------
|
|
@@ -501,6 +534,17 @@ function buildElement(
|
|
|
501
534
|
};
|
|
502
535
|
}
|
|
503
536
|
|
|
537
|
+
case 'announcement': {
|
|
538
|
+
const d = data as AnnouncementData;
|
|
539
|
+
return {
|
|
540
|
+
element: React.createElement(Announcement, {
|
|
541
|
+
branding,
|
|
542
|
+
...d,
|
|
543
|
+
...(locale === undefined ? {} : { locale }),
|
|
544
|
+
}),
|
|
545
|
+
subject: getAnnouncementSubject(d),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
504
548
|
case 'credential-updated': {
|
|
505
549
|
const d = data as CredentialUpdatedData;
|
|
506
550
|
const props: CredentialUpdatedProps = { branding, ...d, locale };
|
|
@@ -570,6 +614,35 @@ function buildElement(
|
|
|
570
614
|
};
|
|
571
615
|
}
|
|
572
616
|
|
|
617
|
+
case 'escrow-pin-sign-in': {
|
|
618
|
+
const d = data as EscrowPinSignInData;
|
|
619
|
+
const props: EscrowPinSignInProps = {
|
|
620
|
+
branding,
|
|
621
|
+
signedInAt: d.signedInAt,
|
|
622
|
+
secureUrl: d.secureUrl,
|
|
623
|
+
...localeProp(locale),
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
element: React.createElement(EscrowPinSignIn, props),
|
|
628
|
+
subject: getEscrowPinSignInSubject(branding, locale),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
case 'account-secured': {
|
|
633
|
+
const d = data as AccountSecuredData;
|
|
634
|
+
const props: AccountSecuredProps = {
|
|
635
|
+
branding,
|
|
636
|
+
securedAt: d.securedAt,
|
|
637
|
+
...localeProp(locale),
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
element: React.createElement(AccountSecured, props),
|
|
642
|
+
subject: getAccountSecuredSubject(branding, locale),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
573
646
|
default: {
|
|
574
647
|
const _exhaustive: never = templateId;
|
|
575
648
|
throw new Error(`Unknown template: ${_exhaustive}`);
|