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/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.css';
|
import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
import { SigningProgressBar } from './SigningProgressBar';
|
import { SigningProgressBar } from './SigningProgressBar';
|
||||||
|
import { SignatureModal } from './SignatureModal';
|
||||||
import type { SignatureFieldData } from '@/lib/db/schema';
|
import type { SignatureFieldData } from '@/lib/db/schema';
|
||||||
|
|
||||||
// Worker setup — reuse same pattern as PdfViewerWrapper (no CDN, works in local/Docker)
|
// Worker setup — reuse same pattern as PdfViewerWrapper (no CDN, works in local/Docker)
|
||||||
@@ -32,24 +33,25 @@ interface SigningPageClientProps {
|
|||||||
token: string;
|
token: string;
|
||||||
documentName: string;
|
documentName: string;
|
||||||
signatureFields: SignatureFieldData[];
|
signatureFields: SignatureFieldData[];
|
||||||
/** Called when user clicks an unsigned field. Modal added in Plan 04. */
|
|
||||||
onFieldClick?: (fieldId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SigningPageClient({
|
export function SigningPageClient({
|
||||||
token,
|
token,
|
||||||
documentName,
|
documentName,
|
||||||
signatureFields,
|
signatureFields,
|
||||||
onFieldClick,
|
|
||||||
}: SigningPageClientProps) {
|
}: SigningPageClientProps) {
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
// Map from page number (1-indexed) to rendered dimensions
|
// Map from page number (1-indexed) to rendered dimensions
|
||||||
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
|
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
|
||||||
// Set of signed field IDs
|
// Map of fieldId -> dataURL for signed fields
|
||||||
const [signedFields, setSignedFields] = useState<Set<string>>(new Set());
|
const [signedFields, setSignedFields] = useState<Map<string, string>>(new Map());
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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[]>([]);
|
const signaturesRef = useRef<SignatureCapture[]>([]);
|
||||||
|
|
||||||
// Group fields by page for efficient lookup
|
// Group fields by page for efficient lookup
|
||||||
@@ -81,11 +83,34 @@ export function SigningPageClient({
|
|||||||
const handleFieldClick = useCallback(
|
const handleFieldClick = useCallback(
|
||||||
(fieldId: string) => {
|
(fieldId: string) => {
|
||||||
if (signedFields.has(fieldId)) return;
|
if (signedFields.has(fieldId)) return;
|
||||||
if (onFieldClick) {
|
setActiveFieldId(fieldId);
|
||||||
onFieldClick(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(() => {
|
const handleJumpToNext = useCallback(() => {
|
||||||
@@ -98,11 +123,30 @@ export function SigningPageClient({
|
|||||||
}
|
}
|
||||||
}, [signatureFields, signedFields]);
|
}, [signatureFields, signedFields]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (signedFields.size < signatureFields.length || submitting) return;
|
if (signedFields.size < signatureFields.length || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// Submission POST handler added in Plan 04
|
try {
|
||||||
}, [signedFields.size, signatureFields.length, submitting]);
|
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.
|
* Convert PDF user-space coordinates (bottom-left origin) to screen overlay position.
|
||||||
@@ -235,6 +279,7 @@ export function SigningPageClient({
|
|||||||
fieldsOnPage.map((field) => {
|
fieldsOnPage.map((field) => {
|
||||||
const isSigned = signedFields.has(field.id);
|
const isSigned = signedFields.has(field.id);
|
||||||
const overlayStyle = getFieldOverlayStyle(field, dims);
|
const overlayStyle = getFieldOverlayStyle(field, dims);
|
||||||
|
const sigDataURL = signedFields.get(field.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={field.id}
|
||||||
@@ -251,7 +296,22 @@ export function SigningPageClient({
|
|||||||
handleFieldClick(field.id);
|
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>
|
</div>
|
||||||
@@ -269,9 +329,20 @@ export function SigningPageClient({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitting={submitting}
|
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 };
|
export type { SigningPageClientProps };
|
||||||
|
|||||||
Reference in New Issue
Block a user