@learncard/email-templates 1.2.0 → 1.2.2
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/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.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-hold-cancelled-scenarios.d.ts +6 -0
- package/dist/previews/escrow-hold-cancelled-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-hold-cancelled-scenarios.js +7 -0
- package/dist/previews/escrow-hold-cancelled-scenarios.js.map +1 -0
- package/dist/previews/escrow-hold-released-scenarios.d.ts +6 -0
- package/dist/previews/escrow-hold-released-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-hold-released-scenarios.js +7 -0
- package/dist/previews/escrow-hold-released-scenarios.js.map +1 -0
- package/dist/previews/escrow-hold-reminder-scenarios.d.ts +6 -0
- package/dist/previews/escrow-hold-reminder-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-hold-reminder-scenarios.js +7 -0
- package/dist/previews/escrow-hold-reminder-scenarios.js.map +1 -0
- package/dist/previews/escrow-hold-started-scenarios.d.ts +6 -0
- package/dist/previews/escrow-hold-started-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-hold-started-scenarios.js +7 -0
- package/dist/previews/escrow-hold-started-scenarios.js.map +1 -0
- package/dist/previews/escrow-pin-locked-scenarios.d.ts +6 -0
- package/dist/previews/escrow-pin-locked-scenarios.d.ts.map +1 -0
- package/dist/previews/escrow-pin-locked-scenarios.js +7 -0
- package/dist/previews/escrow-pin-locked-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 +45 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +67 -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/escrow-hold-cancelled.d.ts +23 -0
- package/dist/templates/escrow-hold-cancelled.d.ts.map +1 -0
- package/dist/templates/escrow-hold-cancelled.js +131 -0
- package/dist/templates/escrow-hold-cancelled.js.map +1 -0
- package/dist/templates/escrow-hold-released.d.ts +21 -0
- package/dist/templates/escrow-hold-released.d.ts.map +1 -0
- package/dist/templates/escrow-hold-released.js +92 -0
- package/dist/templates/escrow-hold-released.js.map +1 -0
- package/dist/templates/escrow-hold-reminder.d.ts +21 -0
- package/dist/templates/escrow-hold-reminder.d.ts.map +1 -0
- package/dist/templates/escrow-hold-reminder.js +92 -0
- package/dist/templates/escrow-hold-reminder.js.map +1 -0
- package/dist/templates/escrow-hold-started.d.ts +25 -0
- package/dist/templates/escrow-hold-started.d.ts.map +1 -0
- package/dist/templates/escrow-hold-started.js +96 -0
- package/dist/templates/escrow-hold-started.js.map +1 -0
- package/dist/templates/escrow-pin-locked.d.ts +24 -0
- package/dist/templates/escrow-pin-locked.d.ts.map +1 -0
- package/dist/templates/escrow-pin-locked.js +97 -0
- package/dist/templates/escrow-pin-locked.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 +21 -28
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +20 -9
- package/dist/templates/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/i18n.test.ts +62 -1
- package/src/__tests__/render.test.ts +306 -0
- package/src/i18n.ts +33 -0
- package/src/index.ts +8 -0
- package/src/previews/account-secured-scenarios.tsx +41 -0
- package/src/previews/escrow-hold-cancelled-scenarios.tsx +79 -0
- package/src/previews/escrow-hold-released-scenarios.tsx +58 -0
- package/src/previews/escrow-hold-reminder-scenarios.tsx +52 -0
- package/src/previews/escrow-hold-started-scenarios.tsx +67 -0
- package/src/previews/escrow-pin-locked-scenarios.tsx +60 -0
- package/src/previews/escrow-pin-sign-in-scenarios.tsx +52 -0
- package/src/render.ts +162 -0
- package/src/templates/account-secured.tsx +236 -0
- package/src/templates/escrow-hold-cancelled.tsx +212 -0
- package/src/templates/escrow-hold-released.tsx +184 -0
- package/src/templates/escrow-hold-reminder.tsx +184 -0
- package/src/templates/escrow-hold-started.tsx +216 -0
- package/src/templates/escrow-pin-locked.tsx +233 -0
- package/src/templates/escrow-pin-sign-in.tsx +197 -0
- package/src/templates/index.ts +100 -25
|
@@ -138,6 +138,41 @@ const TEMPLATE_FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
138
138
|
credential: { name: 'Introduction to Biology' },
|
|
139
139
|
},
|
|
140
140
|
'account-sign-in-changed': {},
|
|
141
|
+
|
|
142
|
+
'escrow-hold-started': {
|
|
143
|
+
requestedAt: 'September 25, 2026, 3:00 PM',
|
|
144
|
+
releaseAfter: 'October 2, 2026, 3:00 PM',
|
|
145
|
+
cancelUrl: 'https://learncard.app/recovery/cancel?token=abc123',
|
|
146
|
+
deviceHint: 'Chrome on Windows',
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
'escrow-hold-reminder': {
|
|
150
|
+
releaseAfter: 'October 2, 2026, 3:00 PM',
|
|
151
|
+
cancelUrl: 'https://learncard.app/recovery/cancel?token=abc123',
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
'escrow-hold-released': {
|
|
155
|
+
completedAt: 'October 2, 2026, 3:00 PM',
|
|
156
|
+
supportUrl: 'https://learncard.app/support',
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
'escrow-hold-cancelled': {
|
|
160
|
+
cancelledAt: 'September 26, 2026, 9:15 AM',
|
|
161
|
+
reason: 'user',
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
'escrow-pin-locked': {
|
|
165
|
+
lockedAt: 'September 25, 2026, 3:00 PM',
|
|
166
|
+
releaseAfter: 'October 2, 2026, 3:00 PM',
|
|
167
|
+
cancelUrl: 'https://learncard.app/recovery/cancel?token=abc123',
|
|
168
|
+
},
|
|
169
|
+
'escrow-pin-sign-in': {
|
|
170
|
+
signedInAt: 'October 2, 2026, 3:00 PM',
|
|
171
|
+
secureUrl: 'https://learncard.app/account/secure',
|
|
172
|
+
},
|
|
173
|
+
'account-secured': {
|
|
174
|
+
securedAt: 'October 2, 2026, 3:00 PM',
|
|
175
|
+
},
|
|
141
176
|
};
|
|
142
177
|
|
|
143
178
|
const ALL_TEMPLATE_IDS = Object.keys(TEMPLATE_FIXTURES) as TemplateId[];
|
|
@@ -386,6 +421,277 @@ describe('renderEmail — credential-updated', () => {
|
|
|
386
421
|
});
|
|
387
422
|
});
|
|
388
423
|
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Escrow hold lifecycle (nitro-escrow-enclave, P5.1) — plain-language recovery
|
|
426
|
+
// notifications. No user-facing jargon: never render "wallet", "DID",
|
|
427
|
+
// "share", "enclave", "escrow", "SSS", or "key derivation".
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
describe('renderEmail — escrow hold lifecycle', () => {
|
|
431
|
+
const JARGON = ['wallet', 'DID', 'share', 'enclave', 'escrow', 'SSS', 'key derivation'];
|
|
432
|
+
|
|
433
|
+
const assertNoJargon = (text: string) => {
|
|
434
|
+
const lower = text.toLowerCase();
|
|
435
|
+
for (const word of JARGON) {
|
|
436
|
+
expect(lower).not.toContain(word.toLowerCase());
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
it('escrow-hold-started includes the cancel URL, both dates, and the device hint', async () => {
|
|
441
|
+
const data = TEMPLATE_FIXTURES['escrow-hold-started'];
|
|
442
|
+
const { html, text, subject } = await renderEmail(
|
|
443
|
+
'escrow-hold-started',
|
|
444
|
+
DEFAULT_BRANDING,
|
|
445
|
+
data
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
expect(html).toContain(data.cancelUrl);
|
|
449
|
+
expect(html).toContain(data.requestedAt);
|
|
450
|
+
expect(html).toContain(data.releaseAfter);
|
|
451
|
+
expect(html).toContain(data.deviceHint);
|
|
452
|
+
expect(subject).toBe('Account recovery started');
|
|
453
|
+
assertNoJargon(html);
|
|
454
|
+
assertNoJargon(text);
|
|
455
|
+
assertNoJargon(subject);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('escrow-hold-started renders without the optional device hint', async () => {
|
|
459
|
+
const { html } = await renderEmail('escrow-hold-started', DEFAULT_BRANDING, {
|
|
460
|
+
requestedAt: 'September 25, 2026, 3:00 PM',
|
|
461
|
+
releaseAfter: 'October 2, 2026, 3:00 PM',
|
|
462
|
+
cancelUrl: 'https://learncard.app/recovery/cancel?token=xyz',
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(html).toContain('https://learncard.app/recovery/cancel?token=xyz');
|
|
466
|
+
expect(html).not.toContain('undefined');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('escrow-hold-reminder includes the cancel URL and the release date', async () => {
|
|
470
|
+
const data = TEMPLATE_FIXTURES['escrow-hold-reminder'];
|
|
471
|
+
const { html, text, subject } = await renderEmail(
|
|
472
|
+
'escrow-hold-reminder',
|
|
473
|
+
DEFAULT_BRANDING,
|
|
474
|
+
data
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
expect(html).toContain(data.cancelUrl);
|
|
478
|
+
expect(html).toContain(data.releaseAfter);
|
|
479
|
+
expect(subject).toBe('Recovery completes tomorrow');
|
|
480
|
+
assertNoJargon(html);
|
|
481
|
+
assertNoJargon(text);
|
|
482
|
+
assertNoJargon(subject);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('escrow-hold-released includes the completion date and the support link when provided', async () => {
|
|
486
|
+
const data = TEMPLATE_FIXTURES['escrow-hold-released'];
|
|
487
|
+
const { html, text, subject } = await renderEmail(
|
|
488
|
+
'escrow-hold-released',
|
|
489
|
+
DEFAULT_BRANDING,
|
|
490
|
+
data
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
expect(html).toContain(data.completedAt);
|
|
494
|
+
expect(html).toContain(data.supportUrl);
|
|
495
|
+
expect(subject).toBe('Your account was recovered');
|
|
496
|
+
assertNoJargon(html);
|
|
497
|
+
assertNoJargon(text);
|
|
498
|
+
assertNoJargon(subject);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('escrow-hold-released renders without the optional support link', async () => {
|
|
502
|
+
const { html } = await renderEmail('escrow-hold-released', DEFAULT_BRANDING, {
|
|
503
|
+
completedAt: 'October 2, 2026, 3:00 PM',
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(html).toContain('October 2, 2026, 3:00 PM');
|
|
507
|
+
expect(html).not.toContain('undefined');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it.each([
|
|
511
|
+
['user', 'cancelled this request'],
|
|
512
|
+
['superseded', 'newer recovery request'],
|
|
513
|
+
['pin-locked', 'incorrect PIN attempts'],
|
|
514
|
+
['release-failed', 'could not be completed'],
|
|
515
|
+
] as const)('escrow-hold-cancelled renders reason "%s"', async (reason, expectedSnippet) => {
|
|
516
|
+
const { html, subject } = await renderEmail('escrow-hold-cancelled', DEFAULT_BRANDING, {
|
|
517
|
+
cancelledAt: 'September 26, 2026, 9:15 AM',
|
|
518
|
+
reason,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(html).toContain('September 26, 2026, 9:15 AM');
|
|
522
|
+
expect(html).toContain(expectedSnippet);
|
|
523
|
+
expect(subject).toBe('Recovery request cancelled');
|
|
524
|
+
assertNoJargon(html);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('escrow-hold-cancelled renders a generic message when reason is omitted', async () => {
|
|
528
|
+
const { html } = await renderEmail('escrow-hold-cancelled', DEFAULT_BRANDING, {
|
|
529
|
+
cancelledAt: 'September 26, 2026, 9:15 AM',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
expect(html).toContain('No further action is needed');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('escrow-pin-locked includes the locked date, release date, and cancel URL when a hold is pending', async () => {
|
|
536
|
+
const data = TEMPLATE_FIXTURES['escrow-pin-locked'];
|
|
537
|
+
const { html, text, subject } = await renderEmail(
|
|
538
|
+
'escrow-pin-locked',
|
|
539
|
+
DEFAULT_BRANDING,
|
|
540
|
+
data
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
expect(html).toContain(data.lockedAt);
|
|
544
|
+
expect(html).toContain(data.releaseAfter);
|
|
545
|
+
expect(html).toContain(data.cancelUrl);
|
|
546
|
+
expect(subject).toBe('Recovery PIN locked');
|
|
547
|
+
assertNoJargon(html);
|
|
548
|
+
assertNoJargon(text);
|
|
549
|
+
assertNoJargon(subject);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('escrow-pin-locked renders generic fallback copy with no button when no hold is pending', async () => {
|
|
553
|
+
const { html } = await renderEmail('escrow-pin-locked', DEFAULT_BRANDING, {
|
|
554
|
+
lockedAt: 'September 25, 2026, 3:00 PM',
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(html).toContain('September 25, 2026, 3:00 PM');
|
|
558
|
+
expect(html).toContain('waiting-period recovery is still available');
|
|
559
|
+
expect(html).not.toContain('undefined');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('escrow-pin-sign-in includes the sign-in date and the secure-account link', async () => {
|
|
563
|
+
const data = TEMPLATE_FIXTURES['escrow-pin-sign-in'];
|
|
564
|
+
const { html, text, subject } = await renderEmail(
|
|
565
|
+
'escrow-pin-sign-in',
|
|
566
|
+
DEFAULT_BRANDING,
|
|
567
|
+
data
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
expect(html).toContain(data.signedInAt);
|
|
571
|
+
expect(html).toContain(data.secureUrl);
|
|
572
|
+
expect(text).toContain('Not you? Secure your account.');
|
|
573
|
+
expect(text).toContain('Secure my account');
|
|
574
|
+
expect(subject).toBe('New device signed in with your PIN');
|
|
575
|
+
assertNoJargon(html);
|
|
576
|
+
assertNoJargon(text);
|
|
577
|
+
assertNoJargon(subject);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('escrow-pin-sign-in formats the ISO timestamp lca-api sends, per locale', async () => {
|
|
581
|
+
const iso = '2026-11-02T15:04:05.123Z';
|
|
582
|
+
const en = await renderEmail('escrow-pin-sign-in', DEFAULT_BRANDING, {
|
|
583
|
+
signedInAt: iso,
|
|
584
|
+
secureUrl: 'https://learncard.app/account/secure',
|
|
585
|
+
});
|
|
586
|
+
const fr = await renderEmail(
|
|
587
|
+
'escrow-pin-sign-in',
|
|
588
|
+
DEFAULT_BRANDING,
|
|
589
|
+
{ signedInAt: iso, secureUrl: 'https://learncard.app/account/secure' },
|
|
590
|
+
'fr'
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(en.text).not.toContain(iso);
|
|
594
|
+
expect(en.text).toContain('November 2, 2026');
|
|
595
|
+
expect(en.text).toContain('UTC');
|
|
596
|
+
expect(fr.text).toContain('2 novembre 2026');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('escrow-pin-sign-in carries tenant branding', async () => {
|
|
600
|
+
const { html } = await renderEmail('escrow-pin-sign-in', VETPASS_BRANDING, {
|
|
601
|
+
signedInAt: 'October 2, 2026, 3:00 PM',
|
|
602
|
+
secureUrl: 'https://vetpass.app/account/secure',
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(html).toContain('VetPass');
|
|
606
|
+
expect(html).toContain('https://vetpass.app/account/secure');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('account-secured works for an owner who did not do it (no secure link, support first)', async () => {
|
|
610
|
+
const data = TEMPLATE_FIXTURES['account-secured'];
|
|
611
|
+
const { html, text, subject } = await renderEmail(
|
|
612
|
+
'account-secured',
|
|
613
|
+
DEFAULT_BRANDING,
|
|
614
|
+
data
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
expect(html).toContain(data.securedAt);
|
|
618
|
+
expect(subject).toBe('Other devices were signed out of your account');
|
|
619
|
+
// The owner's login may be gone, so the notice must not depend on the in-app action.
|
|
620
|
+
expect(html).not.toContain('/account/secure');
|
|
621
|
+
expect(text).toContain('your own sign-in may have been removed');
|
|
622
|
+
// The revoke reset every recovery method and removed automatic recovery, so the
|
|
623
|
+
// lost-login path is not offered as the way back; support is.
|
|
624
|
+
expect(text).toContain('reset its recovery methods');
|
|
625
|
+
expect(text).not.toContain('I lost access to my school email or login');
|
|
626
|
+
expect(text).toContain(`Contact ${DEFAULT_BRANDING.supportEmail} right away`);
|
|
627
|
+
expect(html).toContain(`href="${DEFAULT_BRANDING.appUrl}"`);
|
|
628
|
+
assertNoJargon(html);
|
|
629
|
+
assertNoJargon(text);
|
|
630
|
+
assertNoJargon(subject);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('account-secured formats the ISO timestamp lca-api sends, per locale', async () => {
|
|
634
|
+
const iso = '2026-11-02T15:04:05.123Z';
|
|
635
|
+
const en = await renderEmail('account-secured', DEFAULT_BRANDING, { securedAt: iso });
|
|
636
|
+
const es = await renderEmail('account-secured', DEFAULT_BRANDING, { securedAt: iso }, 'es');
|
|
637
|
+
|
|
638
|
+
expect(en.text).not.toContain(iso);
|
|
639
|
+
expect(en.text).toContain('November 2, 2026');
|
|
640
|
+
expect(en.text).toContain('UTC');
|
|
641
|
+
expect(es.text).toContain('2 de noviembre de 2026');
|
|
642
|
+
expect(es.subject).not.toBe(en.subject);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('account-secured carries tenant branding and the tenant support address', async () => {
|
|
646
|
+
const { html, text } = await renderEmail('account-secured', VETPASS_BRANDING, {
|
|
647
|
+
securedAt: 'October 2, 2026, 3:00 PM',
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
expect(html).toContain('VetPass');
|
|
651
|
+
expect(text).toContain(`Contact ${VETPASS_BRANDING.supportEmail} right away`);
|
|
652
|
+
expect(html).toContain(`href="${VETPASS_BRANDING.appUrl}"`);
|
|
653
|
+
expect(text).not.toContain(DEFAULT_BRANDING.supportEmail);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('account-secured points to the organization when the tenant has no support address', async () => {
|
|
657
|
+
const { text } = await renderEmail(
|
|
658
|
+
'account-secured',
|
|
659
|
+
{ ...DEFAULT_BRANDING, supportEmail: '' },
|
|
660
|
+
TEMPLATE_FIXTURES['account-secured']
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
expect(text).not.toContain('Contact right away');
|
|
664
|
+
expect(text).toContain(
|
|
665
|
+
`Contact the organization that gave you access to ${DEFAULT_BRANDING.brandName} right away`
|
|
666
|
+
);
|
|
667
|
+
expect(text).not.toContain('I lost access to my school email or login');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('all escrow hold templates include the "This wasn\'t me" cancel CTA when a cancel URL is present', async () => {
|
|
671
|
+
const started = await renderEmail(
|
|
672
|
+
'escrow-hold-started',
|
|
673
|
+
DEFAULT_BRANDING,
|
|
674
|
+
TEMPLATE_FIXTURES['escrow-hold-started']
|
|
675
|
+
);
|
|
676
|
+
const reminder = await renderEmail(
|
|
677
|
+
'escrow-hold-reminder',
|
|
678
|
+
DEFAULT_BRANDING,
|
|
679
|
+
TEMPLATE_FIXTURES['escrow-hold-reminder']
|
|
680
|
+
);
|
|
681
|
+
const pinLocked = await renderEmail(
|
|
682
|
+
'escrow-pin-locked',
|
|
683
|
+
DEFAULT_BRANDING,
|
|
684
|
+
TEMPLATE_FIXTURES['escrow-pin-locked']
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Plain text output has no HTML entity escaping, so the apostrophe is literal.
|
|
688
|
+
for (const { text } of [started, reminder, pinLocked]) {
|
|
689
|
+
expect(text).toContain("This wasn't me");
|
|
690
|
+
expect(text).toContain('Cancel recovery');
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
389
695
|
// ---------------------------------------------------------------------------
|
|
390
696
|
// Branding tests — custom branding flows through to rendered output
|
|
391
697
|
// ---------------------------------------------------------------------------
|
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
|
@@ -39,7 +39,15 @@ export type {
|
|
|
39
39
|
GuardianRejectedCredentialData,
|
|
40
40
|
CredentialUpdatedData,
|
|
41
41
|
AccountSignInChangedData,
|
|
42
|
+
EscrowHoldStartedData,
|
|
43
|
+
EscrowHoldReminderData,
|
|
44
|
+
EscrowHoldReleasedData,
|
|
45
|
+
EscrowHoldCancelledData,
|
|
46
|
+
EscrowPinLockedData,
|
|
47
|
+
EscrowPinSignInData,
|
|
48
|
+
AccountSecuredData,
|
|
42
49
|
} from './render';
|
|
50
|
+
export type { EscrowHoldCancelledReason } from './templates/escrow-hold-cancelled';
|
|
43
51
|
|
|
44
52
|
// SMS rendering
|
|
45
53
|
export { renderSms } from './sms';
|
|
@@ -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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escrow-hold-cancelled — all preview scenarios stacked (one per reason).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { EscrowHoldCancelled } from '../templates/escrow-hold-cancelled';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function EscrowHoldCancelledScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="Reason: user" />
|
|
14
|
+
<EscrowHoldCancelled
|
|
15
|
+
branding={LEARNCARD_BRANDING}
|
|
16
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
17
|
+
reason="user"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<ScenarioDivider label="Reason: superseded" />
|
|
21
|
+
<EscrowHoldCancelled
|
|
22
|
+
branding={LEARNCARD_BRANDING}
|
|
23
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
24
|
+
reason="superseded"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<ScenarioDivider label="Reason: pin-locked" />
|
|
28
|
+
<EscrowHoldCancelled
|
|
29
|
+
branding={LEARNCARD_BRANDING}
|
|
30
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
31
|
+
reason="pin-locked"
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<ScenarioDivider label="Reason: release-failed" />
|
|
35
|
+
<EscrowHoldCancelled
|
|
36
|
+
branding={LEARNCARD_BRANDING}
|
|
37
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
38
|
+
reason="release-failed"
|
|
39
|
+
/>
|
|
40
|
+
|
|
41
|
+
<ScenarioDivider label="No reason (generic message)" />
|
|
42
|
+
<EscrowHoldCancelled
|
|
43
|
+
branding={LEARNCARD_BRANDING}
|
|
44
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<ScenarioDivider label="Spanish" />
|
|
48
|
+
<EscrowHoldCancelled
|
|
49
|
+
branding={LEARNCARD_BRANDING}
|
|
50
|
+
cancelledAt="26 de septiembre de 2026, 9:15 a.m."
|
|
51
|
+
reason="user"
|
|
52
|
+
locale="es"
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
<ScenarioDivider label="French" />
|
|
56
|
+
<EscrowHoldCancelled
|
|
57
|
+
branding={LEARNCARD_BRANDING}
|
|
58
|
+
cancelledAt="26 septembre 2026, 9h15"
|
|
59
|
+
reason="user"
|
|
60
|
+
locale="fr"
|
|
61
|
+
/>
|
|
62
|
+
|
|
63
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
64
|
+
<EscrowHoldCancelled
|
|
65
|
+
branding={LEARNCARD_BRANDING}
|
|
66
|
+
cancelledAt="26 سبتمبر 2026، 9:15 ص"
|
|
67
|
+
reason="user"
|
|
68
|
+
locale="ar"
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
72
|
+
<EscrowHoldCancelled
|
|
73
|
+
branding={VETPASS_BRANDING}
|
|
74
|
+
cancelledAt="September 26, 2026, 9:15 AM"
|
|
75
|
+
reason="user"
|
|
76
|
+
/>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escrow-hold-released — all preview scenarios stacked.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { EscrowHoldReleased } from '../templates/escrow-hold-released';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function EscrowHoldReleasedScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="With support link" />
|
|
14
|
+
<EscrowHoldReleased
|
|
15
|
+
branding={LEARNCARD_BRANDING}
|
|
16
|
+
completedAt="October 2, 2026, 3:00 PM"
|
|
17
|
+
supportUrl="https://learncard.app/support"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<ScenarioDivider label="No support link (footer contact only)" />
|
|
21
|
+
<EscrowHoldReleased
|
|
22
|
+
branding={LEARNCARD_BRANDING}
|
|
23
|
+
completedAt="October 2, 2026, 3:00 PM"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<ScenarioDivider label="Spanish" />
|
|
27
|
+
<EscrowHoldReleased
|
|
28
|
+
branding={LEARNCARD_BRANDING}
|
|
29
|
+
completedAt="2 de octubre de 2026, 3:00 p.m."
|
|
30
|
+
supportUrl="https://learncard.app/support"
|
|
31
|
+
locale="es"
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<ScenarioDivider label="French" />
|
|
35
|
+
<EscrowHoldReleased
|
|
36
|
+
branding={LEARNCARD_BRANDING}
|
|
37
|
+
completedAt="2 octobre 2026, 15h00"
|
|
38
|
+
supportUrl="https://learncard.app/support"
|
|
39
|
+
locale="fr"
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
43
|
+
<EscrowHoldReleased
|
|
44
|
+
branding={LEARNCARD_BRANDING}
|
|
45
|
+
completedAt="2 أكتوبر 2026، 3:00 م"
|
|
46
|
+
supportUrl="https://learncard.app/support"
|
|
47
|
+
locale="ar"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
51
|
+
<EscrowHoldReleased
|
|
52
|
+
branding={VETPASS_BRANDING}
|
|
53
|
+
completedAt="October 2, 2026, 3:00 PM"
|
|
54
|
+
supportUrl="https://vetpass.app/support"
|
|
55
|
+
/>
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escrow-hold-reminder — all preview scenarios stacked.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { EscrowHoldReminder } from '../templates/escrow-hold-reminder';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function EscrowHoldReminderScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="Default — LearnCard branding" />
|
|
14
|
+
<EscrowHoldReminder
|
|
15
|
+
branding={LEARNCARD_BRANDING}
|
|
16
|
+
releaseAfter="October 2, 2026, 3:00 PM"
|
|
17
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<ScenarioDivider label="Spanish" />
|
|
21
|
+
<EscrowHoldReminder
|
|
22
|
+
branding={LEARNCARD_BRANDING}
|
|
23
|
+
releaseAfter="2 de octubre de 2026, 3:00 p.m."
|
|
24
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
25
|
+
locale="es"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<ScenarioDivider label="French" />
|
|
29
|
+
<EscrowHoldReminder
|
|
30
|
+
branding={LEARNCARD_BRANDING}
|
|
31
|
+
releaseAfter="2 octobre 2026, 15h00"
|
|
32
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
33
|
+
locale="fr"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
37
|
+
<EscrowHoldReminder
|
|
38
|
+
branding={LEARNCARD_BRANDING}
|
|
39
|
+
releaseAfter="2 أكتوبر 2026، 3:00 م"
|
|
40
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
41
|
+
locale="ar"
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
45
|
+
<EscrowHoldReminder
|
|
46
|
+
branding={VETPASS_BRANDING}
|
|
47
|
+
releaseAfter="October 2, 2026, 3:00 PM"
|
|
48
|
+
cancelUrl="https://vetpass.app/recovery/cancel?token=abc123"
|
|
49
|
+
/>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escrow-hold-started — all preview scenarios stacked.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { EscrowHoldStarted } from '../templates/escrow-hold-started';
|
|
8
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
9
|
+
|
|
10
|
+
export default function EscrowHoldStartedScenarios() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<ScenarioDivider label="Default — with device hint" />
|
|
14
|
+
<EscrowHoldStarted
|
|
15
|
+
branding={LEARNCARD_BRANDING}
|
|
16
|
+
requestedAt="September 25, 2026, 3:00 PM"
|
|
17
|
+
releaseAfter="October 2, 2026, 3:00 PM"
|
|
18
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
19
|
+
deviceHint="Chrome on Windows"
|
|
20
|
+
/>
|
|
21
|
+
|
|
22
|
+
<ScenarioDivider label="No device hint" />
|
|
23
|
+
<EscrowHoldStarted
|
|
24
|
+
branding={LEARNCARD_BRANDING}
|
|
25
|
+
requestedAt="September 25, 2026, 3:00 PM"
|
|
26
|
+
releaseAfter="October 2, 2026, 3:00 PM"
|
|
27
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<ScenarioDivider label="Spanish" />
|
|
31
|
+
<EscrowHoldStarted
|
|
32
|
+
branding={LEARNCARD_BRANDING}
|
|
33
|
+
requestedAt="25 de septiembre de 2026, 3:00 p.m."
|
|
34
|
+
releaseAfter="2 de octubre de 2026, 3:00 p.m."
|
|
35
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
36
|
+
locale="es"
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
<ScenarioDivider label="French" />
|
|
40
|
+
<EscrowHoldStarted
|
|
41
|
+
branding={LEARNCARD_BRANDING}
|
|
42
|
+
requestedAt="25 septembre 2026, 15h00"
|
|
43
|
+
releaseAfter="2 octobre 2026, 15h00"
|
|
44
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
45
|
+
locale="fr"
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
49
|
+
<EscrowHoldStarted
|
|
50
|
+
branding={LEARNCARD_BRANDING}
|
|
51
|
+
requestedAt="25 سبتمبر 2026، 3:00 م"
|
|
52
|
+
releaseAfter="2 أكتوبر 2026، 3:00 م"
|
|
53
|
+
cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
|
|
54
|
+
locale="ar"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<ScenarioDivider label="VetPass tenant branding" />
|
|
58
|
+
<EscrowHoldStarted
|
|
59
|
+
branding={VETPASS_BRANDING}
|
|
60
|
+
requestedAt="September 25, 2026, 3:00 PM"
|
|
61
|
+
releaseAfter="October 2, 2026, 3:00 PM"
|
|
62
|
+
cancelUrl="https://vetpass.app/recovery/cancel?token=abc123"
|
|
63
|
+
deviceHint="Safari on iPhone"
|
|
64
|
+
/>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|