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:
Matt Ciaccio
2026-05-07 00:00:45 +02:00
parent 1a87f28fd4
commit 05babe57a0
14 changed files with 380 additions and 322 deletions

View File

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