feat(email): port remaining 7 templates to react-email
Phase 2 (single commit) — applies the portal-auth.tsx pattern to every hand-strung transactional email template. JSX components rendered via @react-email/components' render() replace inline-style string templates + hand-rolled escapeHtml(). Ported (.ts → .tsx, public function signatures become async): crm-invite.tsx — admin/super-admin CRM invite admin-email-change.tsx — sign-in email changed notification inquiry-client-confirmation.tsx — public berth inquiry receipt inquiry-sales-notification.tsx — internal sales alert for inquiries residential-inquiry.tsx — pair: client confirmation + sales alert notification-digest.tsx — daily/hourly unread-notification digest document-signing.tsx — triplet: invitation + completed + reminder Each template now defines its body as a typed React component, drops escapeHtml() entirely (react-email auto-escapes string interpolation in JSX text + attributes), and passes the rendered HTML to the existing renderShell() for shell wrapping. The shell + branding flow is unchanged. Caller migration (all sync → async): src/app/api/public/residential-inquiries/route.ts src/lib/queue/workers/email.ts src/lib/services/notification-digest.service.ts src/lib/services/users.service.ts src/lib/services/document-signing-emails.service.ts src/lib/services/crm-invite.service.ts All call sites already lived inside async functions; only the await was needed. No public API shape changes other than return type (now Promise). The pattern now applies uniformly across all 8 email templates (portal- auth.tsx + the 7 in this commit). Email template directory is fully react-email-based. 1298/1298 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,7 +144,7 @@ async function sendResidentialNotifications(args: {
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation(
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
@@ -186,7 +186,7 @@ async function sendResidentialNotifications(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert(
|
||||
const alert = await residentialSalesAlert(
|
||||
{
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface AdminEmailChangeData {
|
||||
recipientName?: string;
|
||||
/** New address the user should sign in with from now on. */
|
||||
newEmail: string;
|
||||
/** Display name of the admin who initiated the change — surfaced so the
|
||||
* recipient knows who to follow up with. */
|
||||
changedByDisplayName?: string;
|
||||
/** Optional URL for the sign-in page. */
|
||||
loginUrl?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function adminEmailChangeEmail(
|
||||
data: AdminEmailChangeData,
|
||||
overrides?: RenderOpts,
|
||||
): { subject: string; html: string; text: string } {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `An administrator updated your ${portName} sign-in email`;
|
||||
const greeting = data.recipientName ? `Hello ${escapeHtml(data.recipientName)},` : 'Hello,';
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const adminLine = data.changedByDisplayName
|
||||
? `${escapeHtml(data.changedByDisplayName)} (an administrator)`
|
||||
: 'an administrator';
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Your sign-in email was changed
|
||||
</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;">
|
||||
${adminLine} just updated the email address linked to your ${escapeHtml(
|
||||
portName,
|
||||
)} account. From now on, please sign in with the new address below:
|
||||
</p>
|
||||
<p style="margin:20px 0; text-align:center; font-size:16px;">
|
||||
<strong>${escapeHtml(data.newEmail)}</strong>
|
||||
</p>
|
||||
${
|
||||
data.loginUrl
|
||||
? `<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${safeUrl(data.loginUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Sign in
|
||||
</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 weren't expecting this change, contact your administrator immediately.
|
||||
Your old address (the one this message was sent to) can no longer be used to
|
||||
sign in.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thanks,<br />
|
||||
<strong>${escapeHtml(portName)}</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Your sign-in email was changed`,
|
||||
'',
|
||||
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
|
||||
`From now on, sign in with: ${data.newEmail}`,
|
||||
'',
|
||||
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
|
||||
'',
|
||||
`If you weren't expecting this change, contact your administrator immediately.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const html = renderShell({
|
||||
branding: overrides?.branding ?? null,
|
||||
title: subject,
|
||||
body,
|
||||
});
|
||||
|
||||
return { subject, html, text };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
109
src/lib/email/templates/admin-email-change.tsx
Normal file
109
src/lib/email/templates/admin-email-change.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Button, Hr, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface AdminEmailChangeData {
|
||||
recipientName?: string;
|
||||
newEmail: string;
|
||||
changedByDisplayName?: string;
|
||||
loginUrl?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
function AdminEmailChangeBody({
|
||||
portName,
|
||||
newEmail,
|
||||
recipientName,
|
||||
changedByDisplayName,
|
||||
loginUrl,
|
||||
accent,
|
||||
}: AdminEmailChangeData & { portName: string; accent: string }) {
|
||||
const greeting = recipientName ? `Hello ${recipientName},` : 'Hello,';
|
||||
const adminLine = changedByDisplayName
|
||||
? `${changedByDisplayName} (an administrator)`
|
||||
: 'an administrator';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Your sign-in email was changed
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
{adminLine} just updated the email address linked to your {portName} account. From now on,
|
||||
please sign in with the new address below:
|
||||
</Text>
|
||||
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
|
||||
<strong>{newEmail}</strong>
|
||||
</Text>
|
||||
{loginUrl ? (
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(loginUrl)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 35px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<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 weren't expecting this change, contact your administrator immediately. Your old
|
||||
address (the one this message was sent to) can no longer be used to sign in.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thanks,
|
||||
<br />
|
||||
<strong>{portName}</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function adminEmailChangeEmail(
|
||||
data: AdminEmailChangeData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `An administrator updated your ${portName} sign-in email`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
<AdminEmailChangeBody {...data} portName={portName} accent={accent} />,
|
||||
{
|
||||
pretty: false,
|
||||
},
|
||||
);
|
||||
|
||||
const text = [
|
||||
`Your sign-in email was changed`,
|
||||
'',
|
||||
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
|
||||
`From now on, sign in with: ${data.newEmail}`,
|
||||
'',
|
||||
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
|
||||
'',
|
||||
`If you weren't expecting this change, contact your administrator immediately.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ branding: overrides?.branding ?? null, title: subject, body }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface InviteData {
|
||||
link: string;
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
/** Display name for the port — falls back to "Port Nimara" so the
|
||||
* pre-multi-tenant default still reads correctly. */
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function crmInviteEmail(
|
||||
data: InviteData,
|
||||
overrides?: RenderOpts,
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
} {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `You're invited to the ${portName} CRM`;
|
||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Welcome to the ${escapeHtml(portName)} CRM
|
||||
</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 the ${escapeHtml(portName)} CRM as a ${role}. 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;">
|
||||
Set up your 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(portName)} CRM</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Welcome to the ${portName} CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`${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, ''');
|
||||
}
|
||||
121
src/lib/email/templates/crm-invite.tsx
Normal file
121
src/lib/email/templates/crm-invite.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface InviteData {
|
||||
link: string;
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
/** Display name for the port — falls back to "Port Nimara" so the
|
||||
* pre-multi-tenant default still reads correctly. */
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
function InviteBody({
|
||||
portName,
|
||||
link,
|
||||
ttlHours,
|
||||
recipientName,
|
||||
role,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
link: string;
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
role: string;
|
||||
accent: string;
|
||||
}) {
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Welcome to the {portName} CRM
|
||||
</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 the {portName} CRM as a {role}. 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',
|
||||
}}
|
||||
>
|
||||
Set up your 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function crmInviteEmail(
|
||||
data: InviteData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `You're invited to the ${portName} CRM`;
|
||||
const role = data.isSuperAdmin ? 'super administrator' : 'administrator';
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
<InviteBody
|
||||
portName={portName}
|
||||
link={data.link}
|
||||
ttlHours={data.ttlHours}
|
||||
recipientName={data.recipientName}
|
||||
role={role}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = [
|
||||
`Welcome to the ${portName} CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Branded transactional emails for the Documenso signing lifecycle.
|
||||
*
|
||||
* Three template families:
|
||||
*
|
||||
* 1. `signingInvitation` — sent to a single signer when their turn
|
||||
* to sign comes up. Used both for the initial client invite (after
|
||||
* EOI/contract/reservation generation) AND for the cascading
|
||||
* "your turn" emails when an earlier signer completes (developer
|
||||
* after client signs, approver after developer signs, etc).
|
||||
*
|
||||
* 2. `signingCompleted` — sent to ALL signers (with the finalized
|
||||
* signed PDF as an attachment) when the document reaches a fully
|
||||
* signed state. Mirrors the old system's
|
||||
* `sendFinalizedDocumentToSignatories` flow.
|
||||
*
|
||||
* 3. `signingReminder` — sent when a rep nudges an unsigned recipient
|
||||
* manually OR when the rate-limited reminder service fires. Same
|
||||
* visual shape as `signingInvitation` with different copy.
|
||||
*
|
||||
* All three use the per-port `BrandingShell` (logo + primary color +
|
||||
* header/footer HTML) so each tenant's outbound emails match its
|
||||
* brand. The signing URL passed in is expected to already be
|
||||
* embedded-format (e.g. `https://portnimara.com/sign/<type>/<token>`)
|
||||
* — the caller (interest service / webhook handler) does the
|
||||
* transformation from the raw Documenso URL.
|
||||
*/
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
interface InvitationData {
|
||||
/** Display name for the recipient — used in the greeting. */
|
||||
recipientName: string;
|
||||
/** Friendly document type label. e.g. "Expression of Interest", "Sales Contract", "Reservation Agreement". */
|
||||
documentLabel: string;
|
||||
/** Optional. The signer's role: 'client' | 'developer' | 'approver' | 'witness' etc. Drives copy nuance. */
|
||||
signerRole?: string | null;
|
||||
/** Embedded signing URL (already wrapped to the public branded host). */
|
||||
signingUrl: string;
|
||||
/** Port name to brand the email. */
|
||||
portName: string;
|
||||
/** Sales rep / sender name shown in the closing. Falls back to "{portName} team". */
|
||||
senderName?: string | null;
|
||||
/** Optional plain-text message from the rep to include above the CTA. */
|
||||
customMessage?: string | null;
|
||||
}
|
||||
|
||||
export function signingInvitationEmail(
|
||||
data: InvitationData,
|
||||
overrides?: RenderOpts,
|
||||
): { subject: string; html: string; text: string } {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const docLabelEsc = escapeHtml(data.documentLabel);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName)
|
||||
: `${data.documentLabel} ready to sign — ${data.portName}`;
|
||||
|
||||
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
|
||||
const closer = data.senderName
|
||||
? `${escapeHtml(data.senderName)}<br /><strong>${escapeHtml(data.portName)}</strong>`
|
||||
: `<strong>The ${escapeHtml(data.portName)} team</strong>`;
|
||||
|
||||
// Slightly different lead paragraph based on signer role so the
|
||||
// developer / approver emails don't read as if they're the client.
|
||||
const isClient = (data.signerRole ?? 'client') === 'client';
|
||||
const leadCopy = isClient
|
||||
? `Your ${docLabelEsc} for <strong>${escapeHtml(data.portName)}</strong> is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
|
||||
: data.signerRole === 'approver'
|
||||
? `An ${docLabelEsc} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
|
||||
: `An ${docLabelEsc} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
|
||||
|
||||
const customMessageBlock = data.customMessage
|
||||
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
${docLabelEsc} ready to sign
|
||||
</p>
|
||||
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
|
||||
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">${leadCopy}</p>
|
||||
${customMessageBlock}
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Review & sign
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; margin-top:18px;">
|
||||
Signing happens directly inside our website — your data isn't sent to a third-party signing service.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
${closer}
|
||||
</p>`;
|
||||
|
||||
const text = `${greeting}\n\n${stripTags(leadCopy)}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
interface CompletedData {
|
||||
recipientName: string;
|
||||
documentLabel: string;
|
||||
/** Identity of the linked client (the deal's primary subject). */
|
||||
clientName: string;
|
||||
portName: string;
|
||||
/** When the document reached fully-signed state. */
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
export function signingCompletedEmail(
|
||||
data: CompletedData,
|
||||
overrides?: RenderOpts,
|
||||
): { subject: string; html: string; text: string } {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const docLabelEsc = escapeHtml(data.documentLabel);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} fully signed — ${data.clientName}`;
|
||||
|
||||
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
|
||||
const completedDateStr = data.completedAt.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
${docLabelEsc} signed by all parties
|
||||
</p>
|
||||
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
|
||||
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
|
||||
The ${docLabelEsc} for <strong>${escapeHtml(data.clientName)}</strong> has been signed by every party as of ${completedDateStr}.
|
||||
</p>
|
||||
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
|
||||
The fully signed PDF is attached to this email for your records. A copy has also been stored in the ${escapeHtml(data.portName)} CRM.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
<strong>The ${escapeHtml(data.portName)} team</strong>
|
||||
</p>`;
|
||||
|
||||
const text = `${greeting}\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
interface ReminderData {
|
||||
recipientName: string;
|
||||
documentLabel: string;
|
||||
signingUrl: string;
|
||||
portName: string;
|
||||
/** Human-readable string of how long ago the original invitation was sent. */
|
||||
invitedAgo: string;
|
||||
customMessage?: string | null;
|
||||
}
|
||||
|
||||
export function signingReminderEmail(
|
||||
data: ReminderData,
|
||||
overrides?: RenderOpts,
|
||||
): { subject: string; html: string; text: string } {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const docLabelEsc = escapeHtml(data.documentLabel);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
|
||||
|
||||
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
|
||||
const customMessageBlock = data.customMessage
|
||||
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
|
||||
: '';
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Just a quick reminder
|
||||
</p>
|
||||
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
|
||||
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
|
||||
We sent you a ${docLabelEsc} ${escapeHtml(data.invitedAgo)} that's still awaiting your signature. If you've already signed, please disregard this message — it can take a few minutes for our system to catch up.
|
||||
</p>
|
||||
${customMessageBlock}
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Sign now
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
|
||||
Direct link: <a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
<strong>The ${escapeHtml(data.portName)} team</strong>
|
||||
</p>`;
|
||||
|
||||
const text = `${greeting}\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
327
src/lib/email/templates/document-signing.tsx
Normal file
327
src/lib/email/templates/document-signing.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Branded transactional emails for the Documenso signing lifecycle.
|
||||
*
|
||||
* Three template families:
|
||||
*
|
||||
* 1. signingInvitation — sent to a single signer when their turn to sign
|
||||
* comes up. Used both for initial client invites AND cascading "your
|
||||
* turn" emails when an earlier signer completes.
|
||||
*
|
||||
* 2. signingCompleted — sent to ALL signers (with the finalized signed
|
||||
* PDF as an attachment) when the document reaches a fully signed state.
|
||||
*
|
||||
* 3. signingReminder — sent when a rep nudges manually OR when the
|
||||
* rate-limited reminder service fires.
|
||||
*
|
||||
* All three use the per-port BrandingShell. The signing URL is expected
|
||||
* to already be embedded-format (e.g. /sign/<type>/<token>) — the caller
|
||||
* does the transformation from the raw Documenso URL.
|
||||
*/
|
||||
|
||||
import { Button, Hr, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
// ─── 1. Invitation ───────────────────────────────────────────────────────────
|
||||
|
||||
interface InvitationData {
|
||||
recipientName: string;
|
||||
documentLabel: string;
|
||||
signerRole?: string | null;
|
||||
signingUrl: string;
|
||||
portName: string;
|
||||
senderName?: string | null;
|
||||
customMessage?: string | null;
|
||||
}
|
||||
|
||||
function InvitationBody({ data, accent }: { data: InvitationData; accent: string }) {
|
||||
const greeting = `Dear ${data.recipientName},`;
|
||||
const isClient = (data.signerRole ?? 'client') === 'client';
|
||||
const leadCopy = isClient
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
|
||||
: data.signerRole === 'approver'
|
||||
? `An ${data.documentLabel} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
|
||||
: `An ${data.documentLabel} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
{data.documentLabel} ready to sign
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>{leadCopy}</Text>
|
||||
{data.customMessage ? (
|
||||
<Text
|
||||
style={{
|
||||
margin: '20px 0',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: '#444',
|
||||
padding: '14px 18px',
|
||||
background: '#f8f9fb',
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{data.customMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.signingUrl)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
Review & sign
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
|
||||
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
|
||||
If the button doesn't work, paste this link into your browser:
|
||||
<br />
|
||||
<Link
|
||||
href={safeUrl(data.signingUrl)}
|
||||
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||
>
|
||||
{data.signingUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', marginTop: '18px' }}>
|
||||
Signing happens directly inside our website — your data isn't sent to a third-party
|
||||
signing service.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
{data.senderName ? (
|
||||
<>
|
||||
{data.senderName}
|
||||
<br />
|
||||
<strong>{data.portName}</strong>
|
||||
</>
|
||||
) : (
|
||||
<strong>The {data.portName} team</strong>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function signingInvitationEmail(
|
||||
data: InvitationData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName)
|
||||
: `${data.documentLabel} ready to sign — ${data.portName}`;
|
||||
|
||||
const body = await render(<InvitationBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
const isClient = (data.signerRole ?? 'client') === 'client';
|
||||
const leadText = isClient
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing.`
|
||||
: data.signerRole === 'approver'
|
||||
? `An ${data.documentLabel} is awaiting your approval.`
|
||||
: `An ${data.documentLabel} is awaiting your signature.`;
|
||||
const text = `Dear ${data.recipientName},\n\n${leadText}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 2. Completed ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CompletedData {
|
||||
recipientName: string;
|
||||
documentLabel: string;
|
||||
clientName: string;
|
||||
portName: string;
|
||||
completedAt: Date;
|
||||
}
|
||||
|
||||
function CompletedBody({
|
||||
data,
|
||||
accent,
|
||||
completedDateStr,
|
||||
}: {
|
||||
data: CompletedData;
|
||||
accent: string;
|
||||
completedDateStr: string;
|
||||
}) {
|
||||
const greeting = `Dear ${data.recipientName},`;
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
{data.documentLabel} signed by all parties
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The {data.documentLabel} for <strong>{data.clientName}</strong> has been signed by every
|
||||
party as of {completedDateStr}.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The fully signed PDF is attached to this email for your records. A copy has also been stored
|
||||
in the {data.portName} CRM.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
<strong>The {data.portName} team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function signingCompletedEmail(
|
||||
data: CompletedData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} fully signed — ${data.clientName}`;
|
||||
const completedDateStr = data.completedAt.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const body = await render(
|
||||
<CompletedBody data={data} accent={accent} completedDateStr={completedDateStr} />,
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 3. Reminder ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReminderData {
|
||||
recipientName: string;
|
||||
documentLabel: string;
|
||||
signingUrl: string;
|
||||
portName: string;
|
||||
invitedAgo: string;
|
||||
customMessage?: string | null;
|
||||
}
|
||||
|
||||
function ReminderBody({ data, accent }: { data: ReminderData; accent: string }) {
|
||||
const greeting = `Dear ${data.recipientName},`;
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Just a quick reminder
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
We sent you a {data.documentLabel} {data.invitedAgo} that's still awaiting your
|
||||
signature. If you've already signed, please disregard this message — it can take a few
|
||||
minutes for our system to catch up.
|
||||
</Text>
|
||||
{data.customMessage ? (
|
||||
<Text
|
||||
style={{
|
||||
margin: '20px 0',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: '#444',
|
||||
padding: '14px 18px',
|
||||
background: '#f8f9fb',
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{data.customMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.signingUrl)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
Sign now
|
||||
</Button>
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
|
||||
<Text style={{ fontSize: '13px', color: '#666', lineHeight: '1.5', padding: '14px 0 0' }}>
|
||||
Direct link:{' '}
|
||||
<Link
|
||||
href={safeUrl(data.signingUrl)}
|
||||
style={{ color: accent, textDecoration: 'underline', wordBreak: 'break-all' }}
|
||||
>
|
||||
{data.signingUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
<strong>The {data.portName} team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function signingReminderEmail(
|
||||
data: ReminderData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
|
||||
|
||||
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
const text = `Dear ${data.recipientName},\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
export interface InquiryClientConfirmationData {
|
||||
firstName: string;
|
||||
mooringNumber: string | null;
|
||||
contactEmail: string;
|
||||
/** Display name; falls back to "Port Nimara". */
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function inquiryClientConfirmation(
|
||||
data: InquiryClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const { firstName, mooringNumber, contactEmail } = data;
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
|
||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
|
||||
const subject = mooringNumber
|
||||
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
||||
: `Thank You for Your Interest in a ${portName} Berth`;
|
||||
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:16px;">Dear ${escapeHtml(firstName)},</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
Thank you for expressing interest in ${escapeHtml(berthText)}.
|
||||
Our team has registered your interest, and we will reach out to you very shortly
|
||||
by your preferred method of contact with more information.
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
If you have any questions, please feel free to reach out to us at
|
||||
<a href="mailto:${escapeHtml(contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px;">
|
||||
Best regards,<br />
|
||||
The ${escapeHtml(portName)} Sales Team
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
'',
|
||||
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
'',
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
`The ${portName} Sales Team`,
|
||||
].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, ''');
|
||||
}
|
||||
96
src/lib/email/templates/inquiry-client-confirmation.tsx
Normal file
96
src/lib/email/templates/inquiry-client-confirmation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
export interface InquiryClientConfirmationData {
|
||||
firstName: string;
|
||||
mooringNumber: string | null;
|
||||
contactEmail: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
function ClientConfirmationBody({
|
||||
firstName,
|
||||
berthText,
|
||||
contactEmail,
|
||||
portName,
|
||||
accent,
|
||||
}: {
|
||||
firstName: string;
|
||||
berthText: string;
|
||||
contactEmail: string;
|
||||
portName: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
|
||||
we will reach out to you very shortly by your preferred method of contact with more
|
||||
information.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
If you have any questions, please feel free to reach out to us at{' '}
|
||||
<Link
|
||||
href={`mailto:${contactEmail}`}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
{contactEmail}
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {portName} Sales Team
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function inquiryClientConfirmation(
|
||||
data: InquiryClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const { firstName, mooringNumber, contactEmail } = data;
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const berthText = mooringNumber ? `Berth ${mooringNumber}` : `a ${portName} Berth`;
|
||||
const subject = mooringNumber
|
||||
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
||||
: `Thank You for Your Interest in a ${portName} Berth`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
firstName={firstName}
|
||||
berthText={berthText}
|
||||
contactEmail={contactEmail}
|
||||
portName={portName}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
'',
|
||||
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
'',
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
`The ${portName} Sales Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
export interface InquirySalesNotificationData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mooringNumber: string | null;
|
||||
crmUrl: string;
|
||||
/** Display name; falls back to "Port Nimara". */
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function inquirySalesNotification(
|
||||
data: InquirySalesNotificationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const { fullName, email, phone, mooringNumber, crmUrl } = data;
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const mooringDisplay = mooringNumber || 'None';
|
||||
const subject = `New Interest - ${portName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:16px;">Dear Administrator,</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">${escapeHtml(fullName)} has expressed their interest in <strong>${escapeHtml(portName)}</strong>. Here are their details:</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Name:</strong> ${escapeHtml(fullName)}</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Email:</strong> ${escapeHtml(email)}</p>
|
||||
<p style="margin-bottom:0; font-size:16px;"><strong>Telephone:</strong> ${escapeHtml(phone)}</p>
|
||||
<p style="margin:0 0 16px 0; font-size:16px;"><strong>Berths Selected:</strong> ${escapeHtml(mooringDisplay)}</p>
|
||||
<p style="margin-bottom:10px; font-size:16px;">Please visit the <a href="${escapeHtml(crmUrl)}" target="_blank" style="color:${accent}; text-decoration:underline;">${escapeHtml(portName)} CRM</a> to view more information.</p>
|
||||
<p style="font-size:16px;">Thank you,<br/>${escapeHtml(portName)} CRM</p>`;
|
||||
|
||||
const text = [
|
||||
'Dear Administrator,',
|
||||
'',
|
||||
`${fullName} has expressed their interest in ${portName}. Here are their details:`,
|
||||
'',
|
||||
`Name: ${fullName}`,
|
||||
`Email: ${email}`,
|
||||
`Telephone: ${phone}`,
|
||||
`Berths Selected: ${mooringDisplay}`,
|
||||
'',
|
||||
`Please visit the ${portName} CRM (${crmUrl}) to view more information.`,
|
||||
'',
|
||||
'Thank you',
|
||||
`${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, ''');
|
||||
}
|
||||
115
src/lib/email/templates/inquiry-sales-notification.tsx
Normal file
115
src/lib/email/templates/inquiry-sales-notification.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
export interface InquirySalesNotificationData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mooringNumber: string | null;
|
||||
crmUrl: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
function SalesNotificationBody({
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
mooringDisplay,
|
||||
crmUrl,
|
||||
portName,
|
||||
accent,
|
||||
}: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mooringDisplay: string;
|
||||
crmUrl: string;
|
||||
portName: string;
|
||||
accent: string;
|
||||
}) {
|
||||
const detailStyle = { margin: '0 0 0', fontSize: '16px' } as const;
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear Administrator,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
{fullName} has expressed their interest in <strong>{portName}</strong>. Here are their
|
||||
details:
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {fullName}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Email:</strong> {email}
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Telephone:</strong> {phone}
|
||||
</Text>
|
||||
<Text style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
|
||||
<strong>Berths Selected:</strong> {mooringDisplay}
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Please visit the{' '}
|
||||
<Link href={crmUrl} style={{ color: accent, textDecoration: 'underline' }}>
|
||||
{portName} CRM
|
||||
</Link>{' '}
|
||||
to view more information.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
{portName} CRM
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function inquirySalesNotification(
|
||||
data: InquirySalesNotificationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const mooringDisplay = data.mooringNumber || 'None';
|
||||
const subject = `New Interest - ${portName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
<SalesNotificationBody
|
||||
fullName={data.fullName}
|
||||
email={data.email}
|
||||
phone={data.phone}
|
||||
mooringDisplay={mooringDisplay}
|
||||
crmUrl={data.crmUrl}
|
||||
portName={portName}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = [
|
||||
'Dear Administrator,',
|
||||
'',
|
||||
`${data.fullName} has expressed their interest in ${portName}. Here are their details:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
`Telephone: ${data.phone}`,
|
||||
`Berths Selected: ${mooringDisplay}`,
|
||||
'',
|
||||
`Please visit the ${portName} CRM (${data.crmUrl}) to view more information.`,
|
||||
'',
|
||||
'Thank you',
|
||||
`${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Daily / hourly digest email of a CRM user's unread notifications.
|
||||
* Used by the notification-digest scheduler (queued in `email` worker).
|
||||
*/
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface DigestData {
|
||||
portName: string;
|
||||
recipientName: string;
|
||||
/** Each notification we want to surface. Trimmed to ~20 by the
|
||||
* caller — anything longer drops a link to the in-app inbox. */
|
||||
items: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
totalUnread: number;
|
||||
inboxLink: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reminder_due: 'Reminder due',
|
||||
reminder_overdue: 'Reminder overdue',
|
||||
new_registration: 'New inquiry',
|
||||
eoi_signed: 'EOI signed',
|
||||
eoi_completed: 'EOI completed',
|
||||
email_received: 'New email',
|
||||
duplicate_alert: 'Possible duplicate',
|
||||
invoice_overdue: 'Invoice overdue',
|
||||
system_alert: 'System alert',
|
||||
follow_up_created: 'Follow-up',
|
||||
tenure_expiring: 'Tenure expiring',
|
||||
berth_released: 'Berth released',
|
||||
};
|
||||
|
||||
export function notificationDigestEmail(
|
||||
data: DigestData,
|
||||
overrides?: RenderOpts,
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
} {
|
||||
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const itemsHtml = data.items
|
||||
.map((item) => {
|
||||
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
|
||||
const titleHtml = item.link
|
||||
? `<a href="${safeUrl(item.link)}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
||||
: `<strong>${escapeHtml(item.title)}</strong>`;
|
||||
const desc = item.description
|
||||
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
|
||||
: '';
|
||||
return `<tr><td style="padding:10px 0; border-bottom:1px solid #eee;">
|
||||
<div style="font-size:11px; text-transform:uppercase; color:#999; letter-spacing:0.04em;">${label}</div>
|
||||
<div style="font-size:14px; margin-top:2px;">${titleHtml}</div>
|
||||
${desc}
|
||||
</td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const tail =
|
||||
data.totalUnread > data.items.length
|
||||
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
|
||||
<a href="${safeUrl(data.inboxLink)}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
|
||||
: '';
|
||||
|
||||
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
|
||||
|
||||
const body = `
|
||||
<p style="font-size:18px; font-weight:bold; color:${accent}; margin:0 0 6px;">
|
||||
Your ${escapeHtml(data.portName)} CRM digest
|
||||
</p>
|
||||
<p style="font-size:14px; line-height:1.5; margin:0 0 14px;">${greeting}</p>
|
||||
<p style="font-size:14px; line-height:1.5; margin:0 0 16px;">
|
||||
You have <strong>${data.totalUnread}</strong> unread notification${data.totalUnread === 1 ? '' : 's'} since the last digest.
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
${itemsHtml}
|
||||
</table>
|
||||
${tail}
|
||||
<p style="margin-top:24px; font-size:13px; color:#666;">
|
||||
Thank you,<br />
|
||||
<strong>${escapeHtml(data.portName)} CRM</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`${data.portName} CRM digest`,
|
||||
'',
|
||||
`You have ${data.totalUnread} unread notifications.`,
|
||||
'',
|
||||
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
|
||||
'',
|
||||
`Inbox: ${data.inboxLink}`,
|
||||
].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, ''');
|
||||
}
|
||||
140
src/lib/email/templates/notification-digest.tsx
Normal file
140
src/lib/email/templates/notification-digest.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface DigestData {
|
||||
portName: string;
|
||||
recipientName: string;
|
||||
items: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
totalUnread: number;
|
||||
inboxLink: string;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
reminder_due: 'Reminder due',
|
||||
reminder_overdue: 'Reminder overdue',
|
||||
new_registration: 'New inquiry',
|
||||
eoi_signed: 'EOI signed',
|
||||
eoi_completed: 'EOI completed',
|
||||
email_received: 'New email',
|
||||
duplicate_alert: 'Possible duplicate',
|
||||
invoice_overdue: 'Invoice overdue',
|
||||
system_alert: 'System alert',
|
||||
follow_up_created: 'Follow-up',
|
||||
tenure_expiring: 'Tenure expiring',
|
||||
berth_released: 'Berth released',
|
||||
};
|
||||
|
||||
function DigestBody({
|
||||
portName,
|
||||
recipientName,
|
||||
items,
|
||||
totalUnread,
|
||||
inboxLink,
|
||||
accent,
|
||||
}: DigestData & { accent: string }) {
|
||||
const greeting = recipientName ? `Hi ${recipientName},` : 'Hi,';
|
||||
return (
|
||||
<>
|
||||
<Text style={{ fontSize: '18px', fontWeight: 'bold', color: accent, margin: '0 0 6px' }}>
|
||||
Your {portName} CRM digest
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 14px' }}>{greeting}</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 16px' }}>
|
||||
You have <strong>{totalUnread}</strong> unread notification
|
||||
{totalUnread === 1 ? '' : 's'} since the last digest.
|
||||
</Text>
|
||||
<table role="presentation" width="100%" cellSpacing={0} cellPadding={0} border={0}>
|
||||
<tbody>
|
||||
{items.map((item, i) => {
|
||||
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: '10px 0', borderBottom: '1px solid #eee' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#999',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', marginTop: '2px' }}>
|
||||
{item.link ? (
|
||||
<Link
|
||||
href={safeUrl(item.link)}
|
||||
style={{ color: accent, textDecoration: 'none' }}
|
||||
>
|
||||
<strong>{item.title}</strong>
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{item.title}</strong>
|
||||
)}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div style={{ fontSize: '13px', color: '#666', marginTop: '4px' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalUnread > items.length ? (
|
||||
<Text style={{ marginTop: '14px', fontSize: '13px', color: '#666' }}>
|
||||
…and {totalUnread - items.length} more.{' '}
|
||||
<Link href={safeUrl(inboxLink)} style={{ color: accent }}>
|
||||
Open the inbox
|
||||
</Link>{' '}
|
||||
to see everything.
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ marginTop: '24px', fontSize: '13px', color: '#666' }}>
|
||||
Thank you,
|
||||
<br />
|
||||
<strong>{portName} CRM</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function notificationDigestEmail(
|
||||
data: DigestData,
|
||||
overrides?: RenderOpts,
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });
|
||||
|
||||
const text = [
|
||||
`${data.portName} CRM digest`,
|
||||
'',
|
||||
`You have ${data.totalUnread} unread notifications.`,
|
||||
'',
|
||||
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
|
||||
'',
|
||||
`Inbox: ${data.inboxLink}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
/** Display name; falls back to "Port Nimara". */
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
export function residentialClientConfirmation(
|
||||
data: ResidentialClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `Thank You for Your Interest - ${portName} Residences`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Welcome to ${escapeHtml(portName)}
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
Dear ${escapeHtml(data.firstName)},
|
||||
</p>
|
||||
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
|
||||
Thank you for expressing interest in ${escapeHtml(portName)} residences. Our residential
|
||||
sales team has received your inquiry and will reach out to you shortly with
|
||||
more information.
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">
|
||||
If you have any questions in the meantime, please reach us at
|
||||
<a href="mailto:${escapeHtml(data.contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Best regards,<br />
|
||||
<strong>The ${escapeHtml(portName)} Residential Team</strong>
|
||||
</p>`;
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
crmDeepLink?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides?: RenderOpts) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `New Residential Inquiry - ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
New residential inquiry
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="font-size:14px; line-height:1.4; margin-bottom:20px;">
|
||||
<tr><td style="color:#666; width:140px;">Name</td><td>${escapeHtml(data.fullName)}</td></tr>
|
||||
<tr><td style="color:#666;">Email</td><td>${escapeHtml(data.email)}</td></tr>
|
||||
<tr><td style="color:#666;">Phone</td><td>${escapeHtml(data.phone)}</td></tr>
|
||||
${data.placeOfResidence ? `<tr><td style="color:#666;">Residence</td><td>${escapeHtml(data.placeOfResidence)}</td></tr>` : ''}
|
||||
${data.preferredContactMethod ? `<tr><td style="color:#666;">Prefers</td><td>${escapeHtml(data.preferredContactMethod)}</td></tr>` : ''}
|
||||
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
|
||||
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${safeUrl(data.crmDeepLink)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
||||
<p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
190
src/lib/email/templates/residential-inquiry.tsx
Normal file
190
src/lib/email/templates/residential-inquiry.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Button, Link, Text, render } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export interface ResidentialClientConfirmationData {
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
function ClientConfirmationBody({
|
||||
portName,
|
||||
firstName,
|
||||
contactEmail,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
firstName: string;
|
||||
contactEmail: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
Welcome to {portName}
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Dear {firstName},
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
Thank you for expressing interest in {portName} residences. Our residential sales team has
|
||||
received your inquiry and will reach out to you shortly with more information.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
If you have any questions in the meantime, please reach us at{' '}
|
||||
<Link
|
||||
href={`mailto:${contactEmail}`}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
>
|
||||
{contactEmail}
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Best regards,
|
||||
<br />
|
||||
<strong>The {portName} Residential Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function residentialClientConfirmation(
|
||||
data: ResidentialClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `Thank You for Your Interest - ${portName} Residences`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(
|
||||
<ClientConfirmationBody
|
||||
portName={portName}
|
||||
firstName={data.firstName}
|
||||
contactEmail={data.contactEmail}
|
||||
accent={accent}
|
||||
/>,
|
||||
{ pretty: false },
|
||||
);
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResidentialSalesAlertData {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
placeOfResidence?: string;
|
||||
preferredContactMethod?: 'email' | 'phone';
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
crmDeepLink?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
function SalesAlertBody({
|
||||
portName,
|
||||
data,
|
||||
accent,
|
||||
}: {
|
||||
portName: string;
|
||||
data: ResidentialSalesAlertData;
|
||||
accent: string;
|
||||
}) {
|
||||
const labelCell = { color: '#666', width: '140px' } as const;
|
||||
return (
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
|
||||
New residential inquiry
|
||||
</Text>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellPadding={6}
|
||||
cellSpacing={0}
|
||||
style={{ fontSize: '14px', lineHeight: '1.4', marginBottom: '20px' }}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={labelCell}>Name</td>
|
||||
<td>{data.fullName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelCell}>Email</td>
|
||||
<td>{data.email}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelCell}>Phone</td>
|
||||
<td>{data.phone}</td>
|
||||
</tr>
|
||||
{data.placeOfResidence ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Residence</td>
|
||||
<td>{data.placeOfResidence}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferredContactMethod ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Prefers</td>
|
||||
<td>{data.preferredContactMethod}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.preferences ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Preferences</td>
|
||||
<td>{data.preferences}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.notes ? (
|
||||
<tr>
|
||||
<td style={labelCell}>Notes</td>
|
||||
<td>{data.notes}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.crmDeepLink ? (
|
||||
<div style={{ textAlign: 'center', margin: '24px 0' }}>
|
||||
<Button
|
||||
href={safeUrl(data.crmDeepLink)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: accent,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '5px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Open in CRM
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Text style={{ fontSize: '14px', color: '#666' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function residentialSalesAlert(
|
||||
data: ResidentialSalesAlertData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const subject = `New Residential Inquiry - ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
});
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export const emailWorker = new Worker(
|
||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const email = inquiryClientConfirmation(
|
||||
const email = await inquiryClientConfirmation(
|
||||
{ firstName, mooringNumber, contactEmail, portName },
|
||||
{ branding },
|
||||
);
|
||||
@@ -67,7 +67,7 @@ export const emailWorker = new Worker(
|
||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const notification = inquirySalesNotification(
|
||||
const notification = await inquirySalesNotification(
|
||||
{
|
||||
fullName,
|
||||
email,
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function createCrmInvite(args: {
|
||||
});
|
||||
|
||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||
const result = crmInviteEmail({
|
||||
const result = await crmInviteEmail({
|
||||
link,
|
||||
ttlHours: INVITE_TTL_HOURS,
|
||||
recipientName: args.name,
|
||||
@@ -232,7 +232,7 @@ export async function resendCrmInvite(
|
||||
|
||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||
const branding = await getBrandingShell(meta.portId);
|
||||
const result = crmInviteEmail(
|
||||
const result = await crmInviteEmail(
|
||||
{
|
||||
link,
|
||||
ttlHours: INVITE_TTL_HOURS,
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function sendSigningInvitation(args: SigningInvitationArgs): Promis
|
||||
args.signerRole,
|
||||
);
|
||||
|
||||
const { subject, html, text } = signingInvitationEmail(
|
||||
const { subject, html, text } = await signingInvitationEmail(
|
||||
{
|
||||
recipientName: args.recipient.name,
|
||||
documentLabel: args.documentLabel,
|
||||
@@ -194,7 +194,7 @@ export async function sendSigningReminder(args: SigningReminderArgs): Promise<vo
|
||||
args.signerRole,
|
||||
);
|
||||
|
||||
const { subject, html, text } = signingReminderEmail(
|
||||
const { subject, html, text } = await signingReminderEmail(
|
||||
{
|
||||
recipientName: args.recipient.name,
|
||||
documentLabel: args.documentLabel,
|
||||
@@ -242,7 +242,7 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
|
||||
await Promise.all(
|
||||
args.recipients.map((recipient) =>
|
||||
sendLimit(async () => {
|
||||
const { subject, html, text } = signingCompletedEmail(
|
||||
const { subject, html, text } = await signingCompletedEmail(
|
||||
{
|
||||
recipientName: recipient.name,
|
||||
documentLabel: args.documentLabel,
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
|
||||
const visible = rows.slice(0, MAX_ITEMS_PER_USER);
|
||||
const inboxLink = `${env.APP_URL}/notifications`;
|
||||
const result = notificationDigestEmail(
|
||||
const result = await notificationDigestEmail(
|
||||
{
|
||||
portName: port.name,
|
||||
recipientName: u.name ?? '',
|
||||
|
||||
@@ -385,7 +385,7 @@ async function notifyAdminEmailChange(args: {
|
||||
getBrandingShell(args.portId).catch(() => null),
|
||||
]);
|
||||
|
||||
const { subject, html, text } = adminEmailChangeEmail(
|
||||
const { subject, html, text } = await adminEmailChangeEmail(
|
||||
{
|
||||
recipientName: args.displayName,
|
||||
newEmail: args.newEmail,
|
||||
|
||||
Reference in New Issue
Block a user