feat(06-05): confirmation page + router.push redirect after signing
- Rewrite confirmed/page.tsx: verifies signing token, shows document name + signed timestamp + download button - Generate 15-min download token server-side; pass as dt= query param to /api/sign/[token]/download - Success checkmark (navy circle + gold checkmark), document name, formatted signed date - Download link valid for 15 minutes note shown below button - Update SigningPageClient.tsx: replace window.location.href with router.push for SPA navigation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
@@ -40,6 +41,7 @@ export function SigningPageClient({
|
||||
documentName,
|
||||
signatureFields,
|
||||
}: SigningPageClientProps) {
|
||||
const router = useRouter();
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
// Map from page number (1-indexed) to rendered dimensions
|
||||
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
|
||||
@@ -133,10 +135,10 @@ export function SigningPageClient({
|
||||
body: JSON.stringify({ signatures: signaturesRef.current }),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = `/sign/${token}/confirmed`;
|
||||
router.push(`/sign/${token}/confirmed`);
|
||||
} else if (res.status === 409) {
|
||||
// Already signed — navigate to confirmed anyway
|
||||
window.location.href = `/sign/${token}/confirmed`;
|
||||
router.push(`/sign/${token}/confirmed`);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(`Submission failed: ${(data as { error?: string }).error ?? res.status}. Please try again.`);
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
// /sign/[token]/confirmed — shown after a document is successfully signed
|
||||
// Static success screen; no token re-validation needed (submission already committed)
|
||||
import { verifySigningToken, createDownloadToken } from '@/lib/signing/token';
|
||||
import { db } from '@/lib/db';
|
||||
import { signingTokens, documents } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export default function ConfirmedPage() {
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export default async function ConfirmedPage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
|
||||
// Verify signing token to get documentId (allow expired — confirmation page visited after signing)
|
||||
let documentId: string | null = null;
|
||||
let jti: string | null = null;
|
||||
try {
|
||||
const payload = await verifySigningToken(token);
|
||||
documentId = payload.documentId;
|
||||
jti = payload.jti;
|
||||
} catch {
|
||||
// Token expired is OK here — signing may have happened right before expiry
|
||||
// documentId/jti remain null — handled below
|
||||
}
|
||||
|
||||
if (!documentId) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Georgia, serif',
|
||||
color: '#1B2B4B',
|
||||
}}
|
||||
>
|
||||
Document not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.id, documentId),
|
||||
});
|
||||
|
||||
// Get signed timestamp from signingTokens.usedAt
|
||||
let signedAt: Date | null = null;
|
||||
if (jti) {
|
||||
const tokenRow = await db.query.signingTokens.findFirst({
|
||||
where: eq(signingTokens.jti, jti),
|
||||
});
|
||||
signedAt = tokenRow?.usedAt ?? doc?.signedAt ?? null;
|
||||
}
|
||||
|
||||
if (!doc || !doc.signedFilePath) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -13,59 +62,91 @@ export default function ConfirmedPage() {
|
||||
fontFamily: 'Georgia, serif',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '40px' }}>
|
||||
{/* Branded header strip */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#1B2B4B',
|
||||
color: '#fff',
|
||||
padding: '16px 24px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', letterSpacing: '0.02em' }}>
|
||||
Teressa Copeland Homes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success card */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderTop: 'none',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
padding: '40px 32px',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '56px', marginBottom: '16px', color: '#22c55e' }}>✓</div>
|
||||
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px', margin: '0 0 12px' }}>
|
||||
Document Signed
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: '#555',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
margin: '0 0 24px',
|
||||
}}
|
||||
>
|
||||
Your signature has been recorded and the document has been securely submitted.
|
||||
Teressa Copeland will be in touch shortly.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: '16px',
|
||||
}}
|
||||
>
|
||||
You may close this window.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<h1 style={{ color: '#1B2B4B' }}>Document not yet available</h1>
|
||||
<p style={{ color: '#555' }}>Please check back shortly.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate 15-minute download token for client copy
|
||||
const downloadToken = await createDownloadToken(doc.id);
|
||||
const downloadUrl = `/api/sign/${token}/download?dt=${downloadToken}`;
|
||||
|
||||
const formattedDate = signedAt
|
||||
? signedAt.toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Just now';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#FAF9F7',
|
||||
fontFamily: 'Georgia, serif',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '48px 32px' }}>
|
||||
{/* Success checkmark */}
|
||||
<div
|
||||
style={{
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#1B2B4B',
|
||||
color: '#C9A84C',
|
||||
fontSize: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 24px',
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
|
||||
You've signed
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: '#1B2B4B',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{doc.name}
|
||||
</p>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '32px' }}>
|
||||
Signed on {formattedDate}
|
||||
</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#C9A84C',
|
||||
color: '#fff',
|
||||
padding: '12px 28px',
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '15px',
|
||||
}}
|
||||
>
|
||||
Download your copy
|
||||
</a>
|
||||
<p style={{ color: '#aaa', fontSize: '12px', marginTop: '16px' }}>
|
||||
Download link valid for 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user