Files
pn-new-crm/src/components/documents/upload-for-signing-dialog.tsx
Matt 65ff5961f2 feat(uat-batch): Group L — UploadForSigningDialog rework
L41 from the 2026-05-21 plan.

Shipped (4 sub-tasks):
  - **Dialog width**: already fixed in an earlier session
    (max-w-[1400px] w-[95vw] on the DialogContent).
  - **Draft persistence to localStorage**: scoped per
    interest+documentType (`pn-crm.upload-for-signing.draft.v1:<id>:<type>`),
    versioned for future shape evolution. Persists step / title /
    recipients / fields / invitationMessage with a 500ms debounce so
    rapid edits (typing the custom note, dragging a field) don't
    hammer storage. The PDF File object itself is NOT persisted
    (large blobs + browser quota); on reopen the rep re-picks the
    file but every other piece of state survives. Pristine "no
    progress yet" state actively clears any stale draft. Header
    surfaces a "Draft saved" indicator + Discard button when a
    draft exists. Successful submission clears the draft so the
    shadow doesn't outlive the doc.
  - **PDF preview error handling + zoom**: `onLoadError` now sets
    `pdfLoadError` and replaces the spinner with a useful failure
    block (error message + re-pick guidance) so reps don't see an
    infinite loading state on a broken file. Toolbar gains zoom
    controls (50–200% in 25% steps); field coordinates stay in %
    of page dimensions so placements scale automatically with the
    canvas.
  - **Field-placement keyboard shortcuts**: window-level keydown
    handler responds to Delete / Backspace (remove selected field),
    arrow keys (nudge 0.5% per press, Shift + arrow = 5% per press).
    Ignored when focus is in a real input / textarea / contenteditable
    so the shortcuts never steal typing.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:16:00 +02:00

1338 lines
47 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-[1400px] w-[95vw] 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';
/**
* 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 <relative>". */
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<Step>(initialDraft?.step ?? 'select-file');
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState(initialDraft?.title ?? '');
const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(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<string | null>(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<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]);
// 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<ReturnType<typeof setTimeout> | 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<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;
// 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 (
<>
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<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>
</div>
{/* 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 ? (
<div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
<span title={`Draft auto-saved ${new Date(draftSavedAt).toLocaleString()}`}>
Draft saved
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-muted-foreground hover:text-destructive"
onClick={discardDraft}
>
Discard
</Button>
</div>
) : null}
</div>
</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}
invitationMessage={invitationMessage}
onInvitationMessageChange={setInvitationMessage}
/>
)}
{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,
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<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="flex gap-2 items-center">
<span className="w-8 shrink-0 text-xs text-center text-muted-foreground tabular-nums">
#{r.signingOrder}
</span>
<Input
className="flex-1 min-w-0"
placeholder="Name"
value={r.name}
onChange={(e) => update(i, { name: e.target.value })}
/>
<Input
className="flex-[2] min-w-0"
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="w-40 shrink-0">
<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="shrink-0"
>
<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 className="space-y-2">
<Label htmlFor="invitation-message">
Optional message to include in the signing invitation
</Label>
<textarea
id="invitation-message"
value={invitationMessage}
onChange={(e) => onInvitationMessageChange(e.target.value)}
placeholder="Hi John, please review the attached contract before signing. Reach out if anything needs adjusting."
rows={6}
maxLength={1000}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
/>
<p className="text-xs text-muted-foreground">
Appears above the Sign button in every recipient&rsquo;s invitation email. Plain text
only; 1000 characters max.
</p>
</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);
// PDF render zoom — defaults to 1 (the historical fixed scale). Buttons
// below the page-nav let reps zoom out for an overview or zoom in for
// tight placement work. Field coordinates stay in % of page dimensions
// so the placed-field overlay scales automatically with the PDF.
const [pdfScale, setPdfScale] = useState(1);
// Surfaces a useful error message when the PDF fails to load (CORS
// mismatch, malformed file, worker init failure). Previously the
// dialog showed an infinite spinner that gave reps no signal to act on.
const [pdfLoadError, setPdfLoadError] = useState<string | 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);
}
// Keyboard shortcuts on the placement canvas — Delete / Backspace
// removes the selected field; arrow keys nudge it by 0.5% (Shift = 5%
// for coarser moves). Listens at document level so the handler still
// fires when the rep's focus is on the PDF canvas (which doesn't take
// text input).
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!selectedFieldId) return;
// Ignore when the focus is on a real input / textarea so the
// shortcuts never steal typing.
const tgt = e.target as HTMLElement | null;
if (
tgt &&
(tgt.tagName === 'INPUT' ||
tgt.tagName === 'TEXTAREA' ||
tgt.tagName === 'SELECT' ||
tgt.isContentEditable)
) {
return;
}
const field = fields.find((f) => f.id === selectedFieldId);
if (!field) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
removeField(selectedFieldId);
return;
}
if (e.key.startsWith('Arrow')) {
const step = e.shiftKey ? 5 : 0.5;
const patch: Partial<PlacedField> = {};
if (e.key === 'ArrowUp') patch.pageY = Math.max(0, field.pageY - step);
if (e.key === 'ArrowDown')
patch.pageY = Math.min(100 - field.pageHeight, field.pageY + step);
if (e.key === 'ArrowLeft') patch.pageX = Math.max(0, field.pageX - step);
if (e.key === 'ArrowRight')
patch.pageX = Math.min(100 - field.pageWidth, field.pageX + step);
if (Object.keys(patch).length > 0) {
e.preventDefault();
updateField(selectedFieldId, patch);
}
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedFieldId, fields, onFieldsChange]); // eslint-disable-line react-hooks/exhaustive-deps
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>
{/* Zoom controls — render zoom only, field coordinates stay
in % so placements scale automatically with the canvas. */}
<div className="ml-3 flex items-center gap-1 border-l pl-3">
<Button
type="button"
variant="ghost"
size="icon"
disabled={pdfScale <= 0.5}
onClick={() => setPdfScale((s) => Math.max(0.5, Math.round((s - 0.25) * 100) / 100))}
aria-label="Zoom out"
>
<span className="text-base font-bold leading-none"></span>
</Button>
<span className="min-w-[44px] text-center text-xs tabular-nums text-muted-foreground">
{Math.round(pdfScale * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={pdfScale >= 2}
onClick={() => setPdfScale((s) => Math.min(2, Math.round((s + 0.25) * 100) / 100))}
aria-label="Zoom in"
>
<span className="text-base font-bold leading-none">+</span>
</Button>
</div>
{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);
}}
>
{pdfLoadError ? (
<div className="flex h-96 w-[600px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<p className="font-medium text-destructive">PDF preview failed to load</p>
<p className="font-mono text-xs break-all">{pdfLoadError}</p>
<p className="text-xs">
Field placement still works once the file is uploaded; placements drag onto a
blank canvas. Re-pick the file from step 1 to retry.
</p>
</div>
) : (
<Document
file={fileUrl}
onLoadSuccess={({ numPages: n }) => {
setNumPages(n);
setPdfLoadError(null);
}}
onLoadError={(err) => {
setPdfLoadError(err instanceof Error ? err.message : 'Unknown error');
}}
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={pdfScale}
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>
);
}