@learncard/email-templates 1.2.0 → 1.2.1

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 (67) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/previews/escrow-hold-cancelled-scenarios.d.ts +6 -0
  5. package/dist/previews/escrow-hold-cancelled-scenarios.d.ts.map +1 -0
  6. package/dist/previews/escrow-hold-cancelled-scenarios.js +7 -0
  7. package/dist/previews/escrow-hold-cancelled-scenarios.js.map +1 -0
  8. package/dist/previews/escrow-hold-released-scenarios.d.ts +6 -0
  9. package/dist/previews/escrow-hold-released-scenarios.d.ts.map +1 -0
  10. package/dist/previews/escrow-hold-released-scenarios.js +7 -0
  11. package/dist/previews/escrow-hold-released-scenarios.js.map +1 -0
  12. package/dist/previews/escrow-hold-reminder-scenarios.d.ts +6 -0
  13. package/dist/previews/escrow-hold-reminder-scenarios.d.ts.map +1 -0
  14. package/dist/previews/escrow-hold-reminder-scenarios.js +7 -0
  15. package/dist/previews/escrow-hold-reminder-scenarios.js.map +1 -0
  16. package/dist/previews/escrow-hold-started-scenarios.d.ts +6 -0
  17. package/dist/previews/escrow-hold-started-scenarios.d.ts.map +1 -0
  18. package/dist/previews/escrow-hold-started-scenarios.js +7 -0
  19. package/dist/previews/escrow-hold-started-scenarios.js.map +1 -0
  20. package/dist/previews/escrow-pin-locked-scenarios.d.ts +6 -0
  21. package/dist/previews/escrow-pin-locked-scenarios.d.ts.map +1 -0
  22. package/dist/previews/escrow-pin-locked-scenarios.js +7 -0
  23. package/dist/previews/escrow-pin-locked-scenarios.js.map +1 -0
  24. package/dist/render.d.ts +34 -0
  25. package/dist/render.d.ts.map +1 -1
  26. package/dist/render.js +41 -1
  27. package/dist/render.js.map +1 -1
  28. package/dist/templates/escrow-hold-cancelled.d.ts +23 -0
  29. package/dist/templates/escrow-hold-cancelled.d.ts.map +1 -0
  30. package/dist/templates/escrow-hold-cancelled.js +131 -0
  31. package/dist/templates/escrow-hold-cancelled.js.map +1 -0
  32. package/dist/templates/escrow-hold-released.d.ts +21 -0
  33. package/dist/templates/escrow-hold-released.d.ts.map +1 -0
  34. package/dist/templates/escrow-hold-released.js +92 -0
  35. package/dist/templates/escrow-hold-released.js.map +1 -0
  36. package/dist/templates/escrow-hold-reminder.d.ts +21 -0
  37. package/dist/templates/escrow-hold-reminder.d.ts.map +1 -0
  38. package/dist/templates/escrow-hold-reminder.js +92 -0
  39. package/dist/templates/escrow-hold-reminder.js.map +1 -0
  40. package/dist/templates/escrow-hold-started.d.ts +25 -0
  41. package/dist/templates/escrow-hold-started.d.ts.map +1 -0
  42. package/dist/templates/escrow-hold-started.js +96 -0
  43. package/dist/templates/escrow-hold-started.js.map +1 -0
  44. package/dist/templates/escrow-pin-locked.d.ts +24 -0
  45. package/dist/templates/escrow-pin-locked.d.ts.map +1 -0
  46. package/dist/templates/escrow-pin-locked.js +97 -0
  47. package/dist/templates/escrow-pin-locked.js.map +1 -0
  48. package/dist/templates/index.d.ts +10 -0
  49. package/dist/templates/index.d.ts.map +1 -1
  50. package/dist/templates/index.js +5 -0
  51. package/dist/templates/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/i18n.test.ts +30 -0
  54. package/src/__tests__/render.test.ts +191 -0
  55. package/src/index.ts +6 -0
  56. package/src/previews/escrow-hold-cancelled-scenarios.tsx +79 -0
  57. package/src/previews/escrow-hold-released-scenarios.tsx +58 -0
  58. package/src/previews/escrow-hold-reminder-scenarios.tsx +52 -0
  59. package/src/previews/escrow-hold-started-scenarios.tsx +67 -0
  60. package/src/previews/escrow-pin-locked-scenarios.tsx +60 -0
  61. package/src/render.ts +111 -0
  62. package/src/templates/escrow-hold-cancelled.tsx +212 -0
  63. package/src/templates/escrow-hold-released.tsx +184 -0
  64. package/src/templates/escrow-hold-reminder.tsx +184 -0
  65. package/src/templates/escrow-hold-started.tsx +216 -0
  66. package/src/templates/escrow-pin-locked.tsx +233 -0
  67. package/src/templates/index.ts +15 -0
