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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user