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