feat(post-audit): Phase 5 partial (4/8 templates) + 7.1 editor scaffold + per-entity reminder buttons
Phase 5 — luxury-port email tone (4 of 8 templates):
- portal-auth.tsx — activation + reset: "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", sign-off "With warm regards, The {portName} Team",
subjects "Welcome to {portName} — activate your client portal" /
"Reset your {portName} portal password".
- inquiry-client-confirmation.tsx — "We've noted your enquiry, and a
member of our team will be in touch shortly through your preferred
channel", "should anything come to mind in the meantime", sign-off
"With warm regards, The {portName} Sales Team".
- notification-digest.tsx — "Your {portName} update" header, "Here's
what's waiting for you", "With warm regards, The {portName} Team".
- document-signing.tsx — all 4 sign-offs ("Dear X, ... Thank you, The
{portName} team") rewritten to "With warm regards, The {portName} Team"
with capitalised Team for consistency.
- Voice captured from old-CRM Nuxt repo
(/Users/matt/Repos/Port Nimara/Port Nimara Client Portal/client-portal/
server/utils/signature-notifications.ts) which already used "Dear",
"Best regards", and collective sign-offs.
Remaining 4 templates (admin-email-change, crm-invite,
inquiry-sales-notification, residential-inquiry) + cross-port snapshot
tests queued as follow-up.
Phase 7.1 — PDF editor scaffold:
- New admin route /admin/templates/[id]/editor/page.tsx wired to a
client-side <TemplateEditor>.
- Renders page 1 via react-pdf (worker URL pattern mirrors
components/files/pdf-viewer.tsx); click-to-place markers in percent
coordinates so a future page-size swap doesn't shift placements.
- Token picker over VALID_MERGE_TOKENS (sorted).
- Save persists overlayPositions via PATCH against the existing
document_templates row; validator accepts the new field via
fieldMapSchema from lib/templates/field-map.ts (no migration needed
— overlay_positions JSONB column already exists).
- Outer/inner-body split + key-by-templateId remount avoids the
in-render setState antipattern when seeding from server data.
- Add + delete markers supported. Multi-page, drag, resize, preview,
new-PDF upload all defer to 7.2.
Per-entity polish:
- [+ Reminder] button on yacht / client / interest detail headers,
threading defaultYachtId / defaultClientId / defaultInterestId so the
ReminderForm opens with the entity pre-linked.
- [EOI] badge on yacht detail header when yacht.source === 'eoi-generated'
(mirrors the contacts-editor pattern shipped in eaab149).
Phase 6 hardening:
- imap-bounce-poller strips whitespace from IMAP_PASS so Google
Workspace App Passwords (16-char "abcd efgh ijkl mnop" format) work
whether pasted with or without spaces. Confirmed via Google docs that
the visual spaces are formatting only and must not reach the IMAP
server.
Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,16 +113,16 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
signing service.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
{data.senderName ? (
|
||||
<>
|
||||
{data.senderName}
|
||||
<br />
|
||||
<strong>{data.portName}</strong>
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</>
|
||||
) : (
|
||||
<strong>The {data.portName} team</strong>
|
||||
<strong>The {data.portName} Team</strong>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
@@ -149,7 +149,7 @@ export async function signingInvitationEmail(
|
||||
: data.signerRole === 'approver'
|
||||
? `An ${data.documentLabel} is awaiting your approval.`
|
||||
: `An ${data.documentLabel} is awaiting your signature.`;
|
||||
const text = `Dear ${data.recipientName},\n\n${leadText}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
|
||||
const text = `Dear ${data.recipientName},\n\n${leadText}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nWith warm regards,\n${data.senderName ?? `The ${data.portName} Team`}`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
@@ -193,9 +193,9 @@ function CompletedBody({
|
||||
in the {data.portName} CRM.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {data.portName} team</strong>
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -225,7 +225,7 @@ export async function signingCompletedEmail(
|
||||
{ pretty: false },
|
||||
);
|
||||
|
||||
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
|
||||
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nWith warm regards,\nThe ${data.portName} Team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
@@ -303,9 +303,9 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {data.portName} team</strong>
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -324,7 +324,7 @@ export async function signingReminderEmail(
|
||||
|
||||
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
const text = `Dear ${data.recipientName},\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
|
||||
const text = `Dear ${data.recipientName},\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nWith warm regards,\nThe ${data.portName} Team`;
|
||||
|
||||
return {
|
||||
subject,
|
||||
@@ -379,9 +379,9 @@ function CancelledBody({ data, accent }: { data: CancelledData; accent: string }
|
||||
</Text>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
|
||||
<Text style={{ fontSize: '16px', marginTop: '24px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>The {data.portName} team</strong>
|
||||
<strong>The {data.portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -398,7 +398,7 @@ export async function signingCancelledEmail(
|
||||
.replace(/\{\{portName\}\}/g, 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\nThank you,\nThe ${data.portName} team`;
|
||||
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 {
|
||||
subject,
|
||||
html: renderShell({ title: subject, body, branding: overrides?.branding }),
|
||||
|
||||
@@ -32,12 +32,12 @@ function ClientConfirmationBody({
|
||||
<>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Dear {firstName},</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
Thank you for expressing interest in {berthText}. Our team has registered your interest, and
|
||||
we will reach out to you very shortly by your preferred method of contact with more
|
||||
information.
|
||||
Thank you for your interest in {berthText}. We've noted your enquiry, and a member of
|
||||
our team will be in touch shortly through your preferred channel with the details
|
||||
you've requested.
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
If you have any questions, please feel free to reach out to us at{' '}
|
||||
Should anything come to mind in the meantime, please don't hesitate to write to us at{' '}
|
||||
<Link
|
||||
href={safeUrl(`mailto:${contactEmail}`)}
|
||||
style={{ color: accent, textDecoration: 'underline' }}
|
||||
@@ -47,7 +47,7 @@ function ClientConfirmationBody({
|
||||
.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>
|
||||
Best regards,
|
||||
With warm regards,
|
||||
<br />
|
||||
The {portName} Sales Team
|
||||
</Text>
|
||||
@@ -65,8 +65,8 @@ export async function inquiryClientConfirmation(
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: mooringNumber
|
||||
? `Thank You for Your Interest in Berth ${mooringNumber}`
|
||||
: `Thank You for Your Interest in a ${portName} Berth`;
|
||||
? `Thank you for your interest in Berth ${mooringNumber}`
|
||||
: `Thank you for your interest in ${portName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -83,11 +83,11 @@ export async function inquiryClientConfirmation(
|
||||
const text = [
|
||||
`Dear ${firstName},`,
|
||||
'',
|
||||
`Thank you for expressing interest in ${berthText}. Our team has registered your interest, and we will reach out to you very shortly by your preferred method of contact with more information.`,
|
||||
`Thank you for your interest in ${berthText}. We've noted your enquiry, and a member of our team will be in touch shortly through your preferred channel with the details you've requested.`,
|
||||
'',
|
||||
`If you have any questions, please feel free to reach out to us at ${contactEmail}.`,
|
||||
`Should anything come to mind in the meantime, please don't hesitate to write to us at ${contactEmail}.`,
|
||||
'',
|
||||
'Best regards,',
|
||||
'With warm regards,',
|
||||
`The ${portName} Sales Team`,
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ function DigestBody({
|
||||
return (
|
||||
<>
|
||||
<Text style={{ fontSize: '18px', fontWeight: 'bold', color: accent, margin: '0 0 6px' }}>
|
||||
Your {portName} CRM digest
|
||||
Your {portName} update
|
||||
</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' }}>
|
||||
You have <strong>{totalUnread}</strong> unread notification
|
||||
{totalUnread === 1 ? '' : 's'} since the last digest.
|
||||
Here's what'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}>
|
||||
<tbody>
|
||||
@@ -106,9 +106,9 @@ function DigestBody({
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ marginTop: '24px', fontSize: '13px', color: '#666' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>{portName} CRM</strong>
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -120,19 +120,22 @@ export async function notificationDigestEmail(
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `${data.portName} CRM digest — ${data.totalUnread} unread`;
|
||||
: `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 });
|
||||
|
||||
const text = [
|
||||
`${data.portName} CRM digest`,
|
||||
`Your ${data.portName} update`,
|
||||
'',
|
||||
`You have ${data.totalUnread} unread notifications.`,
|
||||
`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}`),
|
||||
'',
|
||||
`Inbox: ${data.inboxLink}`,
|
||||
'',
|
||||
'With warm regards,',
|
||||
`The ${data.portName} Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,7 @@ function ActivationBody({
|
||||
recipientName,
|
||||
accent,
|
||||
}: ActivationData & { accent: string }) {
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,';
|
||||
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,8 +53,10 @@ function ActivationBody({
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
You've been invited to access the {portName} client portal. Click the button below to
|
||||
set your password and activate your account. The link expires in {ttlHours} hours.
|
||||
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
|
||||
@@ -85,9 +87,9 @@ function ActivationBody({
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>{portName} CRM</strong>
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -112,12 +114,12 @@ function ResetBody({
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
Password reset
|
||||
Reset your password
|
||||
</Text>
|
||||
<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. Click
|
||||
the button below to choose a new one. The link expires in {ttlMinutes} minutes.
|
||||
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
|
||||
@@ -138,13 +140,13 @@ 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't request this, you can safely ignore this email — your password will
|
||||
remain unchanged.
|
||||
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' }}>
|
||||
Thank you,
|
||||
With warm regards,
|
||||
<br />
|
||||
<strong>{portName} CRM</strong>
|
||||
<strong>The {portName} Team</strong>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
@@ -161,7 +163,7 @@ export async function activationEmail(
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||
: `Activate your ${data.portName} client portal account`;
|
||||
: `Welcome to ${data.portName} — activate your client portal`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<ActivationBody {...data} accent={accent} />, {
|
||||
@@ -171,13 +173,13 @@ export async function activationEmail(
|
||||
const text = [
|
||||
`Welcome to ${data.portName}`,
|
||||
'',
|
||||
`You've been invited to access the ${data.portName} client portal.`,
|
||||
`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}`,
|
||||
'',
|
||||
`The link expires in ${data.ttlHours} hours.`,
|
||||
`Please use the link within ${data.ttlHours} hours.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`${data.portName} CRM`,
|
||||
`With warm regards,`,
|
||||
`The ${data.portName} Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
@@ -196,7 +198,7 @@ export async function resetEmail(
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||
.replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes))
|
||||
: `Reset your ${data.portName} client portal password`;
|
||||
: `Reset your ${data.portName} portal password`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<ResetBody {...data} accent={accent} />, {
|
||||
@@ -204,15 +206,15 @@ export async function resetEmail(
|
||||
});
|
||||
|
||||
const text = [
|
||||
`Password reset for ${data.portName}`,
|
||||
`Reset your ${data.portName} portal password`,
|
||||
'',
|
||||
`Reset your password by visiting: ${data.link}`,
|
||||
`The link expires in ${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 can safely ignore this email.`,
|
||||
`If you didn't request this, you may safely ignore this message — your existing password will continue to work.`,
|
||||
'',
|
||||
`Thank you,`,
|
||||
`${data.portName} CRM`,
|
||||
`With warm regards,`,
|
||||
`The ${data.portName} Team`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { baseListQuerySchema } from '@/lib/api/list-query';
|
||||
import { VALID_MERGE_TOKENS, isCustomMergeToken } from '@/lib/templates/merge-fields';
|
||||
import { fieldMapSchema } from '@/lib/templates/field-map';
|
||||
|
||||
// A token is acceptable if it's in the static catalog OR matches the
|
||||
// dynamic `{{custom.<fieldName>}}` shape. The resolver checks the actual
|
||||
@@ -40,6 +41,11 @@ const createTemplateBaseSchema = z.object({
|
||||
bodyHtml: z.string().min(1).optional(),
|
||||
mergeFields: mergeFieldsSchema,
|
||||
isActive: z.boolean().default(true),
|
||||
// Phase 7.1 — PDF overlay markers (percent-coord) saved by the
|
||||
// in-app editor. Reused by templateFormat='pdf_overlay' at fill time.
|
||||
// Stays optional so legacy html/pdf_form templates can be PATCHed
|
||||
// without an empty array round-trip.
|
||||
overlayPositions: fieldMapSchema.optional(),
|
||||
});
|
||||
|
||||
export const createTemplateSchema = createTemplateBaseSchema.refine(
|
||||
|
||||
Reference in New Issue
Block a user