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; fieldId: string;
onConfirm: (fieldId: string, dataURL: string, save: boolean) => void; onConfirm: (fieldId: string, dataURL: string, save: boolean) => void;
onClose: () => 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 [tab, setTab] = useState<'draw' | 'type' | 'saved'>('draw');
const [typedName, setTypedName] = useState(''); const [typedName, setTypedName] = useState('');
const [saveForLater, setSaveForLater] = useState(false); const [saveForLater, setSaveForLater] = useState(false);
@@ -112,7 +115,7 @@ export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: Signatur
> >
{/* Header */} {/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}> <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 <button
onClick={onClose} onClick={onClose}
style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#666' }} 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', fontSize: '14px',
}} }}
> >
Apply Signature {buttonLabel}
</button> </button>
</div> </div>
</div> </div>

View File

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