chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -14,7 +14,7 @@ interface AuthShellBranding {
* Pre-port-context surfaces (login, forgot-password, set-password,
* the better-auth password-reset email) need branding before the user
* has picked a port. Resolve against the first active port in the
* system for a single-tenant deploy that's the right port; for a
* system - for a single-tenant deploy that's the right port; for a
* multi-tenant deploy the operator should host each tenant on its own
* subdomain so the wrong-tenant logo doesn't surface here.
*

View File

@@ -1,5 +1,5 @@
/**
* Phase 6 IMAP bounce parser library.
* Phase 6 - IMAP bounce parser library.
*
* Walks an inbound delivery-status notification (DSN / NDR) message and
* extracts the original recipient, bounce class (hard / soft / ooo /
@@ -10,12 +10,12 @@
* 7-day window before updating the send row's bounce_* columns.
*
* The parser handles three common NDR shapes:
* 1. RFC 3464 multipart/report Postfix, Exim, Sendmail, Gmail. The
* 1. RFC 3464 multipart/report - Postfix, Exim, Sendmail, Gmail. The
* message has a `message/delivery-status` part whose headers carry
* structured Action / Status / Original-Recipient fields.
* 2. Microsoft Outlook / Exchange non-DSN reports with the original
* 2. Microsoft Outlook / Exchange - non-DSN reports with the original
* recipient embedded in the subject line + body prose.
* 3. Out-of-office auto-replies distinct from bounces; classed as
* 3. Out-of-office auto-replies - distinct from bounces; classed as
* `ooo` so the UI banner stays informational rather than alarming.
*
* When the parser can't extract a recipient it returns

View File

@@ -3,7 +3,7 @@
*
* 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 then
* create-time before a port is selected) pass null - the shell then
* falls back to neutral defaults (no logo, plain background, slate
* accent). Configure per-port branding via /admin/branding.
*/

View File

@@ -137,7 +137,7 @@ export async function sendEmail(
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO the redirect target
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO - the redirect target
// already gets the message; CCing additional recipients would defeat
// the dev safety net.
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
@@ -165,12 +165,12 @@ export async function sendEmail(
// When EMAIL_REDIRECT_TO is set we elevate to `warn` so the dev-only
// safety net is visible in any logger config. Prod boot already refuses
// when both are set (see env.ts superRefine) this catches the dev /
// when both are set (see env.ts superRefine) - this catches the dev /
// staging window where someone left it in a .env by mistake.
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO recipient overridden)',
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO - recipient overridden)',
);
} else {
logger.debug(

View File

@@ -21,7 +21,7 @@ import type { TemplateKey } from '@/lib/email/template-catalog';
export async function resolveSubject(args: {
key: TemplateKey;
/** Optional when omitted (e.g. system-level emails with no port
/** Optional - when omitted (e.g. system-level emails with no port
* context), only the fallback subject is returned. */
portId?: string | null;
fallback: string;

View File

@@ -4,9 +4,9 @@
* 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
* - 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.
@@ -18,7 +18,7 @@
import { absolutizeBrandingUrl } from '@/lib/branding/url';
// Neutral defaults no tenant-specific imagery leaks across ports.
// 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().
@@ -100,12 +100,12 @@ export function brandingPrimaryColor(branding?: BrandingShell | null): string {
* 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
* 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
* 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.
@@ -120,7 +120,7 @@ export function safeUrl(url: string | null | undefined): string {
lower.startsWith('https://') ||
lower.startsWith('mailto:') ||
lower.startsWith('tel:') ||
// Relative or root-relative paths are also acceptable they
// 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('/') ||

View File

@@ -45,7 +45,7 @@ export interface TemplateMetadata {
export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
portal_activation: {
key: 'portal_activation',
label: 'Portal activation invite',
label: 'Portal - activation invite',
description:
'Sent to a client when an admin invites them to activate their portal account. Contains the activation link.',
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
@@ -53,7 +53,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
},
portal_reset: {
key: 'portal_reset',
label: 'Portal password reset',
label: 'Portal - password reset',
description:
'Sent when a portal user requests a password reset. Contains the reset link with a short TTL.',
mergeTokens: ['portName', 'recipientName', 'ttlMinutes'],
@@ -61,45 +61,45 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
},
portal_invite_resend: {
key: 'portal_invite_resend',
label: 'Portal invite resend',
label: 'Portal - invite resend',
description: 'Re-sent activation email when an admin resends a pending portal invite.',
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
defaultSubject: 'Activate your {{portName}} client portal account',
},
crm_invite: {
key: 'crm_invite',
label: 'CRM staff invite',
label: 'CRM - staff invite',
description: 'Sent to a new staff user when an admin invites them to the CRM.',
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
defaultSubject: 'You have been invited to {{portName}} CRM',
},
inquiry_client_confirmation: {
key: 'inquiry_client_confirmation',
label: 'Inquiry client confirmation',
label: 'Inquiry - client confirmation',
description: 'Auto-reply confirmation sent to the client after a website berth inquiry.',
mergeTokens: ['portName', 'recipientName', 'mooringNumber'],
defaultSubject: 'We received your inquiry {{portName}}',
defaultSubject: 'We received your inquiry - {{portName}}',
},
inquiry_sales_notification: {
key: 'inquiry_sales_notification',
label: 'Inquiry sales notification',
label: 'Inquiry - sales notification',
description: 'Internal alert sent to the sales team when a new website inquiry arrives.',
mergeTokens: ['portName', 'clientName', 'mooringNumber', 'email'],
defaultSubject: 'New berth inquiry {{clientName}}',
defaultSubject: 'New berth inquiry - {{clientName}}',
},
residential_inquiry_client_confirmation: {
key: 'residential_inquiry_client_confirmation',
label: 'Residential inquiry client confirmation',
label: 'Residential inquiry - client confirmation',
description: 'Auto-reply sent to the client after a residential property inquiry.',
mergeTokens: ['portName', 'recipientName'],
defaultSubject: 'We received your residential inquiry {{portName}}',
defaultSubject: 'We received your residential inquiry - {{portName}}',
},
residential_inquiry_sales_alert: {
key: 'residential_inquiry_sales_alert',
label: 'Residential inquiry sales alert',
label: 'Residential inquiry - sales alert',
description: 'Internal alert sent to residential sales recipients when an inquiry arrives.',
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry {{clientName}}',
defaultSubject: 'New residential inquiry - {{clientName}}',
},
notification_digest: {
key: 'notification_digest',
@@ -107,7 +107,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
description:
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
defaultSubject: 'Your {{portName}} CRM digest {{unreadCount}} updates',
defaultSubject: 'Your {{portName}} CRM digest - {{unreadCount}} updates',
},
};

View File

@@ -40,5 +40,5 @@ export function applySubjectTokens(
}
// Suppress unused-import lint when the helper is not yet referenced from
// every template every consumer uses `and` once it integrates.
// every template - every consumer uses `and` once it integrates.
void and;

View File

@@ -8,7 +8,7 @@ interface InviteData {
ttlHours: number;
recipientName?: string;
isSuperAdmin: boolean;
/** Display name for the port falls back to "Port Nimara" so the
/** Display name for the port - falls back to "Port Nimara" so the
* pre-multi-tenant default still reads correctly. */
portName?: string;
}
@@ -42,7 +42,7 @@ function InviteBody({
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
You&apos;ve been invited to join the {portName} CRM as a {role}. Use the button below to set
your password and activate your account at your convenience the link will remain valid for{' '}
your password and activate your account at your convenience - the link will remain valid for{' '}
{ttlHours} hours.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>

View File

@@ -3,18 +3,18 @@
*
* Three template families:
*
* 1. signingInvitation sent to a single signer when their turn to sign
* 1. signingInvitation - sent to a single signer when their turn to sign
* comes up. Used both for initial client invites AND cascading "your
* turn" emails when an earlier signer completes.
*
* 2. signingCompleted sent to ALL signers (with the finalized signed
* 2. signingCompleted - sent to ALL signers (with the finalized signed
* PDF as an attachment) when the document reaches a fully signed state.
*
* 3. signingReminder sent when a rep nudges manually OR when the
* 3. signingReminder - sent when a rep nudges manually OR when the
* rate-limited reminder service fires.
*
* All three use the per-port BrandingShell. The signing URL is expected
* to already be embedded-format (e.g. /sign/<type>/<token>) the caller
* to already be embedded-format (e.g. /sign/<type>/<token>) - the caller
* does the transformation from the raw Documenso URL.
*/
@@ -50,7 +50,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
// signer.
const leadCopy =
role === 'client'
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign it should only take a couple of minutes.`
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign - it should only take a couple of minutes.`
: role === 'approver'
? `An ${data.documentLabel} is awaiting your approval. Please review and sign to finalise the document.`
: role === 'developer'
@@ -110,7 +110,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
</Link>
</Text>
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', marginTop: '18px' }}>
Signing happens directly inside our website your data isn&apos;t sent to a third-party
Signing happens directly inside our website - your data isn&apos;t sent to a third-party
signing service.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
@@ -140,7 +140,7 @@ export async function signingInvitationEmail(
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName)
: `${data.documentLabel} ready to sign ${data.portName}`;
: `${data.documentLabel} ready to sign - ${data.portName}`;
const body = await render(<InvitationBody data={data} accent={accent} />, { pretty: false });
@@ -212,7 +212,7 @@ export async function signingCompletedEmail(
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} fully signed ${data.clientName}`;
: `${data.documentLabel} fully signed - ${data.clientName}`;
const completedDateStr = formatDate(data.completedAt, 'datetime.medium');
const body = await render(
@@ -250,7 +250,7 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
We sent you a {data.documentLabel} {data.invitedAgo} that&apos;s still awaiting your
signature. If you&apos;ve already signed, please disregard this message it can take a few
signature. If you&apos;ve already signed, please disregard this message - it can take a few
minutes for our system to catch up.
</Text>
{data.customMessage ? (
@@ -315,7 +315,7 @@ export async function signingReminderEmail(
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `Friendly reminder: ${data.documentLabel} still awaiting your signature ${data.portName}`;
: `Friendly reminder: ${data.documentLabel} still awaiting your signature - ${data.portName}`;
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
@@ -350,7 +350,7 @@ function CancelledBody({ data, accent }: { data: CancelledData; accent: string }
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
action is required from you any signing link previously sent is no longer valid.
action is required from you - any signing link previously sent is no longer valid.
</Text>
{data.reason ? (
<Text
@@ -391,7 +391,7 @@ export async function signingCancelledEmail(
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} cancelled ${data.portName}`;
: `${data.documentLabel} cancelled - ${data.portName}`;
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nWith warm regards,\nThe ${data.portName} Team`;
return {

View File

@@ -40,7 +40,7 @@ function SalesNotificationBody({
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
A new enquiry has come in for <strong>{portName}</strong>. {fullName} has asked us to be in
touch full details below:
touch - full details below:
</Text>
<Text style={detailStyle}>
<strong>Name:</strong> {fullName}
@@ -61,7 +61,7 @@ function SalesNotificationBody({
</Link>{' '}
to follow up.
</Text>
<Text style={{ fontSize: '16px' }}> {portName} CRM</Text>
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
</>
);
}
@@ -74,7 +74,7 @@ export async function inquirySalesNotification(
const mooringDisplay = data.mooringNumber || 'None';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New enquiry ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
: `New enquiry - ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(
@@ -93,7 +93,7 @@ export async function inquirySalesNotification(
const text = [
'Hello,',
'',
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch full details below:`,
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
'',
`Name: ${data.fullName}`,
`Email: ${data.email}`,
@@ -102,7 +102,7 @@ export async function inquirySalesNotification(
'',
`Open the ${portName} CRM (${data.crmUrl}) to follow up.`,
'',
` ${portName} CRM`,
`- ${portName} CRM`,
].join('\n');
return {

View File

@@ -53,7 +53,7 @@ function DigestBody({
</Text>
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 14px' }}>{greeting}</Text>
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 16px' }}>
Here&apos;s what&apos;s waiting for you <strong>{totalUnread}</strong> item
Here&apos;s what&apos;s waiting for you - <strong>{totalUnread}</strong> item
{totalUnread === 1 ? '' : 's'} since your last digest.
</Text>
<table role="presentation" width="100%" cellSpacing={0} cellPadding={0} border={0}>
@@ -120,7 +120,7 @@ export async function notificationDigestEmail(
): Promise<{ subject: string; html: string; text: string }> {
const subject = overrides?.subject?.trim()
? overrides.subject
: `Your ${data.portName} update ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
: `Your ${data.portName} update - ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });
@@ -128,7 +128,7 @@ export async function notificationDigestEmail(
const text = [
`Your ${data.portName} update`,
'',
`Here's what's waiting for you ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
`Here's what's waiting for you - ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
'',
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
'',

View File

@@ -27,7 +27,7 @@ interface RenderOpts {
// react-email's `render()` auto-escapes string interpolation, so we don't
// need our hand-rolled escapeHtml() on these bodies. Inline styles use
// camelCase per CSSProperties react-email serialises them to
// camelCase per CSSProperties - react-email serialises them to
// email-client-safe inline `style="..."` attributes on output.
function ActivationBody({
@@ -53,7 +53,7 @@ function ActivationBody({
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
It&apos;s our pleasure to invite you to the {portName} client portal your private space to
It&apos;s our pleasure to invite you to the {portName} client portal - your private space to
review your berth, manage signed documents, and stay in touch with your sales liaison. The
button below will let you set a password and activate your account at your convenience.
Please use it within {ttlHours} hours.
@@ -119,7 +119,7 @@ function ResetBody({
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
We received a request to reset the password on your {portName} client portal account. Use
the button below to choose a new one the link will remain valid for {ttlMinutes} minutes.
the button below to choose a new one - the link will remain valid for {ttlMinutes} minutes.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
@@ -140,7 +140,7 @@ function ResetBody({
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
If you didn&apos;t request this, you may safely ignore this message your existing password
If you didn&apos;t request this, you may safely ignore this message - your existing password
will continue to work.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
@@ -163,7 +163,7 @@ export async function activationEmail(
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
: `Welcome to ${data.portName} activate your client portal`;
: `Welcome to ${data.portName} - activate your client portal`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<ActivationBody {...data} accent={accent} />, {
@@ -173,7 +173,7 @@ export async function activationEmail(
const text = [
`Welcome to ${data.portName}`,
'',
`It's our pleasure to invite you to the ${data.portName} client portal your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
`It's our pleasure to invite you to the ${data.portName} client portal - your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
`Activate your account by visiting: ${data.link}`,
'',
`Please use the link within ${data.ttlHours} hours.`,
@@ -208,10 +208,10 @@ export async function resetEmail(
const text = [
`Reset your ${data.portName} portal password`,
'',
`Use the following link to choose a new password it will remain valid for ${data.ttlMinutes} minutes:`,
`Use the following link to choose a new password - it will remain valid for ${data.ttlMinutes} minutes:`,
data.link,
'',
`If you didn't request this, you may safely ignore this message your existing password will continue to work.`,
`If you didn't request this, you may safely ignore this message - your existing password will continue to work.`,
'',
`With warm regards,`,
`The ${data.portName} Team`,

View File

@@ -184,7 +184,7 @@ export async function residentialSalesAlert(
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New residential enquiry ${data.fullName}`;
: `New residential enquiry - ${data.fullName}`;
const accent = brandingPrimaryColor(overrides?.branding);
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
pretty: false,

View File

@@ -0,0 +1,247 @@
/**
* Registry of every transactional template the system can emit, with a
* pre-baked sample-prop fixture so an admin can fire a realistic
* preview to a designated address without needing to trigger the real
* upstream flow (a real signing send, a real portal invite, etc.).
*
* Consumed by `<TestTemplateCard>` (admin → Email page) and
* `/api/v1/admin/email/test-template`. New templates land here once
* they're plumbed; the UI dropdown reflects the registry at runtime so
* adding an entry surfaces it without any UI change.
*/
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
import { notificationDigestEmail } from '@/lib/email/templates/notification-digest';
import {
signingInvitationEmail,
signingCompletedEmail,
signingReminderEmail,
signingCancelledEmail,
} from '@/lib/email/templates/document-signing';
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
import { inquirySalesNotification } from '@/lib/email/templates/inquiry-sales-notification';
import {
residentialClientConfirmation,
residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry';
export type RenderedEmail = { subject: string; html: string; text?: string };
export interface TestTemplateMeta {
/** Stable id - used as the dropdown value + the POST body key. */
id: string;
/** Human-facing dropdown label. */
label: string;
/** One-line description shown under the dropdown to clarify which
* real flow fires this template in production. */
description: string;
/** Renders a fully-formed email with placeholder data baked in. */
render: (sample: SampleContext) => Promise<RenderedEmail>;
}
/**
* Shared sample fixture passed to every renderer so the previewed
* subject/body line up with the admin's current port. Real flows
* resolve these from DB lookups; the tester injects synthetic but
* plausible values instead.
*/
export interface SampleContext {
recipientName: string;
recipientEmail: string;
portName: string;
portUrl: string;
}
export const TEST_TEMPLATES: TestTemplateMeta[] = [
{
id: 'portal_activation',
label: 'Portal · Activation invite',
description: 'Fires when an admin invites a client to activate their portal account.',
render: (s) =>
activationEmail({
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/activate/sample-token`,
ttlHours: 24,
}),
},
{
id: 'portal_reset',
label: 'Portal · Password reset',
description: 'Fires when a portal user requests a password reset link.',
render: (s) =>
resetEmail({
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/reset/sample-token`,
ttlMinutes: 120,
}),
},
{
id: 'crm_invite',
label: 'CRM · Teammate invitation',
description: 'Fires when a super-admin invites a new teammate to the CRM.',
render: (s) =>
crmInviteEmail({
recipientName: s.recipientName,
portName: s.portName,
isSuperAdmin: false,
link: `${s.portUrl}/invite/sample-token`,
ttlHours: 72,
}),
},
{
id: 'admin_email_change',
label: 'CRM · Admin email change confirmation',
description: 'Fires when an admin updates their CRM login email - confirmation step.',
render: (s) =>
adminEmailChangeEmail({
recipientName: s.recipientName,
portName: s.portName,
newEmail: s.recipientEmail,
changedByDisplayName: 'Sample Admin',
loginUrl: `${s.portUrl}/login`,
}),
},
{
id: 'notification_digest',
label: 'Reminders · Notification digest',
description: 'Fires on the configured cadence (daily/weekly) with the reps open reminders.',
render: (s) =>
notificationDigestEmail({
recipientName: s.recipientName,
portName: s.portName,
items: [
{
type: 'reminder',
title: 'Follow up with Matthew Ciaccio on Berth A1',
description: 'Reservation EOI sent 5 days ago - no response yet.',
link: `${s.portUrl}/clients/sample-client-id`,
createdAt: new Date(Date.now() - 86_400_000),
},
{
type: 'alert',
title: 'Berth B12 PDF parse failed',
description: null,
link: `${s.portUrl}/berths/sample-berth-id`,
createdAt: new Date(Date.now() - 2 * 86_400_000),
},
],
totalUnread: 2,
inboxLink: `${s.portUrl}/inbox`,
}),
},
{
id: 'signing_invitation',
label: 'Documenso · Signing invitation',
description: 'Fires when the rep dispatches the first signing-invite email for a doc.',
render: (s) =>
signingInvitationEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signerRole: 'client',
signingUrl: `${s.portUrl}/sign/sample-token`,
senderName: 'Sample Sales Manager',
customMessage: null,
}),
},
{
id: 'signing_reminder',
label: 'Documenso · Signing reminder',
description: 'Fires when a manual reminder is dispatched for an outstanding signer.',
render: (s) =>
signingReminderEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signingUrl: `${s.portUrl}/sign/sample-token`,
invitedAgo: '5 days ago',
customMessage: null,
}),
},
{
id: 'signing_completed',
label: 'Documenso · Fully signed notification',
description: 'Fires when every required signer has signed and the document is complete.',
render: (s) =>
signingCompletedEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
clientName: s.recipientName,
completedAt: new Date(),
}),
},
{
id: 'signing_cancelled',
label: 'Documenso · Signing cancelled',
description: 'Fires when the rep cancels a document mid-signature with notify-recipients.',
render: (s) =>
signingCancelledEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
reason: 'Customer renegotiated terms; a fresh contract will follow.',
}),
},
{
id: 'inquiry_client_confirmation',
label: 'Public inquiry · Client confirmation',
description: 'Fires when a public-site visitor submits the contact form (their copy).',
render: (s) =>
inquiryClientConfirmation({
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
mooringNumber: 'A1',
contactEmail: 'sales@portnimara.com',
portName: s.portName,
}),
},
{
id: 'inquiry_sales_notification',
label: 'Public inquiry · Sales notification',
description: 'Fires alongside the client confirmation - alerts the sales rep to a new lead.',
render: (s) =>
inquirySalesNotification({
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
mooringNumber: 'A1',
crmUrl: `${s.portUrl}/clients/sample-client-id`,
portName: s.portName,
}),
},
{
id: 'residential_client_confirmation',
label: 'Residential inquiry · Client confirmation',
description: 'Fires when a residential-site visitor submits the contact form.',
render: (s) =>
residentialClientConfirmation({
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
contactEmail: 'sales@portnimara.com',
portName: s.portName,
}),
},
{
id: 'residential_sales_alert',
label: 'Residential inquiry · Sales alert',
description: 'Fires alongside the residential client confirmation - alerts the sales team.',
render: (s) =>
residentialSalesAlert({
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
placeOfResidence: 'Monaco',
preferredContactMethod: 'email',
notes: 'Looking for year-round mooring + marina apartment access.',
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
portName: s.portName,
}),
},
];
export function findTestTemplate(id: string): TestTemplateMeta | undefined {
return TEST_TEMPLATES.find((t) => t.id === id);
}

View File

@@ -17,7 +17,7 @@ import { env } from '@/lib/env';
interface InjectOptions {
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
* Required so the pixel link is absolute relative URLs break in
* Required so the pixel link is absolute - relative URLs break in
* email clients. */
appBaseUrl: string;
/** UUID of the row in `document_sends`. */