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:
320
src/components/admin/templates/template-editor.tsx
Normal file
320
src/components/admin/templates/template-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user