feat(10-03): extend signing page for initials, overlay suppression, and updated progress counting

- Add optional title prop to SignatureModal (defaults to "Add Signature"); button label derives from title
- Add activeFieldType state and setActiveFieldType in SigningPageClient
- handleFieldClick opens modal for both client-signature and initials fields
- handleSubmit and SigningProgressBar total now count both client-signature and initials
- handleJumpToNext jumps to next unsigned required field (client-signature or initials only)
- Non-interactive fields (text/checkbox/date) suppressed from field overlay rendering via early return
- Initials overlays use purple pulse animation (pulse-border-purple); signature overlays use blue
- SignatureModal receives title prop: "Add Initials" for initials, "Add Signature" for client-signature
This commit is contained in:
Chandler Copeland
2026-03-21 12:54:57 -06:00
parent 9f190b3fc8
commit 50f082d20f
2 changed files with 42 additions and 16 deletions

View File

@@ -10,9 +10,12 @@ interface SignatureModalProps {
fieldId: string;
onConfirm: (fieldId: string, dataURL: string, save: boolean) => void;
onClose: () => void;
title?: string; // defaults to "Add Signature"
}
export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: SignatureModalProps) {
export function SignatureModal({ isOpen, fieldId, onConfirm, onClose, title = 'Add Signature' }: SignatureModalProps) {
// Derive button label from title — "Add Initials" → "Apply Initials", otherwise "Apply Signature"
const buttonLabel = title.startsWith('Add ') ? title.replace('Add ', 'Apply ') : 'Apply Signature';
const [tab, setTab] = useState<'draw' | 'type' | 'saved'>('draw');
const [typedName, setTypedName] = useState('');
const [saveForLater, setSaveForLater] = useState(false);
@@ -112,7 +115,7 @@ export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: Signatur
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 style={{ color: '#1B2B4B', margin: 0, fontSize: '18px' }}>Add Signature</h2>
<h2 style={{ color: '#1B2B4B', margin: 0, fontSize: '18px' }}>{title}</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#666' }}
@@ -272,7 +275,7 @@ export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: Signatur
fontSize: '14px',
}}
>
Apply Signature
{buttonLabel}
</button>
</div>
</div>

View File

@@ -53,6 +53,7 @@ export function SigningPageClient({
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
const [activeFieldType, setActiveFieldType] = useState<'client-signature' | 'initials'>('client-signature');
// Ref for all captured signatures (fieldId -> dataURL)
const signaturesRef = useRef<SignatureCapture[]>([]);
@@ -87,11 +88,12 @@ export function SigningPageClient({
(fieldId: string) => {
const field = signatureFields.find((f) => f.id === fieldId);
if (!field) return;
// Defense-in-depth: primary protection is the server filter in GET /api/sign/[token]
// Only client-signature fields open the modal; Phase 10 will expand this for initials
if (getFieldType(field) !== 'client-signature') return;
const ft = getFieldType(field);
// Only client-signature and initials require client action
if (ft !== 'client-signature' && ft !== 'initials') return;
if (signedFields.has(fieldId)) return;
setActiveFieldId(fieldId);
setActiveFieldType(ft as 'client-signature' | 'initials');
setModalOpen(true);
},
[signatureFields, signedFields]
@@ -122,7 +124,9 @@ export function SigningPageClient({
);
const handleJumpToNext = useCallback(() => {
const nextUnsigned = signatureFields.find((f) => !signedFields.has(f.id));
const nextUnsigned = signatureFields.find(
(f) => (getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials') && !signedFields.has(f.id)
);
if (nextUnsigned) {
document.getElementById(`field-${nextUnsigned.id}`)?.scrollIntoView({
behavior: 'smooth',
@@ -132,10 +136,10 @@ export function SigningPageClient({
}, [signatureFields, signedFields]);
const handleSubmit = useCallback(async () => {
const clientSigFields = signatureFields.filter(
(f) => getFieldType(f) === 'client-signature'
const requiredFields = signatureFields.filter(
(f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials'
);
if (signedFields.size < clientSigFields.length || submitting) return;
if (signedFields.size < requiredFields.length || submitting) return;
setSubmitting(true);
try {
const res = await fetch(`/api/sign/${token}`, {
@@ -205,6 +209,10 @@ export function SigningPageClient({
box-shadow: 0 0 0 3px #3b82f6, 0 0 16px 4px rgba(59,130,246,0.6);
}
}
@keyframes pulse-border-purple {
0%, 100% { box-shadow: 0 0 0 2px #7c3aed, 0 0 8px 2px rgba(124,58,237,0.4); }
50% { box-shadow: 0 0 0 3px #7c3aed, 0 0 16px 4px rgba(124,58,237,0.6); }
}
.signing-field-signed {
box-shadow: 0 0 0 2px #22c55e !important;
animation: none !important;
@@ -288,19 +296,31 @@ export function SigningPageClient({
{/* Signature field overlays — rendered once page dimensions are known */}
{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 overlayStyle = getFieldOverlayStyle(field, dims);
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={overlayStyle}
style={fieldOverlayStyle}
onClick={() => handleFieldClick(field.id)}
role="button"
tabIndex={0}
aria-label={`Signature field${isSigned ? ' (signed)' : ' — click to sign'}`}
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();
@@ -308,12 +328,12 @@ export function SigningPageClient({
}
}}
>
{/* Show signature preview when signed */}
{/* Show signature/initials preview when signed */}
{isSigned && sigDataURL && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={sigDataURL}
alt="Signature"
alt={ft === 'initials' ? 'Initials' : 'Signature'}
style={{
width: '100%',
height: '100%',
@@ -334,7 +354,9 @@ export function SigningPageClient({
{/* Sticky progress bar */}
<SigningProgressBar
total={signatureFields.filter((f) => getFieldType(f) === 'client-signature').length}
total={signatureFields.filter(
(f) => getFieldType(f) === 'client-signature' || getFieldType(f) === 'initials'
).length}
signed={signedFields.size}
onJumpToNext={handleJumpToNext}
onSubmit={handleSubmit}
@@ -345,6 +367,7 @@ export function SigningPageClient({
<SignatureModal
isOpen={modalOpen}
fieldId={activeFieldId ?? ''}
title={activeFieldType === 'initials' ? 'Add Initials' : 'Add Signature'}
onConfirm={handleModalConfirm}
onClose={() => {
setModalOpen(false);