@learncard/email-templates 1.0.2 → 1.0.3
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/README.md +43 -43
- package/dist/previews/account-approved-scenarios.d.ts +2 -1
- package/dist/previews/account-approved-scenarios.d.ts.map +1 -1
- package/dist/previews/endorsement-request-scenarios.d.ts +2 -1
- package/dist/previews/endorsement-request-scenarios.d.ts.map +1 -1
- package/dist/previews/guardian-approval-scenarios.d.ts +2 -1
- package/dist/previews/guardian-approval-scenarios.d.ts.map +1 -1
- package/dist/previews/guardian-credential-scenarios.d.ts +2 -1
- package/dist/previews/guardian-credential-scenarios.d.ts.map +1 -1
- package/dist/previews/inbox-claim-scenarios.d.ts +2 -1
- package/dist/previews/inbox-claim-scenarios.d.ts.map +1 -1
- package/dist/previews/recovery-key-scenarios.d.ts +2 -1
- package/dist/previews/recovery-key-scenarios.d.ts.map +1 -1
- package/dist/previews/verification-code-scenarios.d.ts +2 -1
- package/dist/previews/verification-code-scenarios.d.ts.map +1 -1
- package/dist/templates/account-approved.d.ts +1 -1
- package/dist/templates/account-approved.d.ts.map +1 -1
- package/dist/templates/credential-awaiting-guardian.d.ts +1 -1
- package/dist/templates/credential-awaiting-guardian.d.ts.map +1 -1
- package/dist/templates/email-verification.d.ts +1 -1
- package/dist/templates/email-verification.d.ts.map +1 -1
- package/dist/templates/endorsement-request.d.ts +1 -1
- package/dist/templates/endorsement-request.d.ts.map +1 -1
- package/dist/templates/guardian-approval.d.ts +1 -1
- package/dist/templates/guardian-approval.d.ts.map +1 -1
- package/dist/templates/guardian-approved-claim.d.ts +1 -1
- package/dist/templates/guardian-approved-claim.d.ts.map +1 -1
- package/dist/templates/guardian-credential-approval.d.ts +1 -1
- package/dist/templates/guardian-credential-approval.d.ts.map +1 -1
- package/dist/templates/guardian-email-otp.d.ts +1 -1
- package/dist/templates/guardian-email-otp.d.ts.map +1 -1
- package/dist/templates/guardian-rejected-credential.d.ts +1 -1
- package/dist/templates/guardian-rejected-credential.d.ts.map +1 -1
- package/dist/templates/inbox-claim.d.ts +1 -1
- package/dist/templates/inbox-claim.d.ts.map +1 -1
- package/dist/templates/recovery-key.d.ts +1 -1
- package/dist/templates/recovery-key.d.ts.map +1 -1
- package/dist/templates/verification-code.d.ts +1 -1
- package/dist/templates/verification-code.d.ts.map +1 -1
- package/dist/templates/verification-code.js +9 -9
- package/dist/templates/verification-code.js.map +1 -1
- package/package.json +49 -39
- package/src/__tests__/render.test.ts +432 -0
- package/src/__tests__/sms.test.ts +95 -0
- package/src/__tests__/tenant-registry.test.ts +257 -0
- package/src/branding.ts +61 -0
- package/src/components/CodeBlock.tsx +61 -0
- package/src/components/EmailButton.tsx +36 -0
- package/src/components/IssuerLogo.tsx +41 -0
- package/src/components/Layout.tsx +192 -0
- package/src/components/LinkFallback.tsx +50 -0
- package/src/index.ts +50 -0
- package/src/previews/_fixtures.tsx +51 -0
- package/src/previews/account-approved-scenarios.tsx +31 -0
- package/src/previews/endorsement-request-scenarios.tsx +50 -0
- package/src/previews/guardian-approval-scenarios.tsx +39 -0
- package/src/previews/guardian-credential-scenarios.tsx +88 -0
- package/src/previews/inbox-claim-scenarios.tsx +66 -0
- package/src/previews/recovery-key-scenarios.tsx +32 -0
- package/src/previews/verification-code-scenarios.tsx +60 -0
- package/src/render.ts +409 -0
- package/src/sms.ts +68 -0
- package/src/templates/account-approved.tsx +105 -0
- package/src/templates/credential-awaiting-guardian.tsx +137 -0
- package/src/templates/email-verification.tsx +110 -0
- package/src/templates/endorsement-request.tsx +157 -0
- package/src/templates/guardian-approval.tsx +122 -0
- package/src/templates/guardian-approved-claim.tsx +114 -0
- package/src/templates/guardian-credential-approval.tsx +127 -0
- package/src/templates/guardian-email-otp.tsx +101 -0
- package/src/templates/guardian-rejected-credential.tsx +103 -0
- package/src/templates/inbox-claim.tsx +141 -0
- package/src/templates/index.ts +35 -0
- package/src/templates/recovery-key.tsx +102 -0
- package/src/templates/verification-code.tsx +164 -0
- package/src/tenant-registry.ts +204 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveTenantFromRequest,
|
|
5
|
+
registerOriginMapping,
|
|
6
|
+
registerTenantBranding,
|
|
7
|
+
} from '../tenant-registry';
|
|
8
|
+
import type { RequestHeaders } from '../tenant-registry';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// resolveTenantFromRequest
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
describe('resolveTenantFromRequest', () => {
|
|
15
|
+
// -- Priority 1: X-Tenant-Id header --------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('X-Tenant-Id header', () => {
|
|
18
|
+
it('resolves tenant from explicit header', () => {
|
|
19
|
+
const headers: RequestHeaders = { 'x-tenant-id': 'vetpass' };
|
|
20
|
+
const result = resolveTenantFromRequest(headers);
|
|
21
|
+
|
|
22
|
+
expect(result.id).toBe('vetpass');
|
|
23
|
+
expect(result.resolvedVia).toBe('header');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('takes priority over Origin header', () => {
|
|
27
|
+
const headers: RequestHeaders = {
|
|
28
|
+
'x-tenant-id': 'vetpass',
|
|
29
|
+
origin: 'https://learncard.app',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const result = resolveTenantFromRequest(headers);
|
|
33
|
+
|
|
34
|
+
expect(result.id).toBe('vetpass');
|
|
35
|
+
expect(result.resolvedVia).toBe('header');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('takes priority over env default', () => {
|
|
39
|
+
const headers: RequestHeaders = { 'x-tenant-id': 'vetpass' };
|
|
40
|
+
const result = resolveTenantFromRequest(headers, 'learncard');
|
|
41
|
+
|
|
42
|
+
expect(result.id).toBe('vetpass');
|
|
43
|
+
expect(result.resolvedVia).toBe('header');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles array header values (takes first)', () => {
|
|
47
|
+
const headers: RequestHeaders = { 'x-tenant-id': ['vetpass', 'learncard'] };
|
|
48
|
+
const result = resolveTenantFromRequest(headers);
|
|
49
|
+
|
|
50
|
+
expect(result.id).toBe('vetpass');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// -- Priority 2: Origin / Referer header ---------------------------------
|
|
55
|
+
|
|
56
|
+
describe('Origin header', () => {
|
|
57
|
+
it('resolves learncard from learncard.app origin', () => {
|
|
58
|
+
const headers: RequestHeaders = { origin: 'https://learncard.app' };
|
|
59
|
+
const result = resolveTenantFromRequest(headers);
|
|
60
|
+
|
|
61
|
+
expect(result.id).toBe('learncard');
|
|
62
|
+
expect(result.resolvedVia).toBe('origin');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('resolves vetpass from vetpass.app origin', () => {
|
|
66
|
+
const headers: RequestHeaders = { origin: 'https://vetpass.app' };
|
|
67
|
+
const result = resolveTenantFromRequest(headers);
|
|
68
|
+
|
|
69
|
+
expect(result.id).toBe('vetpass');
|
|
70
|
+
expect(result.resolvedVia).toBe('origin');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('resolves vetpass from alpha.vetpass.app', () => {
|
|
74
|
+
const headers: RequestHeaders = { origin: 'https://alpha.vetpass.app' };
|
|
75
|
+
const result = resolveTenantFromRequest(headers);
|
|
76
|
+
|
|
77
|
+
expect(result.id).toBe('vetpass');
|
|
78
|
+
expect(result.resolvedVia).toBe('origin');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resolves vetpass from staging.vetpass.app', () => {
|
|
82
|
+
const headers: RequestHeaders = { origin: 'https://staging.vetpass.app' };
|
|
83
|
+
const result = resolveTenantFromRequest(headers);
|
|
84
|
+
|
|
85
|
+
expect(result.id).toBe('vetpass');
|
|
86
|
+
expect(result.resolvedVia).toBe('origin');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('resolves learncard from staging.learncard.app', () => {
|
|
90
|
+
const headers: RequestHeaders = { origin: 'https://staging.learncard.app' };
|
|
91
|
+
const result = resolveTenantFromRequest(headers);
|
|
92
|
+
|
|
93
|
+
expect(result.id).toBe('learncard');
|
|
94
|
+
expect(result.resolvedVia).toBe('origin');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('resolves learncard from app.learncard.com', () => {
|
|
98
|
+
const headers: RequestHeaders = { origin: 'https://app.learncard.com' };
|
|
99
|
+
const result = resolveTenantFromRequest(headers);
|
|
100
|
+
|
|
101
|
+
expect(result.id).toBe('learncard');
|
|
102
|
+
expect(result.resolvedVia).toBe('origin');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('resolves learncard from localhost', () => {
|
|
106
|
+
const headers: RequestHeaders = { origin: 'http://localhost:3000' };
|
|
107
|
+
const result = resolveTenantFromRequest(headers);
|
|
108
|
+
|
|
109
|
+
expect(result.id).toBe('learncard');
|
|
110
|
+
expect(result.resolvedVia).toBe('origin');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('strips subdomains progressively (deep subdomain)', () => {
|
|
114
|
+
const headers: RequestHeaders = { origin: 'https://deep.nested.vetpass.app' };
|
|
115
|
+
const result = resolveTenantFromRequest(headers);
|
|
116
|
+
|
|
117
|
+
expect(result.id).toBe('vetpass');
|
|
118
|
+
expect(result.resolvedVia).toBe('origin');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('Referer fallback', () => {
|
|
123
|
+
it('uses referer when origin is missing', () => {
|
|
124
|
+
const headers: RequestHeaders = { referer: 'https://vetpass.app/some/page' };
|
|
125
|
+
const result = resolveTenantFromRequest(headers);
|
|
126
|
+
|
|
127
|
+
expect(result.id).toBe('vetpass');
|
|
128
|
+
expect(result.resolvedVia).toBe('origin');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// -- Priority 3: env default ---------------------------------------------
|
|
133
|
+
|
|
134
|
+
describe('env default', () => {
|
|
135
|
+
it('uses envTenantId when no headers match', () => {
|
|
136
|
+
const headers: RequestHeaders = {};
|
|
137
|
+
const result = resolveTenantFromRequest(headers, 'vetpass');
|
|
138
|
+
|
|
139
|
+
expect(result.id).toBe('vetpass');
|
|
140
|
+
expect(result.resolvedVia).toBe('env');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('ignores unknown origin and falls through to env', () => {
|
|
144
|
+
const headers: RequestHeaders = { origin: 'https://unknown-site.com' };
|
|
145
|
+
const result = resolveTenantFromRequest(headers, 'vetpass');
|
|
146
|
+
|
|
147
|
+
expect(result.id).toBe('vetpass');
|
|
148
|
+
expect(result.resolvedVia).toBe('env');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// -- Priority 4: safe default --------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe('safe default', () => {
|
|
155
|
+
it('returns learncard when nothing matches', () => {
|
|
156
|
+
const headers: RequestHeaders = {};
|
|
157
|
+
const result = resolveTenantFromRequest(headers);
|
|
158
|
+
|
|
159
|
+
expect(result.id).toBe('learncard');
|
|
160
|
+
expect(result.resolvedVia).toBe('default');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns learncard for unknown origin with no env fallback', () => {
|
|
164
|
+
const headers: RequestHeaders = { origin: 'https://random-site.io' };
|
|
165
|
+
const result = resolveTenantFromRequest(headers);
|
|
166
|
+
|
|
167
|
+
expect(result.id).toBe('learncard');
|
|
168
|
+
expect(result.resolvedVia).toBe('default');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// -- Branding resolved correctly -----------------------------------------
|
|
173
|
+
|
|
174
|
+
describe('emailBranding', () => {
|
|
175
|
+
it('returns empty branding for learncard (uses defaults)', () => {
|
|
176
|
+
const result = resolveTenantFromRequest({ 'x-tenant-id': 'learncard' });
|
|
177
|
+
|
|
178
|
+
expect(result.emailBranding).toEqual({});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('returns vetpass overrides for vetpass tenant', () => {
|
|
182
|
+
const result = resolveTenantFromRequest({ 'x-tenant-id': 'vetpass' });
|
|
183
|
+
|
|
184
|
+
expect(result.emailBranding.brandName).toBe('VetPass');
|
|
185
|
+
expect(result.emailBranding.primaryColor).toBe('#1B5E20');
|
|
186
|
+
expect(result.emailBranding.supportEmail).toBe('support@vetpass.app');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns empty branding for unknown tenant', () => {
|
|
190
|
+
const result = resolveTenantFromRequest({ 'x-tenant-id': 'unknown-tenant' });
|
|
191
|
+
|
|
192
|
+
expect(result.emailBranding).toEqual({});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// -- Edge cases ----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
describe('edge cases', () => {
|
|
199
|
+
it('handles empty string header gracefully', () => {
|
|
200
|
+
const headers: RequestHeaders = { 'x-tenant-id': '' };
|
|
201
|
+
const result = resolveTenantFromRequest(headers);
|
|
202
|
+
|
|
203
|
+
// Empty string is falsy → falls through to default
|
|
204
|
+
expect(result.resolvedVia).not.toBe('header');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('handles malformed origin URL gracefully', () => {
|
|
208
|
+
const headers: RequestHeaders = { origin: 'not-a-url' };
|
|
209
|
+
const result = resolveTenantFromRequest(headers);
|
|
210
|
+
|
|
211
|
+
// Should not throw, falls through to default
|
|
212
|
+
expect(result.id).toBe('learncard');
|
|
213
|
+
expect(result.resolvedVia).toBe('default');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// registerOriginMapping
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('registerOriginMapping', () => {
|
|
223
|
+
it('allows resolving a newly registered origin', () => {
|
|
224
|
+
registerOriginMapping('custom-deploy.example.com', 'custom-tenant');
|
|
225
|
+
|
|
226
|
+
const result = resolveTenantFromRequest({
|
|
227
|
+
origin: 'https://custom-deploy.example.com',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result.id).toBe('custom-tenant');
|
|
231
|
+
expect(result.resolvedVia).toBe('origin');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// registerTenantBranding
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
describe('registerTenantBranding', () => {
|
|
240
|
+
it('adds branding for a new tenant', () => {
|
|
241
|
+
registerTenantBranding('new-tenant', { brandName: 'NewBrand' });
|
|
242
|
+
|
|
243
|
+
const result = resolveTenantFromRequest({ 'x-tenant-id': 'new-tenant' });
|
|
244
|
+
|
|
245
|
+
expect(result.emailBranding.brandName).toBe('NewBrand');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('merges branding with existing values', () => {
|
|
249
|
+
registerTenantBranding('vetpass', { logoUrl: 'https://vetpass.app/logo.png' });
|
|
250
|
+
|
|
251
|
+
const result = resolveTenantFromRequest({ 'x-tenant-id': 'vetpass' });
|
|
252
|
+
|
|
253
|
+
// Merged — both old and new values present
|
|
254
|
+
expect(result.emailBranding.brandName).toBe('VetPass');
|
|
255
|
+
expect(result.emailBranding.logoUrl).toBe('https://vetpass.app/logo.png');
|
|
256
|
+
});
|
|
257
|
+
});
|
package/src/branding.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant branding for email and SMS templates.
|
|
3
|
+
*
|
|
4
|
+
* Values are typically sourced from the tenant config's `email` section.
|
|
5
|
+
* Every field has a sensible LearnCard default so templates always render,
|
|
6
|
+
* even if the caller provides a partial (or empty) branding object.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TenantBranding {
|
|
10
|
+
/** Display name shown in email headers, subject lines, and SMS. */
|
|
11
|
+
brandName: string;
|
|
12
|
+
|
|
13
|
+
/** Full URL to a hosted logo image (PNG/SVG, ideally 200×50 or similar). */
|
|
14
|
+
logoUrl: string;
|
|
15
|
+
|
|
16
|
+
/** Alt text for the logo image. */
|
|
17
|
+
logoAlt: string;
|
|
18
|
+
|
|
19
|
+
/** Primary brand color (hex). Used for CTA buttons and accents. */
|
|
20
|
+
primaryColor: string;
|
|
21
|
+
|
|
22
|
+
/** Text color on primary buttons. */
|
|
23
|
+
primaryTextColor: string;
|
|
24
|
+
|
|
25
|
+
/** Support / reply-to email address shown in footers. */
|
|
26
|
+
supportEmail: string;
|
|
27
|
+
|
|
28
|
+
/** Marketing website URL shown in footers. */
|
|
29
|
+
websiteUrl: string;
|
|
30
|
+
|
|
31
|
+
/** Base app URL for constructing links (e.g. "https://learncard.app"). */
|
|
32
|
+
appUrl: string;
|
|
33
|
+
|
|
34
|
+
/** Email domain for the From address (e.g. "learncard.com"). */
|
|
35
|
+
fromDomain: string;
|
|
36
|
+
|
|
37
|
+
/** Copyright holder name for the footer. */
|
|
38
|
+
copyrightHolder: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_BRANDING: TenantBranding = {
|
|
42
|
+
brandName: 'LearnCard',
|
|
43
|
+
logoUrl: 'https://learncard.app/assets/icon/icon.png',
|
|
44
|
+
logoAlt: 'LearnCard',
|
|
45
|
+
primaryColor: '#6366f1',
|
|
46
|
+
primaryTextColor: '#ffffff',
|
|
47
|
+
supportEmail: 'support@learningeconomy.io',
|
|
48
|
+
websiteUrl: 'https://www.learncard.com',
|
|
49
|
+
appUrl: 'https://learncard.app',
|
|
50
|
+
fromDomain: 'learncard.com',
|
|
51
|
+
copyrightHolder: 'Learning Economy Foundation',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Merge partial branding with defaults.
|
|
56
|
+
* Any missing field falls back to the LearnCard default.
|
|
57
|
+
*/
|
|
58
|
+
export const resolveBranding = (partial?: Partial<TenantBranding>): TenantBranding => ({
|
|
59
|
+
...DEFAULT_BRANDING,
|
|
60
|
+
...partial,
|
|
61
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeBlock — displays a verification code or recovery key prominently.
|
|
3
|
+
*
|
|
4
|
+
* Used by verification-code and recovery-key templates.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Section, Text } from '@react-email/components';
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
|
|
10
|
+
interface CodeBlockProps {
|
|
11
|
+
code: string;
|
|
12
|
+
/** Optional label above the code (e.g. "Your verification code"). */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** 'code' (default) for short 6-digit codes, 'key' for long recovery keys. */
|
|
15
|
+
variant?: 'code' | 'key';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, label, variant = 'code' }) => (
|
|
19
|
+
<Section style={wrapper}>
|
|
20
|
+
{label && <Text style={labelStyle}>{label}</Text>}
|
|
21
|
+
|
|
22
|
+
<Text style={variant === 'key' ? keyStyle : codeStyle}>{code}</Text>
|
|
23
|
+
</Section>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const wrapper: React.CSSProperties = {
|
|
27
|
+
backgroundColor: '#f9fafb',
|
|
28
|
+
border: '1px solid #e5e7eb',
|
|
29
|
+
borderRadius: 8,
|
|
30
|
+
padding: '16px 24px',
|
|
31
|
+
margin: '0 0 24px',
|
|
32
|
+
textAlign: 'center' as const,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const labelStyle: React.CSSProperties = {
|
|
36
|
+
fontSize: 12,
|
|
37
|
+
fontWeight: 600,
|
|
38
|
+
color: '#6b7280',
|
|
39
|
+
margin: '0 0 8px',
|
|
40
|
+
textTransform: 'uppercase' as const,
|
|
41
|
+
letterSpacing: '0.05em',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const codeStyle: React.CSSProperties = {
|
|
45
|
+
fontSize: 32,
|
|
46
|
+
fontWeight: 700,
|
|
47
|
+
color: '#111827',
|
|
48
|
+
letterSpacing: 6,
|
|
49
|
+
margin: 0,
|
|
50
|
+
fontFamily: "'Courier New', Courier, monospace",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const keyStyle: React.CSSProperties = {
|
|
54
|
+
fontSize: 13,
|
|
55
|
+
fontWeight: 400,
|
|
56
|
+
color: '#111827',
|
|
57
|
+
lineHeight: '20px',
|
|
58
|
+
margin: 0,
|
|
59
|
+
fontFamily: "'Courier New', Courier, monospace",
|
|
60
|
+
wordBreak: 'break-all' as const,
|
|
61
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailButton — branded CTA button for emails.
|
|
3
|
+
*
|
|
4
|
+
* Renders as an anchor tag with inline styles for maximum email client compatibility.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Button } from '@react-email/components';
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
|
|
10
|
+
import type { TenantBranding } from '../branding';
|
|
11
|
+
|
|
12
|
+
interface EmailButtonProps {
|
|
13
|
+
href: string;
|
|
14
|
+
branding: TenantBranding;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const EmailButton: React.FC<EmailButtonProps> = ({ href, branding, children }) => (
|
|
19
|
+
<Button
|
|
20
|
+
href={href}
|
|
21
|
+
style={{
|
|
22
|
+
backgroundColor: branding.primaryColor,
|
|
23
|
+
color: branding.primaryTextColor,
|
|
24
|
+
borderRadius: 4,
|
|
25
|
+
fontSize: 16,
|
|
26
|
+
fontWeight: 600,
|
|
27
|
+
textDecoration: 'none',
|
|
28
|
+
textAlign: 'center' as const,
|
|
29
|
+
display: 'inline-block',
|
|
30
|
+
padding: '16px 24px',
|
|
31
|
+
lineHeight: '1',
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</Button>
|
|
36
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IssuerLogo — circular avatar for the credential issuer.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the 80×80 circular logo used in the original Postmark templates.
|
|
5
|
+
* Only renders when `logoUrl` is provided; otherwise renders nothing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Img, Section } from '@react-email/components';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
|
|
11
|
+
interface IssuerLogoProps {
|
|
12
|
+
logoUrl?: string;
|
|
13
|
+
alt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const IssuerLogo: React.FC<IssuerLogoProps> = ({ logoUrl, alt }) => {
|
|
17
|
+
if (!logoUrl) return null;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Section style={wrapper}>
|
|
21
|
+
<Img
|
|
22
|
+
src={logoUrl}
|
|
23
|
+
alt={alt ?? 'Issuer logo'}
|
|
24
|
+
width={80}
|
|
25
|
+
height={80}
|
|
26
|
+
style={avatar}
|
|
27
|
+
/>
|
|
28
|
+
</Section>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const wrapper: React.CSSProperties = {
|
|
33
|
+
textAlign: 'center' as const,
|
|
34
|
+
margin: '0 0 24px',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const avatar: React.CSSProperties = {
|
|
38
|
+
borderRadius: '50%',
|
|
39
|
+
display: 'inline-block',
|
|
40
|
+
objectFit: 'cover',
|
|
41
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantLayout — shared email wrapper with branded header + footer.
|
|
3
|
+
*
|
|
4
|
+
* Every email template wraps its content in this layout so branding
|
|
5
|
+
* is consistent across all tenant emails.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Html,
|
|
10
|
+
Head,
|
|
11
|
+
Body,
|
|
12
|
+
Container,
|
|
13
|
+
Section,
|
|
14
|
+
Img,
|
|
15
|
+
Text,
|
|
16
|
+
Link,
|
|
17
|
+
Preview,
|
|
18
|
+
} from '@react-email/components';
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
|
|
21
|
+
import type { TenantBranding } from '../branding';
|
|
22
|
+
|
|
23
|
+
interface LayoutProps {
|
|
24
|
+
branding: TenantBranding;
|
|
25
|
+
preview: string;
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
/** Show the brand icon at the top of the card. Defaults to true.
|
|
28
|
+
* Set to false when the template already has an IssuerLogo. */
|
|
29
|
+
showHeaderLogo?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const Layout: React.FC<LayoutProps> = ({ branding, preview, children, showHeaderLogo = true }) => (
|
|
33
|
+
<Html lang="en">
|
|
34
|
+
<Head />
|
|
35
|
+
|
|
36
|
+
<Preview>{preview}</Preview>
|
|
37
|
+
|
|
38
|
+
<Body style={body}>
|
|
39
|
+
{/* Top spacer — Gmail strips <body> padding, so use an inline div */}
|
|
40
|
+
<div style={topSpacer}> </div>
|
|
41
|
+
|
|
42
|
+
{/* Wordmark — sits above the white card like the Postmark layout */}
|
|
43
|
+
<Container style={wordmarkContainer}>
|
|
44
|
+
<Text style={wordmark}>
|
|
45
|
+
<Link href={branding.websiteUrl} style={wordmarkLink}>
|
|
46
|
+
{branding.brandName.toUpperCase()}
|
|
47
|
+
</Link>
|
|
48
|
+
</Text>
|
|
49
|
+
</Container>
|
|
50
|
+
|
|
51
|
+
<Container style={container}>
|
|
52
|
+
{/* Optional brand icon */}
|
|
53
|
+
{showHeaderLogo && (
|
|
54
|
+
<Section style={headerLogo}>
|
|
55
|
+
<Img
|
|
56
|
+
src={branding.logoUrl}
|
|
57
|
+
alt={branding.logoAlt}
|
|
58
|
+
width={80}
|
|
59
|
+
height={80}
|
|
60
|
+
style={logoImg}
|
|
61
|
+
/>
|
|
62
|
+
</Section>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{/* Content */}
|
|
66
|
+
<Section style={showHeaderLogo ? contentAfterLogo : content}>
|
|
67
|
+
{children}
|
|
68
|
+
</Section>
|
|
69
|
+
</Container>
|
|
70
|
+
|
|
71
|
+
{/* Footer — sits below the white card in the gray area */}
|
|
72
|
+
<Container style={footerContainer}>
|
|
73
|
+
<Text style={footerDisclaimer}>
|
|
74
|
+
You received this email because someone requested to add this email
|
|
75
|
+
to their {branding.brandName} account. Please ignore this email if
|
|
76
|
+
this was not you.
|
|
77
|
+
</Text>
|
|
78
|
+
|
|
79
|
+
<Text style={footerCopyright}>
|
|
80
|
+
{branding.copyrightHolder} © {new Date().getFullYear()}
|
|
81
|
+
</Text>
|
|
82
|
+
|
|
83
|
+
<Text style={footerLinks}>
|
|
84
|
+
<Link href={`mailto:${branding.supportEmail}`} style={footerLink}>
|
|
85
|
+
Contact Support
|
|
86
|
+
</Link>
|
|
87
|
+
</Text>
|
|
88
|
+
</Container>
|
|
89
|
+
</Body>
|
|
90
|
+
</Html>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Styles (inline for email compatibility)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const body: React.CSSProperties = {
|
|
98
|
+
backgroundColor: '#f3f4f6',
|
|
99
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
100
|
+
margin: 0,
|
|
101
|
+
padding: 0,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const topSpacer: React.CSSProperties = {
|
|
105
|
+
height: 56,
|
|
106
|
+
lineHeight: '56px',
|
|
107
|
+
fontSize: 1,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const wordmarkContainer: React.CSSProperties = {
|
|
111
|
+
maxWidth: 560,
|
|
112
|
+
margin: '0 auto',
|
|
113
|
+
padding: '0 40px',
|
|
114
|
+
textAlign: 'center' as const,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const wordmark: React.CSSProperties = {
|
|
118
|
+
fontSize: 13,
|
|
119
|
+
fontWeight: 600,
|
|
120
|
+
letterSpacing: 4,
|
|
121
|
+
color: '#52597A',
|
|
122
|
+
margin: '0 0 32px',
|
|
123
|
+
padding: '8px 0 0',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const wordmarkLink: React.CSSProperties = {
|
|
127
|
+
color: '#52597A',
|
|
128
|
+
textDecoration: 'none',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const container: React.CSSProperties = {
|
|
132
|
+
backgroundColor: '#ffffff',
|
|
133
|
+
borderRadius: 4,
|
|
134
|
+
margin: '0 auto',
|
|
135
|
+
maxWidth: 600,
|
|
136
|
+
padding: 0,
|
|
137
|
+
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const headerLogo: React.CSSProperties = {
|
|
141
|
+
padding: '48px 48px 0',
|
|
142
|
+
textAlign: 'center' as const,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const logoImg: React.CSSProperties = {
|
|
146
|
+
borderRadius: 10,
|
|
147
|
+
display: 'inline-block',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const content: React.CSSProperties = {
|
|
151
|
+
padding: '48px 48px 48px',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const contentAfterLogo: React.CSSProperties = {
|
|
155
|
+
padding: '24px 48px 48px',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const footerContainer: React.CSSProperties = {
|
|
159
|
+
maxWidth: 560,
|
|
160
|
+
margin: '0 auto',
|
|
161
|
+
padding: '32px 40px 40px',
|
|
162
|
+
textAlign: 'center' as const,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const footerDisclaimer: React.CSSProperties = {
|
|
166
|
+
fontSize: 12,
|
|
167
|
+
color: '#6b7280',
|
|
168
|
+
lineHeight: '18px',
|
|
169
|
+
margin: '0 0 16px',
|
|
170
|
+
textAlign: 'center' as const,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const footerCopyright: React.CSSProperties = {
|
|
174
|
+
fontSize: 12,
|
|
175
|
+
color: '#6b7280',
|
|
176
|
+
lineHeight: '18px',
|
|
177
|
+
margin: '0 0 8px',
|
|
178
|
+
textAlign: 'center' as const,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const footerLinks: React.CSSProperties = {
|
|
182
|
+
fontSize: 12,
|
|
183
|
+
color: '#6b7280',
|
|
184
|
+
lineHeight: '18px',
|
|
185
|
+
margin: 0,
|
|
186
|
+
textAlign: 'center' as const,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const footerLink: React.CSSProperties = {
|
|
190
|
+
color: '#6b7280',
|
|
191
|
+
textDecoration: 'underline',
|
|
192
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkFallback — "copy and paste this link" fallback below CTA buttons.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the fallback link pattern used in the original Postmark templates.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Text, Link, Hr } from '@react-email/components';
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
|
|
10
|
+
interface LinkFallbackProps {
|
|
11
|
+
href: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const LinkFallback: React.FC<LinkFallbackProps> = ({ href }) => (
|
|
15
|
+
<>
|
|
16
|
+
<Hr style={divider} />
|
|
17
|
+
|
|
18
|
+
<Text style={hint}>
|
|
19
|
+
If you have trouble with the button above, copy and paste this link into your browser:
|
|
20
|
+
</Text>
|
|
21
|
+
|
|
22
|
+
<Text style={linkText}>
|
|
23
|
+
<Link href={href} style={link}>{href}</Link>
|
|
24
|
+
</Text>
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const divider: React.CSSProperties = {
|
|
29
|
+
borderColor: '#e5e7eb',
|
|
30
|
+
margin: '24px 0 16px',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const hint: React.CSSProperties = {
|
|
34
|
+
fontSize: 12,
|
|
35
|
+
color: '#6b7280',
|
|
36
|
+
lineHeight: '18px',
|
|
37
|
+
margin: '0 0 4px',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const linkText: React.CSSProperties = {
|
|
41
|
+
fontSize: 12,
|
|
42
|
+
lineHeight: '18px',
|
|
43
|
+
margin: '0 0 4px',
|
|
44
|
+
wordBreak: 'break-all' as const,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const link: React.CSSProperties = {
|
|
48
|
+
color: '#6366f1',
|
|
49
|
+
textDecoration: 'none',
|
|
50
|
+
};
|