feat(email): Phase 5 — branding chain ext'd with per-port background

Surface hard-coded portnimara.com background image as a per-port
override:

- BrandingShell gains backgroundUrl; renderShell reads from
  branding.backgroundUrl with the existing Port Nimara overhead URL
  as the fallback default.
- getBrandingShell threads the value through from getPortBrandingConfig.
- PortBrandingConfig gains emailBackgroundUrl; SETTING_KEYS adds
  brandingEmailBackgroundUrl mapped to 'branding_email_background_url'.
- /admin/branding page exposes the new field as an image-upload below
  the logo with sizing guidance (1920x1080 JPG, pre-blurred).

This closes the last hard-coded portnimara.com asset URL in the email
shell — every transactional email now fully respects per-port branding
when the admin uploads their own assets. Logo override path was
already in place from R2-H15; the background was the missing piece.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:12:28 +02:00
parent 9f5786890e
commit df1594d596
4 changed files with 32 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ export async function getBrandingShell(
const cfg = await getPortBrandingConfig(portId);
return {
logoUrl: cfg.logoUrl,
backgroundUrl: cfg.emailBackgroundUrl,
primaryColor: cfg.primaryColor,
emailHeaderHtml: cfg.emailHeaderHtml,
emailFooterHtml: cfg.emailFooterHtml,

View File

@@ -23,6 +23,10 @@ const DEFAULT_PRIMARY_COLOR = '#0F4C81';
export interface BrandingShell {
logoUrl: string | null;
/** Phase 5: blurred page-background image rendered behind the white
* card. Defaults to the Port Nimara overhead image. Ports with
* their own marina photography override via system_settings. */
backgroundUrl: string | null;
primaryColor: string | null;
emailHeaderHtml: string | null;
emailFooterHtml: string | null;
@@ -36,6 +40,7 @@ interface ShellOpts {
export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
@@ -52,7 +57,7 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
</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;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${backgroundUrl}'); 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);">