feat: client-text and client-checkbox field types — signer fills text/checks boxes on signing page
This commit is contained in:
@@ -112,8 +112,10 @@ export async function POST(
|
|||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
|
|
||||||
// 1. Parse body
|
// 1. Parse body
|
||||||
const { signatures } = (await req.json()) as {
|
const { signatures, clientTextValues = [], clientCheckboxValues = [] } = (await req.json()) as {
|
||||||
signatures: Array<{ fieldId: string; dataURL: string }>;
|
signatures: Array<{ fieldId: string; dataURL: string }>;
|
||||||
|
clientTextValues?: Array<{ fieldId: string; value: string }>;
|
||||||
|
clientCheckboxValues?: Array<{ fieldId: string; checked: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Verify JWT
|
// 2. Verify JWT
|
||||||
@@ -229,7 +231,57 @@ export async function POST(
|
|||||||
await writeFile(dateStampedPath, stampedBytes);
|
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
|
// 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
|
// CRITICAL: x/y/width/height come ONLY from the DB — never from the request body
|
||||||
const signableFields = (doc.signatureFields ?? []).filter((f) => {
|
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
|
// 9. Embed signatures into PDF — input: date-stamped working PDF, output: this signer's partial
|
||||||
let pdfHash: string;
|
let pdfHash: string;
|
||||||
try {
|
try {
|
||||||
pdfHash = await embedSignatureInPdf(dateStampedPath, partialAbsPath, signaturesWithCoords);
|
pdfHash = await embedSignatureInPdf(clientFieldStampedPath, partialAbsPath, signaturesWithCoords);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[sign/POST] embedSignatureInPdf failed:', err);
|
console.error('[sign/POST] embedSignatureInPdf failed:', err);
|
||||||
return NextResponse.json({ error: 'pdf-embed-failed' }, { status: 500 });
|
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) {
|
if (dateStampedPath !== workingAbsPath) {
|
||||||
unlink(dateStampedPath).catch(() => {});
|
unlink(dateStampedPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (clientFieldStampedPath !== dateStampedPath && clientFieldStampedPath !== workingAbsPath) {
|
||||||
|
unlink(clientFieldStampedPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// 10. Log pdf_hash_computed audit event
|
// 10. Log pdf_hash_computed audit event
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ async function persistFields(docId: string, fields: SignatureFieldData[]) {
|
|||||||
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
|
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
|
||||||
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
|
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
|
||||||
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
|
{ 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: '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-signature', label: 'Agent Signature', color: '#dc2626' }, // red
|
||||||
{ id: 'agent-initials', label: 'Agent Initials', color: '#ea580c' }, // orange
|
{ 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;
|
const rawY = ghostRect.top - refRect.top;
|
||||||
|
|
||||||
// Determine the field type from the dnd-kit active.id (token id IS the SignatureFieldType)
|
// 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)
|
const droppedType: SignatureFieldType = validTypes.has(active.id as string)
|
||||||
? (active.id as SignatureFieldType)
|
? (active.id as SignatureFieldType)
|
||||||
: 'client-signature';
|
: 'client-signature';
|
||||||
|
|
||||||
// Checkbox fields are square (24x24pt). All other types: 144x36pt.
|
// 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 fieldW = isCheckbox ? 24 : 144;
|
||||||
const fieldH = isCheckbox ? 24 : 36;
|
const fieldH = isCheckbox ? 24 : 36;
|
||||||
|
|
||||||
@@ -686,11 +688,10 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
if (getFieldType(field) === 'text') {
|
if (getFieldType(field) === 'text' || getFieldType(field) === 'client-text') {
|
||||||
e.stopPropagation(); // prevent DroppableZone's deselect handler from firing
|
e.stopPropagation();
|
||||||
onFieldSelect?.(field.id);
|
onFieldSelect?.(field.id);
|
||||||
} else {
|
} else {
|
||||||
// Non-text field click: deselect any selected text field
|
|
||||||
onFieldSelect?.(null);
|
onFieldSelect?.(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -703,17 +704,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => onFieldValueChange?.(field.id, e.target.value)}
|
onChange={(e) => onFieldValueChange?.(field.id, e.target.value)}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', fontSize: '10px', color: fieldColor, width: '100%', cursor: 'text', padding: 0 }}
|
||||||
flex: 1,
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
fontSize: '10px',
|
|
||||||
color: fieldColor,
|
|
||||||
width: '100%',
|
|
||||||
cursor: 'text',
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
placeholder="Type value..."
|
placeholder="Type value..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -721,8 +712,25 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
{currentValue || fieldLabel}
|
{currentValue || fieldLabel}
|
||||||
</span>
|
</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>
|
<span style={{ pointerEvents: 'none' }}>{fieldLabel}</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -760,7 +768,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly =
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!readOnly && fieldType !== 'checkbox' && (
|
{!readOnly && fieldType !== 'checkbox' && fieldType !== 'client-checkbox' && (
|
||||||
<>
|
<>
|
||||||
{resizeHandle('nw')}
|
{resizeHandle('nw')}
|
||||||
{resizeHandle('ne')}
|
{resizeHandle('ne')}
|
||||||
|
|||||||
@@ -44,10 +44,13 @@ export function SigningPageClient({
|
|||||||
}: SigningPageClientProps) {
|
}: SigningPageClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
// Map from page number (1-indexed) to rendered dimensions
|
|
||||||
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
|
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());
|
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);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
@@ -136,16 +139,28 @@ export function SigningPageClient({
|
|||||||
}, [signatureFields, signedFields]);
|
}, [signatureFields, signedFields]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const requiredFields = signatureFields.filter(
|
const sigFields = signatureFields.filter(
|
||||||
(f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials'
|
(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);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sign/${token}`, {
|
const res = await fetch(`/api/sign/${token}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (res.ok) {
|
||||||
router.push(`/sign/${token}/confirmed`);
|
router.push(`/sign/${token}/confirmed`);
|
||||||
@@ -293,57 +308,74 @@ export function SigningPageClient({
|
|||||||
renderTextLayer={false}
|
renderTextLayer={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Signature field overlays — rendered once page dimensions are known */}
|
{/* Field overlays */}
|
||||||
{dims &&
|
{dims &&
|
||||||
fieldsOnPage.map((field) => {
|
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 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 baseStyle = getFieldOverlayStyle(field, dims);
|
||||||
const animationStyle: React.CSSProperties = ft === 'initials'
|
|
||||||
? { animation: 'pulse-border-purple 2s infinite' }
|
// ── Signature / initials ──
|
||||||
: { animation: 'pulse-border 2s infinite' };
|
if (ft === 'client-signature' || ft === 'initials') {
|
||||||
const fieldOverlayStyle = { ...baseStyle, ...animationStyle };
|
const isSigned = signedFields.has(field.id);
|
||||||
const sigDataURL = signedFields.get(field.id);
|
const animStyle: React.CSSProperties = ft === 'initials'
|
||||||
return (
|
? { animation: 'pulse-border-purple 2s infinite' }
|
||||||
<div
|
: { animation: 'pulse-border 2s infinite' };
|
||||||
key={field.id}
|
const sigDataURL = signedFields.get(field.id);
|
||||||
id={`field-${field.id}`}
|
return (
|
||||||
className={isSigned ? 'signing-field-signed' : ''}
|
<div key={field.id} id={`field-${field.id}`}
|
||||||
style={fieldOverlayStyle}
|
className={isSigned ? 'signing-field-signed' : ''}
|
||||||
onClick={() => handleFieldClick(field.id)}
|
style={{ ...baseStyle, ...animStyle }}
|
||||||
role="button"
|
onClick={() => handleFieldClick(field.id)}
|
||||||
tabIndex={0}
|
role="button" tabIndex={0}
|
||||||
aria-label={ft === 'initials'
|
aria-label={ft === 'initials' ? `Initials field${isSigned ? ' (initialed)' : ' — click to initial'}` : `Signature field${isSigned ? ' (signed)' : ' — click to sign'}`}
|
||||||
? `Initials field${isSigned ? ' (initialed)' : ' — click to initial'}`
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleFieldClick(field.id); } }}
|
||||||
: `Signature field${isSigned ? ' (signed)' : ' — click to sign'}`}
|
>
|
||||||
onKeyDown={(e) => {
|
{isSigned && sigDataURL && (
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
e.preventDefault();
|
<img src={sigDataURL} alt={ft === 'initials' ? 'Initials' : 'Signature'} style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
|
||||||
handleFieldClick(field.id);
|
)}
|
||||||
}
|
</div>
|
||||||
}}
|
);
|
||||||
>
|
}
|
||||||
{/* Show signature/initials preview when signed */}
|
|
||||||
{isSigned && sigDataURL && (
|
// ── Client-text: signer types a value ──
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
if (ft === 'client-text') {
|
||||||
<img
|
const val = textValues.get(field.id) ?? '';
|
||||||
src={sigDataURL}
|
const hint = field.hint || '';
|
||||||
alt={ft === 'initials' ? 'Initials' : 'Signature'}
|
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={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', height: '100%', border: 'none', outline: 'none',
|
||||||
height: '100%',
|
background: val ? 'rgba(8,145,178,0.05)' : 'rgba(8,145,178,0.1)',
|
||||||
objectFit: 'contain',
|
fontSize: '11px', padding: '2px 4px', color: '#0c4a6e',
|
||||||
display: 'block',
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -358,6 +390,10 @@ export function SigningPageClient({
|
|||||||
(f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials'
|
(f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials'
|
||||||
).length}
|
).length}
|
||||||
signed={signedFields.size}
|
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}
|
onJumpToNext={handleJumpToNext}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
interface SigningProgressBarProps {
|
interface SigningProgressBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
signed: number;
|
signed: number;
|
||||||
|
extraRequired?: number; // unfilled client-text + unchecked client-checkbox fields
|
||||||
onJumpToNext: () => void;
|
onJumpToNext: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
@@ -11,64 +12,43 @@ interface SigningProgressBarProps {
|
|||||||
export function SigningProgressBar({
|
export function SigningProgressBar({
|
||||||
total,
|
total,
|
||||||
signed,
|
signed,
|
||||||
|
extraRequired = 0,
|
||||||
onJumpToNext,
|
onJumpToNext,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitting,
|
submitting,
|
||||||
}: SigningProgressBarProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50,
|
||||||
position: 'fixed',
|
backgroundColor: '#1B2B4B', color: '#fff',
|
||||||
bottom: 0,
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
left: 0,
|
padding: '12px 24px', boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
|
||||||
right: 0,
|
}}>
|
||||||
zIndex: 50,
|
<span style={{ fontSize: '15px' }}>{statusText}</span>
|
||||||
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={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
{!allSigned && (
|
{!sigsDone && (
|
||||||
<button
|
<button onClick={onJumpToNext} style={{
|
||||||
onClick={onJumpToNext}
|
backgroundColor: 'transparent', border: '1px solid #C9A84C', color: '#C9A84C',
|
||||||
style={{
|
padding: '8px 18px', borderRadius: '4px', cursor: 'pointer', fontSize: '14px',
|
||||||
backgroundColor: 'transparent',
|
}}>
|
||||||
border: '1px solid #C9A84C',
|
|
||||||
color: '#C9A84C',
|
|
||||||
padding: '8px 18px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Jump to Next
|
Jump to Next
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={onSubmit} disabled={!allDone || submitting} style={{
|
||||||
onClick={onSubmit}
|
backgroundColor: allDone ? '#C9A84C' : '#555', color: '#fff', border: 'none',
|
||||||
disabled={!allSigned || submitting}
|
padding: '8px 22px', borderRadius: '4px',
|
||||||
style={{
|
cursor: allDone ? 'pointer' : 'not-allowed', fontSize: '14px', fontWeight: 'bold',
|
||||||
backgroundColor: allSigned ? '#C9A84C' : '#555',
|
opacity: submitting ? 0.7 : 1,
|
||||||
color: '#fff',
|
}}>
|
||||||
border: 'none',
|
{submitting ? 'Submitting...' : 'Submit'}
|
||||||
padding: '8px 22px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: allSigned ? 'pointer' : 'not-allowed',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
opacity: submitting ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitting ? 'Submitting...' : 'Submit Signature'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const FIELD_HEIGHTS: Record<SignatureFieldType, number> = {
|
|||||||
'date': 12,
|
'date': 12,
|
||||||
'text': 12,
|
'text': 12,
|
||||||
'checkbox': 14,
|
'checkbox': 14,
|
||||||
|
'client-text': 12,
|
||||||
|
'client-checkbox': 14,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Width clamping — use the exact measured blank width but stay within these bounds
|
// 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 },
|
'date': { minW: 50, maxW: 130 },
|
||||||
'text': { minW: 30, maxW: 280 },
|
'text': { minW: 30, maxW: 280 },
|
||||||
'checkbox': { minW: 14, maxW: 20 },
|
'checkbox': { minW: 14, maxW: 20 },
|
||||||
|
'client-text': { minW: 30, maxW: 280 },
|
||||||
|
'client-checkbox': { minW: 14, maxW: 20 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { relations } from "drizzle-orm";
|
|||||||
export type SignatureFieldType =
|
export type SignatureFieldType =
|
||||||
| 'client-signature'
|
| 'client-signature'
|
||||||
| 'initials'
|
| 'initials'
|
||||||
| 'text'
|
| 'text' // agent fills at prepare time
|
||||||
| 'checkbox'
|
| 'checkbox' // agent fills at prepare time
|
||||||
| 'date'
|
| 'date' // auto-stamped with signing date
|
||||||
| 'agent-signature'
|
| '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 {
|
export interface SignatureFieldData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +21,7 @@ export interface SignatureFieldData {
|
|||||||
height: number; // PDF points (default: 36 — 0.5 inches)
|
height: number; // PDF points (default: 36 — 0.5 inches)
|
||||||
type?: SignatureFieldType; // Optional — v1.0 documents have no type; fallback = 'client-signature'
|
type?: SignatureFieldType; // Optional — v1.0 documents have no type; fallback = 'client-signature'
|
||||||
signerEmail?: string; // Optional — absent = legacy single-signer or agent-owned field
|
signerEmail?: string; // Optional — absent = legacy single-signer or agent-owned field
|
||||||
|
hint?: string; // Optional label shown to signer for client-text / client-checkbox fields
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user