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';
|
'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.`);
|
||||||
|
|||||||
@@ -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' }}>✓</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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
|
||||||
|
You'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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user