Files
pn-new-crm/src/components/documents/upload-for-signing-dialog.tsx

1664 lines
60 KiB
TypeScript
Raw Normal View History

feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
'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.
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
*
* 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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
* client + developer + approver prefilled from port + interest
* 3. place-fields - render the PDF page-by-page, run
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
* auto-detect, let the rep drag/place/delete fields per signer
* 4. sending - POST to /upload-for-signing, show spinner
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
*
* The implementation is intentionally compact - the field-overlay
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
* 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. */
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
id: string;
type: FieldType;
recipientIndex: number;
pageNumber: number;
/** All 0..100 percent of page dimensions. */
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
/** Per-instance default value the signer sees on render. For TEXT/NUMBER
* this is the prefilled string; for DROPDOWN it's the option `value`
* string; for CHECKBOX/RADIO the meta.values entries carry their own
* `checked` flags so this is left null. */
defaultValue?: string | null;
/** Documenso v2 fieldMeta passed through verbatim. Per-type shape:
* TEXT/SIGNATURE/INITIALS: { text?, label?, required?, readOnly? }
* NUMBER: { numberFormat?, min?, max?, required? }
* DATE: { dateFormat?, required? }
* CHECKBOX/RADIO: { values: [{ value, checked? }] }
* DROPDOWN: { values: [{ value }], defaultValue? }
* Ignored on v1 instances. */
fieldMeta?: Record<string, unknown>;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}
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
];
export interface UploadForSigningEntity {
type: 'client' | 'company' | 'yacht';
id: string;
/** Display label only used in the dialog header so the rep can
* see which entity the doc will be filed under. */
label?: string;
}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
interface UploadForSigningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Required for eoi / contract / reservation_agreement (the pipeline
* side effects need it). MUST be null for documentType='generic'
* in that case the upload routes through the generic endpoint and
* optionally files the doc against the supplied `entity`. */
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
/** Optional: client name/email to prefill the first recipient.
* When omitted the dialog fetches from the interest (interest-scoped
* flows) or leaves the recipient blank (generic flow). */
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
clientPrefill?: { name: string; email: string };
/** Generic flow only: routes the resulting file/document row to the
* entity's FK column + auto-files it into the entity's system
* folder. Ignored when `interestId` is set. */
entity?: UploadForSigningEntity;
/** Generic flow only: explicit folder placement (e.g. rep is
* uploading from within a Documents Hub folder). */
folderId?: string | null;
/** Generic flow only: caller-supplied success hook. Receives the
* new documentId and can invalidate caches / show a toast. */
onCreated?: (result: { documentId: string }) => void;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}
export function UploadForSigningDialog({
open,
onOpenChange,
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}: 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;
const draftKey = interestId ?? entity?.id ?? 'generic';
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
<DialogBody
key={`${draftKey}:${documentType}`}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
interestId={interestId}
documentType={documentType}
clientPrefill={clientPrefill}
entity={entity}
folderId={folderId ?? null}
onCreated={onCreated}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
onClose={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}
type Step = 'select-file' | 'configure-recipients' | 'place-fields';
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
/**
* 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(scopeId: string, documentType: string): string {
return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
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
}
interface PersistedDraft {
step: Step;
title: string;
recipients: Recipient[];
fields: PlacedField[];
invitationMessage: string;
/** Saved at timestamp - surfaces in the UI as "Draft saved <relative>". */
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
savedAt: string;
}
function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
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
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
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
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedDraft;
// Defensive shape check - drop drafts that look malformed rather
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
// 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(scopeId: string, documentType: string, draft: PersistedDraft): void {
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
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
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
} catch {
// localStorage may throw on private mode or quota - swallow.
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
}
}
function clearDraft(scopeId: string, documentType: string): void {
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
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
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
} catch {
// ignore
}
}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
function DialogBody({
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
onClose,
}: {
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
clientPrefill?: { name: string; email: string };
entity?: UploadForSigningEntity;
folderId?: string | null;
onCreated?: (result: { documentId: string }) => void;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
onClose: () => void;
}) {
// Draft scope: interestId when scoped to a deal, otherwise the
// entity id (so the rep can have one in-flight upload per entity),
// else 'generic' for the root Documents Hub flow.
const draftScopeId = interestId ?? entity?.id ?? 'generic';
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
// 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(draftScopeId, documentType),
[draftScopeId, documentType],
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
);
const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
const [file, setFile] = useState<File | null>(null);
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
const [title, setTitle] = useState(initialDraft?.title ?? '');
const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
// Phase 6 polish - optional rep-authored note that appears above the
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
// CTA in every invitation email for this doc. Empty string means
// "no custom note - use the template default copy".
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
const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
const docLabel =
documentType === 'contract'
? 'Sales Contract'
: documentType === 'eoi'
? 'Expression of Interest'
: documentType === 'reservation_agreement'
? 'Reservation Agreement'
: 'Document';
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
// Defaults endpoint - drives the developer/approver prefill.
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
// caller didn't supply one. Cached so the same dialog open/reopen
// hits the cache. Skipped entirely on the generic path (no interest).
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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: Boolean(interestId) && !clientPrefill,
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
});
/**
* 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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
* 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]);
// We previously passed an object URL into react-pdf, but PDF.js runs
// its parser in a Web Worker loaded from unpkg.com (a different
// origin from localhost). Cross-origin workers can't fetch blob URLs
// minted on the main page - the worker XHR returns response (0) and
// the preview surfaces "Unexpected server response (0)". Reading the
// file into an ArrayBuffer once and handing PDF.js the raw bytes via
// `{ data: ... }` sidesteps the fetch entirely, so the cross-origin
// worker has nothing to retrieve.
const [fileBytes, setFileBytes] = useState<Uint8Array | null>(null);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
useEffect(() => {
if (!file) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear preview bytes when caller drops the file
setFileBytes(null);
return;
}
let cancelled = false;
void file.arrayBuffer().then((buf) => {
if (!cancelled) setFileBytes(new Uint8Array(buf));
});
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
return () => {
cancelled = true;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
};
}, [file]);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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
// 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
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
// 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(draftScopeId, documentType);
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
return;
}
const now = new Date().toISOString();
saveDraft(draftScopeId, documentType, {
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
step,
title,
recipients,
fields,
invitationMessage,
savedAt: now,
});
setDraftSavedAt(now);
}, 500);
return () => {
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
};
}, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);
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
function discardDraft() {
clearDraft(draftScopeId, documentType);
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
setTitle('');
setRecipients([]);
setFields([]);
setInvitationMessage('');
setStep('select-file');
setDraftSavedAt(null);
}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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.');
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}
},
onError: () => {
toast.info('Auto-detect skipped - place fields manually.');
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
},
});
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));
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
if (invitationMessage.trim()) {
form.append('invitationMessage', invitationMessage.trim());
}
// Strip the client-side `id` from each placed field - the server
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
// 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,
...(f.fieldMeta && Object.keys(f.fieldMeta).length > 0
? { fieldMeta: f.fieldMeta }
: {}),
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
})),
),
);
// Generic envelopes go to the cross-cutting endpoint; the
// entity / folder context piggybacks on the form so the file
// row lands under the right system folder. Interest-scoped
// flows keep their dedicated route so the pipeline-stage
// advance + doc-status flip side effects fire.
if (interestId) {
if (documentType === 'generic') {
throw new Error('Generic documentType requires interestId=null');
}
} else {
if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
if (folderId) form.append('folderId', folderId);
}
const endpoint = interestId
? `/api/v1/interests/${interestId}/upload-for-signing`
: `/api/v1/upload-for-signing`;
const res = await fetch(endpoint, {
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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.'
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
: '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' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
if (onCreated && res?.data?.documentId) {
onCreated({ documentId: res.data.documentId });
}
// Clear the draft on successful submission - the in-flight upload
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
// is now an actual document; the localStorage shouldn't keep its
// shadow around.
clearDraft(draftScopeId, documentType);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
onClose();
},
onError: (err) => toastError(err, 'Upload failed'),
});
// ─── Step renderers ─────────────────────────────────────────────
return (
<>
<DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
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
<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
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
(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>
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
</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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
// 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}
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
invitationMessage={invitationMessage}
onInvitationMessageChange={setInvitationMessage}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
/>
)}
{step === 'place-fields' && fileBytes && (
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
<FieldPlacementStep
fileBytes={fileBytes}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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"
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
/>
</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,
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
invitationMessage,
onInvitationMessageChange,
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}: {
recipients: Recipient[];
onChange: (next: Recipient[]) => void;
title: string;
onTitleChange: (t: string) => void;
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
invitationMessage: string;
onInvitationMessageChange: (next: string) => void;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
}) {
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) => (
2026-05-21 17:28:20 +02:00
<div key={i} className="flex gap-2 items-center">
<span className="w-8 shrink-0 text-xs text-center text-muted-foreground tabular-nums">
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
#{r.signingOrder}
</span>
<Input
2026-05-21 17:28:20 +02:00
className="flex-1 min-w-0"
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
placeholder="Name"
value={r.name}
onChange={(e) => update(i, { name: e.target.value })}
/>
<Input
2026-05-21 17:28:20 +02:00
className="flex-[2] min-w-0"
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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'] })}
>
2026-05-21 17:28:20 +02:00
<SelectTrigger className="w-40 shrink-0">
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
<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"
2026-05-21 17:28:20 +02:00
className="shrink-0"
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
>
<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>
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
<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)}
2026-05-21 17:28:20 +02:00
placeholder="Hi John, please review the attached contract before signing. Reach out if anything needs adjusting."
rows={6}
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
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>
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
</div>
);
}
// ─── Step 3: field placement overlay ──────────────────────────────
function FieldPlacementStep({
fileBytes,
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
fields,
onFieldsChange,
recipients,
selectedFieldId,
onSelectField,
isDetecting,
}: {
fileBytes: Uint8Array;
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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
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
// 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);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
const pageContainerRef = useRef<HTMLDivElement>(null);
// react-pdf re-creates its internal PDF document whenever the `file`
// prop's reference identity changes, so the `{ data }` object MUST
// be memoized - otherwise every render restarts parsing from scratch
// and flickers the placeholder.
const pdfFileSource = useMemo(() => ({ data: fileBytes }), [fileBytes]);
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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
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
// 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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
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
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
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>
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
{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);
}}
>
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
{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
// Passing { data } gives PDF.js the raw bytes directly,
// so its (cross-origin) Web Worker doesn't have to fetch
// anything - this is the only way to make react-pdf work
// when the worker is loaded from a CDN.
file={pdfFileSource}
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
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>
)}
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
{/* 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
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
// 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>
<FieldMetaSubPanel field={field} onUpdate={onUpdate} />
feat(documenso-phase-4): recipient configurator + field placement UI Phase 4 lands the visual half of the Documenso build — the upload- for-signing dialog the Contract + Reservation tabs hand off to. Four files of new code; the existing tab placeholders point at it. Files added: - lib/services/document-field-detector.ts — Phase 4c auto-detect scanner. Uses pdfjs-dist to extract per-page text + positions, then matches anchor patterns (Signature, Date, Initials, Email, Name, underscore-runs) and produces percent-coordinate DetectedField rows. Recipient label inference walks ±100pt of each match for Buyer/Seller/Client/Witness/Notary keywords. Returns [] when the PDF is image-only; UI falls back to manual placement without an error. 6 unit tests pin the matching + coordinate math. - app/api/v1/documents/auto-detect-fields/route.ts — multipart POST endpoint that delegates to detectFields(). Permission-gated by documents.send_for_signing. - app/api/v1/documents/signing-defaults/route.ts — GET endpoint that surfaces just the per-port developer + approver display name/email + sendMode flag. No secrets exposed; lets the dialog prefill the recipient configurator without an admin-scoped settings read. - components/documents/upload-for-signing-dialog.tsx — the Phase 4 UI. Three-step state machine inside a single Dialog: 1. select-file: drop/click PDF picker + title input 2. configure-recipients: client + developer + approver prefilled, rep can add/remove/reorder + change role (SIGNER/APPROVER/CC) 3. place-fields: react-pdf renders the source PDF; auto-detect runs in the background on file load and seeds the overlay; rep places, drags, resizes, deletes, reassigns fields via the palette + side panel. Native DOM drag (no dnd-kit dependency added — the coordinate math stays obvious). Send fires POST /api/v1/interests/[id]/upload-for-signing (Phase 3 service); success toast reflects port sendMode (auto fires the invite immediately, manual leaves it for the rep). Files modified: - components/interests/interest-contract-tab.tsx + reservation-tab.tsx: swap the ComingSoonDialog placeholder for the real UploadForSigningDialog with the matching documentType prop. The placeholder ComingSoonDialog helper is deleted from both. - scripts/tsc-staged.mjs: pull src/types/**/*.d.ts into the temp staged-only tsconfig so side-effect CSS imports (e.g. react-pdf/dist/Page/AnnotationLayer.css) resolve via the existing declare-module shim. Without this fix the staged compile reports TS2882 even though the full tsc --noEmit pass passes. Design choices noted in code comments: - Native drag over dnd-kit: the field overlay's percent-based coordinate math is short enough that adding a drag library adds complexity without saving lines. - Auto-detect on file-load (not on demand): runs immediately so the rep doesn't have to click a second button — empty result drops back to manual placement silently. - Per-recipient color swatches indexed by signingOrder. - Recipient seed via useMemo + user-event handler instead of useEffect → setRecipients (Wave 3 set-state-in-effect avoidance). Server-side, Phase 3 plumbing handles the rest: tenant guard, magic- byte verify, Documenso round-trip with per-port v1/v2 routing, recipient signingToken capture for Phase 2 webhook cascade, auto- send when port.sendMode === 'auto'. Tests: 1334 → 1340 ✅ (6 new for the detector); tsc clean. Deferred polish (Phase 6): - Per-field metadata side panel for DROPDOWN/RADIO option lists - Pinch-zoom + zoom-out controls on the field-placement canvas - Recipient drag-reorder via dnd-kit - Required toggle per field Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:03:27 +02:00
<Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
<Trash2 className="size-4" aria-hidden /> Delete field
</Button>
</div>
);
}
/**
* Editable list for CHECKBOX / RADIO / DROPDOWN options. Kept top-level
* so React doesn't recreate the component subtree on every keystroke
* (react-hooks/static-components rule). Single-select variants render
* radio inputs that mutually exclude defaults; multi-select renders
* checkboxes.
*/
function ChoiceMetaEditor({
options,
onChange,
singleSelect,
}: {
options: Array<{ value: string; checked?: boolean }>;
onChange: (next: Array<{ value: string; checked?: boolean }>) => void;
singleSelect: boolean;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Options</Label>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() =>
onChange([...options, { value: `Option ${options.length + 1}`, checked: false }])
}
>
+ Add
</Button>
</div>
{options.length === 0 ? (
<p className="text-xs text-muted-foreground">No options yet, add at least one.</p>
) : (
<ul className="space-y-1">
{options.map((opt, idx) => (
<li key={idx} className="flex items-center gap-1.5">
<input
type={singleSelect ? 'radio' : 'checkbox'}
name="choice-meta"
checked={!!opt.checked}
onChange={() => {
const next = options.map((o, i) =>
singleSelect
? { ...o, checked: i === idx }
: i === idx
? { ...o, checked: !o.checked }
: o,
);
onChange(next);
}}
aria-label={`${opt.value} default ${singleSelect ? 'selection' : 'checked'}`}
className="size-3.5 shrink-0"
/>
<Input
value={opt.value}
onChange={(e) => {
const next = options.map((o, i) =>
i === idx ? { ...o, value: e.target.value } : o,
);
onChange(next);
}}
className="h-7 text-xs"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
onClick={() => onChange(options.filter((_, i) => i !== idx))}
aria-label="Remove option"
>
<X className="size-3" aria-hidden />
</Button>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Per-field-type config (Documenso v2 fieldMeta). Surfaces inputs only
* when the field type carries per-instance config; SIGNATURE / INITIALS /
* DATE / EMAIL / NAME fields render nothing here. Edits write into
* `field.fieldMeta` shallowly so the v2 create-many payload receives
* the shape Documenso expects.
*/
function FieldMetaSubPanel({
field,
onUpdate,
}: {
field: PlacedField;
onUpdate: (patch: Partial<PlacedField>) => void;
}) {
const meta: Record<string, unknown> = (field.fieldMeta as Record<string, unknown>) ?? {};
function patchMeta(diff: Record<string, unknown>) {
onUpdate({ fieldMeta: { ...meta, ...diff } });
}
if (field.type === 'TEXT') {
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Text settings
</p>
<div className="space-y-1">
<Label className="text-xs">Default text</Label>
<Input
value={typeof meta.text === 'string' ? meta.text : ''}
onChange={(e) => patchMeta({ text: e.target.value })}
className="h-7 text-xs"
placeholder="Pre-filled value the signer sees"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label</Label>
<Input
value={typeof meta.label === 'string' ? meta.label : ''}
onChange={(e) => patchMeta({ label: e.target.value })}
className="h-7 text-xs"
placeholder="Helper text above the field"
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={meta.required === true}
onChange={(e) => patchMeta({ required: e.target.checked })}
className="size-3.5"
/>
Required
</label>
</div>
);
}
if (field.type === 'NUMBER') {
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Number settings
</p>
<div className="space-y-1">
<Label className="text-xs">Format</Label>
<Input
value={typeof meta.numberFormat === 'string' ? meta.numberFormat : ''}
onChange={(e) => patchMeta({ numberFormat: e.target.value })}
className="h-7 text-xs"
placeholder="e.g. 0.00, $#,##0"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<div className="space-y-1">
<Label className="text-xs">Min</Label>
<Input
type="number"
value={typeof meta.min === 'number' ? meta.min : ''}
onChange={(e) =>
patchMeta({ min: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max</Label>
<Input
type="number"
value={typeof meta.max === 'number' ? meta.max : ''}
onChange={(e) =>
patchMeta({ max: e.target.value === '' ? undefined : Number(e.target.value) })
}
className="h-7 text-xs"
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={meta.required === true}
onChange={(e) => patchMeta({ required: e.target.checked })}
className="size-3.5"
/>
Required
</label>
</div>
);
}
if (field.type === 'CHECKBOX' || field.type === 'RADIO' || field.type === 'DROPDOWN') {
const rawValues = (meta.values as Array<{ value: string; checked?: boolean }>) ?? [];
return (
<div className="space-y-2 rounded-md border bg-muted/30 p-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{field.type === 'CHECKBOX'
? 'Checkbox options'
: field.type === 'RADIO'
? 'Radio options'
: 'Dropdown options'}
</p>
<ChoiceMetaEditor
options={rawValues}
onChange={(next) => patchMeta({ values: next })}
singleSelect={field.type !== 'CHECKBOX'}
/>
</div>
);
}
// SIGNATURE / INITIALS / DATE / EMAIL / NAME carry no per-instance
// configuration in Documenso v2 today.
return null;
}