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>
This commit is contained in:
@@ -178,6 +178,66 @@ export function UploadForSigningDialog({
|
|||||||
|
|
||||||
type Step = 'select-file' | 'configure-recipients' | 'place-fields';
|
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({
|
function DialogBody({
|
||||||
interestId,
|
interestId,
|
||||||
documentType,
|
documentType,
|
||||||
@@ -189,16 +249,25 @@ function DialogBody({
|
|||||||
clientPrefill?: { name: string; email: string };
|
clientPrefill?: { name: string; email: string };
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<Step>('select-file');
|
// 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 [file, setFile] = useState<File | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState(initialDraft?.title ?? '');
|
||||||
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
|
||||||
const [fields, setFields] = useState<PlacedField[]>([]);
|
const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
|
||||||
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
||||||
// Phase 6 polish — optional rep-authored note that appears above the
|
// Phase 6 polish — optional rep-authored note that appears above the
|
||||||
// CTA in every invitation email for this doc. Empty string means
|
// CTA in every invitation email for this doc. Empty string means
|
||||||
// "no custom note — use the template default copy".
|
// "no custom note — use the template default copy".
|
||||||
const [invitationMessage, setInvitationMessage] = useState('');
|
const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
|
||||||
|
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);
|
||||||
|
|
||||||
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
||||||
|
|
||||||
@@ -269,6 +338,56 @@ function DialogBody({
|
|||||||
};
|
};
|
||||||
}, [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({
|
const autoDetect = useMutation({
|
||||||
mutationFn: async (uploadedFile: File) => {
|
mutationFn: async (uploadedFile: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -365,6 +484,10 @@ function DialogBody({
|
|||||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
|
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
|
||||||
void res;
|
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();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (err) => toastError(err, 'Upload failed'),
|
onError: (err) => toastError(err, 'Upload failed'),
|
||||||
@@ -374,13 +497,40 @@ function DialogBody({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
|
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
|
||||||
<DialogTitle>Send {docLabel.toLowerCase()} for signing</DialogTitle>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<DialogDescription>
|
<div className="min-w-0 flex-1">
|
||||||
{step === 'select-file' && 'Upload the draft PDF to send via Documenso.'}
|
<DialogTitle>Send {docLabel.toLowerCase()} for signing</DialogTitle>
|
||||||
{step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'}
|
<DialogDescription>
|
||||||
{step === 'place-fields' &&
|
{step === 'select-file' && 'Upload the draft PDF to send via Documenso.'}
|
||||||
'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 === 'configure-recipients' && 'Confirm who needs to sign and in what order.'}
|
||||||
</DialogDescription>
|
{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>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
@@ -725,6 +875,15 @@ function FieldPlacementStep({
|
|||||||
const [numPages, setNumPages] = useState(1);
|
const [numPages, setNumPages] = useState(1);
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(1);
|
||||||
const [placingType, setPlacingType] = useState<FieldType | null>(null);
|
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 pageContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const pageFields = useMemo(
|
const pageFields = useMemo(
|
||||||
@@ -761,6 +920,52 @@ function FieldPlacementStep({
|
|||||||
if (selectedFieldId === id) onSelectField(null);
|
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 (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Field palette */}
|
{/* Field palette */}
|
||||||
@@ -837,6 +1042,33 @@ function FieldPlacementStep({
|
|||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</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 && (
|
{isDetecting && (
|
||||||
<span className="ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" /> Auto-detecting fields…
|
<Loader2 className="size-3 animate-spin" /> Auto-detecting fields…
|
||||||
@@ -860,22 +1092,39 @@ function FieldPlacementStep({
|
|||||||
placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
|
placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Document
|
{pdfLoadError ? (
|
||||||
file={fileUrl}
|
<div className="flex h-96 w-[600px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||||||
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
|
<p className="font-medium text-destructive">PDF preview failed to load</p>
|
||||||
loading={
|
<p className="font-mono text-xs break-all">{pdfLoadError}</p>
|
||||||
<div className="flex h-64 w-96 items-center justify-center text-sm text-muted-foreground">
|
<p className="text-xs">
|
||||||
<Loader2 className="size-4 mr-2 animate-spin" /> Loading PDF…
|
Field placement still works once the file is uploaded; placements drag onto a
|
||||||
</div>
|
blank canvas. Re-pick the file from step 1 to retry.
|
||||||
}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Page
|
) : (
|
||||||
pageNumber={pageNumber}
|
<Document
|
||||||
scale={1}
|
file={fileUrl}
|
||||||
renderAnnotationLayer={false}
|
onLoadSuccess={({ numPages: n }) => {
|
||||||
renderTextLayer={false}
|
setNumPages(n);
|
||||||
/>
|
setPdfLoadError(null);
|
||||||
</Document>
|
}}
|
||||||
|
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 */}
|
{/* Overlay layer */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
{pageFields.map((field) => (
|
{pageFields.map((field) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user