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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user