'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); // Phase 6 polish — optional rep-authored note that appears above the // CTA in every invitation email for this doc. Empty string means // "no custom note — use the template default copy". const [invitationMessage, setInvitationMessage] = useState(''); 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)); if (invitationMessage.trim()) { form.append('invitationMessage', invitationMessage.trim()); } // 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, invitationMessage, onInvitationMessageChange, }: { recipients: Recipient[]; onChange: (next: Recipient[]) => void; title: string; onTitleChange: (t: string) => void; invitationMessage: string; onInvitationMessageChange: (next: 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 })} />
))}