diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx new file mode 100644 index 0000000..5f49192 --- /dev/null +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SignatureModal.tsx @@ -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(null); + const canvasRef = useRef(null); + const sigPadRef = useRef(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 ( +
+
+ {/* Header */} +
+

Add Signature

+ +
+ + {/* Tabs */} +
+ + + {savedSig && ( + + )} +
+ + {/* Draw tab */} + {tab === 'draw' && ( +
+ + +
+ )} + + {/* Type tab */} + {tab === 'type' && ( +
+ {/* Load Dancing Script from Google Fonts */} + {/* eslint-disable-next-line @next/next/no-page-custom-font */} + + 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 && ( +
+ + {typedName} + +
+ )} +
+ )} + + {/* Use Saved tab */} + {tab === 'saved' && savedSig && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Saved signature +
+ )} + + {/* Save for later checkbox (only on draw/type tabs) */} + {tab !== 'saved' && ( + + )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx index 1be87e7..d1121d9 100644 --- a/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx +++ b/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx @@ -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>({}); - // Set of signed field IDs - const [signedFields, setSignedFields] = useState>(new Set()); + // Map of fieldId -> dataURL for signed fields + const [signedFields, setSignedFields] = useState>(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(null); + + // Ref for all captured signatures (fieldId -> dataURL) const signaturesRef = useRef([]); // 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 (
+ > + {/* Show signature preview when signed */} + {isSigned && sigDataURL && ( + /* eslint-disable-next-line @next/next/no-img-element */ + Signature + )} +
); })} @@ -269,9 +329,20 @@ export function SigningPageClient({ onSubmit={handleSubmit} submitting={submitting} /> + + {/* Signature capture modal */} + { + 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 };