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:
Chandler Copeland
2026-03-20 11:42:24 -06:00
parent a276da0da1
commit 4cdd9eea80
2 changed files with 134 additions and 51 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css'; import 'react-pdf/dist/Page/TextLayer.css';
@@ -40,6 +41,7 @@ export function SigningPageClient({
documentName, documentName,
signatureFields, signatureFields,
}: SigningPageClientProps) { }: SigningPageClientProps) {
const router = useRouter();
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
// Map from page number (1-indexed) to rendered dimensions // Map from page number (1-indexed) to rendered dimensions
const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({}); const [pageDimensions, setPageDimensions] = useState<Record<number, PageDimensions>>({});
@@ -133,10 +135,10 @@ export function SigningPageClient({
body: JSON.stringify({ signatures: signaturesRef.current }), body: JSON.stringify({ signatures: signaturesRef.current }),
}); });
if (res.ok) { if (res.ok) {
window.location.href = `/sign/${token}/confirmed`; router.push(`/sign/${token}/confirmed`);
} else if (res.status === 409) { } else if (res.status === 409) {
// Already signed — navigate to confirmed anyway // Already signed — navigate to confirmed anyway
window.location.href = `/sign/${token}/confirmed`; router.push(`/sign/${token}/confirmed`);
} else { } else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
alert(`Submission failed: ${(data as { error?: string }).error ?? res.status}. Please try again.`); alert(`Submission failed: ${(data as { error?: string }).error ?? res.status}. Please try again.`);

View File

@@ -1,7 +1,56 @@
// /sign/[token]/confirmed — shown after a document is successfully signed import { verifySigningToken, createDownloadToken } from '@/lib/signing/token';
// Static success screen; no token re-validation needed (submission already committed) 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 ( return (
<div <div
style={{ style={{
@@ -13,59 +62,91 @@ export default function ConfirmedPage() {
fontFamily: 'Georgia, serif', fontFamily: 'Georgia, serif',
}} }}
> >
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '40px' }}> <div style={{ textAlign: 'center', padding: '40px' }}>
{/* Branded header strip */} <h1 style={{ color: '#1B2B4B' }}>Document not yet available</h1>
<div <p style={{ color: '#555' }}>Please check back shortly.</p>
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>
</div> </div>
);
}
{/* Success card */} // 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 <div
style={{ style={{
backgroundColor: '#fff', minHeight: '100vh',
border: '1px solid #e0e0e0', backgroundColor: '#FAF9F7',
borderTop: 'none', fontFamily: 'Georgia, serif',
borderRadius: '0 0 8px 8px', display: 'flex',
padding: '40px 32px', alignItems: 'center',
boxShadow: '0 2px 12px rgba(0,0,0,0.08)', justifyContent: 'center',
}} }}
> >
<div style={{ fontSize: '56px', marginBottom: '16px', color: '#22c55e' }}>&#10003;</div> <div style={{ textAlign: 'center', maxWidth: '480px', padding: '48px 32px' }}>
<h1 style={{ color: '#1B2B4B', fontSize: '24px', marginBottom: '12px', margin: '0 0 12px' }}> {/* Success checkmark */}
Document Signed <div
style={{
width: '72px',
height: '72px',
borderRadius: '50%',
backgroundColor: '#1B2B4B',
color: '#C9A84C',
fontSize: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 24px',
}}
>
&#10003;
</div>
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
You&apos;ve signed
</h1> </h1>
<p <p
style={{ style={{
color: '#555', color: '#1B2B4B',
fontSize: '16px', fontSize: '20px',
lineHeight: '1.6', fontWeight: 'bold',
margin: '0 0 24px', marginBottom: '8px',
}} }}
> >
Your signature has been recorded and the document has been securely submitted. {doc.name}
Teressa Copeland will be in touch shortly.
</p> </p>
<div <p style={{ color: '#888', fontSize: '14px', marginBottom: '32px' }}>
Signed on {formattedDate}
</p>
<a
href={downloadUrl}
style={{ style={{
fontSize: '13px', display: 'inline-block',
color: '#999', backgroundColor: '#C9A84C',
borderTop: '1px solid #f0f0f0', color: '#fff',
paddingTop: '16px', padding: '12px 28px',
borderRadius: '4px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '15px',
}} }}
> >
You may close this window. Download your copy
</div> </a>
</div> <p style={{ color: '#aaa', fontSize: '12px', marginTop: '16px' }}>
Download link valid for 15 minutes.
</p>
</div> </div>
</div> </div>
); );