'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 (
);
}
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(interestId: string, documentType: string): string {
return `pn-crm.upload-for-signing.draft.v1:${interestId}:${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(interestId: string, documentType: string): PersistedDraft | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(draftStorageKey(interestId, 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(interestId: string, documentType: string, draft: PersistedDraft): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
} catch {
// localStorage may throw on private mode or quota — swallow.
}
}
function clearDraft(interestId: string, documentType: string): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(draftStorageKey(interestId, documentType));
} catch {
// ignore
}
}
function DialogBody({
interestId,
documentType,
clientPrefill,
onClose,
}: {
interestId: string;
documentType: 'contract' | 'reservation_agreement';
clientPrefill?: { name: string; email: string };
onClose: () => void;
}) {
// 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(interestId, documentType),
[interestId, 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' : '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]);
// 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(interestId, documentType);
return;
}
const now = new Date().toISOString();
saveDraft(interestId, documentType, {
step,
title,
recipients,
fields,
invitationMessage,
savedAt: now,
});
setDraftSavedAt(now);
}, 500);
return () => {
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
};
}, [step, title, recipients, fields, invitationMessage, interestId, documentType]);
function discardDraft() {
clearDraft(interestId, 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,
})),
),
);
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;
// Clear the draft on successful submission — the in-flight upload
// is now an actual document; the localStorage shouldn't keep its
// shadow around.
clearDraft(interestId, 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' && fileObjectUrl && (
)}
{
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.