From ef0dc5abc4036ec193b1dcd5031d906adda79200 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 18 May 2026 17:09:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(post-audit):=20finish=20Phase=203=20/=204?= =?UTF-8?q?=20/=205=20/=207=20=E2=80=94=20remaining=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — EOI overrides (now ☑): - Address override field with the same per-component input UX as the canonical address form (line1/line2/city/state/postal + ISO subdivision + CountryCombobox). Two-checkbox intent semantics identical to email/phone — useOnlyForThisEoi writes only to documents.override_client_address_* columns; setAsDefault promotes to the canonical client_addresses primary inside the override transaction; neither flag inserts a non-primary address row for future reuse. eoi-context route now returns available.addresses so the dialog can render the picker over existing rows. - yachts.source_document_id backfill — yachts spawned via EOI run BEFORE generateAndSign creates the document row, so source_document_id stayed NULL. Mirrored the bounded-recent backfill pattern from contacts into persistDocumentOverrides for both client_addresses and yachts (every row inserted in the last 60s with NULL source_document_id and the right source flag gets attributed). - Audit-log filter chips for the new verbs — eoi_field_override, promote_to_primary, eoi_spawn_yacht now appear in /admin/audit dropdown + get human labels in the card view. Phase 4 — reminders inline section (now ☑): - New shared component shows the 3-5 most recent open reminders for an entity. Mounted on Overview tab of yacht / client / interest detail. Empty state hints at the header button rather than duplicating it. Phase 5 — email tone (now ☑ across all 8 templates): - admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry — voice + sign-off match the 4 shipped earlier ("Dear X", "With warm regards, The {portName} Team", sentence-case subjects). Snapshot tests deferred — they'd need a 2nd-port fixture set up to catch port-name leaks; templates are correct in review. Phase 7 — PDF editor (now ☑): - 7.1 polish: unsaved-changes guard (beforeunload + "Unsaved changes" badge), ResizeObserver-driven responsive PDF width, required-tokens- unplaced indicator reading template.mergeFields. - 7.2 drag-to-move with on-page clamping. - 7.2 four-corner resize handles with min-size enforcement. - 7.2 right-click context delete via onContextMenu. - 7.2 multi-page navigation + per-page marker filter. - 7.2 live preview endpoint POST /api/v1/document-templates/[id]/preview runs the in-app pdf-lib fill against the supplied interest, uploads to a transient previews/ key, returns a 15-min presigned URL. - 7.2 new-PDF upload POST /api/v1/document-templates/[id]/source-pdf takes multipart FormData, magic-byte verifies %PDF-, parses page count via pdf-lib, swaps documentTemplates.sourceFileId. Editor warns when the new page count truncates the prior set. Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/MASTER-PLAN-2026-05-18.md | 43 +- .../document-templates/[id]/preview/route.ts | 80 +++ .../[id]/source-pdf/route.ts | 114 ++++ .../v1/interests/[id]/eoi-context/route.ts | 18 +- src/components/admin/audit/audit-log-card.tsx | 3 + src/components/admin/audit/audit-log-list.tsx | 3 + .../admin/templates/template-editor.tsx | 582 +++++++++++++++--- src/components/clients/client-tabs.tsx | 3 + .../documents/eoi-generate-dialog.tsx | 366 ++++++++++- src/components/interests/interest-tabs.tsx | 5 + src/components/reminders/reminders-inline.tsx | 134 ++++ src/components/yachts/yacht-tabs.tsx | 5 + .../email/templates/admin-email-change.tsx | 29 +- src/lib/email/templates/crm-invite.tsx | 19 +- .../templates/inquiry-sales-notification.tsx | 29 +- .../email/templates/residential-inquiry.tsx | 13 +- src/lib/services/eoi-overrides.service.ts | 271 +++++++- src/lib/validators/document-templates.ts | 19 +- 18 files changed, 1532 insertions(+), 204 deletions(-) create mode 100644 src/app/api/v1/document-templates/[id]/preview/route.ts create mode 100644 src/app/api/v1/document-templates/[id]/source-pdf/route.ts create mode 100644 src/components/reminders/reminders-inline.tsx diff --git a/docs/MASTER-PLAN-2026-05-18.md b/docs/MASTER-PLAN-2026-05-18.md index 8efff4c2..050f560c 100644 --- a/docs/MASTER-PLAN-2026-05-18.md +++ b/docs/MASTER-PLAN-2026-05-18.md @@ -905,16 +905,15 @@ Deferred: - ☑ 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) +- ☑ Phase 5 — Email-copy refactor (df1594d + 2026-05-18 PM x3) - ☑ Per-port background URL — closes the last hard-coded portnimara.com asset - - ☑ 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 + - ☑ All 8 templates rewritten with luxury-port voice: portal-auth (activation + + reset), inquiry-client-confirmation, notification-digest, document-signing, + admin-email-change, crm-invite, inquiry-sales-notification, residential-inquiry. + Voice: "Dear X", "With warm regards, The {portName} Team", subjects in + sentence case ("Thank you for…" not "Thank You For…"). + - ☐ Snapshot tests per template at port-nimara + 2nd test port (defer — would + need a 2nd-port fixture set up; templates work in code review) - ◐ Phase 6 — IMAP bounce-to-interest linking (9f57868 + session 2026-05-18 PM) - ☑ Schema migration 0074: bounce_status/reason/detected_at on document_sends - ☑ Parser library `src/lib/email/bounce-parser.ts` (RFC 3464 + Outlook + OOO) @@ -939,17 +938,27 @@ signature-notifications.ts` ("Dear X", "With warm regards, The 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 (9f57868 + 2026-05-18 PM) +- ☑ Phase 7 — PDF template editor (9f57868 + 2026-05-18 PM x3) - ☑ FieldMap type definitions + Zod validators + page-count cross-validator - ☑ 7.1 scaffold — `/admin/templates/[id]/editor/page.tsx` + client-side `` 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) + from `VALID_MERGE_TOKENS`, save via PATCH to overlayPositions. + - ☑ 7.1 polish — unsaved-changes guard (beforeunload + visual "Unsaved + changes" badge), responsive PDF width via ResizeObserver, required + tokens unplaced indicator that reads `template.mergeFields`. + - ☑ 7.2 drag-to-move with on-page clamping; 4 corner resize handles + (NW/NE/SW/SE) with min-size + on-page clamping. + - ☑ 7.2 multi-page navigation (page picker + per-page marker filter). + - ☑ 7.2 right-click context delete (onContextMenu → preventDefault → + setMarkers filter). + - ☑ 7.2 live preview endpoint — `POST /api/v1/document-templates/[id]/preview` + accepts {interestId}, runs the same in-app pdf-lib fill, uploads to a + transient `previews/` storage key, returns a 15-minute presigned URL. + - ☑ 7.2 new-PDF upload — `POST /api/v1/document-templates/[id]/source-pdf` + accepts multipart FormData, magic-byte verifies %PDF-, parses page count + via pdf-lib, swaps `documentTemplates.sourceFileId` to the new files row. + Editor warns when new page count truncates the prior set so reps know + their markers on now-orphaned pages won't render. --- diff --git a/src/app/api/v1/document-templates/[id]/preview/route.ts b/src/app/api/v1/document-templates/[id]/preview/route.ts new file mode 100644 index 00000000..ba2b488c --- /dev/null +++ b/src/app/api/v1/document-templates/[id]/preview/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { documentTemplates } from '@/lib/db/schema/documents'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form'; +import { buildEoiContext } from '@/lib/services/eoi-context'; +import { getStorageBackend, presignDownloadUrl } from '@/lib/storage'; +import { buildStoragePath } from '@/lib/minio'; + +const previewBodySchema = z.object({ + interestId: z.string().uuid(), + dimensionUnit: z.enum(['ft', 'm']).optional(), +}); + +/** + * Phase 7.2 — live preview endpoint for the PDF editor. + * + * Generates a transient EOI PDF against the supplied interest using the + * template's current source PDF + overlay markers, uploads it to a + * scratch storage key, and returns a 15-minute presigned download URL. + * + * The blob is intentionally not linked to a `files` row — preview PDFs + * are throwaway. The storage backend's lifecycle policy (TTL on + * `previews/` prefix) cleans them up; in dev the filesystem backend + * just accumulates them, which is acceptable for the editor workflow. + */ +export const POST = withAuth( + withPermission('documents', 'create', async (req, ctx, params) => { + try { + const body = await parseBody(req, previewBodySchema); + + const template = await db.query.documentTemplates.findFirst({ + where: and(eq(documentTemplates.id, params.id!), eq(documentTemplates.portId, ctx.portId)), + }); + if (!template) throw new NotFoundError('Template'); + if (template.templateType !== 'eoi') { + // Live preview is currently EOI-only — that's where the + // editor's overlay-positions flow into rendering. Other + // template types are deferred (no in-app fill yet). + throw new ValidationError( + `Live preview is only available for EOI templates (got "${template.templateType}").`, + ); + } + + const eoiContext = await buildEoiContext(body.interestId, ctx.portId); + const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { + dimensionUnit: body.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft', + }); + + const previewKey = buildStoragePath( + ctx.portSlug, + 'previews', + 'document-templates', + template.id, + `${crypto.randomUUID()}.pdf`, + ); + const backend = await getStorageBackend(); + const buffer = Buffer.from(pdfBytes); + await backend.put(previewKey, buffer, { + contentType: 'application/pdf', + sizeBytes: buffer.length, + }); + + const previewUrl = await presignDownloadUrl( + previewKey, + 900, + `${template.name}.preview.pdf`, + ctx.portSlug, + ); + return NextResponse.json({ data: { previewUrl } }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/document-templates/[id]/source-pdf/route.ts b/src/app/api/v1/document-templates/[id]/source-pdf/route.ts new file mode 100644 index 00000000..947944b9 --- /dev/null +++ b/src/app/api/v1/document-templates/[id]/source-pdf/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { PDFDocument } from 'pdf-lib'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { documentTemplates, files } from '@/lib/db/schema/documents'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { buildStoragePath } from '@/lib/minio'; +import { getStorageBackend } from '@/lib/storage'; +import { env } from '@/lib/env'; +import { createAuditLog } from '@/lib/audit'; + +const MAX_PDF_BYTES = 10 * 1024 * 1024; +const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-" + +/** + * Phase 7.2 — replace the template's source PDF while preserving the + * field map. The existing `overlay_positions` is kept exactly as-is; + * the client warns when the new page count truncates the previous set + * (markers on now-orphaned pages are invisible at render time). + * + * Magic-byte (`%PDF-`) verified server-side so a non-PDF file (with a + * spoofed extension) can't sneak into the storage backend. + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx, params) => { + try { + const template = await db.query.documentTemplates.findFirst({ + where: and(eq(documentTemplates.id, params.id!), eq(documentTemplates.portId, ctx.portId)), + }); + if (!template) throw new NotFoundError('Template'); + + const form = await req.formData(); + const file = form.get('file'); + if (!file || typeof file === 'string') { + throw new ValidationError('Missing "file" field'); + } + const arrayBuf = await file.arrayBuffer(); + if (arrayBuf.byteLength > MAX_PDF_BYTES) { + throw new ValidationError( + `PDF exceeds the ${Math.floor(MAX_PDF_BYTES / 1024 / 1024)} MB cap`, + ); + } + const buf = Buffer.from(arrayBuf); + if (buf.length < PDF_MAGIC.length || !buf.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC)) { + throw new ValidationError('Uploaded file does not look like a PDF (missing %PDF- header)'); + } + + // Resolve the page count so the client can surface a warning if + // the new PDF truncates the prior page set + orphaned markers + // exist. pdf-lib parses fully into memory but that's cheap for a + // sub-10MB editor source. + const pdfDoc = await PDFDocument.load(buf); + const pageCount = pdfDoc.getPageCount(); + + const fileId = crypto.randomUUID(); + const storagePath = buildStoragePath( + ctx.portSlug, + 'document-templates', + template.id, + fileId, + 'pdf', + ); + const backend = await getStorageBackend(); + await backend.put(storagePath, buf, { + contentType: 'application/pdf', + sizeBytes: buf.length, + }); + + const [fileRecord] = await db + .insert(files) + .values({ + portId: ctx.portId, + filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`, + originalName: file.name || `${template.name}.pdf`, + mimeType: 'application/pdf', + sizeBytes: String(buf.length), + storagePath, + storageBucket: env.MINIO_BUCKET, + category: 'eoi', + uploadedBy: ctx.userId, + }) + .returning(); + + await db + .update(documentTemplates) + .set({ sourceFileId: fileRecord!.id, updatedAt: new Date() }) + .where(eq(documentTemplates.id, template.id)); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'documentTemplate', + entityId: template.id, + metadata: { + action: 'replace_source_pdf', + fileId: fileRecord!.id, + pageCount, + sizeBytes: buf.length, + }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + return NextResponse.json({ + data: { sourceFileId: fileRecord!.id, pageCount }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/eoi-context/route.ts b/src/app/api/v1/interests/[id]/eoi-context/route.ts index 2088e45b..ee6cb4be 100644 --- a/src/app/api/v1/interests/[id]/eoi-context/route.ts +++ b/src/app/api/v1/interests/[id]/eoi-context/route.ts @@ -4,7 +4,7 @@ import { and, desc, eq } from 'drizzle-orm'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; -import { clientContacts } from '@/lib/db/schema/clients'; +import { clientAddresses, clientContacts } from '@/lib/db/schema/clients'; import { errorResponse, NotFoundError } from '@/lib/errors'; import { buildEoiContext } from '@/lib/services/eoi-context'; @@ -45,6 +45,21 @@ export const GET = withAuth( .where(eq(clientContacts.clientId, interest.clientId)) .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)); + const addressRows = await db + .select({ + id: clientAddresses.id, + streetAddress: clientAddresses.streetAddress, + city: clientAddresses.city, + subdivisionIso: clientAddresses.subdivisionIso, + postalCode: clientAddresses.postalCode, + countryIso: clientAddresses.countryIso, + isPrimary: clientAddresses.isPrimary, + source: clientAddresses.source, + }) + .from(clientAddresses) + .where(eq(clientAddresses.clientId, interest.clientId)) + .orderBy(desc(clientAddresses.isPrimary), desc(clientAddresses.updatedAt)); + const available = { emails: contactRows .filter((c) => c.channel === 'email') @@ -58,6 +73,7 @@ export const GET = withAuth( channel: c.channel, source: c.source, })), + addresses: addressRows, }; return NextResponse.json({ data: { ...context, available } }); diff --git a/src/components/admin/audit/audit-log-card.tsx b/src/components/admin/audit/audit-log-card.tsx index 51a9e805..b598e1de 100644 --- a/src/components/admin/audit/audit-log-card.tsx +++ b/src/components/admin/audit/audit-log-card.tsx @@ -64,6 +64,9 @@ function actionVerb(action: string): string { merge: 'Merged', revert: 'Reverted', viewed: 'Viewed', + eoi_field_override: 'EOI field override', + promote_to_primary: 'Contact promoted', + eoi_spawn_yacht: 'EOI spawn yacht', }; return map[action] ?? action.charAt(0).toUpperCase() + action.slice(1); } diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 51742bd5..5eec2a56 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -471,6 +471,9 @@ export function AuditLogList() { Outcome cleared Logo uploaded Logo archived + EOI field override + Contact promoted + EOI spawn yacht diff --git a/src/components/admin/templates/template-editor.tsx b/src/components/admin/templates/template-editor.tsx index 5ac5e16d..3cc10665 100644 --- a/src/components/admin/templates/template-editor.tsx +++ b/src/components/admin/templates/template-editor.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, 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 { ChevronLeft, ChevronRight, Eye, Loader2, Save, Trash2, Upload, X } from 'lucide-react'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; @@ -36,6 +36,10 @@ interface TemplateData { templateFormat: string; sourceFileId: string | null; overlayPositions: FieldMap | null; + /** Tokens marked as required for the EOI flow — see + * STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor + * surfaces a checklist of which required tokens are still unplaced. */ + mergeFields?: string[] | null; } interface PendingMarker { @@ -44,19 +48,41 @@ interface PendingMarker { page: number; } +type DragKind = 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se'; + +interface DragState { + index: number; + kind: DragKind; + startMarkerX: number; + startMarkerY: number; + startMarkerW: number; + startMarkerH: number; + startClientX: number; + startClientY: number; + containerW: number; + containerH: number; +} + const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort(); const DEFAULT_MARKER_W = 0.18; const DEFAULT_MARKER_H = 0.04; +const MIN_MARKER_DIM = 0.02; /** - * 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. + * Phase 7.1 + 7.2 — PDF marker editor. * - * 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. + * - Click anywhere to drop a marker (page-aware). + * - Drag markers to move; corner handles to resize. + * - Right-click for context-menu delete. + * - Multi-page navigation via page picker. + * - "Required tokens unplaced" checklist surfaces missing fields. + * - Unsaved-changes guard (beforeunload + visual diff indicator). + * - Responsive PDF width tracks the container via ResizeObserver. + * - Live preview pane renders the AcroForm fill against a chosen + * interest via POST /api/v1/document-templates/[id]/preview. + * - "Replace PDF" reuses the existing template-templates source-file + * upload route while preserving the field map (warn on page-count + * change). */ export function TemplateEditor({ templateId }: { templateId: string }) { const { data: template, isLoading } = useQuery<{ data: TemplateData }>({ @@ -73,10 +99,9 @@ export function TemplateEditor({ templateId }: { templateId: string }) { ); } - // 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. + // Inner body keyed by templateId so a route change re-mounts and the + // markers useState initializer re-reads the server payload, avoiding + // an in-render setState pattern to seed from the query. return ; } @@ -89,22 +114,73 @@ function TemplateEditorBody({ }) { const qc = useQueryClient(); const [markers, setMarkers] = useState(template.overlayPositions ?? []); + // Track the "last saved" baseline as state (not a ref) so the dirty + // check re-runs on save. React Compiler forbids reading refs during + // render, and the isDirty memo needs a stable baseline that updates + // exactly when we commit a save. + const [savedMarkers, setSavedMarkers] = useState(template.overlayPositions ?? []); const [pending, setPending] = useState(null); const [pendingToken, setPendingToken] = useState(''); const [saving, setSaving] = useState(false); const [savedMsg, setSavedMsg] = useState(null); + const [pageNumber, setPageNumber] = useState(1); + const [numPages, setNumPages] = useState(null); + const [pageWidth, setPageWidth] = useState(680); + const [dragState, setDragState] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewInterestId, setPreviewInterestId] = useState(''); const pageContainerRef = useRef(null); + const outerColumnRef = useRef(null); + const fileInputRef = useRef(null); const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null; + // ─── Unsaved changes detection ──────────────────────────────────────────── + const isDirty = useMemo( + () => JSON.stringify(markers) !== JSON.stringify(savedMarkers), + [markers, savedMarkers], + ); + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Modern browsers ignore the returnValue string and show their own + // generic "you have unsaved changes" prompt — setting it still + // triggers the prompt, just without our wording. + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isDirty]); + + // ─── Responsive PDF width ───────────────────────────────────────────────── + useEffect(() => { + const el = outerColumnRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width ?? 680; + // Subtract Card padding/border so the PDF doesn't overshoot the + // container. 32 = px-4 (16) × 2. + setPageWidth(Math.max(360, Math.floor(w - 32))); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // ─── Click-to-place (only when not dragging) ────────────────────────────── function handlePageClick(e: React.MouseEvent) { + if (dragState) return; // drag in progress; ignore click const container = pageContainerRef.current; if (!container) return; + // Ignore clicks bubbling from a marker (handled by its own onClick). + const target = e.target as HTMLElement; + if (target.dataset.markerHandle === 'true') 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 }); + setPending({ x, y, page: pageNumber }); setPendingToken(TOKEN_OPTIONS[0] ?? ''); } @@ -132,6 +208,98 @@ function TemplateEditorBody({ setMarkers((m) => m.filter((_, i) => i !== index)); } + // ─── Drag + resize ──────────────────────────────────────────────────────── + const onMarkerMouseDown = useCallback( + (index: number, kind: DragKind) => (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const container = pageContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const m = markers[index]; + if (!m) return; + setDragState({ + index, + kind, + startMarkerX: m.x, + startMarkerY: m.y, + startMarkerW: m.w ?? DEFAULT_MARKER_W, + startMarkerH: m.h ?? DEFAULT_MARKER_H, + startClientX: e.clientX, + startClientY: e.clientY, + containerW: rect.width, + containerH: rect.height, + }); + }, + [markers], + ); + + useEffect(() => { + if (!dragState) return; + function onMove(e: MouseEvent) { + if (!dragState) return; + const dxPct = (e.clientX - dragState.startClientX) / dragState.containerW; + const dyPct = (e.clientY - dragState.startClientY) / dragState.containerH; + setMarkers((current) => { + const next = current.slice(); + const m = next[dragState.index]; + if (!m) return current; + const w = m.w ?? DEFAULT_MARKER_W; + const h = m.h ?? DEFAULT_MARKER_H; + if (dragState.kind === 'move') { + // Clamp so the box stays fully on-page. + next[dragState.index] = { + ...m, + x: clamp(dragState.startMarkerX + dxPct, 0, 1 - w), + y: clamp(dragState.startMarkerY + dyPct, 0, 1 - h), + }; + } else { + // Resize from a specific corner; the opposing corner stays + // pinned, so x/y/w/h all change for NW (drag the top-left + // corner: x and y increase by the delta, w and h decrease by it). + let nx = dragState.startMarkerX; + let ny = dragState.startMarkerY; + let nw = dragState.startMarkerW; + let nh = dragState.startMarkerH; + if (dragState.kind === 'resize-nw') { + nx = dragState.startMarkerX + dxPct; + ny = dragState.startMarkerY + dyPct; + nw = dragState.startMarkerW - dxPct; + nh = dragState.startMarkerH - dyPct; + } else if (dragState.kind === 'resize-ne') { + ny = dragState.startMarkerY + dyPct; + nw = dragState.startMarkerW + dxPct; + nh = dragState.startMarkerH - dyPct; + } else if (dragState.kind === 'resize-sw') { + nx = dragState.startMarkerX + dxPct; + nw = dragState.startMarkerW - dxPct; + nh = dragState.startMarkerH + dyPct; + } else if (dragState.kind === 'resize-se') { + nw = dragState.startMarkerW + dxPct; + nh = dragState.startMarkerH + dyPct; + } + // Enforce min size + clamp so the box stays on-page. + nw = Math.max(MIN_MARKER_DIM, Math.min(1, nw)); + nh = Math.max(MIN_MARKER_DIM, Math.min(1, nh)); + nx = clamp(nx, 0, 1 - nw); + ny = clamp(ny, 0, 1 - nh); + next[dragState.index] = { ...m, x: nx, y: ny, w: nw, h: nh }; + } + return next; + }); + } + function onUp() { + setDragState(null); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [dragState]); + + // ─── Save ──────────────────────────────────────────────────────────────── async function save() { setSaving(true); setSavedMsg(null); @@ -140,6 +308,7 @@ function TemplateEditorBody({ method: 'PATCH', body: { overlayPositions: markers }, }); + setSavedMarkers(markers); await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); setSavedMsg('Markers saved.'); } catch (err) { @@ -149,7 +318,66 @@ function TemplateEditorBody({ } } - const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]); + // ─── Preview ───────────────────────────────────────────────────────────── + async function generatePreview() { + if (!previewInterestId) { + toastError(new Error('Pick an interest to preview against.')); + return; + } + setPreviewLoading(true); + try { + const res = await apiFetch<{ data: { previewUrl: string } }>( + `/api/v1/document-templates/${templateId}/preview`, + { method: 'POST', body: { interestId: previewInterestId } }, + ); + setPreviewUrl(res.data.previewUrl); + } catch (err) { + toastError(err); + } finally { + setPreviewLoading(false); + } + } + + // ─── New-PDF upload (replace source) ───────────────────────────────────── + async function onReplacePdf(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + try { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch(`/api/v1/document-templates/${templateId}/source-pdf`, { + method: 'POST', + body: fd, + }); + if (!res.ok) throw new Error((await res.text()) || 'Upload failed'); + const json = (await res.json()) as { data?: { pageCount?: number } }; + const newPageCount = json.data?.pageCount; + if (newPageCount && numPages && newPageCount < numPages) { + toastError( + new Error( + `New PDF has ${newPageCount} pages; the previous source had ${numPages}. Markers on pages ${newPageCount + 1}–${numPages} are now orphaned and won't render.`, + ), + ); + } + await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); + } catch (err) { + toastError(err); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + // ─── Derived state ──────────────────────────────────────────────────────── + const visibleMarkers = useMemo( + () => + markers + .map((m, i) => ({ marker: m, index: i })) + .filter((row) => row.marker.page === pageNumber), + [markers, pageNumber], + ); + const placedTokens = useMemo(() => new Set(markers.map((m) => m.token)), [markers]); + const requiredTokens = template.mergeFields ?? []; + const unplacedRequired = requiredTokens.filter((t) => !placedTokens.has(t)); if (!pdfUrl) { return ( @@ -160,8 +388,19 @@ function TemplateEditorBody({ /> - This template has no source PDF attached. Upload one from the template list before - opening the editor. + This template has no source PDF attached. Upload one to get started. +
+ + +
@@ -172,77 +411,119 @@ function TemplateEditorBody({
+ {isDirty ? ( + Unsaved changes + ) : null} + + + +
+ } /> + {savedMsg ?

{savedMsg}

: null} +
- - - Page 1 - - - {/* 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. */} -
- - - Loading PDF… +
+ + +
+ + Page {pageNumber} + {numPages ? ` / ${numPages}` : ''} + + {numPages && numPages > 1 ? ( +
+ +
- } - onLoadError={(err) => { - // Surface load errors via toast rather than blowing up - // — a bad source PDF shouldn't crash the editor shell. - toastError(err); - }} + ) : null} +
+
+ +
- - - - {visibleMarkers.map((m, i) => ( -
setNumPages(n)} + loading={ +
+ + Loading PDF… +
+ } + onLoadError={(err) => toastError(err)} > - {m.token} -
- ))} + + - {pending ? ( -
- ) : null} -
- - + {visibleMarkers.map(({ marker, index }) => ( + onMarkerMouseDown(index, kind)} + onContextMenu={(e) => { + e.preventDefault(); + removeMarker(index); + }} + /> + ))} + + {pending && pending.page === pageNumber ? ( +
+ ) : null} +
+ + +
{pending ? ( @@ -278,9 +559,30 @@ function TemplateEditorBody({ ) : null} + {unplacedRequired.length > 0 ? ( + + + + Required tokens unplaced ({unplacedRequired.length}) + + + +
    + {unplacedRequired.map((t) => ( +
  • + {t} +
  • + ))} +
+
+
+ ) : null} + - Markers ({visibleMarkers.length}) + + Markers on page {pageNumber} ({visibleMarkers.length}) + {visibleMarkers.length === 0 ? ( @@ -288,17 +590,17 @@ function TemplateEditorBody({ Click on the PDF to drop your first marker.

) : ( - visibleMarkers.map((m, i) => ( + visibleMarkers.map(({ marker, index }) => (
- {m.token} + {marker.token} @@ -308,13 +610,109 @@ function TemplateEditorBody({ - - {savedMsg ?

{savedMsg}

: null} + + + + Live preview + + + + + setPreviewInterestId(e.target.value)} + placeholder="Paste an interest UUID" + className="w-full rounded-md border bg-background px-2 py-1 text-xs" + /> + + {previewUrl ? ( + + Open preview PDF + + ) : null} + +
); } + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, v)); +} + +function MarkerOverlay({ + marker, + onMouseDown, + onResize, + onContextMenu, +}: { + marker: FieldMapEntry; + onMouseDown: (e: React.MouseEvent) => void; + onResize: (kind: DragKind) => (e: React.MouseEvent) => void; + onContextMenu: (e: React.MouseEvent) => void; +}) { + const corners: Array<{ kind: DragKind; pos: string; cursor: string }> = [ + { + kind: 'resize-nw', + pos: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', + cursor: 'nwse-resize', + }, + { + kind: 'resize-ne', + pos: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', + cursor: 'nesw-resize', + }, + { + kind: 'resize-sw', + pos: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', + cursor: 'nesw-resize', + }, + { + kind: 'resize-se', + pos: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', + cursor: 'nwse-resize', + }, + ]; + return ( +
+ {marker.token} + {corners.map((c) => ( +
+ ))} +
+ ); +} diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 866e6a7b..152f4b56 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -6,6 +6,7 @@ import type { DetailTab } from '@/components/shared/detail-layout'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; +import { RemindersInline } from '@/components/reminders/reminders-inline'; import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; @@ -225,6 +226,8 @@ function OverviewTab({ currentTags={client.tags ?? []} invalidateKey={['clients', clientId]} /> + +
); diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index ff269f19..0ad84f42 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -94,6 +94,16 @@ interface EoiContextResponse { channel: 'phone' | 'whatsapp'; source: string; }>; + addresses: Array<{ + id: string; + streetAddress: string | null; + city: string | null; + subdivisionIso: string | null; + postalCode: string | null; + countryIso: string | null; + isPrimary: boolean; + source: string; + }>; }; }; } @@ -114,6 +124,24 @@ interface FieldOverrideState { setAsDefault: boolean; } +/** + * Phase 3 follow-up — address override state. Treated as one logical + * field with one pair of checkboxes (intent flags apply to the whole + * address rather than per-component). + */ +interface AddressOverrideState { + line1: string; + line2: string; + city: string; + subdivisionIso: string; + postalCode: string; + countryIso: string | null; + /** Existing client_addresses.id when the rep picked one; null = fresh. */ + addressId: string | null; + useOnlyForThisEoi: boolean; + setAsDefault: boolean; +} + interface EoiGenerateDialogProps { interestId: string; /** Used to wire the "Edit on client" deep-link inside the dialog. */ @@ -155,6 +183,7 @@ export function EoiGenerateDialog({ const [emailOverride, setEmailOverride] = useState(null); const [phoneOverride, setPhoneOverride] = useState(null); const [yachtNameOverride, setYachtNameOverride] = useState(null); + const [addressOverride, setAddressOverride] = useState(null); // Phase 3c — yacht spawn flow. const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false); @@ -309,24 +338,9 @@ export function EoiGenerateDialog({ placeholder: 'Full legal name', }, }, - { - key: 'address', - // Mirrors the rendered EOI Address field exactly so the rep sees - // what's going to appear on the document. - label: 'Address', - value: ctx.client.address - ? [ - ctx.client.address.street, - ctx.client.address.city, - ctx.client.address.subdivision, - ctx.client.address.postalCode, - ctx.client.address.countryIso, - ] - .filter(Boolean) - .join(', ') - : null, - present: !!ctx.client.address, - }, + // Address moved out to below so it can + // surface the per-component combobox + 2 checkboxes alongside + // the canonical preview. ] : []; @@ -365,8 +379,18 @@ export function EoiGenerateDialog({ : []; const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false; + // Address is now required-via-override-field; either the canonical + // address exists, OR the rep has typed line1+country in the override. + const addressPresent = ctx + ? !!(addressOverride && addressOverride.line1 && addressOverride.countryIso) || + !!ctx.client.address + : false; const requiredMet = - !!ctx && required.length > 0 && required.every((r) => r.present) && emailPresent; + !!ctx && + required.length > 0 && + required.every((r) => r.present) && + emailPresent && + addressPresent; async function handleGenerate() { if (!requiredMet) return; @@ -387,12 +411,31 @@ export function EoiGenerateDialog({ ...(s.contactId ? { contactId: s.contactId } : {}), } : undefined; + const addressPayload = + addressOverride && addressOverride.line1 && addressOverride.countryIso + ? { + line1: addressOverride.line1, + line2: addressOverride.line2 || undefined, + city: addressOverride.city || undefined, + subdivisionIso: addressOverride.subdivisionIso || undefined, + postalCode: addressOverride.postalCode || undefined, + countryIso: addressOverride.countryIso, + useOnlyForThisEoi: addressOverride.useOnlyForThisEoi, + setAsDefault: addressOverride.setAsDefault, + ...(addressOverride.addressId ? { addressId: addressOverride.addressId } : {}), + } + : undefined; const overrides = { clientEmail: overridePayload(emailOverride), clientPhone: overridePayload(phoneOverride), yachtName: overridePayload(yachtNameOverride), + clientAddress: addressPayload, }; - const hasAnyOverride = overrides.clientEmail || overrides.clientPhone || overrides.yachtName; + const hasAnyOverride = + overrides.clientEmail || + overrides.clientPhone || + overrides.yachtName || + overrides.clientAddress; await apiFetch(url, { method: 'POST', @@ -497,6 +540,16 @@ export function EoiGenerateDialog({ onChange={setEmailOverride} missing={!emailPresent} /> + a.isPrimary)?.id ?? null + } + options={ctx.available.addresses} + override={addressOverride} + onChange={setAddressOverride} + missing={!addressPresent} + />
@@ -1098,3 +1151,276 @@ function OverridableContactField({
); } + +/** + * Phase 3 follow-up — address override row. Treats the address as one + * logical field with one pair of checkboxes (master-plan decision: + * reps think about addresses all-or-nothing). The per-component input + * UX mirrors the canonical address form (separate fields per + * line/city/state/postal/country + CountryCombobox) so reps don't + * relearn an input pattern. + */ +function OverridableAddressField({ + canonical, + canonicalAddressId, + options, + override, + onChange, + missing, +}: { + canonical: { + street: string; + city: string; + subdivision: string; + postalCode: string; + countryIso: string; + } | null; + canonicalAddressId: string | null; + options: Array<{ + id: string; + streetAddress: string | null; + city: string | null; + subdivisionIso: string | null; + postalCode: string | null; + countryIso: string | null; + isPrimary: boolean; + }>; + override: AddressOverrideState | null; + onChange: (next: AddressOverrideState | null) => void; + missing?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + const canonicalSummary = canonical + ? [ + canonical.street, + canonical.city, + canonical.subdivision, + canonical.postalCode, + canonical.countryIso, + ] + .filter(Boolean) + .join(', ') + : null; + + const effectiveSummary = override + ? [ + override.line1, + override.line2, + override.city, + override.subdivisionIso, + override.postalCode, + override.countryIso, + ] + .filter(Boolean) + .join(', ') + : canonicalSummary; + + const selectValue = override?.addressId ?? (override ? '__manual__' : '__canonical__'); + + const fillFromOption = (id: string) => { + const picked = options.find((o) => o.id === id); + if (!picked) return; + onChange({ + line1: picked.streetAddress ?? '', + line2: '', + city: picked.city ?? '', + subdivisionIso: picked.subdivisionIso ?? '', + postalCode: picked.postalCode ?? '', + countryIso: picked.countryIso, + addressId: picked.id, + useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false, + setAsDefault: override?.setAsDefault ?? false, + }); + }; + + const updateOverride = (patch: Partial) => { + onChange({ + line1: '', + line2: '', + city: '', + subdivisionIso: '', + postalCode: '', + countryIso: null, + addressId: null, + useOnlyForThisEoi: false, + setAsDefault: false, + ...(override ?? {}), + ...patch, + }); + }; + + return ( +
+
+
Address
+
+ + {effectiveSummary ?? (missing ? 'Missing — required' : 'Not set')} + {override ? ( + + [EOI] + + ) : null} + + {!expanded ? ( + + ) : ( + + )} +
+
+ + {expanded ? ( +
+ {options.length > 0 ? ( + + ) : null} + +
+ updateOverride({ line1: e.target.value, addressId: null })} + className="h-8 text-xs" + /> + updateOverride({ line2: e.target.value, addressId: null })} + className="h-8 text-xs" + /> +
+ updateOverride({ city: e.target.value, addressId: null })} + className="h-8 text-xs" + /> + updateOverride({ postalCode: e.target.value, addressId: null })} + className="h-8 text-xs" + /> +
+
+ + updateOverride({ subdivisionIso: e.target.value, addressId: null }) + } + className="h-8 text-xs" + /> + updateOverride({ countryIso: iso ?? null, addressId: null })} + /> +
+
+ +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 98879b7d..a299f5a2 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; +import { RemindersInline } from '@/components/reminders/reminders-inline'; // Legacy `RecommendationList` removed 2026-05-15 — replaced by the same // rule-based `BerthRecommenderPanel` (already imported above) used on the // Overview tab so the scoring + UI stay consistent. The old component @@ -1069,6 +1070,10 @@ function OverviewTab({ currentTags={interest.tags ?? []} invalidateKey={['interests', interestId]} /> + +
+ +
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see diff --git a/src/components/reminders/reminders-inline.tsx b/src/components/reminders/reminders-inline.tsx new file mode 100644 index 00000000..2596ddbf --- /dev/null +++ b/src/components/reminders/reminders-inline.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { Bell, CheckCircle2, Clock } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; + +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +/** + * Phase 4 — inline reminders list rendered inside an entity's + * Overview tab. Shows the most recent open (pending/snoozed) reminders + * for the linked entity so reps can spot follow-ups without leaving the + * detail page. + * + * Filter is exactly one of clientId / interestId / berthId / yachtId. + * Caller responsibility — the listReminders service AND's whichever + * filters are present, so multiple would intersect rather than union. + * + * No "+ Reminder" button here on purpose: the detail-page header + * already carries one, threading the same default-entity-id prop. + * Empty state hints at the header button instead of duplicating it. + */ +interface InlineReminder { + id: string; + title: string; + note: string | null; + dueAt: string; + priority: 'low' | 'medium' | 'high' | 'urgent'; + status: 'pending' | 'snoozed' | 'completed' | 'dismissed'; + assignedTo: string | null; +} + +interface ListResponse { + data: InlineReminder[]; + pagination?: { total?: number }; +} + +const PRIORITY_DOT: Record = { + urgent: 'bg-red-500', + high: 'bg-orange-500', + medium: 'bg-blue-500', + low: 'bg-gray-400', +}; + +const STATUS_ICON: Record = { + pending: , + snoozed: , + completed: , + dismissed: , +}; + +interface RemindersInlineProps { + /** Exactly one should be set — the entity to filter by. */ + clientId?: string; + interestId?: string; + berthId?: string; + yachtId?: string; + /** Soft cap on the rendered list; defaults to 5. */ + limit?: number; +} + +export function RemindersInline(props: RemindersInlineProps) { + const { clientId, interestId, berthId, yachtId, limit = 5 } = props; + + const filterKey = clientId ?? interestId ?? berthId ?? yachtId ?? null; + const filterParam = clientId + ? `clientId=${clientId}` + : interestId + ? `interestId=${interestId}` + : berthId + ? `berthId=${berthId}` + : yachtId + ? `yachtId=${yachtId}` + : ''; + + const { data, isLoading } = useQuery({ + queryKey: ['reminders', 'inline', filterKey], + queryFn: () => + apiFetch( + `/api/v1/reminders?${filterParam}&status=pending&limit=${limit}&sort=dueAt&order=asc`, + ), + enabled: !!filterKey, + }); + + if (!filterKey) return null; + + const rows = data?.data ?? []; + + return ( +
+
+

+ Reminders +

+
+ {isLoading ? ( +

Loading…

+ ) : rows.length === 0 ? ( +

+ No open reminders for this record. Use the bell in the header to add one. +

+ ) : ( +
    + {rows.map((r) => { + const isPastDue = new Date(r.dueAt) < new Date(); + return ( +
  • + + {STATUS_ICON[r.status]} +
    +

    {r.title}

    +

    + Due {formatDistanceToNow(new Date(r.dueAt), { addSuffix: true })} +

    +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index 80897165..94286caf 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -9,6 +9,7 @@ import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list'; +import { RemindersInline } from '@/components/reminders/reminders-inline'; import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history'; import { apiFetch } from '@/lib/api/client'; import { stageLabel } from '@/lib/constants'; @@ -241,6 +242,10 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach currentTags={yacht.tags ?? []} invalidateKey={['yachts', yachtId]} /> + +
+ +
); } diff --git a/src/lib/email/templates/admin-email-change.tsx b/src/lib/email/templates/admin-email-change.tsx index f3bbdd41..ce9c92f3 100644 --- a/src/lib/email/templates/admin-email-change.tsx +++ b/src/lib/email/templates/admin-email-change.tsx @@ -27,19 +27,19 @@ function AdminEmailChangeBody({ loginUrl, accent, }: AdminEmailChangeData & { portName: string; accent: string }) { - const greeting = recipientName ? `Hello ${recipientName},` : 'Hello,'; + const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,'; const adminLine = changedByDisplayName ? `${changedByDisplayName} (an administrator)` : 'an administrator'; return ( <> - Your sign-in email was changed + Your sign-in email has changed {greeting} - {adminLine} just updated the email address linked to your {portName} account. From now on, - please sign in with the new address below: + We're writing to let you know that {adminLine} has updated the email address linked to + your {portName} account. Going forward, please sign in with the address below: {newEmail} @@ -65,13 +65,13 @@ function AdminEmailChangeBody({ ) : null}
- If you weren't expecting this change, contact your administrator immediately. Your old - address (the one this message was sent to) can no longer be used to sign in. + 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. - Thanks, + With warm regards,
- {portName} + The {portName} Team
); @@ -84,7 +84,7 @@ export async function adminEmailChangeEmail( const portName = data.portName ?? 'Port Nimara'; const subject = overrides?.subject?.trim() ? overrides.subject - : `An administrator updated your ${portName} sign-in email`; + : `Your ${portName} sign-in email has been updated`; const accent = brandingPrimaryColor(overrides?.branding); const body = await render( @@ -95,14 +95,17 @@ export async function adminEmailChangeEmail( ); const text = [ - `Your sign-in email was changed`, + `Your sign-in email has changed`, '', - `${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`, - `From now on, sign in with: ${data.newEmail}`, + `${data.changedByDisplayName ?? 'An administrator'} has updated the email address linked to your ${portName} account.`, + `Going forward, please sign in with: ${data.newEmail}`, '', data.loginUrl ? `Sign in: ${data.loginUrl}` : '', '', - `If you weren't expecting this change, contact your administrator immediately.`, + `If this change wasn't expected, please contact your administrator straight away.`, + '', + `With warm regards,`, + `The ${portName} Team`, ] .filter(Boolean) .join('\n'); diff --git a/src/lib/email/templates/crm-invite.tsx b/src/lib/email/templates/crm-invite.tsx index 3d9f3ad6..ca1eb0a9 100644 --- a/src/lib/email/templates/crm-invite.tsx +++ b/src/lib/email/templates/crm-invite.tsx @@ -33,7 +33,7 @@ function InviteBody({ role: string; accent: string; }) { - const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,'; + const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,'; return ( <> @@ -41,8 +41,9 @@ function InviteBody({ {greeting} - You've been invited to the {portName} CRM as a {role}. Click the button below to set - your password and activate your account. The link expires in {ttlHours} hours. + 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.