Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.
UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
Aggregated helpers in notes.service mirror the listFor*Aggregated
symmetric-reach joins. yacht-tabs + company-tabs render the
badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
`width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
fixed inset-0 pin so long forms scroll naturally). Form picks up
port branding (logoUrl + backgroundUrl + appName) via
loadByToken. Address fields completed (street + city + region +
postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
emits toast.success with action link to the destination entity
or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
(5 types visible at once).
Launch infra:
- UTM column wiring (Init 1b step 4): migration
0089_website_submissions_utm.sql adds utm_source/medium/campaign/
term/content + composite index (port_id, utm_source, received_at)
for per-campaign rollups. website-inquiries intake accepts the
five fields. Residential intake intentionally untouched per audit
scope.
- Invoicing module gate (Init 1c spike): new
invoices-module.service + invoices layout guard + registry entry
invoices_module_enabled (default false). Audit conclusion in
launch-readiness.md: payments table is canonical money path;
/invoices flow is parallel infrastructure now hidden by default.
Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
New route-labels.ts + use-smart-back hook +
navigation-history-tracker so back falls through to the parent
route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
breadcrumb-store kept for back-compat consumers but the
breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
upload-receipts, reports kind, tenancies detail, analytics
metric, client detail) migrated.
Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
the reports gap audit (cross-cutting filter set, Marketing +
Financial blockers, custom builder remaining entities, scheduled
CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
(each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
8.1 KiB
TypeScript
210 lines
8.1 KiB
TypeScript
/**
|
|
* 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 })`.
|
|
*/
|
|
|
|
import type * as React from 'react';
|
|
|
|
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
|
|
|
// Neutral defaults - no tenant-specific imagery leaks across ports.
|
|
// When branding hasn't been configured the email renders without a logo
|
|
// and on a plain off-white background. Admins upload their own assets via
|
|
// /admin/branding which then flow through via getPortBrandingConfig().
|
|
const DEFAULT_LOGO_URL: string | null = null;
|
|
const DEFAULT_BACKGROUND_URL: string | null = null;
|
|
const DEFAULT_PRIMARY_COLOR = '#1e293b';
|
|
|
|
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;
|
|
}
|
|
|
|
interface ShellOpts {
|
|
title: string;
|
|
body: string;
|
|
branding?: BrandingShell | null;
|
|
}
|
|
|
|
export function renderShell({ title, body, branding }: ShellOpts): string {
|
|
// Branding URLs are stored path-only (so in-app rendering works across
|
|
// any host). Mail clients have no app origin, so re-absolutize here.
|
|
const logoUrl = absolutizeBrandingUrl(branding?.logoUrl ?? DEFAULT_LOGO_URL);
|
|
const backgroundUrl = absolutizeBrandingUrl(branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL);
|
|
const headerHtml = branding?.emailHeaderHtml ?? '';
|
|
const footerHtml = branding?.emailFooterHtml ?? '';
|
|
|
|
const wrapperStyle = backgroundUrl
|
|
? `background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;`
|
|
: 'background-color:#f2f2f2;';
|
|
const logoBlock = logoUrl
|
|
? `<center><img src="${logoUrl}" alt="Logo" width="100" style="margin-bottom:20px;" /></center>`
|
|
: '';
|
|
|
|
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="${wrapperStyle}">
|
|
<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;">
|
|
${logoBlock}
|
|
${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;
|
|
}
|
|
|
|
/**
|
|
* Shared style conventions for transactional email bodies.
|
|
*
|
|
* Templates compose these instead of inlining one-off `style={{...}}` objects
|
|
* so the visual rhythm stays consistent across every email - centered title
|
|
* in the brand accent, body paragraphs left-aligned at 16px / 1.5 line-height,
|
|
* centered CTA button, fine-print block separated by a soft divider, centered
|
|
* sign-off in the same accent. Modeled on the hand-rolled templates from the
|
|
* original portal (signature-notifications.ts) so the look carries forward.
|
|
*
|
|
* Functions accept an `accent` color (the resolved port primary) where it's
|
|
* load-bearing; constants do not.
|
|
*/
|
|
export const emailStyle = {
|
|
/** Page heading: centered, brand-accent, bold. Used once at the top. */
|
|
title: (accent: string): React.CSSProperties => ({
|
|
textAlign: 'center',
|
|
fontSize: '22px',
|
|
fontWeight: 'bold',
|
|
color: accent,
|
|
margin: '0 0 16px 0',
|
|
}),
|
|
/** Body paragraph: 16px / 1.5 line-height, left-aligned for readability. */
|
|
paragraph: {
|
|
fontSize: '16px',
|
|
lineHeight: '1.5',
|
|
margin: '0 0 16px 0',
|
|
color: '#333333',
|
|
} satisfies React.CSSProperties,
|
|
/** Soft hairline divider above fine-print blocks. */
|
|
divider: {
|
|
border: 'none',
|
|
borderTop: '1px solid #eee',
|
|
margin: '28px 0 0 0',
|
|
} satisfies React.CSSProperties,
|
|
/** Fine print: 14px muted, line-height 1.5. */
|
|
finePrint: {
|
|
fontSize: '14px',
|
|
color: '#666666',
|
|
lineHeight: '1.5',
|
|
margin: '12px 0 0 0',
|
|
} satisfies React.CSSProperties,
|
|
/** Sign-off block: left-aligned, 16px, sits BETWEEN the last body
|
|
* paragraph and the primary CTA so the email reads like a letter
|
|
* (greeting -> body -> sign-off -> button -> button-fallback fine
|
|
* print). Top margin is intentionally modest because preceding
|
|
* paragraphs already carry 16px bottom margin. */
|
|
signoff: {
|
|
textAlign: 'left',
|
|
fontSize: '16px',
|
|
color: '#333333',
|
|
margin: '8px 0 0 0',
|
|
} satisfies React.CSSProperties,
|
|
/** Outer wrapper that centers the primary CTA button. */
|
|
buttonRow: {
|
|
textAlign: 'center',
|
|
margin: '28px 0',
|
|
} satisfies React.CSSProperties,
|
|
/** Primary CTA button style. Compose with `buttonRow` for the surrounding center. */
|
|
button: (accent: string): React.CSSProperties => ({
|
|
display: 'inline-block',
|
|
backgroundColor: accent,
|
|
color: '#ffffff',
|
|
textDecoration: 'none',
|
|
padding: '14px 35px',
|
|
borderRadius: '5px',
|
|
fontWeight: 'bold',
|
|
fontSize: '16px',
|
|
}),
|
|
} as const;
|
|
|
|
/**
|
|
* URL-safe escaper for `href="..."` interpolations inside email
|
|
* templates. The email-deliverability audit flagged that every template
|
|
* inlined `${data.link}` directly into href + visible text without
|
|
* escaping - a `"` (or worse, a `javascript:` scheme) would break out
|
|
* of the attribute or trigger an XSS when the recipient opens the email
|
|
* in a webmail client that runs scripts.
|
|
*
|
|
* Two-step defense:
|
|
* 1. Scheme allow-list - only http(s), mailto, tel survive; everything
|
|
* else (javascript:, data:, vbscript:, file:, …) is rewritten to
|
|
* `about:blank`.
|
|
* 2. HTML-attribute escape on `"`, `<`, `>`, `&`, `'`, backtick.
|
|
*/
|
|
export function safeUrl(url: string | null | undefined): string {
|
|
if (!url) return 'about:blank';
|
|
const trimmed = String(url).trim();
|
|
// Block dangerous schemes. The allow-list is intentionally short.
|
|
const lower = trimmed.toLowerCase();
|
|
const ok =
|
|
lower.startsWith('http://') ||
|
|
lower.startsWith('https://') ||
|
|
lower.startsWith('mailto:') ||
|
|
lower.startsWith('tel:') ||
|
|
// Relative or root-relative paths are also acceptable - they
|
|
// resolve against the host the email links to (rare in transactional
|
|
// mail but used by tracking pixels and unsubscribe headers).
|
|
lower.startsWith('/') ||
|
|
lower.startsWith('#');
|
|
if (!ok) return 'about:blank';
|
|
return trimmed
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/`/g, '`');
|
|
}
|