feat(12-02): PreparePanel preview state, button, gating, modal, and DocumentPageClient wiring

- Add previewToken/onPreviewTokenChange props to PreparePanel (lifted to DocumentPageClient)
- Add handlePreview async function fetching POST /api/documents/[id]/preview
- Add Preview button (gray-700) before Send button; text cycles Preview/Preview again
- Gate Send button on previewToken === null (requires fresh preview before send)
- Wrap TextFillForm onChange to reset previewToken on text fill changes
- Render PreviewModal conditionally when showPreview && previewBytes
- Create DocumentPageClient.tsx: holds previewToken state, passes reset callback to both FieldPlacer (via PdfViewerWrapper/PdfViewer) and PreparePanel
- Update PdfViewerWrapper and PdfViewer to accept and forward onFieldsChanged prop
- Update page.tsx to use DocumentPageClient instead of direct PreparePanel/PdfViewerWrapper siblings
This commit is contained in:
Chandler Copeland
2026-03-21 15:36:47 -06:00
parent aba622a765
commit de195a3e80
5 changed files with 116 additions and 25 deletions

View File

@@ -0,0 +1,51 @@
'use client';
import { useState, useCallback } from 'react';
import { PdfViewerWrapper } from './PdfViewerWrapper';
import { PreparePanel } from './PreparePanel';
interface DocumentPageClientProps {
docId: string;
docStatus: string;
defaultEmail: string;
clientName: string;
agentDownloadUrl?: string | null;
signedAt?: Date | null;
clientPropertyAddress?: string | null;
}
export function DocumentPageClient({
docId,
docStatus,
defaultEmail,
clientName,
agentDownloadUrl,
signedAt,
clientPropertyAddress,
}: DocumentPageClientProps) {
const [previewToken, setPreviewToken] = useState<string | null>(null);
const handleFieldsChanged = useCallback(() => {
setPreviewToken(null);
}, []);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<PdfViewerWrapper docId={docId} docStatus={docStatus} onFieldsChanged={handleFieldsChanged} />
</div>
<div className="lg:col-span-1 lg:sticky lg:top-6 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
<PreparePanel
docId={docId}
defaultEmail={defaultEmail}
clientName={clientName}
currentStatus={docStatus}
agentDownloadUrl={agentDownloadUrl}
signedAt={signedAt}
clientPropertyAddress={clientPropertyAddress}
previewToken={previewToken}
onPreviewTokenChange={setPreviewToken}
/>
</div>
</div>
);
}

View File

@@ -19,7 +19,7 @@ interface PageInfo {
scale: number;
}
export function PdfViewer({ docId, docStatus }: { docId: string; docStatus?: string }) {
export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void }) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1.0);
@@ -69,7 +69,7 @@ export function PdfViewer({ docId, docStatus }: { docId: string; docStatus?: str
</div>
{/* PDF canvas wrapped in FieldPlacer for drag-and-drop field placement */}
<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber} readOnly={readOnly}>
<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber} readOnly={readOnly} onFieldsChanged={onFieldsChanged}>
<Document
file={`/api/documents/${docId}/file`}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}

View File

@@ -3,6 +3,6 @@ import dynamic from 'next/dynamic';
const PdfViewer = dynamic(() => import('./PdfViewer').then(m => m.PdfViewer), { ssr: false });
export function PdfViewerWrapper({ docId, docStatus }: { docId: string; docStatus?: string }) {
return <PdfViewer docId={docId} docStatus={docStatus} />;
export function PdfViewerWrapper({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void }) {
return <PdfViewer docId={docId} docStatus={docStatus} onFieldsChanged={onFieldsChanged} />;
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { TextFillForm } from './TextFillForm';
import { PreviewModal } from './PreviewModal';
interface PreparePanelProps {
docId: string;
@@ -11,6 +12,8 @@ interface PreparePanelProps {
agentDownloadUrl?: string | null;
signedAt?: Date | null;
clientPropertyAddress?: string | null;
previewToken: string | null;
onPreviewTokenChange: (token: string | null) => void;
}
function parseEmails(raw: string | undefined): string[] {
@@ -21,7 +24,7 @@ function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, agentDownloadUrl, signedAt, clientPropertyAddress }: PreparePanelProps) {
export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, agentDownloadUrl, signedAt, clientPropertyAddress, previewToken, onPreviewTokenChange }: PreparePanelProps) {
const router = useRouter();
const [recipients, setRecipients] = useState(defaultEmail ?? '');
// Sync if defaultEmail arrives after initial render (streaming / hydration timing)
@@ -33,6 +36,8 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const [previewBytes, setPreviewBytes] = useState<ArrayBuffer | null>(null);
const [showPreview, setShowPreview] = useState(false);
if (currentStatus === 'Signed') {
return (
@@ -84,6 +89,36 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
);
}
function handleTextFillChange(data: Record<string, string>) {
setTextFillData(data);
onPreviewTokenChange(null);
}
async function handlePreview() {
setLoading(true);
setResult(null);
try {
const res = await fetch(`/api/documents/${docId}/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ textFillData }),
});
if (res.ok) {
const bytes = await res.arrayBuffer();
setPreviewBytes(bytes);
onPreviewTokenChange(Date.now().toString());
setShowPreview(true);
} else {
const err = await res.json().catch(() => ({ error: 'Preview failed' }));
setResult({ ok: false, message: err.message ?? err.error ?? 'Preview failed' });
}
} catch (e) {
setResult({ ok: false, message: String(e) });
} finally {
setLoading(false);
}
}
async function handlePrepare() {
setLoading(true);
setResult(null);
@@ -152,14 +187,23 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Text fill fields</label>
<TextFillForm
onChange={setTextFillData}
onChange={handleTextFillChange}
initialData={clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : undefined}
/>
</div>
<button
onClick={handlePreview}
disabled={loading}
className="w-full py-2 px-4 bg-gray-700 text-white rounded hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
type="button"
>
{loading ? 'Generating preview...' : previewToken ? 'Preview again' : 'Preview'}
</button>
<button
onClick={handlePrepare}
disabled={loading || parseEmails(recipients).length === 0}
disabled={loading || previewToken === null || parseEmails(recipients).length === 0}
className="w-full py-2 px-4 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
type="button"
>
@@ -171,6 +215,10 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
{result.message}
</p>
)}
{showPreview && previewBytes && (
<PreviewModal pdfBytes={previewBytes} onClose={() => setShowPreview(false)} />
)}
</div>
);
}

View File

@@ -4,8 +4,7 @@ import { db } from '@/lib/db';
import { documents, clients } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import Link from 'next/link';
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
import { PreparePanel } from './_components/PreparePanel';
import { DocumentPageClient } from './_components/DocumentPageClient';
import { createAgentDownloadToken } from '@/lib/signing/token';
export default async function DocumentPage({
@@ -53,22 +52,15 @@ export default async function DocumentPage({
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<PdfViewerWrapper docId={docId} docStatus={doc.status} />
</div>
<div className="lg:col-span-1 lg:sticky lg:top-6 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
<PreparePanel
<DocumentPageClient
docId={docId}
docStatus={doc.status}
defaultEmail={docClient?.email ?? ''}
clientName={docClient?.name ?? ''}
currentStatus={doc.status}
agentDownloadUrl={agentDownloadUrl}
signedAt={doc.signedAt ?? null}
clientPropertyAddress={docClient?.propertyAddress ?? null}
/>
</div>
</div>
</div>
);
}