feat: client-text and client-checkbox field types — signer fills text/checks boxes on signing page

This commit is contained in:
Chandler Copeland
2026-04-06 11:35:30 -06:00
parent 116fa2bdfb
commit 6013dfe89f
6 changed files with 212 additions and 126 deletions

View File

@@ -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({

View File

@@ -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<string>(['client-signature', 'initials', 'text', 'checkbox', 'date', 'agent-signature', 'agent-initials']);
const validTypes = new Set<string>(['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}
</span>
)
) : fieldType === 'client-text' ? (
// Agent types a hint stored in textFillData — shown as placeholder on signing page
isSelected ? (
<input
data-no-move
autoFocus
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 }}
placeholder="Hint for signer (e.g. Full legal name)..."
/>
) : (
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', pointerEvents: 'none', fontStyle: currentValue ? 'normal' : 'italic', opacity: currentValue ? 1 : 0.6 }}>
{currentValue || 'Client text — click to add hint'}
</span>
)
) : (
fieldType !== 'checkbox' && (
fieldType !== 'checkbox' && fieldType !== 'client-checkbox' && (
<span style={{ pointerEvents: 'none' }}>{fieldLabel}</span>
)
)}
@@ -760,7 +768,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
×
</button>
)}
{!readOnly && fieldType !== 'checkbox' && (
{!readOnly && fieldType !== 'checkbox' && fieldType !== 'client-checkbox' && (
<>
{resizeHandle('nw')}
{resizeHandle('ne')}

View File

@@ -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<Record<number, PageDimensions>>({});
// Map of fieldId -> dataURL for signed fields
// Signature / initials captures
const [signedFields, setSignedFields] = useState<Map<string, string>>(new Map());
// Client-text values: fieldId -> string
const [textValues, setTextValues] = useState<Map<string, string>>(new Map());
// Client-checkbox values: fieldId -> boolean
const [checkboxValues, setCheckboxValues] = useState<Map<string, boolean>>(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 (
<div
key={field.id}
id={`field-${field.id}`}
className={isSigned ? 'signing-field-signed' : ''}
style={fieldOverlayStyle}
onClick={() => 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 */
<img
src={sigDataURL}
alt={ft === 'initials' ? 'Initials' : 'Signature'}
// ── Signature / initials ──
if (ft === 'client-signature' || ft === 'initials') {
const isSigned = signedFields.has(field.id);
const animStyle: React.CSSProperties = ft === 'initials'
? { animation: 'pulse-border-purple 2s infinite' }
: { animation: 'pulse-border 2s infinite' };
const sigDataURL = signedFields.get(field.id);
return (
<div key={field.id} id={`field-${field.id}`}
className={isSigned ? 'signing-field-signed' : ''}
style={{ ...baseStyle, ...animStyle }}
onClick={() => 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 */
<img src={sigDataURL} alt={ft === 'initials' ? 'Initials' : 'Signature'} style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
)}
</div>
);
}
// ── Client-text: signer types a value ──
if (ft === 'client-text') {
const val = textValues.get(field.id) ?? '';
const hint = field.hint || '';
return (
<div key={field.id} style={{ ...baseStyle, cursor: 'text', animation: val ? 'none' : 'pulse-border 2s infinite', border: val ? '1px solid #0891b2' : undefined }}>
<input
type="text"
value={val}
onChange={(e) => 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',
}}
/>
)}
</div>
);
</div>
);
}
// ── Client-checkbox: signer checks it ──
if (ft === 'client-checkbox') {
const checked = checkboxValues.get(field.id) ?? false;
return (
<div key={field.id}
style={{ ...baseStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', animation: checked ? 'none' : 'pulse-border 2s infinite', border: checked ? '1px solid #0284c7' : undefined, background: checked ? 'rgba(2,132,199,0.08)' : 'rgba(2,132,199,0.12)', borderRadius: '3px' }}
onClick={() => 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 && <span style={{ fontSize: '16px', color: '#0284c7', fontWeight: 'bold', lineHeight: 1 }}></span>}
</div>
);
}
return null; // text/checkbox/date/agent fields handled at prepare time
})}
</div>
);
@@ -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}

View File

@@ -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 (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
backgroundColor: '#1B2B4B',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}
>
<span style={{ fontSize: '15px' }}>
{signed} of {total} signature{total !== 1 ? 's' : ''} complete
</span>
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50,
backgroundColor: '#1B2B4B', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
}}>
<span style={{ fontSize: '15px' }}>{statusText}</span>
<div style={{ display: 'flex', gap: '12px' }}>
{!allSigned && (
<button
onClick={onJumpToNext}
style={{
backgroundColor: 'transparent',
border: '1px solid #C9A84C',
color: '#C9A84C',
padding: '8px 18px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
{!sigsDone && (
<button onClick={onJumpToNext} style={{
backgroundColor: 'transparent', border: '1px solid #C9A84C', color: '#C9A84C',
padding: '8px 18px', borderRadius: '4px', cursor: 'pointer', fontSize: '14px',
}}>
Jump to Next
</button>
)}
<button
onClick={onSubmit}
disabled={!allSigned || submitting}
style={{
backgroundColor: allSigned ? '#C9A84C' : '#555',
color: '#fff',
border: 'none',
padding: '8px 22px',
borderRadius: '4px',
cursor: allSigned ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: 'bold',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Submitting...' : 'Submit Signature'}
<button onClick={onSubmit} disabled={!allDone || submitting} style={{
backgroundColor: allDone ? '#C9A84C' : '#555', color: '#fff', border: 'none',
padding: '8px 22px', borderRadius: '4px',
cursor: allDone ? 'pointer' : 'not-allowed', fontSize: '14px', fontWeight: 'bold',
opacity: submitting ? 0.7 : 1,
}}>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>

View File

@@ -38,6 +38,8 @@ const FIELD_HEIGHTS: Record<SignatureFieldType, number> = {
'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<SignatureFieldType, { minW: number; maxW: number }> =
'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 },
};
/**

View File

@@ -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
}
/**