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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ interface PageInfo {
|
|||||||
scale: number;
|
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 [numPages, setNumPages] = useState(0);
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(1);
|
||||||
const [scale, setScale] = useState(1.0);
|
const [scale, setScale] = useState(1.0);
|
||||||
@@ -69,7 +69,7 @@ export function PdfViewer({ docId, docStatus }: { docId: string; docStatus?: str
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF canvas wrapped in FieldPlacer for drag-and-drop field placement */}
|
{/* 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
|
<Document
|
||||||
file={`/api/documents/${docId}/file`}
|
file={`/api/documents/${docId}/file`}
|
||||||
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
const PdfViewer = dynamic(() => import('./PdfViewer').then(m => m.PdfViewer), { ssr: false });
|
const PdfViewer = dynamic(() => import('./PdfViewer').then(m => m.PdfViewer), { ssr: false });
|
||||||
|
|
||||||
export function PdfViewerWrapper({ docId, docStatus }: { docId: string; docStatus?: string }) {
|
export function PdfViewerWrapper({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void }) {
|
||||||
return <PdfViewer docId={docId} docStatus={docStatus} />;
|
return <PdfViewer docId={docId} docStatus={docStatus} onFieldsChanged={onFieldsChanged} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { TextFillForm } from './TextFillForm';
|
import { TextFillForm } from './TextFillForm';
|
||||||
|
import { PreviewModal } from './PreviewModal';
|
||||||
|
|
||||||
interface PreparePanelProps {
|
interface PreparePanelProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
@@ -11,6 +12,8 @@ interface PreparePanelProps {
|
|||||||
agentDownloadUrl?: string | null;
|
agentDownloadUrl?: string | null;
|
||||||
signedAt?: Date | null;
|
signedAt?: Date | null;
|
||||||
clientPropertyAddress?: string | null;
|
clientPropertyAddress?: string | null;
|
||||||
|
previewToken: string | null;
|
||||||
|
onPreviewTokenChange: (token: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEmails(raw: string | undefined): string[] {
|
function parseEmails(raw: string | undefined): string[] {
|
||||||
@@ -21,7 +24,7 @@ function isValidEmail(email: string): boolean {
|
|||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
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 router = useRouter();
|
||||||
const [recipients, setRecipients] = useState(defaultEmail ?? '');
|
const [recipients, setRecipients] = useState(defaultEmail ?? '');
|
||||||
// Sync if defaultEmail arrives after initial render (streaming / hydration timing)
|
// 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 [loading, setLoading] = useState(false);
|
||||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
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') {
|
if (currentStatus === 'Signed') {
|
||||||
return (
|
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() {
|
async function handlePrepare() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
@@ -152,14 +187,23 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Text fill fields</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Text fill fields</label>
|
||||||
<TextFillForm
|
<TextFillForm
|
||||||
onChange={setTextFillData}
|
onChange={handleTextFillChange}
|
||||||
initialData={clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : undefined}
|
initialData={clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handlePrepare}
|
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"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -171,6 +215,10 @@ export function PreparePanel({ docId, defaultEmail, clientName, currentStatus, a
|
|||||||
{result.message}
|
{result.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPreview && previewBytes && (
|
||||||
|
<PreviewModal pdfBytes={previewBytes} onClose={() => setShowPreview(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { db } from '@/lib/db';
|
|||||||
import { documents, clients } from '@/lib/db/schema';
|
import { documents, clients } from '@/lib/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PdfViewerWrapper } from './_components/PdfViewerWrapper';
|
import { DocumentPageClient } from './_components/DocumentPageClient';
|
||||||
import { PreparePanel } from './_components/PreparePanel';
|
|
||||||
import { createAgentDownloadToken } from '@/lib/signing/token';
|
import { createAgentDownloadToken } from '@/lib/signing/token';
|
||||||
|
|
||||||
export default async function DocumentPage({
|
export default async function DocumentPage({
|
||||||
@@ -53,22 +52,15 @@ export default async function DocumentPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<DocumentPageClient
|
||||||
<div className="lg:col-span-2">
|
docId={docId}
|
||||||
<PdfViewerWrapper docId={docId} docStatus={doc.status} />
|
docStatus={doc.status}
|
||||||
</div>
|
defaultEmail={docClient?.email ?? ''}
|
||||||
<div className="lg:col-span-1 lg:sticky lg:top-6 lg:self-start lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
|
clientName={docClient?.name ?? ''}
|
||||||
<PreparePanel
|
agentDownloadUrl={agentDownloadUrl}
|
||||||
docId={docId}
|
signedAt={doc.signedAt ?? null}
|
||||||
defaultEmail={docClient?.email ?? ''}
|
clientPropertyAddress={docClient?.propertyAddress ?? null}
|
||||||
clientName={docClient?.name ?? ''}
|
/>
|
||||||
currentStatus={doc.status}
|
|
||||||
agentDownloadUrl={agentDownloadUrl}
|
|
||||||
signedAt={doc.signedAt ?? null}
|
|
||||||
clientPropertyAddress={docClient?.propertyAddress ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user