@@ -138,6 +138,34 @@ 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
+ },
141
169
  };
142
170
 
143
171
  const ALL_TEMPLATE_IDS = Object.keys(TEMPLATE_FIXTURES) as TemplateId[];
@@ -386,6 +414,169 @@ describe('renderEmail — credential-updated', () => {
386
414
  });
387
415
  });
388
416
 
417
+ // ---------------------------------------------------------------------------
418
+ // Escrow hold lifecycle (nitro-escrow-enclave, P5.1) — plain-language recovery
419
+ // notifications. No user-facing jargon: never render "wallet", "DID",
420
+ // "share", "enclave", "escrow", "SSS", or "key derivation".
421
+ // ---------------------------------------------------------------------------
422
+
423
+ describe('renderEmail — escrow hold lifecycle', () => {
424
+ const JARGON = ['wallet', 'DID', 'share', 'enclave', 'escrow', 'SSS', 'key derivation'];
425
+
426
+ const assertNoJargon = (text: string) => {
427
+ const lower = text.toLowerCase();
428
+ for (const word of JARGON) {
429
+ expect(lower).not.toContain(word.toLowerCase());
430
+ }
431
+ };
432
+
433
+ it('escrow-hold-started includes the cancel URL, both dates, and the device hint', async () => {
434
+ const data = TEMPLATE_FIXTURES['escrow-hold-started'];
435
+ const { html, text, subject } = await renderEmail(
436
+ 'escrow-hold-started',
437
+ DEFAULT_BRANDING,
438
+ data
439
+ );
440
+
441
+ expect(html).toContain(data.cancelUrl);
442
+ expect(html).toContain(data.requestedAt);
443
+ expect(html).toContain(data.releaseAfter);
444
+ expect(html).toContain(data.deviceHint);
445
+ expect(subject).toBe('Account recovery started');
446
+ assertNoJargon(html);
447
+ assertNoJargon(text);
448
+ assertNoJargon(subject);
449
+ });
450
+
451
+ it('escrow-hold-started renders without the optional device hint', async () => {
452
+ const { html } = await renderEmail('escrow-hold-started', DEFAULT_BRANDING, {
453
+ requestedAt: 'September 25, 2026, 3:00 PM',
454
+ releaseAfter: 'October 2, 2026, 3:00 PM',
455
+ cancelUrl: 'https://learncard.app/recovery/cancel?token=xyz',
456
+ });
457
+
458
+ expect(html).toContain('https://learncard.app/recovery/cancel?token=xyz');
459
+ expect(html).not.toContain('undefined');
460
+ });
461
+
462
+ it('escrow-hold-reminder includes the cancel URL and the release date', async () => {
463
+ const data = TEMPLATE_FIXTURES['escrow-hold-reminder'];
464
+ const { html, text, subject } = await renderEmail(
465
+ 'escrow-hold-reminder',
466
+ DEFAULT_BRANDING,
467
+ data
468
+ );
469
+
470
+ expect(html).toContain(data.cancelUrl);
471
+ expect(html).toContain(data.releaseAfter);
472
+ expect(subject).toBe('Recovery completes tomorrow');
473
+ assertNoJargon(html);
474
+ assertNoJargon(text);
475
+ assertNoJargon(subject);
476
+ });
477
+
478
+ it('escrow-hold-released includes the completion date and the support link when provided', async () => {
479
+ const data = TEMPLATE_FIXTURES['escrow-hold-released'];
480
+ const { html, text, subject } = await renderEmail(
481
+ 'escrow-hold-released',
482
+ DEFAULT_BRANDING,
483
+ data
484
+ );
485
+
486
+ expect(html).toContain(data.completedAt);
487
+ expect(html).toContain(data.supportUrl);
488
+ expect(subject).toBe('Your account was recovered');
489
+ assertNoJargon(html);
490
+ assertNoJargon(text);
491
+ assertNoJargon(subject);
492
+ });
493
+
494
+ it('escrow-hold-released renders without the optional support link', async () => {
495
+ const { html } = await renderEmail('escrow-hold-released', DEFAULT_BRANDING, {
496
+ completedAt: 'October 2, 2026, 3:00 PM',
497
+ });
498
+
499
+ expect(html).toContain('October 2, 2026, 3:00 PM');
500
+ expect(html).not.toContain('undefined');
501
+ });
502
+
503
+ it.each([
504
+ ['user', 'cancelled this request'],
505
+ ['superseded', 'newer recovery request'],
506
+ ['pin-locked', 'incorrect PIN attempts'],
507
+ ['release-failed', 'could not be completed'],
508
+ ] as const)('escrow-hold-cancelled renders reason "%s"', async (reason, expectedSnippet) => {
509
+ const { html, subject } = await renderEmail('escrow-hold-cancelled', DEFAULT_BRANDING, {
510
+ cancelledAt: 'September 26, 2026, 9:15 AM',
511
+ reason,
512
+ });
513
+
514
+ expect(html).toContain('September 26, 2026, 9:15 AM');
515
+ expect(html).toContain(expectedSnippet);
516
+ expect(subject).toBe('Recovery request cancelled');
517
+ assertNoJargon(html);
518
+ });
519
+
520
+ it('escrow-hold-cancelled renders a generic message when reason is omitted', async () => {
521
+ const { html } = await renderEmail('escrow-hold-cancelled', DEFAULT_BRANDING, {
522
+ cancelledAt: 'September 26, 2026, 9:15 AM',
523
+ });
524
+
525
+ expect(html).toContain('No further action is needed');
526
+ });
527
+
528
+ it('escrow-pin-locked includes the locked date, release date, and cancel URL when a hold is pending', async () => {
529
+ const data = TEMPLATE_FIXTURES['escrow-pin-locked'];
530
+ const { html, text, subject } = await renderEmail(
531
+ 'escrow-pin-locked',
532
+ DEFAULT_BRANDING,
533
+ data
534
+ );
535
+
536
+ expect(html).toContain(data.lockedAt);
537
+ expect(html).toContain(data.releaseAfter);
538
+ expect(html).toContain(data.cancelUrl);
539
+ expect(subject).toBe('Recovery PIN locked');
540
+ assertNoJargon(html);
541
+ assertNoJargon(text);
542
+ assertNoJargon(subject);
543
+ });
544
+
545
+ it('escrow-pin-locked renders generic fallback copy with no button when no hold is pending', async () => {
546
+ const { html } = await renderEmail('escrow-pin-locked', DEFAULT_BRANDING, {
547
+ lockedAt: 'September 25, 2026, 3:00 PM',
548
+ });
549
+
550
+ expect(html).toContain('September 25, 2026, 3:00 PM');
551
+ expect(html).toContain('waiting-period recovery is still available');
552
+ expect(html).not.toContain('undefined');
553
+ });
554
+
555
+ it('all escrow hold templates include the "This wasn\'t me" cancel CTA when a cancel URL is present', async () => {
556
+ const started = await renderEmail(
557
+ 'escrow-hold-started',
558
+ DEFAULT_BRANDING,
559
+ TEMPLATE_FIXTURES['escrow-hold-started']
560
+ );
561
+ const reminder = await renderEmail(
562
+ 'escrow-hold-reminder',
563
+ DEFAULT_BRANDING,
564
+ TEMPLATE_FIXTURES['escrow-hold-reminder']
565
+ );
566
+ const pinLocked = await renderEmail(
567
+ 'escrow-pin-locked',
568
+ DEFAULT_BRANDING,
569
+ TEMPLATE_FIXTURES['escrow-pin-locked']
570
+ );
571
+
572
+ // Plain text output has no HTML entity escaping, so the apostrophe is literal.
573
+ for (const { text } of [started, reminder, pinLocked]) {
574
+ expect(text).toContain("This wasn't me");
575
+ expect(text).toContain('Cancel recovery');
576
+ }
577
+ });
578
+ });
579
+
389
580
  // ---------------------------------------------------------------------------
