'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 ( onOpenChange(false)} /> ); } 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('select-file'); const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [recipients, setRecipients] = useState([]); const [fields, setFields] = useState([]); const [selectedFieldId, setSelectedFieldId] = useState(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(() => { 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 }; }>; }, 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 ( <> Send {docLabel.toLowerCase()} for signing {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.'}
{step === 'select-file' && ( { 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' && ( )} {step === 'place-fields' && fileObjectUrl && ( )}
{step === 'configure-recipients' && ( )} {step === 'place-fields' && ( )} {step !== 'place-fields' && ( )} {step === 'place-fields' && ( )}
); } 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 (
{dots.map((d, i) => ( {d.label} {i < dots.length - 1 && } ))}
); } // ─── 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(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 (
onTitleChange(e.target.value)} placeholder="e.g. Berth A-12 Sales Contract — John Smith" />
{ 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(); } }} >

Drop a PDF here, or click to browse

Max 50 MB.

{ const f = e.target.files?.[0]; if (f) handleFile(f); }} />
); } // ─── 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) { 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 (
onTitleChange(e.target.value)} />
{recipients.map((r, i) => (
#{r.signingOrder} update(i, { name: e.target.value })} /> update(i, { email: e.target.value })} />
))}
); } // ─── 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(null); const pageContainerRef = useRef(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) { 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 (
{/* Field palette */}

Field palette

{(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 ( ); })}
{placingType && (

Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}.

)}

Recipients

{recipients.map((r, i) => (
{r.name || r.email || `#${r.signingOrder}`}
))}
{/* PDF + overlay */}
{pageNumber} / {numPages} {isDetecting && ( Auto-detecting fields… )} {fields.length} {fields.length === 1 ? 'field' : 'fields'} placed
{ 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); }} > setNumPages(n)} loading={
Loading PDF…
} >
{/* Overlay layer */}
{pageFields.map((field) => ( onSelectField(field.id)} onUpdate={(patch) => updateField(field.id, patch)} onRemove={() => removeField(field.id)} /> ))}
{/* Side panel for selected field */} {selectedFieldId && ( f.id === selectedFieldId)!} recipients={recipients} onUpdate={(patch) => updateField(selectedFieldId, patch)} onRemove={() => removeField(selectedFieldId)} onClose={() => onSelectField(null)} /> )}
); } function FieldOverlay({ field, selected, recipients, onSelect, onUpdate, onRemove, }: { field: PlacedField; selected: boolean; recipients: Recipient[]; onSelect: () => void; onUpdate: (patch: Partial) => 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 (
{ 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'}`} > {FIELD_DEFAULTS[field.type].label} {selected && ( )}
); } function FieldSidePanel({ field, recipients, onUpdate, onRemove, onClose, }: { field: PlacedField; recipients: Recipient[]; onUpdate: (patch: Partial) => void; onRemove: () => void; onClose: () => void; }) { return (

Field properties

onUpdate({ pageWidth: Number(e.target.value) })} step="0.5" min="1" max="100" />
onUpdate({ pageHeight: Number(e.target.value) })} step="0.5" min="1" max="100" />
); }