diff --git a/src/components/documents/upload-for-signing-dialog.tsx b/src/components/documents/upload-for-signing-dialog.tsx index b14731ad..ad34bc74 100644 --- a/src/components/documents/upload-for-signing-dialog.tsx +++ b/src/components/documents/upload-for-signing-dialog.tsx @@ -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 ". */ + 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('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(initialDraft?.step ?? 'select-file'); const [file, setFile] = useState(null); - const [title, setTitle] = useState(''); - const [recipients, setRecipients] = useState([]); - const [fields, setFields] = useState([]); + const [title, setTitle] = useState(initialDraft?.title ?? ''); + const [recipients, setRecipients] = useState(initialDraft?.recipients ?? []); + const [fields, setFields] = useState(initialDraft?.fields ?? []); const [selectedFieldId, setSelectedFieldId] = useState(null); // Phase 6 polish — optional rep-authored note that appears above the // CTA in every invitation email for this doc. Empty string means // "no custom note — use the template default copy". - const [invitationMessage, setInvitationMessage] = useState(''); + const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? ''); + const [draftSavedAt, setDraftSavedAt] = useState(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 | 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 ( <> - Send {docLabel.toLowerCase()} for signing - - {step === 'select-file' && 'Upload the draft PDF to send via Documenso.'} - {step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'} - {step === 'place-fields' && - 'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'} - +
+
+ Send {docLabel.toLowerCase()} for signing + + {step === 'select-file' && 'Upload the draft PDF to send via Documenso.'} + {step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'} + {step === 'place-fields' && + 'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'} + +
+ {/* Draft-saved indicator + Discard button. Renders when there's + a persisted draft so the rep knows progress is saved across + dialog open / close cycles. Discard wipes the draft and + resets to the file-picker step. The file itself isn't + persisted (large blobs + browser quota), so on reopen the + rep needs to re-pick the PDF — the rest of the state + (title, signers, placements, custom note) survives. */} + {draftSavedAt ? ( +
+ + Draft saved + + +
+ ) : null} +
@@ -725,6 +875,15 @@ function FieldPlacementStep({ const [numPages, setNumPages] = useState(1); const [pageNumber, setPageNumber] = useState(1); const [placingType, setPlacingType] = useState(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(null); const pageContainerRef = useRef(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 = {}; + 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 (
{/* Field palette */} @@ -837,6 +1042,33 @@ function FieldPlacementStep({ > + {/* Zoom controls — render zoom only, field coordinates stay + in % so placements scale automatically with the canvas. */} +
+ + + {Math.round(pdfScale * 100)}% + + +
{isDetecting && ( Auto-detecting fields… @@ -860,22 +1092,39 @@ function FieldPlacementStep({ placeFieldAt(e.clientX, e.clientY, pageContainerRef.current); }} > - setNumPages(n)} - loading={ -
- Loading PDF… -
- } - > - -
+ {pdfLoadError ? ( +
+

PDF preview failed to load

+

{pdfLoadError}

+

+ Field placement still works once the file is uploaded; placements drag onto a + blank canvas. Re-pick the file from step 1 to retry. +

+
+ ) : ( + { + setNumPages(n); + setPdfLoadError(null); + }} + onLoadError={(err) => { + setPdfLoadError(err instanceof Error ? err.message : 'Unknown error'); + }} + loading={ +
+ Loading PDF… +
+ } + > + +
+ )} {/* Overlay layer */}
{pageFields.map((field) => (