feat(06-04): SignatureModal with Draw/Type/Use Saved tabs + wire SigningPageClient

- Create SignatureModal.tsx with signature_pad Draw tab (devicePixelRatio scaling, touch-none)
- Type tab renders name in Dancing Script cursive via offscreen canvas
- Use Saved tab conditionally shown when localStorage has saved signature
- Save for later checkbox persists drawn/typed sig to localStorage on confirm
- Update SigningPageClient.tsx: import modal, track signedFields as Map<string,string>
- Field overlay shows signature preview image after signing
- handleSubmit POSTs to /api/sign/[token] and redirects to /sign/[token]/confirmed on 200
This commit is contained in:
Chandler Copeland
2026-03-20 11:35:40 -06:00
parent a3026fb44f
commit 05b5207305
2 changed files with 367 additions and 15 deletions

View File

@@ -0,0 +1,281 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import SignaturePad from 'signature_pad';
const SAVED_SIG_KEY = 'teressa_homes_saved_signature';
interface SignatureModalProps {
isOpen: boolean;
fieldId: string;
onConfirm: (fieldId: string, dataURL: string, save: boolean) => void;
onClose: () => void;
}
export function SignatureModal({ isOpen, fieldId, onConfirm, onClose }: SignatureModalProps) {
const [tab, setTab] = useState<'draw' | 'type' | 'saved'>('draw');
const [typedName, setTypedName] = useState('');
const [saveForLater, setSaveForLater] = useState(false);
const [savedSig, setSavedSig] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sigPadRef = useRef<SignaturePad | null>(null);
// Load saved signature from localStorage on open
useEffect(() => {
if (typeof window !== 'undefined') {
setSavedSig(localStorage.getItem(SAVED_SIG_KEY));
}
}, [isOpen]);
// Initialize signature_pad with devicePixelRatio scaling (CRITICAL for mobile sharpness)
useEffect(() => {
if (!isOpen || tab !== 'draw' || !canvasRef.current) return;
const canvas = canvasRef.current;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext('2d')?.scale(ratio, ratio);
sigPadRef.current = new SignaturePad(canvas, {
backgroundColor: 'rgba(0,0,0,0)',
penColor: '#1B2B4B',
});
return () => sigPadRef.current?.off();
}, [isOpen, tab]);
function renderTypedSignature(name: string): string {
// Offscreen canvas render of typed name in Dancing Script cursive font
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 80;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, 400, 80);
ctx.font = "bold 44px 'Dancing Script', cursive";
ctx.fillStyle = '#1B2B4B';
ctx.textBaseline = 'middle';
ctx.fillText(name, 10, 40);
return canvas.toDataURL('image/png');
}
function handleConfirm() {
let dataURL: string | null = null;
if (tab === 'draw') {
if (!sigPadRef.current || sigPadRef.current.isEmpty()) return;
dataURL = sigPadRef.current.toDataURL('image/png');
} else if (tab === 'type') {
if (!typedName.trim()) return;
dataURL = renderTypedSignature(typedName.trim());
} else if (tab === 'saved') {
dataURL = savedSig;
}
if (!dataURL) return;
if (saveForLater) {
localStorage.setItem(SAVED_SIG_KEY, dataURL);
}
onConfirm(fieldId, dataURL, saveForLater);
}
if (!isOpen) return null;
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '8px 20px',
cursor: 'pointer',
border: 'none',
background: 'none',
borderBottom: active ? '2px solid #C9A84C' : '2px solid transparent',
color: active ? '#1B2B4B' : '#666',
fontWeight: active ? 'bold' : 'normal',
fontSize: '15px',
});
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '8px',
padding: '24px',
width: '480px',
maxWidth: '95vw',
maxHeight: '90vh',
overflow: 'auto',
}}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
<h2 style={{ color: '#1B2B4B', margin: 0, fontSize: '18px' }}>Add Signature</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#666' }}
>
&#x2715;
</button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid #e0e0e0', marginBottom: '16px' }}>
<button style={tabStyle(tab === 'draw')} onClick={() => setTab('draw')}>Draw</button>
<button style={tabStyle(tab === 'type')} onClick={() => setTab('type')}>Type</button>
{savedSig && (
<button style={tabStyle(tab === 'saved')} onClick={() => setTab('saved')}>Use Saved</button>
)}
</div>
{/* Draw tab */}
{tab === 'draw' && (
<div>
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '140px',
border: '1px solid #ddd',
borderRadius: '4px',
touchAction: 'none',
display: 'block',
}}
/>
<button
onClick={() => sigPadRef.current?.clear()}
style={{
marginTop: '8px',
fontSize: '13px',
color: '#888',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
Clear
</button>
</div>
)}
{/* Type tab */}
{tab === 'type' && (
<div>
{/* Load Dancing Script from Google Fonts */}
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&display=swap"
rel="stylesheet"
/>
<input
type="text"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="Type your full name"
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #ddd',
borderRadius: '4px',
boxSizing: 'border-box',
}}
/>
{typedName && (
<div
style={{
marginTop: '12px',
padding: '16px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#fafafa',
}}
>
<span
style={{
fontFamily: "'Dancing Script', cursive",
fontSize: '36px',
color: '#1B2B4B',
}}
>
{typedName}
</span>
</div>
)}
</div>
)}
{/* Use Saved tab */}
{tab === 'saved' && savedSig && (
<div
style={{
padding: '16px',
border: '1px solid #ddd',
borderRadius: '4px',
textAlign: 'center',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={savedSig} alt="Saved signature" style={{ maxWidth: '100%', maxHeight: '100px' }} />
</div>
)}
{/* Save for later checkbox (only on draw/type tabs) */}
{tab !== 'saved' && (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginTop: '12px',
fontSize: '14px',
color: '#555',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={saveForLater}
onChange={(e) => setSaveForLater(e.target.checked)}
/>
Save signature for other fields
</label>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{
padding: '10px 20px',
border: '1px solid #ddd',
borderRadius: '4px',
background: 'none',
cursor: 'pointer',
fontSize: '14px',
}}
>
Cancel
</button>
<button
onClick={handleConfirm}
style={{
padding: '10px 24px',
backgroundColor: '#C9A84C',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '14px',
}}
>
Apply Signature
</button>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { SigningProgressBar } from './SigningProgressBar';
import { SignatureModal } from './SignatureModal';
import type { SignatureFieldData } from '@/lib/db/schema';
// Worker setup — reuse same pattern as PdfViewerWrapper (no CDN, works in local/Docker)
@@ -32,24 +33,25 @@ interface SigningPageClientProps {
token: string;
documentName: string;
signatureFields: SignatureFieldData[];
/** Called when user clicks an unsigned field. Modal added in Plan 04. */
onFieldClick?: (fieldId: string) => void;
}
export function SigningPageClient({
token,
documentName,
signatureFields,
onFieldClick,
}: SigningPageClientProps) {
const [numPages, setNumPages] = useState(0);
// Map from page number (1-indexed) to rendered dimensions
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
// Set of signed field IDs
const [signedFields, setSignedFields] = useState<Set<string>>(new Set());
// Map of fieldId -> dataURL for signed fields
const [signedFields, setSignedFields] = useState<Map<string, string>>(new Map());
const [submitting, setSubmitting] = useState(false);
// Exposed ref for Plan 04 to populate with captured signatures
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
// Ref for all captured signatures (fieldId -> dataURL)
const signaturesRef = useRef<SignatureCapture[]>([]);
// Group fields by page for efficient lookup
@@ -81,11 +83,34 @@ export function SigningPageClient({
const handleFieldClick = useCallback(
(fieldId: string) => {
if (signedFields.has(fieldId)) return;
if (onFieldClick) {
onFieldClick(fieldId);
}
setActiveFieldId(fieldId);
setModalOpen(true);
},
[signedFields, onFieldClick]
[signedFields]
);
const handleModalConfirm = useCallback(
(fieldId: string, dataURL: string, save: boolean) => {
// Persist to ref for POST submission
signaturesRef.current = [
...signaturesRef.current.filter((s) => s.fieldId !== fieldId),
{ fieldId, dataURL },
];
// Update signed fields map (triggers re-render of overlay)
setSignedFields((prev) => {
const next = new Map(prev);
next.set(fieldId, dataURL);
return next;
});
// If save was requested, localStorage is already set by SignatureModal
void save; // acknowledged — handled inside modal
setModalOpen(false);
setActiveFieldId(null);
},
[]
);
const handleJumpToNext = useCallback(() => {
@@ -98,11 +123,30 @@ export function SigningPageClient({
}
}, [signatureFields, signedFields]);
const handleSubmit = useCallback(() => {
const handleSubmit = useCallback(async () => {
if (signedFields.size < signatureFields.length || submitting) return;
setSubmitting(true);
// Submission POST handler added in Plan 04
}, [signedFields.size, signatureFields.length, submitting]);
try {
const res = await fetch(`/api/sign/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signatures: signaturesRef.current }),
});
if (res.ok) {
window.location.href = `/sign/${token}/confirmed`;
} else if (res.status === 409) {
// Already signed — navigate to confirmed anyway
window.location.href = `/sign/${token}/confirmed`;
} else {
const data = await res.json().catch(() => ({}));
alert(`Submission failed: ${(data as { error?: string }).error ?? res.status}. Please try again.`);
setSubmitting(false);
}
} catch {
alert('Network error. Please check your connection and try again.');
setSubmitting(false);
}
}, [signedFields.size, signatureFields.length, submitting, token]);
/**
* Convert PDF user-space coordinates (bottom-left origin) to screen overlay position.
@@ -235,6 +279,7 @@ export function SigningPageClient({
fieldsOnPage.map((field) => {
const isSigned = signedFields.has(field.id);
const overlayStyle = getFieldOverlayStyle(field, dims);
const sigDataURL = signedFields.get(field.id);
return (
<div
key={field.id}
@@ -251,7 +296,22 @@ export function SigningPageClient({
handleFieldClick(field.id);
}
}}
>
{/* Show signature preview when signed */}
{isSigned && sigDataURL && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={sigDataURL}
alt="Signature"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
)}
</div>
);
})}
</div>
@@ -269,9 +329,20 @@ export function SigningPageClient({
onSubmit={handleSubmit}
submitting={submitting}
/>
{/* Signature capture modal */}
<SignatureModal
isOpen={modalOpen}
fieldId={activeFieldId ?? ''}
onConfirm={handleModalConfirm}
onClose={() => {
setModalOpen(false);
setActiveFieldId(null);
}}
/>
</>
);
}
// Export signed fields setter so Plan 04 can mark fields as signed after modal capture
// Export type for downstream use
export type { SigningPageClientProps };