390
581
  // Branding tests — custom branding flows through to rendered output
391
582
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -39,7 +39,13 @@ export type {
39
39
  GuardianRejectedCredentialData,
40
40
  CredentialUpdatedData,
41
41
  AccountSignInChangedData,
42
+ EscrowHoldStartedData,
43
+ EscrowHoldReminderData,
44
+ EscrowHoldReleasedData,
45
+ EscrowHoldCancelledData,
46
+ EscrowPinLockedData,
42
47
  } from './render';
48
+ export type { EscrowHoldCancelledReason } from './templates/escrow-hold-cancelled';
43
49
 
44
50
  // SMS rendering
45
51
  export { renderSms } from './sms';
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * escrow-pin-locked — all preview scenarios stacked.
3
+ */
4
+
5
+ import * as React from 'react';
6
+
7
+ import { EscrowPinLocked } from '../templates/escrow-pin-locked';
8
+ import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
9
+
10
+ export default function EscrowPinLockedScenarios() {
11
+ return (
12
+ <>
13
+ <ScenarioDivider label="With pending waiting-period recovery (cancel link)" />
14
+ <EscrowPinLocked
15
+ branding={LEARNCARD_BRANDING}
16
+ lockedAt="September 25, 2026, 3:00 PM"
17
+ releaseAfter="October 2, 2026, 3:00 PM"
18
+ cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
19
+ />
20
+
21
+ <ScenarioDivider label="No pending recovery" />
22
+ <EscrowPinLocked branding={LEARNCARD_BRANDING} lockedAt="September 25, 2026, 3:00 PM" />
23
+
24
+ <ScenarioDivider label="Spanish" />
25
+ <EscrowPinLocked
26
+ branding={LEARNCARD_BRANDING}
27
+ lockedAt="25 de septiembre de 2026, 3:00 p.m."
28
+ releaseAfter="2 de octubre de 2026, 3:00 p.m."
29
+ cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
30
+ locale="es"
31
+ />
32
+
33
+ <ScenarioDivider label="French" />
34
+ <EscrowPinLocked
35
+ branding={LEARNCARD_BRANDING}
36
+ lockedAt="25 septembre 2026, 15h00"
37
+ releaseAfter="2 octobre 2026, 15h00"
38
+ cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
39
+ locale="fr"
40
+ />
41
+
42
+ <ScenarioDivider label="Arabic (RTL)" />
43
+ <EscrowPinLocked
44
+ branding={LEARNCARD_BRANDING}
45
+ lockedAt="25 سبتمبر 2026، 3:00 م"
46
+ releaseAfter="2 أكتوبر 2026، 3:00 م"
47
+ cancelUrl="https://learncard.app/recovery/cancel?token=abc123"
48
+ locale="ar"
49
+ />
50
+
51
+ <ScenarioDivider label="VetPass tenant branding" />
52
+ <EscrowPinLocked
53
+ branding={VETPASS_BRANDING}
54
+ lockedAt="September 25, 2026, 3:00 PM"
55
+ releaseAfter="October 2, 2026, 3:00 PM"
56
+ cancelUrl="https://vetpass.app/recovery/cancel?token=abc123"
57
+ />
58
+ </>
59
+ );
60
+ }
package/src/render.ts CHANGED
@@ -43,6 +43,16 @@ import {
43
43
  getCredentialUpdatedSubject,
44
44
  AccountSignInChanged,
45
45
  getAccountSignInChangedSubject,
46
+ EscrowHoldStarted,
47
+ getEscrowHoldStartedSubject,
48
+ EscrowHoldReminder,
49
+ getEscrowHoldReminderSubject,
50
+ EscrowHoldReleased,
51
+ getEscrowHoldReleasedSubject,
52
+ EscrowHoldCancelled,
53
+ getEscrowHoldCancelledSubject,
54
+ EscrowPinLocked,
55
+ getEscrowPinLockedSubject,
46
56
  } from './templates';
