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:
2026-05-18 16:37:19 +02:00
parent eaab14943b
commit f938847ed9
12 changed files with 502 additions and 65 deletions

View File

@@ -884,8 +884,12 @@ Deferred:
so the EOI's yacht block populates without a manual re-link.
- ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary`
(transactional demote+promote via `promoteContactToPrimary`); `[EOI]`
badge on non-primary contact rows in `<ContactsEditor>` with title-attr
explainer. Yacht detail-page badge deferred.
badge on non-primary contact rows in `<ContactsEditor>` + on yacht
detail header when `yacht.source === 'eoi-generated'`.
- ☐ Address override field in EOI dialog (schema columns exist)
- ☐ Audit-log UI surfacing of new verbs (rows written, filter chips missing)
- ☐ Backfill yachts.source_document_id after EOI document is created
(currently set NULL because the yacht is spawned BEFORE the doc row exists)
- ◐ Phase 4 — Reminders (fb4a09e + session 2026-05-18 PM)
- ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note
- ☑ Service + validators accept yachtId with port-scoping check
@@ -898,10 +902,18 @@ Deferred:
- ☑ `user_profiles.preferences.digestTimeOfDay` picker on `/settings`
(time input + help text). `<ReminderForm>` honours the preference via
a React-Query me-prefs fetch keyed `['me', 'preferences']`.
- Per-entity-page `[+ Task]` buttons threading `defaultYachtId` (etc.)
- ◐ Phase 5 — Email-copy refactor (branding chain only; df1594d)
- Per-entity `[+ Reminder]` buttons on yacht / client / interest detail
headers threading defaultYachtId / defaultClientId / defaultInterestId
- ☐ Per-entity reminders LIST inline on detail pages (button exists; section TBD)
- ◐ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM)
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
- ☐ Tone rewrite across 8 templates using old-CRM Nuxt repo as reference
- ☑ 4/8 templates rewritten with luxury-port voice (portal-auth activation
- reset, inquiry-client-confirmation, notification-digest, document-signing
sign-offs). Voice captured from old-CRM Nuxt repo `server/utils/
signature-notifications.ts` ("Dear X", "With warm regards, The
{portName} Team").
- ☐ Remaining 4 templates: admin-email-change, crm-invite,
inquiry-sales-notification, residential-inquiry
- ☐ Snapshot tests per template at port-nimara + 2nd test port
- ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM)
- ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends
@@ -927,10 +939,17 @@ Deferred:
envelope sender's mailbox (the SMTP user account), so pointing the
poller at that single mailbox catches every automated-email bounce
in one place.
- ◐ Phase 7 — PDF template editor (field-map types only; 9f57868)
- ◐ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM)
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator
- 7.1 Read + place (~2 weeks): editor shell, page picker, marker drop
- ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview, new-PDF upload
- 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side
`<TemplateEditor>` with react-pdf, click-to-place markers, token picker
from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. Page 1
only; add + delete markers supported.
- ☐ 7.1 polish: unsaved-changes guard, responsive PDF width,
"required tokens unplaced" indicator
- ☐ 7.2 Edit + preview (~1-2 weeks): drag/resize, live preview pane with
sample interest data, multi-page navigation, new-PDF upload (replace
source while preserving field map)
---

View File

@@ -0,0 +1,15 @@
import { TemplateEditor } from '@/components/admin/templates/template-editor';
/**
* Phase 7.1 — PDF template editor (read + place markers).
*
* Renders the source PDF for the selected template and lets the admin
* drop merge-field markers by clicking on the page. Persists the marker
* coordinates to `document_templates.overlay_positions` via PATCH so
* the existing `pdf_overlay` fill path can use them at generate time.
*
* Phase 7.2 (drag/resize/preview/multi-page) is queued separately.
*/
export default function TemplateEditorPage({ params }: { params: { id: string } }) {
return <TemplateEditor templateId={params.id} />;
}

View File

@@ -0,0 +1,320 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import { Loader2, Save, Trash2, X } from 'lucide-react';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
import type { FieldMap, FieldMapEntry } from '@/lib/templates/field-map';
// Worker setup mirrors src/components/files/pdf-viewer.tsx so we don't
// pay for a second bundle. Calling GlobalWorkerOptions.workerSrc twice
// with the same URL is a no-op in pdfjs.
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface TemplateData {
id: string;
name: string;
templateType: string;
templateFormat: string;
sourceFileId: string | null;
overlayPositions: FieldMap | null;
}
interface PendingMarker {
x: number;
y: number;
page: number;
}
const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort();
const DEFAULT_MARKER_W = 0.18;
const DEFAULT_MARKER_H = 0.04;
/**
* Phase 7.1 — page-1 PDF marker editor. Click anywhere on the rendered
* PDF to drop a marker, pick which merge token it represents, save.
*
* Scope intentionally narrow:
* - Page 1 only (multi-page page-picker is a 7.2 ticket).
* - Add + delete markers; drag-to-move + corner-resize defer to 7.2.
* - Coordinates stored as percent of page width/height so a future
* page-size swap (A4 ↔ Letter) doesn't shift placements.
*/
export function TemplateEditor({ templateId }: { templateId: string }) {
const { data: template, isLoading } = useQuery<{ data: TemplateData }>({
queryKey: ['document-template', templateId],
queryFn: () => apiFetch<{ data: TemplateData }>(`/api/v1/document-templates/${templateId}`),
});
if (isLoading || !template) {
return (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading template
</div>
);
}
// Inner body keyed by templateId so a route change re-mounts and
// the markers useState initializer re-reads the server payload.
// Avoids the in-render setState pattern (React anti-pattern) that
// a single component would otherwise need to seed from the query.
return <TemplateEditorBody key={templateId} templateId={templateId} template={template.data} />;
}
function TemplateEditorBody({
templateId,
template,
}: {
templateId: string;
template: TemplateData;
}) {
const qc = useQueryClient();
const [markers, setMarkers] = useState<FieldMap>(template.overlayPositions ?? []);
const [pending, setPending] = useState<PendingMarker | null>(null);
const [pendingToken, setPendingToken] = useState<string>('');
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null;
function handlePageClick(e: React.MouseEvent<HTMLDivElement>) {
const container = pageContainerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
setPending({ x, y, page: 1 });
setPendingToken(TOKEN_OPTIONS[0] ?? '');
}
function commitPending() {
if (!pending || !pendingToken) return;
const entry: FieldMapEntry = {
token: pendingToken,
page: pending.page,
x: pending.x,
y: pending.y,
w: DEFAULT_MARKER_W,
h: DEFAULT_MARKER_H,
};
setMarkers((m) => [...m, entry]);
setPending(null);
setPendingToken('');
}
function cancelPending() {
setPending(null);
setPendingToken('');
}
function removeMarker(index: number) {
setMarkers((m) => m.filter((_, i) => i !== index));
}
async function save() {
setSaving(true);
setSavedMsg(null);
try {
await apiFetch(`/api/v1/document-templates/${templateId}`, {
method: 'PATCH',
body: { overlayPositions: markers },
});
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
setSavedMsg('Markers saved.');
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]);
if (!pdfUrl) {
return (
<div className="space-y-4">
<PageHeader
title={`Edit "${template.name}"`}
description="Place merge-field markers on the source PDF."
/>
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
This template has no source PDF attached. Upload one from the template list before
opening the editor.
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<PageHeader
title={`Edit "${template.name}"`}
description="Click anywhere on the page to drop a merge-field marker. Phase 1: page 1, click-to-place. Drag/resize/preview lands later."
/>
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Page 1</CardTitle>
</CardHeader>
<CardContent>
{/* Wrapper carries the click handler. react-pdf renders the
actual page inside; we overlay markers as positioned
divs using the same percent coordinates the server-side
fill path consumes. */}
<div
ref={pageContainerRef}
onClick={handlePageClick}
className="relative inline-block cursor-crosshair select-none"
>
<Document
file={pdfUrl}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
</div>
}
onLoadError={(err) => {
// Surface load errors via toast rather than blowing up
// — a bad source PDF shouldn't crash the editor shell.
toastError(err);
}}
>
<Page
pageNumber={1}
width={680}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{visibleMarkers.map((m, i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${m.x * 100}%`,
top: `${m.y * 100}%`,
width: `${(m.w ?? DEFAULT_MARKER_W) * 100}%`,
height: `${(m.h ?? DEFAULT_MARKER_H) * 100}%`,
}}
className="pointer-events-none rounded border-2 border-primary/70 bg-primary/15 px-1 py-0.5 text-[10px] font-medium text-primary"
>
{m.token}
</div>
))}
{pending ? (
<div
style={{
position: 'absolute',
left: `${pending.x * 100}%`,
top: `${pending.y * 100}%`,
width: `${DEFAULT_MARKER_W * 100}%`,
height: `${DEFAULT_MARKER_H * 100}%`,
}}
className="pointer-events-none rounded border-2 border-amber-500 bg-amber-500/15"
/>
) : null}
</div>
</CardContent>
</Card>
<div className="space-y-4">
{pending ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">New marker</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Merge token</Label>
<Select value={pendingToken} onValueChange={setPendingToken}>
<SelectTrigger>
<SelectValue placeholder="Pick a token…" />
</SelectTrigger>
<SelectContent className="max-h-72">
{TOKEN_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" size="sm" onClick={cancelPending}>
<X className="mr-1.5 h-3.5 w-3.5" /> Cancel
</Button>
<Button size="sm" disabled={!pendingToken} onClick={commitPending}>
Add marker
</Button>
</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Markers ({visibleMarkers.length})</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{visibleMarkers.length === 0 ? (
<p className="text-xs text-muted-foreground">
Click on the PDF to drop your first marker.
</p>
) : (
visibleMarkers.map((m, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-xs"
>
<span className="font-mono">{m.token}</span>
<button
type="button"
onClick={() => removeMarker(markers.indexOf(m))}
className="text-muted-foreground hover:text-destructive"
aria-label={`Remove ${m.token} marker`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</CardContent>
</Card>
<Button onClick={save} disabled={saving} className="w-full">
<Save className="mr-1.5 h-4 w-4" />
{saving ? 'Saving…' : 'Save markers'}
</Button>
{savedMsg ? <p className="text-center text-xs text-emerald-700">{savedMsg}</p> : null}
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Archive, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns';
@@ -13,6 +13,8 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { SmartArchiveDialog } from '@/components/clients/smart-archive-dialog';
import { SmartRestoreDialog } from '@/components/clients/smart-restore-dialog';
import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog';
import { ReminderForm } from '@/components/reminders/reminder-form';
import { useQueryClient } from '@tanstack/react-query';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
@@ -42,6 +44,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const router = useRouter();
const [archiveOpen, setArchiveOpen] = useState(false);
const [hardDeleteOpen, setHardDeleteOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
const qc = useQueryClient();
const isArchived = !!client.archivedAt;
@@ -172,6 +176,15 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</button>
</PermissionGate>
)}
<button
type="button"
onClick={() => setReminderOpen(true)}
aria-label="Add reminder for this client"
title="Add reminder for this client"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-primary"
>
<Bell className="size-4" aria-hidden />
</button>
<button
type="button"
onClick={() => setArchiveOpen(true)}
@@ -193,6 +206,13 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</div>
</DetailHeaderStrip>
<ReminderForm
open={reminderOpen}
onOpenChange={setReminderOpen}
defaultClientId={client.id}
onSuccess={() => qc.invalidateQueries({ queryKey: ['reminders'] })}
/>
{isArchived ? (
<SmartRestoreDialog
open={archiveOpen}

View File

@@ -6,6 +6,7 @@ import { toast } from 'sonner';
import {
Pencil,
Archive,
Bell,
RotateCcw,
Trophy,
XCircle,
@@ -27,6 +28,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { ReminderForm } from '@/components/reminders/reminder-form';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { AssignedToChip } from '@/components/interests/assigned-to-chip';
@@ -135,6 +137,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
// Opens the same Sheet the Contact log tab uses without forcing the rep
// to tab-navigate first.
const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
const isArchived = !!interest.archivedAt;
@@ -494,6 +497,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<Pencil className="size-4" aria-hidden />
</button>
</PermissionGate>
<button
type="button"
onClick={() => setReminderOpen(true)}
aria-label="Add reminder for this interest"
title="Add reminder for this interest"
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-primary"
>
<Bell className="size-4" aria-hidden />
</button>
<PermissionGate resource="interests" action="delete">
<button
type="button"
@@ -532,6 +544,14 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
/>
<ReminderForm
open={reminderOpen}
onOpenChange={setReminderOpen}
defaultInterestId={interest.id}
defaultClientId={interest.clientId ?? undefined}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, ArrowRightLeft } from 'lucide-react';
import { Pencil, Archive, ArrowRightLeft, Bell } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { ReminderForm } from '@/components/reminders/reminder-form';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
@@ -39,6 +40,10 @@ interface YachtDetailHeaderYacht {
status: string;
notes: string | null;
archivedAt: string | null;
/** Phase 3d — origin tag. Surfaces an [EOI] badge next to the yacht
* name when the row was spawned from an EOI override. */
source?: string | null;
sourceDocumentId?: string | null;
}
interface YachtDetailHeaderProps {
@@ -110,6 +115,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
const isArchived = !!yacht.archivedAt;
@@ -154,6 +160,18 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
Archived
</Badge>
)}
{yacht.source === 'eoi-generated' ? (
<span
className="inline-flex items-center rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800"
title={
yacht.sourceDocumentId
? 'Spawned from an EOI — open the source document for context.'
: 'Spawned from an EOI override.'
}
>
EOI
</span>
) : null}
</div>
{dimensions && <p className="text-muted-foreground mt-0.5 text-sm">{dimensions}</p>}
@@ -185,6 +203,10 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
Transfer
</Button>
</PermissionGate>
<Button variant="outline" size="sm" onClick={() => setReminderOpen(true)}>
<Bell className="mr-1.5 h-3.5 w-3.5" aria-hidden />
Add reminder
</Button>
<Button
variant="outline"
size="sm"
@@ -198,6 +220,13 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
</div>
</DetailHeaderStrip>
<ReminderForm
open={reminderOpen}
onOpenChange={setReminderOpen}
defaultYachtId={yacht.id}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['reminders'] })}
/>
<YachtForm
open={editOpen}
onOpenChange={setEditOpen}

View File

@@ -37,6 +37,9 @@ export interface YachtData {
archivedAt: string | null;
createdAt: string;
updatedAt: string;
/** Phase 3c — origin tracking. Surfaces the [EOI] badge in the header. */
source?: string | null;
sourceDocumentId?: string | null;
}
interface YachtDetailProps {

View File

@@ -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 }),

View File

@@ -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&apos;ve noted your enquiry, and a member of
our team will be in touch shortly through your preferred channel with the details
you&apos;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&apos;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');

View File

@@ -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&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}>
<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 {

View File

@@ -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&apos;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&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
@@ -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&apos;t request this, you can safely ignore this email your password will
remain unchanged.
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' }}>
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 {

View File

@@ -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(