Redesign transactional email templates to match Echo brand
- Replace bare inline HTML with a shared emailShell template system
- Use brand colors (warm neutrals), Quicksand font, and rounded card layout
- Add hidden preheader text for better inbox previews
- Proper table-based layout for email client compatibility (Outlook, Gmail, Apple Mail)
- Include Mso conditionals for Outlook button rendering
- Add dev-only email preview route at /api/dev/email-preview (gated behind NODE_ENV)
- Fix invitation email copy bug ("has invited you" / "You've been invited")
Made-with: Cursor
This commit is contained in:
parent
c9f5b1e2ee
commit
7ab2fe6894
2 changed files with 304 additions and 30 deletions
121
src/app/api/dev/email-preview/route.ts
Normal file
121
src/app/api/dev/email-preview/route.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "Not available" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Dynamically import to access the private emailShell via the public wrappers
|
||||
// We'll just render inline for preview purposes
|
||||
const { searchParams } = req.nextUrl;
|
||||
const template = searchParams.get("template") || "verification";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
|
||||
|
||||
const BRAND = {
|
||||
dark: "#2d2b28",
|
||||
accent: "#3d3a36",
|
||||
muted: "#8a8580",
|
||||
bg: "#f7f5f2",
|
||||
cardBg: "#ffffff",
|
||||
border: "#e8e4df",
|
||||
cta: "#2d2b28",
|
||||
ctaText: "#fafaf8",
|
||||
link: "#5c5650",
|
||||
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
};
|
||||
|
||||
const templates: Record<string, { preheader: string; heading: string; body: string; ctaLabel: string; ctaUrl: string; footer: string }> = {
|
||||
verification: {
|
||||
preheader: "Confirm your email to start scanning cards with Echo.",
|
||||
heading: "Verify your email address",
|
||||
body: `
|
||||
<p style="margin:0 0 16px;">Welcome to Echo! Before you can start scanning response cards, we need to verify your email address.</p>
|
||||
<p style="margin:0;">Click the button below to confirm your email and get started.</p>
|
||||
`,
|
||||
ctaLabel: "Verify Email Address",
|
||||
ctaUrl: "#",
|
||||
footer: "If you didn't create an Echo account, you can safely ignore this email. This link expires in 24 hours.",
|
||||
},
|
||||
invitation: {
|
||||
preheader: "Sarah invited you to join Grace Community Church on Echo.",
|
||||
heading: "You're invited to Grace Community Church",
|
||||
body: `
|
||||
<p style="margin:0 0 16px;"><strong>Sarah Johnson</strong> has invited you to join <strong>Grace Community Church</strong> on Echo — AI-powered response card scanning for churches and ministries.</p>
|
||||
<p style="margin:0;">Accept the invitation below to join the team and start collaborating.</p>
|
||||
`,
|
||||
ctaLabel: "Accept Invitation",
|
||||
ctaUrl: "#",
|
||||
footer: "This invitation expires in 7 days. If you weren't expecting this email, you can safely ignore it.",
|
||||
},
|
||||
};
|
||||
|
||||
const t = templates[template] || templates.verification;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${t.heading}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;">${t.preheader}</div>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 16px 32px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:32px;">
|
||||
<a href="${SITE_URL}" style="text-decoration:none;" target="_blank">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background-color:${BRAND.dark};border-radius:8px;width:30px;height:30px;text-align:center;line-height:30px;font-size:16px;" valign="middle">
|
||||
<span style="color:${BRAND.ctaText};">⧗</span>
|
||||
</td>
|
||||
<td style="padding-left:10px;font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};letter-spacing:-0.3px;" valign="middle">
|
||||
Echo
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 4px 16px rgba(0,0,0,0.03);">
|
||||
<tr>
|
||||
<td style="padding:48px 40px 44px;">
|
||||
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;letter-spacing:-0.3px;">${t.heading}</h1>
|
||||
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">${t.body}</div>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${t.ctaUrl}" target="_blank" style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;letter-spacing:-0.2px;text-align:center;">
|
||||
${t.ctaLabel}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">${t.footer}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 0 8px;">
|
||||
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">AI-powered response card scanning for churches</p>
|
||||
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;"><a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,147 @@ const DEFAULT_SENDER = {
|
|||
email: process.env.EMAIL_FROM_ADDRESS || "mars@noreply.stillwell.cloud",
|
||||
};
|
||||
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://echoocr.com";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared template shell */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const BRAND = {
|
||||
dark: "#2d2b28",
|
||||
accent: "#3d3a36",
|
||||
muted: "#8a8580",
|
||||
bg: "#f7f5f2",
|
||||
cardBg: "#ffffff",
|
||||
border: "#e8e4df",
|
||||
cta: "#2d2b28",
|
||||
ctaText: "#fafaf8",
|
||||
link: "#5c5650",
|
||||
font: "'Quicksand', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
};
|
||||
|
||||
function emailShell({
|
||||
preheader,
|
||||
heading,
|
||||
body,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
footer,
|
||||
}: {
|
||||
preheader: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
ctaUrl: string;
|
||||
footer: string;
|
||||
}) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<title>${heading}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!--[if mso]>
|
||||
<style>
|
||||
* { font-family: 'Segoe UI', Roboto, Arial, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:${BRAND.font};-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;">
|
||||
<!-- Preheader (hidden preview text) -->
|
||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${preheader}͏‌ ͏‌ ͏‌ ͏‌ ͏‌ ͏‌ </div>
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:${BRAND.bg};">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 16px 32px;">
|
||||
|
||||
<!-- Logo -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:32px;">
|
||||
<a href="${SITE_URL}" style="text-decoration:none;" target="_blank">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="background-color:${BRAND.dark};border-radius:8px;width:30px;height:30px;text-align:center;line-height:30px;font-size:16px;" valign="middle">
|
||||
<span style="color:${BRAND.ctaText};">⧗</span>
|
||||
</td>
|
||||
<td style="padding-left:10px;font-family:${BRAND.font};font-size:20px;font-weight:700;color:${BRAND.dark};letter-spacing:-0.3px;" valign="middle">
|
||||
Echo
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Card -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;background-color:${BRAND.cardBg};border-radius:16px;border:1px solid ${BRAND.border};box-shadow:0 1px 3px rgba(0,0,0,0.04),0 4px 16px rgba(0,0,0,0.03);">
|
||||
<tr>
|
||||
<td style="padding:48px 40px 44px;">
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 style="margin:0 0 16px;font-family:${BRAND.font};font-size:24px;font-weight:700;color:${BRAND.dark};line-height:1.3;letter-spacing:-0.3px;">
|
||||
${heading}
|
||||
</h1>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="margin:0 0 32px;font-family:${BRAND.font};font-size:15px;color:${BRAND.accent};line-height:1.65;">
|
||||
${body}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${ctaUrl}" target="_blank"
|
||||
style="display:inline-block;padding:14px 32px;background-color:${BRAND.cta};color:${BRAND.ctaText};font-family:${BRAND.font};font-size:15px;font-weight:600;text-decoration:none;border-radius:10px;letter-spacing:-0.2px;mso-padding-alt:0;text-align:center;">
|
||||
<!--[if mso]><i style="mso-font-width:150%;mso-text-raise:22pt;" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise:11pt;">${ctaLabel}</span>
|
||||
<!--[if mso]><i style="mso-font-width:150%;" hidden> </i><![endif]-->
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer note -->
|
||||
<p style="margin:28px 0 0;font-family:${BRAND.font};font-size:13px;color:${BRAND.muted};line-height:1.55;">
|
||||
${footer}
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Bottom branding -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:520px;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 0 8px;">
|
||||
<p style="margin:0;font-family:${BRAND.font};font-size:12px;color:${BRAND.muted};line-height:1.5;">
|
||||
AI-powered response card scanning for churches
|
||||
</p>
|
||||
<p style="margin:6px 0 0;font-family:${BRAND.font};font-size:12px;line-height:1.5;">
|
||||
<a href="${SITE_URL}" style="color:${BRAND.link};text-decoration:underline;" target="_blank">echoocr.com</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Core send function */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function sendEmail(to: string, subject: string, html: string) {
|
||||
await brevo.transactionalEmails.sendTransacEmail({
|
||||
sender: DEFAULT_SENDER,
|
||||
|
|
@ -18,9 +159,13 @@ export async function sendEmail(to: string, subject: string, html: string) {
|
|||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Verification email */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function generateVerificationToken(email: string): Promise<string> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { identifier: email },
|
||||
|
|
@ -41,24 +186,26 @@ export async function sendVerificationEmail(email: string, baseUrl: string) {
|
|||
const token = await generateVerificationToken(email);
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email/confirm?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
|
||||
<h2 style="margin-bottom:16px">Verify your email address</h2>
|
||||
<p>Click the button below to verify your email for Echo OCR.</p>
|
||||
<a href="${verifyUrl}"
|
||||
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="font-size:13px;color:#666">
|
||||
If you didn't create an account, you can ignore this email.
|
||||
This link expires in 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const html = emailShell({
|
||||
preheader: "Confirm your email to start scanning cards with Echo.",
|
||||
heading: "Verify your email address",
|
||||
body: `
|
||||
<p style="margin:0 0 16px;">Welcome to Echo! Before you can start scanning response cards, we need to verify your email address.</p>
|
||||
<p style="margin:0;">Click the button below to confirm your email and get started.</p>
|
||||
`,
|
||||
ctaLabel: "Verify Email Address",
|
||||
ctaUrl: verifyUrl,
|
||||
footer:
|
||||
"If you didn't create an Echo account, you can safely ignore this email. This link expires in 24 hours.",
|
||||
});
|
||||
|
||||
await sendEmail(email, "Verify your email — Echo OCR", html);
|
||||
await sendEmail(email, "Verify your email — Echo", html);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Invitation email */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function sendInvitationEmail(
|
||||
email: string,
|
||||
baseUrl: string,
|
||||
|
|
@ -68,19 +215,25 @@ export async function sendInvitationEmail(
|
|||
) {
|
||||
const inviteUrl = `${baseUrl}/invite/${inviteToken}`;
|
||||
|
||||
const html = `
|
||||
<div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#1a1a1a">
|
||||
<h2 style="margin-bottom:16px">You're invited to ${orgName}</h2>
|
||||
<p>${inviterName ? `${inviterName} has` : "You've been"} invited you to join <strong>${orgName}</strong> on Echo OCR.</p>
|
||||
<a href="${inviteUrl}"
|
||||
style="display:inline-block;margin:24px 0;padding:12px 24px;background:#6366f1;color:#fff;text-decoration:none;border-radius:8px;font-weight:600">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<p style="font-size:13px;color:#666">
|
||||
This invitation expires in 7 days. If you weren't expecting this, you can ignore it.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const inviterLine = inviterName
|
||||
? `<strong>${inviterName}</strong> has invited you to join`
|
||||
: "You've been invited to join";
|
||||
|
||||
await sendEmail(email, `You're invited to ${orgName} — Echo OCR`, html);
|
||||
const html = emailShell({
|
||||
preheader: `${inviterName || "Your team"} invited you to join ${orgName} on Echo.`,
|
||||
heading: `You're invited to ${orgName}`,
|
||||
body: `
|
||||
<p style="margin:0 0 16px;">${inviterLine} <strong>${orgName}</strong> on Echo — AI-powered response card scanning for churches and ministries.</p>
|
||||
<p style="margin:0;">Accept the invitation below to join the team and start collaborating.</p>
|
||||
`,
|
||||
ctaLabel: "Accept Invitation",
|
||||
ctaUrl: inviteUrl,
|
||||
footer: `This invitation expires in 7 days. If you weren't expecting this email, you can safely ignore it.`,
|
||||
});
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
`You're invited to ${orgName} — Echo`,
|
||||
html
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue