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>
293 lines
9.7 KiB
TypeScript
293 lines
9.7 KiB
TypeScript
/**
|
||
* 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 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';
|
||
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;
|
||
/** 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[] = [
|
||
{
|
||
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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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`,
|
||
},
|
||
{ 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`,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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(),
|
||
},
|
||
{ 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.',
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ 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,
|
||
},
|
||
{ branding: s.branding },
|
||
),
|
||
},
|
||
];
|
||
|
||
export function findTestTemplate(id: string): TestTemplateMeta | undefined {
|
||
return TEST_TEMPLATES.find((t) => t.id === id);
|
||
}
|