diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts index 3e17318..3c4b62a 100644 --- a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts +++ b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts @@ -112,8 +112,10 @@ export async function POST( const { token } = await params; // 1. Parse body - const { signatures } = (await req.json()) as { + const { signatures, clientTextValues = [], clientCheckboxValues = [] } = (await req.json()) as { signatures: Array<{ fieldId: string; dataURL: string }>; + clientTextValues?: Array<{ fieldId: string; value: string }>; + clientCheckboxValues?: Array<{ fieldId: string; checked: boolean }>; }; // 2. Verify JWT @@ -229,7 +231,57 @@ export async function POST( await writeFile(dateStampedPath, stampedBytes); } - // 8b. Build signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4) + // 8b. Embed client-text and client-checkbox values into the PDF + let clientFieldStampedPath = dateStampedPath; + const clientTextFields = (doc.signatureFields ?? []).filter((f) => { + if (getFieldType(f) !== 'client-text') return false; + if (signerEmail !== null) return f.signerEmail === signerEmail; + return true; + }); + const clientCheckboxFields = (doc.signatureFields ?? []).filter((f) => { + if (getFieldType(f) !== 'client-checkbox') return false; + if (signerEmail !== null) return f.signerEmail === signerEmail; + return true; + }); + + if (clientTextFields.length > 0 || clientCheckboxFields.length > 0) { + const pdfBytes = await readFile(clientFieldStampedPath); + const pdfDoc = await PDFDocument.load(pdfBytes); + const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); + const pages = pdfDoc.getPages(); + + for (const field of clientTextFields) { + const val = clientTextValues.find(v => v.fieldId === field.id)?.value ?? ''; + if (!val) continue; + const page = pages[field.page - 1]; + if (!page) continue; + page.drawText(val, { + x: field.x + 4, + y: field.y + field.height / 2 - 4, + size: 10, font: helvetica, color: rgb(0.05, 0.05, 0.05), + maxWidth: field.width - 8, + }); + } + + for (const field of clientCheckboxFields) { + const checked = clientCheckboxValues.find(v => v.fieldId === field.id)?.checked ?? false; + if (!checked) continue; + const page = pages[field.page - 1]; + if (!page) continue; + // Draw a bold checkmark centred in the field box + page.drawText('✓', { + x: field.x + field.width / 2 - 5, + y: field.y + field.height / 2 - 5, + size: 14, font: helvetica, color: rgb(0.02, 0.52, 0.78), + }); + } + + const stampedBytes = await pdfDoc.save(); + clientFieldStampedPath = `${workingAbsPath}.clientfields_${payload.jti}.tmp`; + await writeFile(clientFieldStampedPath, stampedBytes); + } + + // 8c. Build signaturesWithCoords — scoped to this signer's signable fields (Pitfall 4) // text/checkbox/date are embedded at prepare time; the client was never shown these as interactive fields // CRITICAL: x/y/width/height come ONLY from the DB — never from the request body const signableFields = (doc.signatureFields ?? []).filter((f) => { @@ -258,16 +310,19 @@ export async function POST( // 9. Embed signatures into PDF — input: date-stamped working PDF, output: this signer's partial let pdfHash: string; try { - pdfHash = await embedSignatureInPdf(dateStampedPath, partialAbsPath, signaturesWithCoords); + pdfHash = await embedSignatureInPdf(clientFieldStampedPath, partialAbsPath, signaturesWithCoords); } catch (err) { console.error('[sign/POST] embedSignatureInPdf failed:', err); return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 }); } - // 9.5. Clean up temporary date-stamped file if it was created + // 9.5. Clean up temporary files if (dateStampedPath !== workingAbsPath) { unlink(dateStampedPath).catch(() => {}); } + if (clientFieldStampedPath !== dateStampedPath && clientFieldStampedPath !== workingAbsPath) { + unlink(clientFieldStampedPath).catch(() => {}); + } // 10. Log pdf_hash_computed audit event await logAuditEvent({ diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx index 06349b2..3cf6160 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx @@ -70,9 +70,11 @@ async function persistFields(docId: string, fields: SignatureFieldData[]) { const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [ { id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue { id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple - { id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green + { id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green (agent fills) { id: 'date', label: 'Date', color: '#d97706' }, // amber - { id: 'text', label: 'Text', color: '#64748b' }, // slate + { id: 'text', label: 'Text', color: '#64748b' }, // slate (agent fills) + { id: 'client-text', label: 'Client Text', color: '#0891b2' }, // cyan (signer fills) + { id: 'client-checkbox', label: 'Client Checkbox', color: '#0284c7' }, // sky (signer checks) { id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' }, // red { id: 'agent-initials', label: 'Agent Initials', color: '#ea580c' }, // orange ]; @@ -282,13 +284,13 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = const rawY = ghostRect.top - refRect.top; // Determine the field type from the dnd-kit active.id (token id IS the SignatureFieldType) - const validTypes = new Set(['client-signature', 'initials', 'text', 'checkbox', 'date', 'agent-signature', 'agent-initials']); + const validTypes = new Set(['client-signature', 'initials', 'text', 'checkbox', 'date', 'agent-signature', 'agent-initials', 'client-text', 'client-checkbox']); const droppedType: SignatureFieldType = validTypes.has(active.id as string) ? (active.id as SignatureFieldType) : 'client-signature'; // Checkbox fields are square (24x24pt). All other types: 144x36pt. - const isCheckbox = droppedType === 'checkbox'; + const isCheckbox = droppedType === 'checkbox' || droppedType === 'client-checkbox'; const fieldW = isCheckbox ? 24 : 144; const fieldH = isCheckbox ? 24 : 36; @@ -686,11 +688,10 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = }} onClick={(e) => { if (readOnly) return; - if (getFieldType(field) === 'text') { - e.stopPropagation(); // prevent DroppableZone's deselect handler from firing + if (getFieldType(field) === 'text' || getFieldType(field) === 'client-text') { + e.stopPropagation(); onFieldSelect?.(field.id); } else { - // Non-text field click: deselect any selected text field onFieldSelect?.(null); } }} @@ -703,17 +704,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = value={currentValue} onChange={(e) => onFieldValueChange?.(field.id, e.target.value)} onPointerDown={(e) => e.stopPropagation()} - style={{ - flex: 1, - background: 'transparent', - border: 'none', - outline: 'none', - fontSize: '10px', - color: fieldColor, - width: '100%', - cursor: 'text', - padding: 0, - }} + style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', fontSize: '10px', color: fieldColor, width: '100%', cursor: 'text', padding: 0 }} placeholder="Type value..." /> ) : ( @@ -721,8 +712,25 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = {currentValue || fieldLabel} ) + ) : fieldType === 'client-text' ? ( + // Agent types a hint stored in textFillData — shown as placeholder on signing page + isSelected ? ( + onFieldValueChange?.(field.id, e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', fontSize: '10px', color: fieldColor, width: '100%', cursor: 'text', padding: 0 }} + placeholder="Hint for signer (e.g. Full legal name)..." + /> + ) : ( + + {currentValue || 'Client text — click to add hint'} + + ) ) : ( - fieldType !== 'checkbox' && ( + fieldType !== 'checkbox' && fieldType !== 'client-checkbox' && ( {fieldLabel} ) )} @@ -760,7 +768,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = × )} - {!readOnly && fieldType !== 'checkbox' && ( + {!readOnly && fieldType !== 'checkbox' && fieldType !== 'client-checkbox' && ( <> {resizeHandle('nw')} {resizeHandle('ne')} diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx index 99018bf..f934bb4 100644 --- a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx @@ -44,10 +44,13 @@ export function SigningPageClient({ }: SigningPageClientProps) { const router = useRouter(); const [numPages, setNumPages] = useState(0); - // Map from page number (1-indexed) to rendered dimensions const [pageDimensions, setPageDimensions] = useState>({}); - // Map of fieldId -> dataURL for signed fields + // Signature / initials captures const [signedFields, setSignedFields] = useState>(new Map()); + // Client-text values: fieldId -> string + const [textValues, setTextValues] = useState>(new Map()); + // Client-checkbox values: fieldId -> boolean + const [checkboxValues, setCheckboxValues] = useState>(new Map()); const [submitting, setSubmitting] = useState(false); // Modal state @@ -136,16 +139,28 @@ export function SigningPageClient({ }, [signatureFields, signedFields]); const handleSubmit = useCallback(async () => { - const requiredFields = signatureFields.filter( + const sigFields = signatureFields.filter( (f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials' ); - if (signedFields.size < requiredFields.length || submitting) return; + const clientTextFields = signatureFields.filter((f) => getFieldType(f) === 'client-text'); + const clientCheckboxFields = signatureFields.filter((f) => getFieldType(f) === 'client-checkbox'); + + // All required: signatures + text fields must have a value + checkboxes must be checked + if (signedFields.size < sigFields.length) return; + if (clientTextFields.some((f) => !textValues.get(f.id)?.trim())) return; + if (clientCheckboxFields.some((f) => !checkboxValues.get(f.id))) return; + if (submitting) return; + setSubmitting(true); try { const res = await fetch(`/api/sign/${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ signatures: signaturesRef.current }), + body: JSON.stringify({ + signatures: signaturesRef.current, + clientTextValues: Array.from(textValues.entries()).map(([fieldId, value]) => ({ fieldId, value })), + clientCheckboxValues: Array.from(checkboxValues.entries()).map(([fieldId, checked]) => ({ fieldId, checked })), + }), }); if (res.ok) { router.push(`/sign/${token}/confirmed`); @@ -293,57 +308,74 @@ export function SigningPageClient({ renderTextLayer={false} /> - {/* Signature field overlays — rendered once page dimensions are known */} + {/* Field overlays */} {dims && fieldsOnPage.map((field) => { - // Only render interactive overlays for client-signature and initials fields - // text/checkbox/date are embedded at prepare time — no client interaction needed const ft = getFieldType(field); - const isInteractive = ft === 'client-signature' || ft === 'initials'; - if (!isInteractive) return null; - - const isSigned = signedFields.has(field.id); const baseStyle = getFieldOverlayStyle(field, dims); - const animationStyle: React.CSSProperties = ft === 'initials' - ? { animation: 'pulse-border-purple 2s infinite' } - : { animation: 'pulse-border 2s infinite' }; - const fieldOverlayStyle = { ...baseStyle, ...animationStyle }; - const sigDataURL = signedFields.get(field.id); - return ( -
handleFieldClick(field.id)} - role="button" - tabIndex={0} - aria-label={ft === 'initials' - ? `Initials field${isSigned ? ' (initialed)' : ' — click to initial'}` - : `Signature field${isSigned ? ' (signed)' : ' — click to sign'}`} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleFieldClick(field.id); - } - }} - > - {/* Show signature/initials preview when signed */} - {isSigned && sigDataURL && ( - /* eslint-disable-next-line @next/next/no-img-element */ - {ft handleFieldClick(field.id)} + role="button" tabIndex={0} + aria-label={ft === 'initials' ? `Initials field${isSigned ? ' (initialed)' : ' — click to initial'}` : `Signature field${isSigned ? ' (signed)' : ' — click to sign'}`} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleFieldClick(field.id); } }} + > + {isSigned && sigDataURL && ( + /* eslint-disable-next-line @next/next/no-img-element */ + {ft + )} +
+ ); + } + + // ── Client-text: signer types a value ── + if (ft === 'client-text') { + const val = textValues.get(field.id) ?? ''; + const hint = field.hint || ''; + return ( +
+ setTextValues(prev => new Map(prev).set(field.id, e.target.value))} + placeholder={hint || 'Type here…'} style={{ - width: '100%', - height: '100%', - objectFit: 'contain', - display: 'block', + width: '100%', height: '100%', border: 'none', outline: 'none', + background: val ? 'rgba(8,145,178,0.05)' : 'rgba(8,145,178,0.1)', + fontSize: '11px', padding: '2px 4px', color: '#0c4a6e', + fontFamily: 'Georgia, serif', }} /> - )} -
- ); + + ); + } + + // ── Client-checkbox: signer checks it ── + if (ft === 'client-checkbox') { + const checked = checkboxValues.get(field.id) ?? false; + return ( +
setCheckboxValues(prev => new Map(prev).set(field.id, !prev.get(field.id)))} + role="checkbox" aria-checked={checked} tabIndex={0} + onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setCheckboxValues(prev => new Map(prev).set(field.id, !prev.get(field.id))); } }} + > + {checked && } +
+ ); + } + + return null; // text/checkbox/date/agent fields handled at prepare time })} ); @@ -358,6 +390,10 @@ export function SigningPageClient({ (f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials' ).length} signed={signedFields.size} + extraRequired={ + signatureFields.filter((f) => getFieldType(f) === 'client-text').filter((f) => !textValues.get(f.id)?.trim()).length + + signatureFields.filter((f) => getFieldType(f) === 'client-checkbox').filter((f) => !checkboxValues.get(f.id)).length + } onJumpToNext={handleJumpToNext} onSubmit={handleSubmit} submitting={submitting} diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx index ec0d5c5..4016c7b 100644 --- a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningProgressBar.tsx @@ -3,6 +3,7 @@ interface SigningProgressBarProps { total: number; signed: number; + extraRequired?: number; // unfilled client-text + unchecked client-checkbox fields onJumpToNext: () => void; onSubmit: () => void; submitting: boolean; @@ -11,64 +12,43 @@ interface SigningProgressBarProps { export function SigningProgressBar({ total, signed, + extraRequired = 0, onJumpToNext, onSubmit, submitting, }: SigningProgressBarProps) { - const allSigned = signed >= total; + const sigsDone = signed >= total; + const allDone = sigsDone && extraRequired === 0; + + let statusText = `${signed} of ${total} signature${total !== 1 ? 's' : ''} complete`; + if (sigsDone && extraRequired > 0) { + statusText = `${extraRequired} field${extraRequired !== 1 ? 's' : ''} still need${extraRequired === 1 ? 's' : ''} to be filled`; + } + return ( -
- - {signed} of {total} signature{total !== 1 ? 's' : ''} complete - +
+ {statusText}
- {!allSigned && ( - )} -
diff --git a/teressa-copeland-homes/src/lib/ai/field-placement.ts b/teressa-copeland-homes/src/lib/ai/field-placement.ts index c4ea417..545766e 100644 --- a/teressa-copeland-homes/src/lib/ai/field-placement.ts +++ b/teressa-copeland-homes/src/lib/ai/field-placement.ts @@ -38,6 +38,8 @@ const FIELD_HEIGHTS: Record = { 'date': 12, 'text': 12, 'checkbox': 14, + 'client-text': 12, + 'client-checkbox': 14, }; // Width clamping — use the exact measured blank width but stay within these bounds @@ -49,6 +51,8 @@ const SIZE_LIMITS: Record = 'date': { minW: 50, maxW: 130 }, 'text': { minW: 30, maxW: 280 }, 'checkbox': { minW: 14, maxW: 20 }, + 'client-text': { minW: 30, maxW: 280 }, + 'client-checkbox': { minW: 14, maxW: 20 }, }; /** diff --git a/teressa-copeland-homes/src/lib/db/schema.ts b/teressa-copeland-homes/src/lib/db/schema.ts index a64e54f..71e634d 100644 --- a/teressa-copeland-homes/src/lib/db/schema.ts +++ b/teressa-copeland-homes/src/lib/db/schema.ts @@ -4,11 +4,13 @@ import { relations } from "drizzle-orm"; export type SignatureFieldType = | 'client-signature' | 'initials' - | 'text' - | 'checkbox' - | 'date' + | 'text' // agent fills at prepare time + | 'checkbox' // agent fills at prepare time + | 'date' // auto-stamped with signing date | 'agent-signature' - | 'agent-initials'; + | 'agent-initials' + | 'client-text' // signer types a value on the signing page + | 'client-checkbox'; // signer checks/unchecks on the signing page export interface SignatureFieldData { id: string; @@ -19,6 +21,7 @@ export interface SignatureFieldData { height: number; // PDF points (default: 36 — 0.5 inches) type?: SignatureFieldType; // Optional — v1.0 documents have no type; fallback = 'client-signature' signerEmail?: string; // Optional — absent = legacy single-signer or agent-owned field + hint?: string; // Optional label shown to signer for client-text / client-checkbox fields } /**