Files
red/teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx

278 lines
8.9 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useRef, useCallback } from 'react';
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 type { SignatureFieldData } from '@/lib/db/schema';
// Worker setup — reuse same pattern as PdfViewerWrapper (no CDN, works in local/Docker)
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
// PDF rendered at a fixed width for consistent coordinate math
const PDF_RENDER_WIDTH = 800;
interface PageDimensions {
renderedWidth: number;
renderedHeight: number;
originalWidth: number;
originalHeight: number;
}
export interface SignatureCapture {
fieldId: string;
dataURL: string;
}
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());
const [submitting, setSubmitting] = useState(false);
// Exposed ref for Plan 04 to populate with captured signatures
const signaturesRef = useRef<SignatureCapture[]>([]);
// Group fields by page for efficient lookup
const fieldsByPage = signatureFields.reduce<Record<number, SignatureFieldData[]>>(
(acc, field) => {
const page = field.page;
if (!acc[page]) acc[page] = [];
acc[page].push(field);
return acc;
},
{}
);
const handlePageLoadSuccess = useCallback(
(pageNum: number, page: { width: number; height: number; view: number[] }) => {
setPageDimensions((prev) => ({
...prev,
[pageNum]: {
renderedWidth: page.width,
renderedHeight: page.height,
originalWidth: Math.max(page.view[0], page.view[2]),
originalHeight: Math.max(page.view[1], page.view[3]),
},
}));
},
[]
);
const handleFieldClick = useCallback(
(fieldId: string) => {
if (signedFields.has(fieldId)) return;
if (onFieldClick) {
onFieldClick(fieldId);
}
},
[signedFields, onFieldClick]
);
const handleJumpToNext = useCallback(() => {
const nextUnsigned = signatureFields.find((f) => !signedFields.has(f.id));
if (nextUnsigned) {
document.getElementById(`field-${nextUnsigned.id}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, [signatureFields, signedFields]);
const handleSubmit = useCallback(() => {
if (signedFields.size < signatureFields.length || submitting) return;
setSubmitting(true);
// Submission POST handler added in Plan 04
}, [signedFields.size, signatureFields.length, submitting]);
/**
* Convert PDF user-space coordinates (bottom-left origin) to screen overlay position.
*
* PDF coords: origin at bottom-left, Y increases upward.
* Screen coords: origin at top-left, Y increases downward.
*
* Formula:
* screenTop = renderedHeight - (field.y / originalHeight * renderedHeight) - (field.height / originalHeight * renderedHeight)
* screenLeft = field.x / originalWidth * renderedWidth
*/
const getFieldOverlayStyle = (
field: SignatureFieldData,
dims: PageDimensions
): React.CSSProperties => {
const scaleX = dims.renderedWidth / dims.originalWidth;
const scaleY = dims.renderedHeight / dims.originalHeight;
const top = dims.renderedHeight - field.y * scaleY - field.height * scaleY;
const left = field.x * scaleX;
const width = field.width * scaleX;
const height = field.height * scaleY;
return {
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
height: `${height}px`,
cursor: 'pointer',
borderRadius: '3px',
animation: 'pulse-border 2s infinite',
};
};
return (
<>
{/* Pulse-border keyframes injected inline */}
<style>{`
@keyframes pulse-border {
0%, 100% {
box-shadow: 0 0 0 2px #3b82f6, 0 0 8px 2px rgba(59,130,246,0.4);
}
50% {
box-shadow: 0 0 0 3px #3b82f6, 0 0 16px 4px rgba(59,130,246,0.6);
}
}
.signing-field-signed {
box-shadow: 0 0 0 2px #22c55e !important;
animation: none !important;
background-color: rgba(34,197,94,0.08);
}
`}</style>
<div
style={{
minHeight: '100vh',
backgroundColor: '#FAF9F7',
fontFamily: 'Georgia, serif',
paddingBottom: '80px', /* space for sticky progress bar */
}}
>
{/* Branded header */}
<header
style={{
backgroundColor: '#1B2B4B',
color: '#fff',
padding: '20px 32px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '20px', fontWeight: 'bold', letterSpacing: '0.02em' }}>
Teressa Copeland Homes
</div>
<div
style={{
fontSize: '16px',
marginTop: '6px',
color: '#C9A84C',
fontStyle: 'italic',
}}
>
{documentName}
</div>
<p style={{ fontSize: '14px', marginTop: '8px', color: '#ccc', fontFamily: 'sans-serif' }}>
Please review and sign the document below.
</p>
</header>
{/* PDF viewer */}
<main style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '32px 16px' }}>
<Document
file={`/api/sign/${token}/pdf`}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div style={{ padding: '40px', color: '#555', fontFamily: 'sans-serif' }}>
Loading document...
</div>
}
error={
<div style={{ padding: '40px', color: '#c00', fontFamily: 'sans-serif' }}>
Failed to load document. Please try refreshing.
</div>
}
>
{Array.from({ length: numPages }, (_, i) => {
const pageNum = i + 1;
const dims = pageDimensions[pageNum];
const fieldsOnPage = fieldsByPage[pageNum] ?? [];
return (
<div
key={pageNum}
style={{
position: 'relative',
marginBottom: '24px',
boxShadow: '0 2px 12px rgba(0,0,0,0.12)',
}}
>
<Page
pageNumber={pageNum}
width={PDF_RENDER_WIDTH}
onLoadSuccess={(page) => handlePageLoadSuccess(pageNum, page)}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
{/* Signature field overlays — rendered once page dimensions are known */}
{dims &&
fieldsOnPage.map((field) => {
const isSigned = signedFields.has(field.id);
const overlayStyle = getFieldOverlayStyle(field, dims);
return (
<div
key={field.id}
id={`field-${field.id}`}
className={isSigned ? 'signing-field-signed' : ''}
style={overlayStyle}
onClick={() => handleFieldClick(field.id)}
role="button"
tabIndex={0}
aria-label={`Signature field${isSigned ? ' (signed)' : ' — click to sign'}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleFieldClick(field.id);
}
}}
/>
);
})}
</div>
);
})}
</Document>
</main>
</div>
{/* Sticky progress bar */}
<SigningProgressBar
total={signatureFields.length}
signed={signedFields.size}
onJumpToNext={handleJumpToNext}
onSubmit={handleSubmit}
submitting={submitting}
/>
</>
);
}
// Export signed fields setter so Plan 04 can mark fields as signed after modal capture
export type { SigningPageClientProps };