- page.tsx: server component validates JWT + one-time-use before rendering any UI - Three error states (expired/used/invalid) show static pages with no canvas - SigningPageClientWrapper: dynamic import (ssr:false) for react-pdf browser requirement - SigningPageClient: full-scroll PDF viewer with pulsing blue field overlays - Field overlay coordinates convert PDF user-space (bottom-left) to screen (top-left) - SigningProgressBar: sticky bottom bar with X/Y count + jump-to-next + submit button - api/sign/[token]/pdf: token-authenticated PDF streaming route (no agent auth)
278 lines
8.9 KiB
TypeScript
278 lines
8.9 KiB
TypeScript
'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 };
|