feat(06-03): signing page — server component, PDF viewer, field overlays, progress bar
- 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)
This commit is contained in:
55
teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts
Normal file
55
teressa-copeland-homes/src/app/api/sign/[token]/pdf/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { verifySigningToken } from '@/lib/signing/token';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documents } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Public route — authenticated by signing token, no agent auth required
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ token: string }> }
|
||||||
|
) {
|
||||||
|
const { token } = await params;
|
||||||
|
|
||||||
|
// Validate JWT (no usedAt check — client may still be viewing while session is active)
|
||||||
|
let payload: { documentId: string; jti: string; exp: number };
|
||||||
|
try {
|
||||||
|
payload = await verifySigningToken(token);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch preparedFilePath
|
||||||
|
const doc = await db.query.documents.findFirst({
|
||||||
|
where: eq(documents.id, payload.documentId),
|
||||||
|
columns: { preparedFilePath: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doc?.preparedFilePath) {
|
||||||
|
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path traversal guard — preparedFilePath is stored as relative path (e.g. clients/{id}/{uuid}.pdf)
|
||||||
|
const normalizedPath = doc.preparedFilePath.replace(/^\/+/, '');
|
||||||
|
if (normalizedPath.includes('..')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = join(process.cwd(), 'uploads', normalizedPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await readFile(absolutePath);
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'inline',
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
'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 };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import type { SignatureFieldData } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
// Dynamically import to disable SSR — react-pdf requires browser APIs (canvas, worker)
|
||||||
|
const SigningPageClient = dynamic(
|
||||||
|
() => import('./SigningPageClient').then((m) => m.SigningPageClient),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#FAF9F7',
|
||||||
|
fontFamily: 'Georgia, serif',
|
||||||
|
color: '#555',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading document...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SigningPageClientWrapperProps {
|
||||||
|
token: string;
|
||||||
|
documentName: string;
|
||||||
|
signatureFields: SignatureFieldData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SigningPageClientWrapper({
|
||||||
|
token,
|
||||||
|
documentName,
|
||||||
|
signatureFields,
|
||||||
|
}: SigningPageClientWrapperProps) {
|
||||||
|
return (
|
||||||
|
<SigningPageClient
|
||||||
|
token={token}
|
||||||
|
documentName={documentName}
|
||||||
|
signatureFields={signatureFields}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface SigningProgressBarProps {
|
||||||
|
total: number;
|
||||||
|
signed: number;
|
||||||
|
onJumpToNext: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
submitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SigningProgressBar({
|
||||||
|
total,
|
||||||
|
signed,
|
||||||
|
onJumpToNext,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
}: SigningProgressBarProps) {
|
||||||
|
const allSigned = signed >= total;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
backgroundColor: '#1B2B4B',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 24px',
|
||||||
|
boxShadow: '0 -2px 8px rgba(0,0,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '15px' }}>
|
||||||
|
{signed} of {total} signature{total !== 1 ? 's' : ''} complete
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
{!allSigned && (
|
||||||
|
<button
|
||||||
|
onClick={onJumpToNext}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: '1px solid #C9A84C',
|
||||||
|
color: '#C9A84C',
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Jump to Next
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!allSigned || submitting}
|
||||||
|
style={{
|
||||||
|
backgroundColor: allSigned ? '#C9A84C' : '#555',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 22px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: allSigned ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
opacity: submitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Signature'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
teressa-copeland-homes/src/app/sign/[token]/page.tsx
Normal file
108
teressa-copeland-homes/src/app/sign/[token]/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { verifySigningToken } from '@/lib/signing/token';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { signingTokens, documents } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { SigningPageClientWrapper } from './_components/SigningPageClientWrapper';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SignPage({ params }: Props) {
|
||||||
|
const { token } = await params;
|
||||||
|
|
||||||
|
// CRITICAL: Validate BEFORE rendering any signing UI — no canvas flash on invalid tokens
|
||||||
|
let payload: { documentId: string; jti: string } | null = null;
|
||||||
|
let isExpired = false;
|
||||||
|
try {
|
||||||
|
payload = await verifySigningToken(token);
|
||||||
|
} catch {
|
||||||
|
isExpired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return <ErrorPage type="expired" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return <ErrorPage type="invalid" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check one-time use
|
||||||
|
const tokenRow = await db.query.signingTokens.findFirst({
|
||||||
|
where: eq(signingTokens.jti, payload.jti),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRow) return <ErrorPage type="invalid" />;
|
||||||
|
|
||||||
|
if (tokenRow.usedAt !== null) {
|
||||||
|
return <ErrorPage type="used" signedAt={tokenRow.usedAt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await db.query.documents.findFirst({
|
||||||
|
where: eq(documents.id, payload.documentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!doc || !doc.preparedFilePath) return <ErrorPage type="invalid" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningPageClientWrapper
|
||||||
|
token={token}
|
||||||
|
documentName={doc.name}
|
||||||
|
signatureFields={doc.signatureFields ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorPage({
|
||||||
|
type,
|
||||||
|
signedAt,
|
||||||
|
}: {
|
||||||
|
type: 'expired' | 'used' | 'invalid';
|
||||||
|
signedAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const messages = {
|
||||||
|
expired: {
|
||||||
|
title: 'Link Expired',
|
||||||
|
body: 'This signing link has expired. Please contact Teressa Copeland for a new link.',
|
||||||
|
},
|
||||||
|
used: {
|
||||||
|
title: 'Already Signed',
|
||||||
|
body: `This document has already been signed${
|
||||||
|
signedAt
|
||||||
|
? ' on ' +
|
||||||
|
signedAt.toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}.`,
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
title: 'Invalid Link',
|
||||||
|
body: 'This signing link is not valid. Please check your email for the correct link.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { title, body } = messages[type];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#FAF9F7',
|
||||||
|
fontFamily: 'Georgia, serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', maxWidth: '420px', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
|
||||||
|
{type === 'used' ? '\u2713' : '\u26a0'}
|
||||||
|
</div>
|
||||||
|
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px' }}>{title}</h1>
|
||||||
|
<p style={{ color: '#555', fontSize: '16px', lineHeight: '1.6' }}>{body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user