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