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';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -189,16 +249,25 @@ function DialogBody({
|
||||
clientPrefill?: { name: string; email: string };
|
||||
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 [title, setTitle] = useState('');
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([]);
|
||||
const [fields, setFields] = useState<PlacedField[]>([]);
|
||||
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('');
|
||||
const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
|
||||
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);
|
||||
|
||||
const docLabel = documentType === 'contract' ? 'Sales Contract' : 'Reservation Agreement';
|
||||
|
||||
@@ -269,6 +338,56 @@ function DialogBody({
|
||||
};
|
||||
}, [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();
|
||||
@@ -365,6 +484,10 @@ function DialogBody({
|
||||
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'),
|
||||
@@ -374,13 +497,40 @@ function DialogBody({
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
|
||||
<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 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">
|
||||
@@ -725,6 +875,15 @@ function FieldPlacementStep({
|
||||
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(
|
||||
@@ -761,6 +920,52 @@ function FieldPlacementStep({
|
||||
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 */}
|
||||
@@ -837,6 +1042,33 @@ function FieldPlacementStep({
|
||||
>
|
||||
<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…
|
||||
@@ -860,22 +1092,39 @@ function FieldPlacementStep({
|
||||
placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
|
||||
}}
|
||||
>
|
||||
<Document
|
||||
file={fileUrl}
|
||||
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
|
||||
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={1}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
/>
|
||||
</Document>
|
||||
{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) => (
|
||||
|
||||
Reference in New Issue
Block a user