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:
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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'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't request this, you may safely ignore this message - your existing password
|
||||
will continue to work.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 rep’s 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 },
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user