feat(deps): adopt react-email for portal-auth template
Migrates the activation + reset email templates from hand-strung HTML strings to React components rendered via @react-email/components. Concrete wins this lands: - React auto-escapes interpolation — drops the hand-rolled escapeHtml() helper. Eliminates the entire class of "I forgot to escape" XSS bugs. - @react-email primitives (Button, Hr, Link, Text) render to Outlook/Gmail/AppleMail-safe inline-styled HTML. - JSX over template strings makes the templates editable / reviewable. - Sets the pattern for the remaining 7 templates (crm-invite, document-signing, inquiry-*, notification-digest, admin-email-change, residential-inquiry). Migrate opportunistically when those files are next touched. The shell (logo, blurred background, table-based wrapper) stays via renderShell so this is a strictly inner-body migration — visual parity preserved. Vitest config: added @vitejs/plugin-react so .tsx files imported by tests (transitively via the service that uses the template) transform correctly under Next's tsconfig `jsx: 'preserve'` setting. Verified: tsc clean, vitest 1293/1293 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,11 +56,13 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@react-email/components": "^1.0.12",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-query-devtools": "^5.100.10",
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.6.10",
|
"better-auth": "^1.6.10",
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
"react-day-picker": "^10.0.0",
|
"react-day-picker": "^10.0.0",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
|
"react-email": "^6.1.3",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -120,6 +123,7 @@
|
|||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
704
pnpm-lock.yaml
generated
704
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,144 +0,0 @@
|
|||||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
|
||||||
|
|
||||||
interface ActivationData {
|
|
||||||
portName: string;
|
|
||||||
link: string;
|
|
||||||
ttlHours: number;
|
|
||||||
recipientName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResetData {
|
|
||||||
portName: string;
|
|
||||||
link: string;
|
|
||||||
ttlMinutes: number;
|
|
||||||
recipientName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RenderOpts {
|
|
||||||
subject?: string | null;
|
|
||||||
branding?: BrandingShell | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activationEmail(
|
|
||||||
data: ActivationData,
|
|
||||||
overrides?: RenderOpts,
|
|
||||||
): {
|
|
||||||
subject: string;
|
|
||||||
html: string;
|
|
||||||
text: string;
|
|
||||||
} {
|
|
||||||
const subject = overrides?.subject
|
|
||||||
? overrides.subject
|
|
||||||
.replace(/\{\{portName\}\}/g, data.portName)
|
|
||||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
|
||||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
|
||||||
: `Activate your ${data.portName} client portal account`;
|
|
||||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
|
||||||
Welcome to ${escapeHtml(data.portName)}
|
|
||||||
</p>
|
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
|
||||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
|
||||||
You've been invited to access the ${escapeHtml(data.portName)} client portal.
|
|
||||||
Click the button below to set your password and activate your account.
|
|
||||||
The link expires in ${data.ttlHours} hours.
|
|
||||||
</p>
|
|
||||||
<p style="text-align:center; margin:30px 0;">
|
|
||||||
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
|
||||||
Activate account
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
|
||||||
If the button doesn't work, paste this link into your browser:<br />
|
|
||||||
<a href="${safeUrl(data.link)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
|
||||||
</p>
|
|
||||||
<p style="font-size:16px; margin-top:30px;">
|
|
||||||
Thank you,<br />
|
|
||||||
<strong>${escapeHtml(data.portName)} CRM</strong>
|
|
||||||
</p>`;
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
`Welcome to ${data.portName}`,
|
|
||||||
'',
|
|
||||||
`You've been invited to access the ${data.portName} client portal.`,
|
|
||||||
`Activate your account by visiting: ${data.link}`,
|
|
||||||
'',
|
|
||||||
`The link expires in ${data.ttlHours} hours.`,
|
|
||||||
'',
|
|
||||||
`Thank you,`,
|
|
||||||
`${data.portName} CRM`,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject,
|
|
||||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetEmail(
|
|
||||||
data: ResetData,
|
|
||||||
overrides?: RenderOpts,
|
|
||||||
): { subject: string; html: string; text: string } {
|
|
||||||
const subject = overrides?.subject
|
|
||||||
? overrides.subject
|
|
||||||
.replace(/\{\{portName\}\}/g, data.portName)
|
|
||||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
|
||||||
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
|
||||||
: `Reset your ${data.portName} client portal password`;
|
|
||||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
|
|
||||||
const accent = brandingPrimaryColor(overrides?.branding);
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
|
||||||
Password reset
|
|
||||||
</p>
|
|
||||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
|
||||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
|
||||||
We received a request to reset the password on your ${escapeHtml(data.portName)}
|
|
||||||
client portal account. Click the button below to choose a new one.
|
|
||||||
The link expires in ${data.ttlMinutes} minutes.
|
|
||||||
</p>
|
|
||||||
<p style="text-align:center; margin:30px 0;">
|
|
||||||
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
|
||||||
Reset password
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
|
||||||
If you didn't request this, you can safely ignore this email - your password will remain unchanged.
|
|
||||||
</p>
|
|
||||||
<p style="font-size:16px; margin-top:30px;">
|
|
||||||
Thank you,<br />
|
|
||||||
<strong>${escapeHtml(data.portName)} CRM</strong>
|
|
||||||
</p>`;
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
`Password reset for ${data.portName}`,
|
|
||||||
'',
|
|
||||||
`Reset your password by visiting: ${data.link}`,
|
|
||||||
`The link expires in ${data.ttlMinutes} minutes.`,
|
|
||||||
'',
|
|
||||||
`If you didn't request this, you can safely ignore this email.`,
|
|
||||||
'',
|
|
||||||
`Thank you,`,
|
|
||||||
`${data.portName} CRM`,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
subject,
|
|
||||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
223
src/lib/email/templates/portal-auth.tsx
Normal file
223
src/lib/email/templates/portal-auth.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { render } from '@react-email/components';
|
||||||
|
import { Button, Hr, Link, Text } from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||||
|
|
||||||
|
interface ActivationData {
|
||||||
|
portName: string;
|
||||||
|
link: string;
|
||||||
|
ttlHours: number;
|
||||||
|
recipientName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetData {
|
||||||
|
portName: string;
|
||||||
|
link: string;
|
||||||
|
ttlMinutes: number;
|
||||||
|
recipientName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderOpts {
|
||||||
|
subject?: string | null;
|
||||||
|
branding?: BrandingShell | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── React-email body components ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// react-email's `render()` auto-escapes string interpolation, so we don't
|
||||||
|
// need our hand-rolled escapeHtml() on these bodies. Inline styles use
|
||||||
|
// camelCase per CSSProperties — react-email serialises them to
|
||||||
|
// email-client-safe inline `style="..."` attributes on output.
|
||||||
|
|
||||||
|
function ActivationBody({
|
||||||
|
portName,
|
||||||
|
link,
|
||||||
|
ttlHours,
|
||||||
|
recipientName,
|
||||||
|
accent,
|
||||||
|
}: ActivationData & { accent: string }) {
|
||||||
|
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to {portName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||||
|
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||||
|
You've been invited to access the {portName} client portal. Click the button below to
|
||||||
|
set your password and activate your account. The link expires in {ttlHours} hours.
|
||||||
|
</Text>
|
||||||
|
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||||
|
<Button
|
||||||
|
href={safeUrl(link)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: accent,
|
||||||
|
color: '#ffffff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '14px 35px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activate account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||||
|
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||||
|
If the button doesn't work, paste this link into your browser:
|
||||||
|
<br />
|
||||||
|
<Link
|
||||||
|
href={safeUrl(link)}
|
||||||
|
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||||
|
Thank you,
|
||||||
|
<br />
|
||||||
|
<strong>{portName} CRM</strong>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetBody({
|
||||||
|
portName,
|
||||||
|
link,
|
||||||
|
ttlMinutes,
|
||||||
|
recipientName,
|
||||||
|
accent,
|
||||||
|
}: ResetData & { accent: string }) {
|
||||||
|
const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginBottom: '10px',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password reset
|
||||||
|
</Text>
|
||||||
|
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||||
|
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||||
|
We received a request to reset the password on your {portName} client portal account. Click
|
||||||
|
the button below to choose a new one. The link expires in {ttlMinutes} minutes.
|
||||||
|
</Text>
|
||||||
|
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||||
|
<Button
|
||||||
|
href={safeUrl(link)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
backgroundColor: accent,
|
||||||
|
color: '#ffffff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '14px 35px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||||
|
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||||
|
If you didn't request this, you can safely ignore this email — your password will
|
||||||
|
remain unchanged.
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||||
|
Thank you,
|
||||||
|
<br />
|
||||||
|
<strong>{portName} CRM</strong>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public surface ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function activationEmail(
|
||||||
|
data: ActivationData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const subject = overrides?.subject
|
||||||
|
? overrides.subject
|
||||||
|
.replace(/\{\{portName\}\}/g, data.portName)
|
||||||
|
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||||
|
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||||
|
: `Activate your ${data.portName} client portal account`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
|
const body = await render(<ActivationBody {...data} accent={accent} />, {
|
||||||
|
pretty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Welcome to ${data.portName}`,
|
||||||
|
'',
|
||||||
|
`You've been invited to access the ${data.portName} client portal.`,
|
||||||
|
`Activate your account by visiting: ${data.link}`,
|
||||||
|
'',
|
||||||
|
`The link expires in ${data.ttlHours} hours.`,
|
||||||
|
'',
|
||||||
|
`Thank you,`,
|
||||||
|
`${data.portName} CRM`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetEmail(
|
||||||
|
data: ResetData,
|
||||||
|
overrides?: RenderOpts,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
const subject = overrides?.subject
|
||||||
|
? overrides.subject
|
||||||
|
.replace(/\{\{portName\}\}/g, data.portName)
|
||||||
|
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||||
|
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
||||||
|
: `Reset your ${data.portName} client portal password`;
|
||||||
|
const accent = brandingPrimaryColor(overrides?.branding);
|
||||||
|
|
||||||
|
const body = await render(<ResetBody {...data} accent={accent} />, {
|
||||||
|
pretty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Password reset for ${data.portName}`,
|
||||||
|
'',
|
||||||
|
`Reset your password by visiting: ${data.link}`,
|
||||||
|
`The link expires in ${data.ttlMinutes} minutes.`,
|
||||||
|
'',
|
||||||
|
`If you didn't request this, you can safely ignore this email.`,
|
||||||
|
'',
|
||||||
|
`Thank you,`,
|
||||||
|
`${data.portName} CRM`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@ async function issueActivationToken(
|
|||||||
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
||||||
const branding = await getBrandingShell(portId);
|
const branding = await getBrandingShell(portId);
|
||||||
const { subject, html, text } = activationEmail(
|
const { subject, html, text } = await activationEmail(
|
||||||
{
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
@@ -408,7 +408,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||||
const branding = await getBrandingShell(user.portId);
|
const branding = await getBrandingShell(user.portId);
|
||||||
const { subject, html, text } = resetEmail(
|
const { subject, html, text } = await resetEmail(
|
||||||
{
|
{
|
||||||
portName,
|
portName,
|
||||||
link,
|
link,
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { loadEnv } = require('vite');
|
const { loadEnv } = require('vite');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Next.js tsconfig sets jsx: 'preserve' so .tsx files imported by
|
||||||
|
// tests (e.g. react-email templates) aren't transformed by vite's
|
||||||
|
// default loader. The official react plugin handles the JSX
|
||||||
|
// transform in test-time only — Next's runtime keeps its preserve
|
||||||
|
// setting for the prod build.
|
||||||
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
|||||||
Reference in New Issue
Block a user