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:
2026-05-21 23:16:00 +02:00
parent 03a7521729
commit 65ff5961f2

View File

@@ -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) => (