Files
pn-new-crm/src/components/documents/upload-for-signing-dialog.tsx
Matt 7bf587de90 feat(documenso-phase-4): recipient configurator + field placement UI
Phase 4 lands the visual half of the Documenso build — the upload-
for-signing dialog the Contract + Reservation tabs hand off to. Four
files of new code; the existing tab placeholders point at it.

Files added:
- lib/services/document-field-detector.ts — Phase 4c auto-detect
  scanner. Uses pdfjs-dist to extract per-page text + positions, then
  matches anchor patterns (Signature, Date, Initials, Email, Name,
  underscore-runs) and produces percent-coordinate DetectedField
  rows. Recipient label inference walks ±100pt of each match for
  Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the
  PDF is image-only; UI falls back to manual placement without an
  error. 6 unit tests pin the matching + coordinate math.

- app/api/v1/documents/auto-detect-fields/route.ts — multipart POST
  endpoint that delegates to detectFields(). Permission-gated by
  documents.send_for_signing.

- app/api/v1/documents/signing-defaults/route.ts — GET endpoint that
  surfaces just the per-port developer + approver display name/email
  + sendMode flag. No secrets exposed; lets the dialog prefill the
  recipient configurator without an admin-scoped settings read.

- components/documents/upload-for-signing-dialog.tsx — the Phase 4
  UI. Three-step state machine inside a single Dialog:
    1. select-file:  drop/click PDF picker + title input
    2. configure-recipients: client + developer + approver prefilled,
       rep can add/remove/reorder + change role (SIGNER/APPROVER/CC)
    3. place-fields: react-pdf renders the source PDF; auto-detect
       runs in the background on file load and seeds the overlay;
       rep places, drags, resizes, deletes, reassigns fields via the
       palette + side panel. Native DOM drag (no dnd-kit dependency
       added — the coordinate math stays obvious).
  Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3
  service); success toast reflects port sendMode (auto fires the
  invite immediately, manual leaves it for the rep).

Files modified:
- components/interests/interest-contract-tab.tsx + reservation-tab.tsx:
  swap the ComingSoonDialog placeholder for the real
  UploadForSigningDialog with the matching documentType prop. The
  placeholder ComingSoonDialog helper is deleted from both.

- scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp
  staged-only tsconfig so side-effect CSS imports (e.g.
  react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing
  declare-module shim. Without this fix the staged compile reports
  TS2882 even though the full tsc --noEmit pass passes.

Design choices noted in code comments:
- Native drag over dnd-kit: the field overlay's percent-based
  coordinate math is short enough that adding a drag library adds
  complexity without saving lines.
- Auto-detect on file-load (not on demand): runs immediately so the
  rep doesn't have to click a second button — empty result drops
  back to manual placement silently.
- Per-recipient color swatches indexed by signingOrder.
- Recipient seed via useMemo + user-event handler instead of
  useEffect → setRecipients (Wave 3 set-state-in-effect avoidance).

Server-side, Phase 3 plumbing handles the rest: tenant guard, magic-
byte verify, Documenso round-trip with per-port v1/v2 routing,
recipient signingToken capture for Phase 2 webhook cascade, auto-
send when port.sendMode === 'auto'.

Tests: 1334 → 1340  (6 new for the detector); tsc clean.

Deferred polish (Phase 6):
- Per-field metadata side panel for DROPDOWN/RADIO option lists
- Pinch-zoom + zoom-out controls on the field-placement canvas
- Recipient drag-reorder via dnd-kit
- Required toggle per field

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00

1058 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import { toast } from 'sonner';
import {
ChevronLeft,
ChevronRight,
Loader2,
Plus,
Trash2,
X,
Pen,
Calendar as CalendarIcon,
Type,
CheckSquare,
Mail,
User as UserIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
/**
* Phase 4 — Upload-for-Documenso-signing dialog.
*
* Four-step flow inside one dialog:
* 1. select-file — drag/drop or click to upload a PDF
* 2. configure-recipients — name/email/role per signer, with
* client + developer + approver prefilled from port + interest
* 3. place-fields — render the PDF page-by-page, run
* auto-detect, let the rep drag/place/delete fields per signer
* 4. sending — POST to /upload-for-signing, show spinner
*
* The implementation is intentionally compact — the field-overlay
* uses native DOM drag rather than dnd-kit so the coordinate math
* stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so
* the same parser ships once.
*/
interface Recipient {
name: string;
email: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder: number;
}
type FieldType =
| 'SIGNATURE'
| 'FREE_SIGNATURE'
| 'INITIALS'
| 'DATE'
| 'EMAIL'
| 'NAME'
| 'TEXT'
| 'NUMBER'
| 'CHECKBOX'
| 'DROPDOWN'
| 'RADIO';
interface PlacedField {
/** Client-side id only — server doesn't see this. */
id: string;
type: FieldType;
recipientIndex: number;
pageNumber: number;
/** All 0..100 percent of page dimensions. */
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}
interface DetectedFieldResponse {
type: FieldType;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
confidence: number;
anchorText?: string;
inferredRecipientLabel?: string | null;
}
interface SigningDefaults {
developer: { name: string; email: string; label: string };
approver: { name: string; email: string; label: string };
sendMode: 'auto' | 'manual';
}
const FIELD_DEFAULTS: Record<
FieldType,
{ widthPct: number; heightPct: number; label: string; icon: typeof Pen }
> = {
SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Signature', icon: Pen },
FREE_SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Free signature', icon: Pen },
INITIALS: { widthPct: 8, heightPct: 5, label: 'Initials', icon: Pen },
DATE: { widthPct: 12, heightPct: 3, label: 'Date', icon: CalendarIcon },
EMAIL: { widthPct: 25, heightPct: 3, label: 'Email', icon: Mail },
NAME: { widthPct: 20, heightPct: 3, label: 'Name', icon: UserIcon },
TEXT: { widthPct: 25, heightPct: 3, label: 'Text', icon: Type },
NUMBER: { widthPct: 15, heightPct: 3, label: 'Number', icon: Type },
CHECKBOX: { widthPct: 3, heightPct: 3, label: 'Checkbox', icon: CheckSquare },
DROPDOWN: { widthPct: 20, heightPct: 3, label: 'Dropdown', icon: Type },
RADIO: { widthPct: 3, heightPct: 3, label: 'Radio', icon: CheckSquare },
};
const RECIPIENT_COLORS = [
'rgb(59 130 246)', // blue-500
'rgb(168 85 247)', // purple-500
'rgb(34 197 94)', // green-500
'rgb(249 115 22)', // orange-500
'rgb(239 68 68)', // red-500
'rgb(20 184 166)', // teal-500
];
interface UploadForSigningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interestId: string;
/** Pre-set the document type — the parent (Contract/Reservation tab)
* decides which to upload. */
documentType: 'contract' | 'reservation_agreement';
/** Optional: client name/email to prefill the first recipient.
* When omitted the dialog fetches from the interest. */
clientPrefill?: { name: string; email: string };
}
export function UploadForSigningDialog({
open,
onOpenChange,
interestId,
documentType,
clientPrefill,
}: UploadForSigningDialogProps) {
// Re-mount the body on every open so all state resets cleanly. Same
// pattern as hard-delete-dialog (set-state-in-effect avoidance).
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden p-0 flex flex-col">
<DialogBody
key={`${interestId}:${documentType}`}
interestId={interestId}
documentType={documentType}
clientPrefill={clientPrefill}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}
type Step = 'select-file' | 'configure-recipients' | 'place-fields';
function DialogBody({
interestId,
documentType,
clientPrefill,
onClose,
}: {
interestId: string;
documentType: 'contract' | 'reservation_agreement';
clientPrefill?: { name: string; email: string };
onClose: () => void;
}) {
const [step, setStep] = useState<Step>('select-file');
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [fields, setFields] = useState<PlacedField[]>([]);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
// Defaults endpoint — drives the developer/approver prefill.
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
queryKey: ['documents', 'signing-defaults'],
queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'),
});
// Interest endpoint — used to prefill the client recipient when the
// caller didn't supply one. Cached so the same dialog open/reopen
// hits the cache.
const { data: interestData } = useQuery<{
data: { client: { fullName: string; email: string | null } };
}>({
queryKey: ['interest', interestId, 'prefill'],
queryFn: () =>
apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
`/api/v1/interests/${interestId}`,
),
enabled: !clientPrefill,
});
/**
* Build the prefill recipient list from the async query data. The
* dialog reads this on the "Next" button click in the file-picker
* step to seed `recipients` — keeping the seeding as a user-event
* handler rather than an effect avoids the cascading-render lint
* (react-hooks/set-state-in-effect, Wave 3) that earlier versions
* tripped. Returns an empty array until the defaults query resolves;
* the file-picker step's Next button stays clickable so the rep can
* still proceed and add recipients manually if the defaults endpoint
* is slow.
*/
const prefillRecipients = useMemo<Recipient[]>(() => {
if (!defaults?.data) return [];
const client = clientPrefill ?? {
name: interestData?.data?.client?.fullName ?? '',
email: interestData?.data?.client?.email ?? '',
};
const next: Recipient[] = [];
if (client.name && client.email) {
next.push({ name: client.name, email: client.email, role: 'SIGNER', signingOrder: 1 });
}
if (defaults.data.developer.email) {
next.push({
name: defaults.data.developer.name || defaults.data.developer.label,
email: defaults.data.developer.email,
role: 'SIGNER',
signingOrder: next.length + 1,
});
}
if (defaults.data.approver.email) {
next.push({
name: defaults.data.approver.name || defaults.data.approver.label,
email: defaults.data.approver.email,
role: 'APPROVER',
signingOrder: next.length + 1,
});
}
return next;
}, [defaults, interestData, clientPrefill]);
const fileObjectUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
useEffect(() => {
return () => {
if (fileObjectUrl) URL.revokeObjectURL(fileObjectUrl);
};
}, [fileObjectUrl]);
const autoDetect = useMutation({
mutationFn: async (uploadedFile: File) => {
const form = new FormData();
form.append('file', uploadedFile);
// apiFetch JSON-encodes the body when set, so we go raw here.
const res = await fetch('/api/v1/documents/auto-detect-fields', {
method: 'POST',
body: form,
credentials: 'include',
});
if (!res.ok) throw new Error('Auto-detect failed');
return (await res.json()) as { data: { fields: DetectedFieldResponse[] } };
},
onSuccess: (res) => {
// Drop detected fields onto a temporary "unassigned" recipient
// (index 0). Rep reassigns via the side panel.
const placed: PlacedField[] = res.data.fields.map((f) => ({
id: `det-${Math.random().toString(36).slice(2, 10)}`,
type: f.type,
recipientIndex: 0,
pageNumber: f.pageNumber,
pageX: f.pageX,
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
}));
setFields((existing) => [...existing, ...placed]);
if (placed.length > 0) {
toast.success(
`Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`,
);
} else {
toast.info('No fields auto-detected — place them manually.');
}
},
onError: () => {
toast.info('Auto-detect skipped — place fields manually.');
},
});
const queryClient = useQueryClient();
const sendMutation = useMutation({
mutationFn: async () => {
if (!file) throw new Error('No file selected');
const form = new FormData();
form.append('file', file);
form.append('documentType', documentType);
form.append('title', title || file.name.replace(/\.pdf$/i, ''));
form.append('recipients', JSON.stringify(recipients));
// Strip the client-side `id` from each placed field — the server
// assigns its own ids on the documenso side.
form.append(
'fields',
JSON.stringify(
fields.map((f) => ({
type: f.type,
recipientIndex: f.recipientIndex,
pageNumber: f.pageNumber,
pageX: f.pageX,
pageY: f.pageY,
pageWidth: f.pageWidth,
pageHeight: f.pageHeight,
})),
),
);
const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, {
method: 'POST',
body: form,
credentials: 'include',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
requestId?: string;
};
const err = new Error(body.error ?? 'Upload failed');
// Tack the requestId onto the Error so toastError can surface it.
(err as Error & { requestId?: string }).requestId = body.requestId;
throw err;
}
return res.json() as Promise<{
data: { documentId: string; signingUrls: Record<string, string> };
}>;
},
onSuccess: (res) => {
toast.success(
defaults?.data?.sendMode === 'auto'
? 'Document sent for signing — first signer has been invited.'
: 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.',
);
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
void res;
onClose();
},
onError: (err) => toastError(err, 'Upload failed'),
});
// ─── Step renderers ─────────────────────────────────────────────
return (
<>
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
<DialogTitle>Send {docLabel.toLowerCase()} for signing</DialogTitle>
<DialogDescription>
{step === 'select-file' && 'Upload the draft PDF to send via Documenso.'}
{step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'}
{step === 'place-fields' &&
'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{step === 'select-file' && (
<FilePickerStep
onFileSelected={(f) => {
setFile(f);
setTitle(f.name.replace(/\.pdf$/i, ''));
// Seed recipients from the prefill snapshot when the rep
// first lands a file — only if they haven't already
// edited the list. This pattern keeps the prefill
// synchronization in user-event handlers (no setState-
// in-effect lint trip).
if (recipients.length === 0 && prefillRecipients.length > 0) {
setRecipients(prefillRecipients);
}
setStep('configure-recipients');
autoDetect.mutate(f);
}}
title={title}
onTitleChange={setTitle}
/>
)}
{step === 'configure-recipients' && (
<RecipientsStep
recipients={recipients}
onChange={setRecipients}
title={title}
onTitleChange={setTitle}
/>
)}
{step === 'place-fields' && fileObjectUrl && (
<FieldPlacementStep
fileUrl={fileObjectUrl}
fields={fields}
onFieldsChange={setFields}
recipients={recipients}
selectedFieldId={selectedFieldId}
onSelectField={setSelectedFieldId}
isDetecting={autoDetect.isPending}
/>
)}
</div>
<DialogFooter className="px-6 py-4 border-t flex-shrink-0 flex items-center gap-2">
<StepIndicator step={step} />
<div className="ml-auto flex gap-2">
{step === 'configure-recipients' && (
<Button variant="outline" onClick={() => setStep('select-file')}>
Back
</Button>
)}
{step === 'place-fields' && (
<Button variant="outline" onClick={() => setStep('configure-recipients')}>
Back
</Button>
)}
{step !== 'place-fields' && (
<Button
onClick={() => {
if (step === 'select-file') {
if (!file) {
toast.error('Pick a PDF first');
return;
}
if (recipients.length === 0 && prefillRecipients.length > 0) {
setRecipients(prefillRecipients);
}
setStep('configure-recipients');
} else if (step === 'configure-recipients') {
if (recipients.length === 0) {
toast.error('Add at least one recipient');
return;
}
if (recipients.some((r) => !r.email || !r.name)) {
toast.error('Every recipient needs a name and email');
return;
}
setStep('place-fields');
}
}}
>
Next
</Button>
)}
{step === 'place-fields' && (
<Button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending || fields.length === 0}
>
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
{defaults?.data?.sendMode === 'auto' ? 'Send for signing' : 'Send'}
</Button>
)}
</div>
</DialogFooter>
</>
);
}
function StepIndicator({ step }: { step: Step }) {
const dots = [
{ key: 'select-file', label: 'File' },
{ key: 'configure-recipients', label: 'Recipients' },
{ key: 'place-fields', label: 'Fields' },
] as const;
const activeIdx = dots.findIndex((d) => d.key === step);
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{dots.map((d, i) => (
<span key={d.key} className="flex items-center gap-1.5">
<span
className={
'size-2 rounded-full ' + (i <= activeIdx ? 'bg-foreground' : 'bg-muted-foreground/30')
}
aria-hidden
/>
<span className={i === activeIdx ? 'font-medium text-foreground' : ''}>{d.label}</span>
{i < dots.length - 1 && <span className="text-muted-foreground/40"></span>}
</span>
))}
</div>
);
}
// ─── Step 1: file picker ──────────────────────────────────────────
function FilePickerStep({
onFileSelected,
title,
onTitleChange,
}: {
onFileSelected: (file: File) => void;
title: string;
onTitleChange: (t: string) => void;
}) {
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function handleFile(f: File) {
if (!f.name.toLowerCase().endsWith('.pdf') && f.type !== 'application/pdf') {
toast.error('Only PDF files are accepted');
return;
}
onFileSelected(f);
}
return (
<div className="px-6 py-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="doc-title">Document title</Label>
<Input
id="doc-title"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="e.g. Berth A-12 Sales Contract — John Smith"
/>
</div>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
className={
'rounded-lg border-2 border-dashed p-12 text-center transition-colors cursor-pointer ' +
(dragging
? 'border-foreground bg-muted/40'
: 'border-muted-foreground/30 hover:border-muted-foreground/60')
}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
}}
>
<p className="text-sm text-foreground font-medium">Drop a PDF here, or click to browse</p>
<p className="text-xs text-muted-foreground mt-1">Max 50 MB.</p>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
</div>
</div>
);
}
// ─── Step 2: recipient configurator ───────────────────────────────
function RecipientsStep({
recipients,
onChange,
title,
onTitleChange,
}: {
recipients: Recipient[];
onChange: (next: Recipient[]) => void;
title: string;
onTitleChange: (t: string) => void;
}) {
function update(i: number, patch: Partial<Recipient>) {
const next = [...recipients];
next[i] = { ...next[i]!, ...patch };
onChange(next);
}
function remove(i: number) {
const next = recipients.filter((_, idx) => idx !== i);
// Reflow signingOrder
onChange(next.map((r, idx) => ({ ...r, signingOrder: idx + 1 })));
}
function add() {
onChange([
...recipients,
{
name: '',
email: '',
role: 'SIGNER',
signingOrder: recipients.length + 1,
},
]);
}
return (
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="doc-title-step2">Document title</Label>
<Input id="doc-title-step2" value={title} onChange={(e) => onTitleChange(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Recipients (in signing order)</Label>
<div className="space-y-2">
{recipients.map((r, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<span className="col-span-1 text-xs text-center text-muted-foreground tabular-nums">
#{r.signingOrder}
</span>
<Input
className="col-span-3"
placeholder="Name"
value={r.name}
onChange={(e) => update(i, { name: e.target.value })}
/>
<Input
className="col-span-4"
placeholder="email@example.com"
type="email"
value={r.email}
onChange={(e) => update(i, { email: e.target.value })}
/>
<Select
value={r.role}
onValueChange={(v) => update(i, { role: v as Recipient['role'] })}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SIGNER">Signer</SelectItem>
<SelectItem value="APPROVER">Approver</SelectItem>
<SelectItem value="CC">CC (no signing)</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(i)}
aria-label="Remove recipient"
className="col-span-1"
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
))}
</div>
<Button type="button" variant="outline" size="sm" onClick={add} className="gap-1.5">
<Plus className="size-4" aria-hidden /> Add recipient
</Button>
</div>
</div>
);
}
// ─── Step 3: field placement overlay ──────────────────────────────
function FieldPlacementStep({
fileUrl,
fields,
onFieldsChange,
recipients,
selectedFieldId,
onSelectField,
isDetecting,
}: {
fileUrl: string;
fields: PlacedField[];
onFieldsChange: (next: PlacedField[]) => void;
recipients: Recipient[];
selectedFieldId: string | null;
onSelectField: (id: string | null) => void;
isDetecting: boolean;
}) {
const [numPages, setNumPages] = useState(1);
const [pageNumber, setPageNumber] = useState(1);
const [placingType, setPlacingType] = useState<FieldType | null>(null);
const pageContainerRef = useRef<HTMLDivElement>(null);
const pageFields = useMemo(
() => fields.filter((f) => f.pageNumber === pageNumber),
[fields, pageNumber],
);
function placeFieldAt(clientX: number, clientY: number, container: HTMLElement) {
if (!placingType) return;
const rect = container.getBoundingClientRect();
const pageX = ((clientX - rect.left) / rect.width) * 100;
const pageY = ((clientY - rect.top) / rect.height) * 100;
const defaults = FIELD_DEFAULTS[placingType];
const newField: PlacedField = {
id: `f-${Math.random().toString(36).slice(2, 10)}`,
type: placingType,
recipientIndex: 0,
pageNumber,
pageX: Math.max(0, Math.min(100 - defaults.widthPct, pageX - defaults.widthPct / 2)),
pageY: Math.max(0, Math.min(100 - defaults.heightPct, pageY - defaults.heightPct / 2)),
pageWidth: defaults.widthPct,
pageHeight: defaults.heightPct,
};
onFieldsChange([...fields, newField]);
onSelectField(newField.id);
setPlacingType(null);
}
function updateField(id: string, patch: Partial<PlacedField>) {
onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
function removeField(id: string) {
onFieldsChange(fields.filter((f) => f.id !== id));
if (selectedFieldId === id) onSelectField(null);
}
return (
<div className="flex-1 flex overflow-hidden">
{/* Field palette */}
<div className="w-44 border-r p-3 flex-shrink-0 overflow-y-auto bg-muted/30">
<p className="text-xs font-medium text-muted-foreground mb-2">Field palette</p>
<div className="space-y-1">
{(Object.keys(FIELD_DEFAULTS) as FieldType[])
.filter((t) => t !== 'FREE_SIGNATURE') // collapsed with SIGNATURE for the palette
.map((t) => {
const def = FIELD_DEFAULTS[t];
const Icon = def.icon;
return (
<button
key={t}
type="button"
onClick={() => setPlacingType(t === placingType ? null : t)}
className={
'w-full text-left text-xs px-2 py-1.5 rounded flex items-center gap-2 transition ' +
(placingType === t
? 'bg-foreground text-background'
: 'hover:bg-background border border-transparent hover:border-border')
}
>
<Icon className="size-3.5" aria-hidden />
{def.label}
</button>
);
})}
</div>
{placingType && (
<p className="mt-3 text-xs text-muted-foreground">
Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}.
</p>
)}
<hr className="my-3 border-muted-foreground/20" />
<p className="text-xs font-medium text-muted-foreground mb-2">Recipients</p>
<div className="space-y-1">
{recipients.map((r, i) => (
<div key={i} className="text-xs flex items-center gap-2">
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: RECIPIENT_COLORS[i % RECIPIENT_COLORS.length] }}
aria-hidden
/>
<span className="truncate">{r.name || r.email || `#${r.signingOrder}`}</span>
</div>
))}
</div>
</div>
{/* PDF + overlay */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b bg-muted/20 px-3 py-2 text-sm">
<Button
type="button"
variant="ghost"
size="icon"
disabled={pageNumber <= 1}
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="min-w-[80px] text-center tabular-nums">
{pageNumber} / {numPages}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={pageNumber >= numPages}
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
{isDetecting && (
<span className="ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Auto-detecting fields
</span>
)}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{fields.length} {fields.length === 1 ? 'field' : 'fields'} placed
</span>
</div>
<div className="flex-1 overflow-auto bg-muted/30 p-4">
<div
ref={pageContainerRef}
className="relative mx-auto bg-white shadow"
style={{ width: 'fit-content', cursor: placingType ? 'crosshair' : 'default' }}
onClick={(e) => {
if (!placingType) return;
if (!pageContainerRef.current) return;
// Bail when the click landed on an existing field (we
// handle those via the field's own onClick).
if ((e.target as HTMLElement).closest('[data-field-id]')) return;
placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
}}
>
<Document
file={fileUrl}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div className="flex h-64 w-96 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="size-4 mr-2 animate-spin" /> Loading PDF
</div>
}
>
<Page
pageNumber={pageNumber}
scale={1}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{/* Overlay layer */}
<div className="absolute inset-0 pointer-events-none">
{pageFields.map((field) => (
<FieldOverlay
key={field.id}
field={field}
selected={selectedFieldId === field.id}
recipients={recipients}
onSelect={() => onSelectField(field.id)}
onUpdate={(patch) => updateField(field.id, patch)}
onRemove={() => removeField(field.id)}
/>
))}
</div>
</div>
</div>
</div>
{/* Side panel for selected field */}
{selectedFieldId && (
<FieldSidePanel
field={fields.find((f) => f.id === selectedFieldId)!}
recipients={recipients}
onUpdate={(patch) => updateField(selectedFieldId, patch)}
onRemove={() => removeField(selectedFieldId)}
onClose={() => onSelectField(null)}
/>
)}
</div>
);
}
function FieldOverlay({
field,
selected,
recipients,
onSelect,
onUpdate,
onRemove,
}: {
field: PlacedField;
selected: boolean;
recipients: Recipient[];
onSelect: () => void;
onUpdate: (patch: Partial<PlacedField>) => void;
onRemove: () => void;
}) {
const Icon = FIELD_DEFAULTS[field.type].icon;
const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length];
const recipient = recipients[field.recipientIndex];
// Drag handler — translate mouse-move pixels into percent deltas
// against the parent container's bounding rect.
function startDrag(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
onSelect();
const container = (e.currentTarget.parentElement?.parentElement as HTMLElement) ?? null;
if (!container) return;
const rect = container.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startPageX = field.pageX;
const startPageY = field.pageY;
function onMove(ev: MouseEvent) {
const dxPct = ((ev.clientX - startX) / rect.width) * 100;
const dyPct = ((ev.clientY - startY) / rect.height) * 100;
onUpdate({
pageX: Math.max(0, Math.min(100 - field.pageWidth, startPageX + dxPct)),
pageY: Math.max(0, Math.min(100 - field.pageHeight, startPageY + dyPct)),
});
}
function onUp() {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
return (
<div
data-field-id={field.id}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onMouseDown={startDrag}
className={
'absolute pointer-events-auto cursor-move rounded border-2 text-xs flex items-center gap-1 px-1 ' +
(selected ? 'ring-2 ring-offset-1 ring-foreground' : '')
}
style={{
left: `${field.pageX}%`,
top: `${field.pageY}%`,
width: `${field.pageWidth}%`,
height: `${field.pageHeight}%`,
backgroundColor: color + '22',
borderColor: color,
}}
role="button"
tabIndex={0}
aria-label={`${FIELD_DEFAULTS[field.type].label} for ${recipient?.name ?? 'unassigned'}`}
>
<Icon className="size-3 shrink-0" aria-hidden style={{ color }} />
<span className="truncate" style={{ color }}>
{FIELD_DEFAULTS[field.type].label}
</span>
{selected && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-auto rounded p-0.5 hover:bg-background/60"
aria-label="Delete field"
>
<X className="size-3" aria-hidden />
</button>
)}
</div>
);
}
function FieldSidePanel({
field,
recipients,
onUpdate,
onRemove,
onClose,
}: {
field: PlacedField;
recipients: Recipient[];
onUpdate: (patch: Partial<PlacedField>) => void;
onRemove: () => void;
onClose: () => void;
}) {
return (
<div className="w-64 border-l p-4 flex-shrink-0 bg-background space-y-3 overflow-y-auto">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Field properties</p>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close panel">
<X className="size-4" aria-hidden />
</Button>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Select value={field.type} onValueChange={(v) => onUpdate({ type: v as FieldType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(FIELD_DEFAULTS) as FieldType[]).map((t) => (
<SelectItem key={t} value={t}>
{FIELD_DEFAULTS[t].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Recipient</Label>
<Select
value={String(field.recipientIndex)}
onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{recipients.map((r, i) => (
<SelectItem key={i} value={String(i)}>
#{r.signingOrder} {r.name || r.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Width %</Label>
<Input
type="number"
value={field.pageWidth.toFixed(1)}
onChange={(e) => onUpdate({ pageWidth: Number(e.target.value) })}
step="0.5"
min="1"
max="100"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Height %</Label>
<Input
type="number"
value={field.pageHeight.toFixed(1)}
onChange={(e) => onUpdate({ pageHeight: Number(e.target.value) })}
step="0.5"
min="1"
max="100"
/>
</div>
</div>
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
<Trash2 className="size-4" aria-hidden /> Delete field
</Button>
</div>
);
}