feat(branding): wire per-port branding through every transactional email + auth shell (R2-H15)
Multi-tenant branding admin (/admin/branding) was saving 5 settings
that no code read — every port's emails shipped Port Nimara's logo
and color regardless. Now wired end-to-end:
New shared infrastructure:
- src/lib/email/shell.ts — renderShell() + brandingPrimaryColor()
helpers; takes BrandingShell { logoUrl, primaryColor,
emailHeaderHtml, emailFooterHtml }, falls back to Port Nimara
defaults when null.
- src/lib/email/branding-resolver.ts — getBrandingShell(portId)
thin wrapper over getPortBrandingConfig() that returns null on
error / missing portId so senders never break on misconfig.
All 6 transactional templates refactored to use renderShell + the
shared accent color; portName now flows through every template
(crm-invite, portal activation/reset, both inquiries, both
residential templates, notification digest).
All 6 senders pass branding via getBrandingShell:
- portal-auth.service.ts (activation + reset)
- crm-invite.service.ts (resend path; create-invite has no portId
yet so falls through to defaults)
- email worker (inquiry confirmation + sales notification)
- residential-inquiries route (client confirmation + sales alert)
- notification-digest.service.ts (digest)
BrandedAuthShell takes an optional `branding` prop with logoUrl +
appName (parent page server-fetches via getPortBrandingConfig).
Defaults to Port Nimara if omitted, so single-tenant deployments
are unaffected.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
@@ -141,18 +142,22 @@ async function sendResidentialNotifications(args: {
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation({
|
||||
const confirmation = residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
});
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
const confirmationSubject = await resolveSubject({
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
portId,
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
||||
});
|
||||
await sendEmail(data.email, confirmationSubject, confirmation.html);
|
||||
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
||||
|
||||
// Sales-team alert - pull recipients from system_settings if configured;
|
||||
// fall back to the inquiry_contact_email if available.
|
||||
@@ -181,7 +186,8 @@ async function sendResidentialNotifications(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert({
|
||||
const alert = residentialSalesAlert(
|
||||
{
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
@@ -190,7 +196,9 @@ async function sendResidentialNotifications(args: {
|
||||
notes: data.notes,
|
||||
preferences: data.preferences,
|
||||
crmDeepLink,
|
||||
});
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
const alertSubject = await resolveSubject({
|
||||
key: 'residential_inquiry_sales_alert',
|
||||
portId,
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const LOGO_URL =
|
||||
const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const DEFAULT_LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
interface BrandedAuthShellProps {
|
||||
children: React.ReactNode;
|
||||
/** Per-port branding override resolved server-side by the page that
|
||||
* renders the shell. When omitted, falls back to the Port Nimara
|
||||
* defaults so single-tenant deployments remain unaffected. Pages
|
||||
* that know their portId at render time should pass the result of
|
||||
* `getPortBrandingConfig(portId)`. */
|
||||
branding?: {
|
||||
logoUrl?: string | null;
|
||||
appName?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Branded shell shared by every auth/form surface - CRM login, portal login,
|
||||
* password set/reset/activate, forgot-password. Renders the blurred Port
|
||||
* Nimara overhead background, the circular logo, and a centered white card
|
||||
* that consumers populate with their own form/content.
|
||||
* password set/reset/activate, forgot-password. Renders the blurred
|
||||
* background, the logo, and a centered white card that consumers
|
||||
* populate with their own form/content.
|
||||
*
|
||||
* Multi-tenant note (R2-H15): the per-port logoUrl from
|
||||
* /admin/branding is rendered when the parent page passes a `branding`
|
||||
* prop. The background image stays as the marina default for all
|
||||
* deployments — admin-authored backgrounds aren't part of the v1
|
||||
* branding surface.
|
||||
*/
|
||||
export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
|
||||
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
||||
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
|
||||
const altText = branding?.appName || 'Port Nimara';
|
||||
return (
|
||||
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
|
||||
{/*
|
||||
Full-viewport background layer - pinned to the visible viewport via
|
||||
`fixed inset-0` so the marina image always reaches the actual screen
|
||||
edges regardless of the iOS Safari URL bar showing/hiding. The shell's
|
||||
layout layer above sits on top via z-index.
|
||||
*/}
|
||||
<div
|
||||
aria-hidden
|
||||
className="fixed inset-0 -z-10"
|
||||
style={{
|
||||
backgroundImage: `url('${BG_URL}')`,
|
||||
backgroundImage: `url('${DEFAULT_BG_URL}')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundColor: '#f2f2f2',
|
||||
@@ -31,7 +46,7 @@ export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" />
|
||||
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
28
src/lib/email/branding-resolver.ts
Normal file
28
src/lib/email/branding-resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Resolve the per-port branding shell for transactional emails.
|
||||
*
|
||||
* Senders that have a portId call this once and pass the result into
|
||||
* the email template. Senders without a portId (e.g. CRM invite at
|
||||
* create-time before a port is selected) pass null — the shell
|
||||
* falls back to the Port Nimara defaults.
|
||||
*/
|
||||
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import type { BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
export async function getBrandingShell(
|
||||
portId: string | null | undefined,
|
||||
): Promise<BrandingShell | null> {
|
||||
if (!portId) return null;
|
||||
try {
|
||||
const cfg = await getPortBrandingConfig(portId);
|
||||
return {
|
||||
logoUrl: cfg.logoUrl,
|
||||
primaryColor: cfg.primaryColor,
|
||||
emailHeaderHtml: cfg.emailHeaderHtml,
|
||||
emailFooterHtml: cfg.emailFooterHtml,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
80
src/lib/email/shell.ts
Normal file
80
src/lib/email/shell.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared HTML shell for transactional emails. Centralises the table-
|
||||
* based layout + the per-port branding override surface so templates
|
||||
* don't each inline a different copy of the boilerplate.
|
||||
*
|
||||
* Per-port branding (R2-H15):
|
||||
* - logoUrl — replaces the default Port Nimara logo image
|
||||
* - primaryColor — used for the page-title accent color
|
||||
* - emailHeaderHtml / emailFooterHtml — admin-authored HTML that
|
||||
* appears above / below the body content (e.g. legal footer,
|
||||
* custom marketing strip). When unset, the existing minimal
|
||||
* "Thank you, {{portName}} CRM" sign-off is rendered by callers.
|
||||
*
|
||||
* Senders resolve a `BrandingShell` via `resolveBrandingShell(portId)`
|
||||
* (or pass `null` for no override) and forward it to the template
|
||||
* function. Templates call `renderShell({ title, body, branding })`.
|
||||
*/
|
||||
|
||||
const DEFAULT_LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const DEFAULT_BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const DEFAULT_PRIMARY_COLOR = '#0F4C81';
|
||||
|
||||
export interface BrandingShell {
|
||||
logoUrl: string | null;
|
||||
primaryColor: string | null;
|
||||
emailHeaderHtml: string | null;
|
||||
emailFooterHtml: string | null;
|
||||
}
|
||||
|
||||
interface ShellOpts {
|
||||
title: string;
|
||||
body: string;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
|
||||
const headerHtml = branding?.emailHeaderHtml ?? '';
|
||||
const footerHtml = branding?.emailFooterHtml ?? '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${DEFAULT_BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${logoUrl}" alt="Port logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${headerHtml ? `<div>${headerHtml}</div>` : ''}
|
||||
${body}
|
||||
${footerHtml ? `<div style="margin-top:24px;">${footerHtml}</div>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Surface the brand primary color to template bodies. */
|
||||
export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
|
||||
}
|
||||
@@ -1,83 +1,59 @@
|
||||
import { brandingPrimaryColor, renderShell, 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;
|
||||
}
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function crmInviteEmail(data: InviteData): {
|
||||
export function crmInviteEmail(
|
||||
data: InviteData,
|
||||
overrides?: RenderOpts,
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
} {
|
||||
const subject = `You're invited to the Port Nimara CRM`;
|
||||
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:#007bff;">
|
||||
Welcome to the Port Nimara CRM
|
||||
<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 Port Nimara CRM as a ${role}. Click the
|
||||
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="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${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="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
<a href="${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>Port Nimara CRM</strong>
|
||||
<strong>${escapeHtml(portName)} CRM</strong>
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Welcome to the Port Nimara CRM`,
|
||||
`Welcome to the ${portName} CRM`,
|
||||
'',
|
||||
`You've been invited as a ${role}.`,
|
||||
`Set up your account: ${data.link}`,
|
||||
@@ -85,10 +61,14 @@ export function crmInviteEmail(data: InviteData): {
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`Port Nimara CRM`,
|
||||
`${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
||||
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 Port Nimara Berth';
|
||||
|
||||
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 Port Nimara Berth';
|
||||
: `Thank You for Your Interest in a ${portName} Berth`;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${subject}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
||||
<center>
|
||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
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)}.
|
||||
@@ -43,20 +35,12 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
||||
</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:#007bff; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
||||
<a href="mailto:${escapeHtml(contactEmail)}" style="color:${accent}; text-decoration:underline;">${escapeHtml(contactEmail)}</a>.
|
||||
</p>
|
||||
<p style="font-size:16px;">
|
||||
Best regards,<br />
|
||||
The Port Nimara Sales Team
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
The ${escapeHtml(portName)} Sales Team
|
||||
</p>`;
|
||||
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
@@ -66,10 +50,14 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'The Port Nimara Sales Team',
|
||||
`The ${portName} Sales Team`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html, text };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
||||
@@ -1,73 +1,60 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function inquirySalesNotification(data: InquirySalesNotificationData) {
|
||||
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 subject = 'New Interest - Port Nimara';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>New Interest - Port Nimara</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('https://s3.portnimara.com/images/Overhead_1_blur.png'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333;">
|
||||
<center>
|
||||
<img src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
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>Port Nimara</strong>. Here are their details:</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:#007bff; text-decoration:underline;">Port Nimara CRM</a> to view more information.</p>
|
||||
<p style="font-size:16px;">Thank you,<br/>Port Nimara CRM</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
<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 Port Nimara. Here are their details:`,
|
||||
`${fullName} has expressed their interest in ${portName}. Here are their details:`,
|
||||
'',
|
||||
`Name: ${fullName}`,
|
||||
`Email: ${email}`,
|
||||
`Telephone: ${phone}`,
|
||||
`Berths Selected: ${mooringDisplay}`,
|
||||
'',
|
||||
`Please visit the Port Nimara CRM (${crmUrl}) to view more information.`,
|
||||
`Please visit the ${portName} CRM (${crmUrl}) to view more information.`,
|
||||
'',
|
||||
'Thank you',
|
||||
'Port Nimara CRM',
|
||||
`${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html, text };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Used by the notification-digest scheduler (queued in `email` worker).
|
||||
*/
|
||||
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface DigestData {
|
||||
portName: string;
|
||||
recipientName: string;
|
||||
@@ -19,45 +21,8 @@ interface DigestData {
|
||||
inboxLink: string;
|
||||
}
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${opts.title}</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:24px; font-family: Arial, sans-serif; color:#333333;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
@@ -75,18 +40,22 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
berth_released: 'Berth released',
|
||||
};
|
||||
|
||||
export function notificationDigestEmail(data: DigestData): {
|
||||
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="${item.link}" style="color:#007bff; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
||||
? `<a href="${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>`
|
||||
@@ -102,13 +71,13 @@ export function notificationDigestEmail(data: DigestData): {
|
||||
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="${data.inboxLink}" style="color:#007bff;">Open the inbox</a> to see everything.</p>`
|
||||
<a href="${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:#0F4C81; margin:0 0 6px;">
|
||||
<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>
|
||||
@@ -134,5 +103,18 @@ export function notificationDigestEmail(data: DigestData): {
|
||||
`Inbox: ${data.inboxLink}`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
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, ''');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface ActivationData {
|
||||
portName: string;
|
||||
link: string;
|
||||
@@ -12,47 +14,14 @@ interface ResetData {
|
||||
recipientName?: string;
|
||||
}
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
branding?: BrandingShell | null;
|
||||
}
|
||||
|
||||
export function activationEmail(
|
||||
data: ActivationData,
|
||||
overrides?: { subject?: string | null },
|
||||
overrides?: RenderOpts,
|
||||
): {
|
||||
subject: string;
|
||||
html: string;
|
||||
@@ -65,9 +34,10 @@ export function activationEmail(
|
||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||
: `Activate your ${data.portName} client portal account`;
|
||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Welcome to ${escapeHtml(data.portName)}
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||
@@ -77,13 +47,13 @@ export function activationEmail(
|
||||
The link expires in ${data.ttlHours} hours.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Activate account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
<a href="${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 />
|
||||
@@ -102,12 +72,16 @@ export function activationEmail(
|
||||
`${data.portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetEmail(
|
||||
data: ResetData,
|
||||
overrides?: { subject?: string | null },
|
||||
overrides?: RenderOpts,
|
||||
): { subject: string; html: string; text: string } {
|
||||
const subject = overrides?.subject
|
||||
? overrides.subject
|
||||
@@ -116,9 +90,10 @@ export function resetEmail(
|
||||
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
||||
: `Reset your ${data.portName} client portal password`;
|
||||
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = `
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
|
||||
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
|
||||
Password reset
|
||||
</p>
|
||||
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
|
||||
@@ -128,7 +103,7 @@ export function resetEmail(
|
||||
The link expires in ${data.ttlMinutes} minutes.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Reset password
|
||||
</a>
|
||||
</p>
|
||||
@@ -152,7 +127,11 @@ export function resetEmail(
|
||||
`${data.portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, html: shell({ title: subject, body }), text };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
||||
@@ -1,69 +1,47 @@
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
function shell(opts: { title: string; body: string }): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>${opts.title}</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { border: 0; display: block; }
|
||||
p { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${opts.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
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) {
|
||||
const subject = 'Thank You for Your Interest - Port Nimara Residences';
|
||||
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:#007bff;">
|
||||
Welcome to Port Nimara
|
||||
<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 Port Nimara residences. Our residential
|
||||
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:#007bff; text-decoration:underline;">${escapeHtml(data.contactEmail)}</a>.
|
||||
<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 Port Nimara Residential Team</strong>
|
||||
<strong>The ${escapeHtml(portName)} Residential Team</strong>
|
||||
</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
return {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResidentialSalesAlertData {
|
||||
@@ -75,12 +53,15 @@ export interface ResidentialSalesAlertData {
|
||||
notes?: string;
|
||||
preferences?: string;
|
||||
crmDeepLink?: string;
|
||||
portName?: string;
|
||||
}
|
||||
|
||||
export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
||||
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:#007bff;">
|
||||
<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;">
|
||||
@@ -92,9 +73,12 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData) {
|
||||
${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="${data.crmDeepLink}" style="display:inline-block; background-color:#007bff; 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;">- Port Nimara CRM</p>`;
|
||||
return { subject, html: shell({ title: subject, body }) };
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${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 {
|
||||
|
||||
@@ -30,7 +30,12 @@ export const emailWorker = new Worker(
|
||||
await import('@/lib/email/templates/inquiry-client-confirmation');
|
||||
const { sendEmail } = await import('@/lib/email/index');
|
||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||
const email = inquiryClientConfirmation({ firstName, mooringNumber, contactEmail });
|
||||
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const email = inquiryClientConfirmation(
|
||||
{ firstName, mooringNumber, contactEmail, portName },
|
||||
{ branding },
|
||||
);
|
||||
const subject = await resolveSubject({
|
||||
key: 'inquiry_client_confirmation',
|
||||
portId,
|
||||
@@ -60,13 +65,19 @@ export const emailWorker = new Worker(
|
||||
await import('@/lib/email/templates/inquiry-sales-notification');
|
||||
const { sendEmail } = await import('@/lib/email/index');
|
||||
const { resolveSubject } = await import('@/lib/email/resolve-subject');
|
||||
const notification = inquirySalesNotification({
|
||||
const { getBrandingShell } = await import('@/lib/email/branding-resolver');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const notification = inquirySalesNotification(
|
||||
{
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
mooringNumber,
|
||||
crmUrl,
|
||||
});
|
||||
portName,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
const subject = await resolveSubject({
|
||||
key: 'inquiry_sales_notification',
|
||||
portId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { env } from '@/lib/env';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { hashToken, mintToken } from '@/lib/portal/passwords';
|
||||
|
||||
@@ -230,12 +231,16 @@ export async function resendCrmInvite(
|
||||
.where(eq(crmUserInvites.id, inviteId));
|
||||
|
||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||
const result = crmInviteEmail({
|
||||
const branding = await getBrandingShell(meta.portId);
|
||||
const result = crmInviteEmail(
|
||||
{
|
||||
link,
|
||||
ttlHours: INVITE_TTL_HOURS,
|
||||
recipientName: invite.name ?? undefined,
|
||||
isSuperAdmin: invite.isSuperAdmin,
|
||||
});
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
// Resend uses the dedicated portal_invite_resend key so admins can
|
||||
// word the resend differently from the original.
|
||||
const subject = await resolveSubject({
|
||||
@@ -248,7 +253,7 @@ export async function resendCrmInvite(
|
||||
ttlHours: INVITE_TTL_HOURS,
|
||||
},
|
||||
});
|
||||
await sendEmail(invite.email, subject, result.html, undefined, result.text);
|
||||
await sendEmail(invite.email, subject, result.html, undefined, result.text, meta.portId);
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getPortReminderConfig } from '@/lib/services/port-config';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
|
||||
const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_ITEMS_PER_USER = 20;
|
||||
@@ -103,6 +104,10 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
|
||||
if (portUsers.length === 0) continue;
|
||||
|
||||
// Resolve branding once per port — every user on this port gets
|
||||
// the same shell.
|
||||
const branding = await getBrandingShell(port.id);
|
||||
|
||||
const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS);
|
||||
|
||||
for (const u of portUsers) {
|
||||
@@ -133,7 +138,8 @@ 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 = notificationDigestEmail(
|
||||
{
|
||||
portName: port.name,
|
||||
recipientName: u.name ?? '',
|
||||
items: visible.map((r) => ({
|
||||
@@ -145,7 +151,9 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
})),
|
||||
totalUnread: rows.length,
|
||||
inboxLink,
|
||||
});
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
|
||||
// The per-port subject override key for the digest is the
|
||||
// existing 'crm_invite' / 'portal_*' family — digest is its own
|
||||
|
||||
@@ -9,6 +9,7 @@ import { env } from '@/lib/env';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||
import { loadSubjectOverride } from '@/lib/email/template-overrides';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import {
|
||||
CodedError,
|
||||
ConflictError,
|
||||
@@ -118,13 +119,14 @@ async function issueActivationToken(
|
||||
|
||||
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
||||
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
||||
const branding = await getBrandingShell(portId);
|
||||
const { subject, html, text } = activationEmail(
|
||||
{
|
||||
portName,
|
||||
link,
|
||||
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
|
||||
},
|
||||
{ subject: subjectOverride },
|
||||
{ subject: subjectOverride, branding },
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -378,13 +380,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
||||
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||
const branding = await getBrandingShell(user.portId);
|
||||
const { subject, html, text } = resetEmail(
|
||||
{
|
||||
portName,
|
||||
link,
|
||||
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
|
||||
},
|
||||
{ subject: subjectOverride },
|
||||
{ subject: subjectOverride, branding },
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user