feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers

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>
This commit is contained in:
2026-05-27 22:42:37 +02:00
parent 3bdf59e917
commit cb8292464c
62 changed files with 2944 additions and 662 deletions

View File

@@ -16,6 +16,8 @@
* 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.
@@ -96,6 +98,77 @@ 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

View File

@@ -44,6 +44,11 @@ function AdminEmailChangeBody({
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
<strong>{newEmail}</strong>
</Text>
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
{loginUrl ? (
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
@@ -68,11 +73,6 @@ function AdminEmailChangeBody({
If this change wasn&apos;t expected, please contact your administrator straight away. The
previous address (where this message was delivered) is no longer accepted for sign-in.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}

View File

@@ -1,7 +1,13 @@
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
import {
brandingPrimaryColor,
emailStyle,
renderShell,
safeUrl,
type BrandingShell,
} from '@/lib/email/shell';
interface InviteData {
link: string;
@@ -36,34 +42,25 @@ function InviteBody({
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to the {portName} CRM
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Welcome to the {portName} CRM</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
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{' '}
{ttlHours} hours.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Set up your account
</Button>
</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' }}>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
@@ -73,11 +70,6 @@ function InviteBody({
{link}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}

View File

@@ -81,6 +81,19 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
{data.customMessage}
</Text>
) : null}
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
{data.senderName ? (
<>
{data.senderName}
<br />
<strong>The {data.portName} Team</strong>
</>
) : (
<strong>The {data.portName} Team</strong>
)}
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
@@ -113,19 +126,6 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
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' }}>
With warm regards,
<br />
{data.senderName ? (
<>
{data.senderName}
<br />
<strong>The {data.portName} Team</strong>
</>
) : (
<strong>The {data.portName} Team</strong>
)}
</Text>
</>
);
}
@@ -270,6 +270,11 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
{data.customMessage}
</Text>
) : null}
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
<strong>The {data.portName} Team</strong>
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
@@ -297,11 +302,6 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
{data.signingUrl}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {data.portName} Team</strong>
</Text>
</>
);
}

View File

@@ -2,7 +2,13 @@ import { render } from '@react-email/components';
import { Button, Hr, Link, Text } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
import {
brandingPrimaryColor,
emailStyle,
renderShell,
safeUrl,
type BrandingShell,
} from '@/lib/email/shell';
interface ActivationData {
portName: string;
@@ -41,42 +47,26 @@ function ActivationBody({
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Welcome to {portName}
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Welcome to {portName}</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
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.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Activate account
</Button>
</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' }}>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
@@ -86,11 +76,6 @@ function ActivationBody({
{link}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}
@@ -106,48 +91,27 @@ function ResetBody({
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Reset your password
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Reset your password</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
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.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Reset password
</Button>
</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
will continue to work.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Reset password
</Button>
</div>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If you didn&apos;t request this, you may safely ignore this message - your existing password
will continue to work.
</Text>
</>
);
}

View File

@@ -10,6 +10,7 @@
* adding an entry surfaces it without any UI change.
*/
import type { BrandingShell } from '@/lib/email/shell';
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';
@@ -52,6 +53,11 @@ export interface SampleContext {
recipientEmail: string;
portName: string;
portUrl: string;
/** Per-port branding shell (logo, blur background, accent color, header/footer
* HTML). Resolved once by the test-template route via getBrandingShell and
* forwarded into every template so previews match the production look.
* Null is acceptable - templates fall back to neutral defaults. */
branding: BrandingShell | null;
}
export const TEST_TEMPLATES: TestTemplateMeta[] = [
@@ -60,185 +66,224 @@ export const TEST_TEMPLATES: TestTemplateMeta[] = [
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,
}),
activationEmail(
{
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/activate/sample-token`,
ttlHours: 24,
},
{ branding: s.branding },
),
},
{
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,
}),
resetEmail(
{
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/reset/sample-token`,
ttlMinutes: 120,
},
{ branding: s.branding },
),
},
{
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,
}),
crmInviteEmail(
{
recipientName: s.recipientName,
portName: s.portName,
isSuperAdmin: false,
link: `${s.portUrl}/invite/sample-token`,
ttlHours: 72,
},
{ branding: s.branding },
),
},
{
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`,
}),
adminEmailChangeEmail(
{
recipientName: s.recipientName,
portName: s.portName,
newEmail: s.recipientEmail,
changedByDisplayName: 'Sample Admin',
loginUrl: `${s.portUrl}/login`,
},
{ branding: s.branding },
),
},
{
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`,
}),
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`,
},
{ branding: s.branding },
),
},
{
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,
}),
signingInvitationEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signerRole: 'client',
signingUrl: `${s.portUrl}/sign/sample-token`,
senderName: 'Sample Sales Manager',
customMessage: null,
},
{ branding: s.branding },
),
},
{
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,
}),
signingReminderEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signingUrl: `${s.portUrl}/sign/sample-token`,
invitedAgo: '5 days ago',
customMessage: null,
},
{ branding: s.branding },
),
},
{
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(),
}),
signingCompletedEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
clientName: s.recipientName,
completedAt: new Date(),
},
{ branding: s.branding },
),
},
{
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.',
}),
signingCancelledEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
reason: 'Customer renegotiated terms; a fresh contract will follow.',
},
{ branding: s.branding },
),
},
{
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,
}),
inquiryClientConfirmation(
{
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
mooringNumber: 'A1',
contactEmail: 'sales@portnimara.com',
portName: s.portName,
},
{ branding: s.branding },
),
},
{
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,
}),
inquirySalesNotification(
{
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
mooringNumber: 'A1',
crmUrl: `${s.portUrl}/clients/sample-client-id`,
portName: s.portName,
},
{ branding: s.branding },
),
},
{
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,
}),
residentialClientConfirmation(
{
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
contactEmail: 'sales@portnimara.com',
portName: s.portName,
},
{ branding: s.branding },
),
},
{
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,
}),
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,
},
{ branding: s.branding },
),
},
];