47
57
 
48
58
  import type {
@@ -59,6 +69,12 @@ import type {
59
69
  EmailVerificationProps,
60
70
  CredentialUpdatedProps,
61
71
  AccountSignInChangedProps,
72
+ EscrowHoldStartedProps,
73
+ EscrowHoldReminderProps,
74
+ EscrowHoldReleasedProps,
75
+ EscrowHoldCancelledProps,
76
+ EscrowHoldCancelledReason,
77
+ EscrowPinLockedProps,
62
78
  } from './templates';
63
79
 
64
80
  // ---------------------------------------------------------------------------
@@ -157,6 +173,23 @@ export interface TemplateDataMap {
157
173
  'credential-updated': CredentialUpdatedData;
158
174
  /** lca-api: lost-login identity rebind security notification */
159
175
  'account-sign-in-changed': AccountSignInChangedData;
176
+
177
+ // -- Recovery hold lifecycle (nitro-escrow-enclave) ----------------------
178
+
179
+ /** lca-api: recovery hold started — includes a cancel link */
180
+ 'escrow-hold-started': EscrowHoldStartedData;
181
+
182
+ /** lca-api: recovery hold reminder (T-24h) — includes a cancel link */
183
+ 'escrow-hold-reminder': EscrowHoldReminderData;
184
+
185
+ /** lca-api: recovery hold completed and access was restored */
186
+ 'escrow-hold-released': EscrowHoldReleasedData;
187
+
188
+ /** lca-api: recovery hold cancelled (user, superseded, PIN lock, or failure) */
189
+ 'escrow-hold-cancelled': EscrowHoldCancelledData;
190
+
191
+ /** lca-api: recovery PIN locked after too many failed attempts */
192
+ 'escrow-pin-locked': EscrowPinLockedData;
160
193
  }
161
194
 
162
195
  export type TemplateId = keyof TemplateDataMap;
@@ -243,6 +276,34 @@ export interface CredentialUpdatedData {
243
276
 
244
277
  export type AccountSignInChangedData = Record<string, never>;
245
278
 
279
+ export interface EscrowHoldStartedData {
280
+ requestedAt: string;
281
+ releaseAfter: string;
282
+ cancelUrl: string;
283
+ deviceHint?: string;
284
+ }
285
+
286
+ export interface EscrowHoldReminderData {
287
+ releaseAfter: string;
288
+ cancelUrl: string;
289
+ }
290
+
291
+ export interface EscrowHoldReleasedData {
292
+ completedAt: string;
293
+ supportUrl?: string;
294
+ }
295
+
296
+ export interface EscrowHoldCancelledData {
297
+ cancelledAt: string;
298
+ reason?: EscrowHoldCancelledReason;
299
+ }
300
+
301
+ export interface EscrowPinLockedData {
302
+ lockedAt: string;
303
+ releaseAfter?: string;
304
+ cancelUrl?: string;
305
+ }
306
+
246
307
  // ---------------------------------------------------------------------------
247
308
  // renderEmail()
248
309
  // ---------------------------------------------------------------------------
@@ -459,6 +520,56 @@ function buildElement(
459
520
  };
460
521
  }
461
522
 
523
+ case 'escrow-hold-started': {
524
+ const d = data as EscrowHoldStartedData;
525
+ const props: EscrowHoldStartedProps = { branding, ...d, locale };
526
+
527
+ return {
528
+ element: React.createElement(EscrowHoldStarted, props),
529
+ subject: getEscrowHoldStartedSubject(branding, locale),
530
+ };
531
+ }
532
+
533
+ case 'escrow-hold-reminder': {
534
+ const d = data as EscrowHoldReminderData;
535
+ const props: EscrowHoldReminderProps = { branding, ...d, locale };
536
+
537
+ return {
538
+ element: React.createElement(EscrowHoldReminder, props),
539
+ subject: getEscrowHoldReminderSubject(branding, locale),
540
+ };
541
+ }
542
+
543
+ case 'escrow-hold-released': {
544
+ const d = data as EscrowHoldReleasedData;
545
+ const props: EscrowHoldReleasedProps = { branding, ...d, locale };
546
+
547
+ return {
548
+ element: React.createElement(EscrowHoldReleased, props),
549
+ subject: getEscrowHoldReleasedSubject(branding, locale),
550
+ };
551
+ }
552
+
553
+ case 'escrow-hold-cancelled': {
554
+ const d = data as EscrowHoldCancelledData;
555
+ const props: EscrowHoldCancelledProps = { branding, ...d, locale };
556
+
557
+ return {
558
+ element: React.createElement(EscrowHoldCancelled, props),
559
+ subject: getEscrowHoldCancelledSubject(branding, locale),
560
+ };
561
+ }
562
+
563
+ case 'escrow-pin-locked': {
564
+ const d = data as EscrowPinLockedData;
565
+ const props: EscrowPinLockedProps = { branding, ...d, locale };
566
+
567
+ return {
568
+ element: React.createElement(EscrowPinLocked, props),
569
+ subject: getEscrowPinLockedSubject(branding, locale),
570
+ };
571
+ }
572
+
462
573
  default: {
463
574
  const _exhaustive: never = templateId;
464
575
  throw new Error(`Unknown template: ${_exhaustive}`);