'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
];
export interface UploadForSigningEntity {
type: 'client' | 'company' | 'yacht';
id: string;
/** Display label only — used in the dialog header so the rep can
* see which entity the doc will be filed under. */
label?: string;
}
interface UploadForSigningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Required for eoi / contract / reservation_agreement (the pipeline
* side effects need it). MUST be null for documentType='generic' —
* in that case the upload routes through the generic endpoint and
* optionally files the doc against the supplied `entity`. */
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
/** Optional: client name/email to prefill the first recipient.
* When omitted the dialog fetches from the interest (interest-scoped
* flows) or leaves the recipient blank (generic flow). */
clientPrefill?: { name: string; email: string };
/** Generic flow only: routes the resulting file/document row to the
* entity's FK column + auto-files it into the entity's system
* folder. Ignored when `interestId` is set. */
entity?: UploadForSigningEntity;
/** Generic flow only: explicit folder placement (e.g. rep is
* uploading from within a Documents Hub folder). */
folderId?: string | null;
/** Generic flow only: caller-supplied success hook. Receives the
* new documentId and can invalidate caches / show a toast. */
onCreated?: (result: { documentId: string }) => void;
}
export function UploadForSigningDialog({
open,
onOpenChange,
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
}: 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;
const draftKey = interestId ?? entity?.id ?? 'generic';
return (
);
}
type Step = 'select-file' | 'configure-recipients' | 'place-fields';
/**
* localStorage key for draft persistence. Versioned (`v1`) so a future
* shape change can invalidate stale drafts without crashing the parser.
* Scoped per interest+documentType so a rep can have an in-flight
* contract upload AND reservation upload in the same browser session
* without them clobbering each other.
*/
function draftStorageKey(scopeId: string, documentType: string): string {
return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
}
interface PersistedDraft {
step: Step;
title: string;
recipients: Recipient[];
fields: PlacedField[];
invitationMessage: string;
/** Saved at timestamp - surfaces in the UI as "Draft saved ". */
savedAt: string;
}
function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedDraft;
// Defensive shape check - drop drafts that look malformed rather
// than crashing the dialog.
if (
typeof parsed.title !== 'string' ||
!Array.isArray(parsed.recipients) ||
!Array.isArray(parsed.fields)
) {
return null;
}
return parsed;
} catch {
return null;
}
}
function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
} catch {
// localStorage may throw on private mode or quota - swallow.
}
}
function clearDraft(scopeId: string, documentType: string): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
} catch {
// ignore
}
}
function DialogBody({
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
onClose,
}: {
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
clientPrefill?: { name: string; email: string };
entity?: UploadForSigningEntity;
folderId?: string | null;
onCreated?: (result: { documentId: string }) => void;
onClose: () => void;
}) {
// Draft scope: interestId when scoped to a deal, otherwise the
// entity id (so the rep can have one in-flight upload per entity),
// else 'generic' for the root Documents Hub flow.
const draftScopeId = interestId ?? entity?.id ?? 'generic';
// Hydrate from the persisted draft once on mount. The `key` prop on
// the parent re-mounts this body on every open, so this useState
// initializer runs once per dialog session.
const initialDraft = useMemo(
() => loadDraft(draftScopeId, documentType),
[draftScopeId, documentType],
);
const [step, setStep] = useState(initialDraft?.step ?? 'select-file');
const [file, setFile] = useState(null);
const [title, setTitle] = useState(initialDraft?.title ?? '');
const [recipients, setRecipients] = useState(initialDraft?.recipients ?? []);
const [fields, setFields] = useState(initialDraft?.fields ?? []);
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(initialDraft?.invitationMessage ?? '');
const [draftSavedAt, setDraftSavedAt] = useState(initialDraft?.savedAt ?? null);
const docLabel =
documentType === 'contract'
? 'Sales Contract'
: documentType === 'eoi'
? 'Expression of Interest'
: documentType === 'reservation_agreement'
? 'Reservation Agreement'
: 'Document';
// 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. Skipped entirely on the generic path (no interest).
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: Boolean(interestId) && !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]);
// We previously passed an object URL into react-pdf, but PDF.js runs
// its parser in a Web Worker loaded from unpkg.com (a different
// origin from localhost). Cross-origin workers can't fetch blob URLs
// minted on the main page - the worker XHR returns response (0) and
// the preview surfaces "Unexpected server response (0)". Reading the
// file into an ArrayBuffer once and handing PDF.js the raw bytes via
// `{ data: ... }` sidesteps the fetch entirely, so the cross-origin
// worker has nothing to retrieve.
const [fileBytes, setFileBytes] = useState(null);
useEffect(() => {
if (!file) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear preview bytes when caller drops the file
setFileBytes(null);
return;
}
let cancelled = false;
void file.arrayBuffer().then((buf) => {
if (!cancelled) setFileBytes(new Uint8Array(buf));
});
return () => {
cancelled = true;
};
}, [file]);
// Persist the rep's progress to localStorage as they work. Debounced
// at 500ms so a flurry of state updates (typing a long invitation
// message, dragging a field across the page) doesn't hammer storage.
// We DO NOT persist the File object itself - the rep has to re-pick
// the PDF after a refresh. Everything else (title, signers,
// placements, custom note) round-trips. The `step` is restored too
// so the dialog reopens on the same screen the rep left.
const draftDebounceRef = useRef | null>(null);
useEffect(() => {
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
draftDebounceRef.current = setTimeout(() => {
// Skip persistence in the pristine "no progress yet" state so
// dismissing the dialog without touching anything doesn't leave
// a phantom draft behind.
const hasProgress =
title.length > 0 ||
recipients.length > 0 ||
fields.length > 0 ||
invitationMessage.length > 0;
if (!hasProgress) {
clearDraft(draftScopeId, documentType);
return;
}
const now = new Date().toISOString();
saveDraft(draftScopeId, documentType, {
step,
title,
recipients,
fields,
invitationMessage,
savedAt: now,
});
setDraftSavedAt(now);
}, 500);
return () => {
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
};
}, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);
function discardDraft() {
clearDraft(draftScopeId, documentType);
setTitle('');
setRecipients([]);
setFields([]);
setInvitationMessage('');
setStep('select-file');
setDraftSavedAt(null);
}
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,
})),
),
);
// Generic envelopes go to the cross-cutting endpoint; the
// entity / folder context piggybacks on the form so the file
// row lands under the right system folder. Interest-scoped
// flows keep their dedicated route so the pipeline-stage
// advance + doc-status flip side effects fire.
if (interestId) {
if (documentType === 'generic') {
throw new Error('Generic documentType requires interestId=null');
}
} else {
if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
if (folderId) form.append('folderId', folderId);
}
const endpoint = interestId
? `/api/v1/interests/${interestId}/upload-for-signing`
: `/api/v1/upload-for-signing`;
const res = await fetch(endpoint, {
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' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
if (onCreated && res?.data?.documentId) {
onCreated({ documentId: res.data.documentId });
}
// Clear the draft on successful submission - the in-flight upload
// is now an actual document; the localStorage shouldn't keep its
// shadow around.
clearDraft(draftScopeId, documentType);
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.'}
{/* Draft-saved indicator + Discard button. Renders when there's
a persisted draft so the rep knows progress is saved across
dialog open / close cycles. Discard wipes the draft and
resets to the file-picker step. The file itself isn't
persisted (large blobs + browser quota), so on reopen the
rep needs to re-pick the PDF - the rest of the state
(title, signers, placements, custom note) survives. */}
{draftSavedAt ? (
Draft saved
) : null}
{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' && fileBytes && (
)}
{
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);
}}
>
{pdfLoadError ? (
PDF preview failed to load
{pdfLoadError}
Field placement still works once the file is uploaded; placements drag onto a
blank canvas. Re-pick the file from step 1 to retry.