diff --git a/src/app/api/public/residential-inquiries/route.ts b/src/app/api/public/residential-inquiries/route.ts index fa2a96f..f6554c3 100644 --- a/src/app/api/public/residential-inquiries/route.ts +++ b/src/app/api/public/residential-inquiries/route.ts @@ -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 { const { portId, data, crmDeepLink } = args; + const branding = await getBrandingShell(portId); // Client confirmation - const confirmation = residentialClientConfirmation({ - firstName: data.firstName, - contactEmail: 'sales@portnimara.com', - }); + 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,16 +186,19 @@ async function sendResidentialNotifications(args: { return; } - const alert = residentialSalesAlert({ - fullName: `${data.firstName} ${data.lastName}`.trim(), - email: data.email, - phone: data.phone, - placeOfResidence: data.placeOfResidence, - preferredContactMethod: data.preferredContactMethod, - notes: data.notes, - preferences: data.preferences, - crmDeepLink, - }); + const alert = residentialSalesAlert( + { + fullName: `${data.firstName} ${data.lastName}`.trim(), + email: data.email, + phone: data.phone, + placeOfResidence: data.placeOfResidence, + preferredContactMethod: data.preferredContactMethod, + notes: data.notes, + preferences: data.preferences, + crmDeepLink, + }, + { branding }, + ); const alertSubject = await resolveSubject({ key: 'residential_inquiry_sales_alert', portId, diff --git a/src/components/shared/branded-auth-shell.tsx b/src/components/shared/branded-auth-shell.tsx index 077a20f..83177ef 100644 --- a/src/components/shared/branded-auth-shell.tsx +++ b/src/components/shared/branded-auth-shell.tsx @@ -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 (
- {/* - 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. - */}
{/* eslint-disable-next-line @next/next/no-img-element */} - Port Nimara + {altText}
{children}
diff --git a/src/lib/email/branding-resolver.ts b/src/lib/email/branding-resolver.ts new file mode 100644 index 0000000..bb30a8f --- /dev/null +++ b/src/lib/email/branding-resolver.ts @@ -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 { + 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; + } +} diff --git a/src/lib/email/shell.ts b/src/lib/email/shell.ts new file mode 100644 index 0000000..079b45f --- /dev/null +++ b/src/lib/email/shell.ts @@ -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 ` + + + + + ${title} + + + + + + + +
+ + + + +
+
+ Port logo +
+ ${headerHtml ? `
${headerHtml}
` : ''} + ${body} + ${footerHtml ? `
${footerHtml}
` : ''} +
+
+ +`; +} + +/** Surface the brand primary color to template bodies. */ +export function brandingPrimaryColor(branding?: BrandingShell | null): string { + return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR; +} diff --git a/src/lib/email/templates/crm-invite.ts b/src/lib/email/templates/crm-invite.ts index 9e60eef..8d8f0d9 100644 --- a/src/lib/email/templates/crm-invite.ts +++ b/src/lib/email/templates/crm-invite.ts @@ -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 ` - - - - - ${opts.title} - - - - - - - -
- - - - -
-
- Port Nimara Logo -
- ${opts.body} -
-
- -`; +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 = ` -

- Welcome to the Port Nimara CRM +

+ Welcome to the ${escapeHtml(portName)} CRM

${greeting}

- 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.

- + Set up your account

If the button doesn't work, paste this link into your browser:
- ${data.link} + ${data.link}

Thank you,
- Port Nimara CRM + ${escapeHtml(portName)} CRM

`; 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 { diff --git a/src/lib/email/templates/inquiry-client-confirmation.ts b/src/lib/email/templates/inquiry-client-confirmation.ts index f84b00d..1821880 100644 --- a/src/lib/email/templates/inquiry-client-confirmation.ts +++ b/src/lib/email/templates/inquiry-client-confirmation.ts @@ -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 = ` - - - - - ${subject} - - - - - - - -
- - - - -
-
- Port Nimara Logo -
+ const accent = brandingPrimaryColor(overrides?.branding); + + const body = `

Dear ${escapeHtml(firstName)},

Thank you for expressing interest in ${escapeHtml(berthText)}. @@ -43,20 +35,12 @@ export function inquiryClientConfirmation(data: InquiryClientConfirmationData) {

If you have any questions, please feel free to reach out to us at - ${escapeHtml(contactEmail)}. + ${escapeHtml(contactEmail)}.

Best regards,
- The Port Nimara Sales Team -

-
-
- -`; + The ${escapeHtml(portName)} Sales Team +

`; 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 { diff --git a/src/lib/email/templates/inquiry-sales-notification.ts b/src/lib/email/templates/inquiry-sales-notification.ts index 4536bb5..1a657a7 100644 --- a/src/lib/email/templates/inquiry-sales-notification.ts +++ b/src/lib/email/templates/inquiry-sales-notification.ts @@ -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 = ` - - - - - New Interest - Port Nimara - - - - - - - -
- - - - -
-
- Port Nimara Logo -
+ const body = `

Dear Administrator,

-

${escapeHtml(fullName)} has expressed their interest in Port Nimara. Here are their details:

+

${escapeHtml(fullName)} has expressed their interest in ${escapeHtml(portName)}. Here are their details:

Name: ${escapeHtml(fullName)}

Email: ${escapeHtml(email)}

Telephone: ${escapeHtml(phone)}

Berths Selected: ${escapeHtml(mooringDisplay)}

-

Please visit the Port Nimara CRM to view more information.

-

Thank you,
Port Nimara CRM

-
-
- -`; +

Please visit the ${escapeHtml(portName)} CRM to view more information.

+

Thank you,
${escapeHtml(portName)} CRM

`; 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 { diff --git a/src/lib/email/templates/notification-digest.ts b/src/lib/email/templates/notification-digest.ts index e64d562..b4a8813 100644 --- a/src/lib/email/templates/notification-digest.ts +++ b/src/lib/email/templates/notification-digest.ts @@ -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 ` - - - - ${opts.title} - - - - - - -
- - - - -
-
- Port Nimara Logo -
- ${opts.body} -
-
- -`; -} - -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); +interface RenderOpts { + branding?: BrandingShell | null; } const TYPE_LABELS: Record = { @@ -75,18 +40,22 @@ const TYPE_LABELS: Record = { 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 - ? `${escapeHtml(item.title)}` + ? `${escapeHtml(item.title)}` : `${escapeHtml(item.title)}`; const desc = item.description ? `
${escapeHtml(item.description)}
` @@ -102,13 +71,13 @@ export function notificationDigestEmail(data: DigestData): { const tail = data.totalUnread > data.items.length ? `

…and ${data.totalUnread - data.items.length} more. - Open the inbox to see everything.

` + Open the inbox to see everything.

` : ''; const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,'; const body = ` -

+

Your ${escapeHtml(data.portName)} CRM digest

${greeting}

@@ -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, '''); } diff --git a/src/lib/email/templates/portal-auth.ts b/src/lib/email/templates/portal-auth.ts index 25dc82e..25c839d 100644 --- a/src/lib/email/templates/portal-auth.ts +++ b/src/lib/email/templates/portal-auth.ts @@ -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 ` - - - - - ${opts.title} - - - - - - - -
- - - - -
-
- Port Nimara Logo -
- ${opts.body} -
-
- -`; +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 = ` -

+

Welcome to ${escapeHtml(data.portName)}

${greeting}

@@ -77,13 +47,13 @@ export function activationEmail( The link expires in ${data.ttlHours} hours.

- + Activate account

If the button doesn't work, paste this link into your browser:
- ${data.link} + ${data.link}

Thank you,
@@ -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 = ` -

+

Password reset

${greeting}

@@ -128,7 +103,7 @@ export function resetEmail( The link expires in ${data.ttlMinutes} minutes.

- + Reset password

@@ -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 { diff --git a/src/lib/email/templates/residential-inquiry.ts b/src/lib/email/templates/residential-inquiry.ts index 4a2f125..25fa5fa 100644 --- a/src/lib/email/templates/residential-inquiry.ts +++ b/src/lib/email/templates/residential-inquiry.ts @@ -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 ` - - - - - ${opts.title} - - - - - - - -
- - - - -
-
- Port Nimara Logo -
- ${opts.body} -
-
- -`; +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 = ` -

- Welcome to Port Nimara +

+ Welcome to ${escapeHtml(portName)}

Dear ${escapeHtml(data.firstName)},

- 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.

If you have any questions in the meantime, please reach us at - ${escapeHtml(data.contactEmail)}. + ${escapeHtml(data.contactEmail)}.

Best regards,
- The Port Nimara Residential Team + The ${escapeHtml(portName)} Residential Team

`; - 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 = ` -

+

New residential inquiry

@@ -92,9 +73,12 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData) { ${data.preferences ? `` : ''} ${data.notes ? `` : ''}
Preferences${escapeHtml(data.preferences)}
Notes${escapeHtml(data.notes)}
- ${data.crmDeepLink ? `

Open in CRM

` : ''} -

- Port Nimara CRM

`; - return { subject, html: shell({ title: subject, body }) }; + ${data.crmDeepLink ? `

Open in CRM

` : ''} +

- ${escapeHtml(portName)} CRM

`; + return { + subject, + html: renderShell({ title: subject, body, branding: overrides?.branding }), + }; } function escapeHtml(str: string): string { diff --git a/src/lib/queue/workers/email.ts b/src/lib/queue/workers/email.ts index 606fbbb..ad4a6f9 100644 --- a/src/lib/queue/workers/email.ts +++ b/src/lib/queue/workers/email.ts @@ -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({ - fullName, - email, - phone, - mooringNumber, - crmUrl, - }); + 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, diff --git a/src/lib/services/crm-invite.service.ts b/src/lib/services/crm-invite.service.ts index ac6fcb7..e7d55f4 100644 --- a/src/lib/services/crm-invite.service.ts +++ b/src/lib/services/crm-invite.service.ts @@ -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({ - link, - ttlHours: INVITE_TTL_HOURS, - recipientName: invite.name ?? undefined, - isSuperAdmin: invite.isSuperAdmin, - }); + 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, diff --git a/src/lib/services/notification-digest.service.ts b/src/lib/services/notification-digest.service.ts index e2865a8..2a25fd5 100644 --- a/src/lib/services/notification-digest.service.ts +++ b/src/lib/services/notification-digest.service.ts @@ -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 ({ - type: r.type, - title: r.title, - description: r.description, - link: r.link ? `${env.APP_URL}${r.link}` : null, - createdAt: r.createdAt, - })), - totalUnread: rows.length, - inboxLink, - }); + const result = notificationDigestEmail( + { + portName: port.name, + recipientName: u.name ?? '', + items: visible.map((r) => ({ + type: r.type, + title: r.title, + description: r.description, + link: r.link ? `${env.APP_URL}${r.link}` : null, + createdAt: r.createdAt, + })), + 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 diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index 38a35a2..df497f4 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -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 { 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 {