feat(11-01): AgentSignaturePanel, profile page, PortalNav link, FieldPlacer token

- Create AgentSignaturePanel.tsx with signature_pad canvas, save/update/thumbnail flow
- Create /portal/profile page (server component fetching agentSignatureData)
- Add Profile link to PortalNav navLinks array
- Add red 'Agent Signature' token to FieldPlacer PALETTE_TOKENS (6th entry)
This commit is contained in:
Chandler Copeland
2026-03-21 14:02:51 -06:00
parent e07ed306cd
commit f383f91445
4 changed files with 190 additions and 40 deletions

View File

@@ -68,11 +68,12 @@ async function persistFields(docId: string, fields: SignatureFieldData[]) {
// Token color palette — each maps to a SignatureFieldType
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
{ id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green
{ id: 'date', label: 'Date', color: '#d97706' }, // amber
{ id: 'text', label: 'Text', color: '#64748b' }, // slate
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
{ id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green
{ id: 'date', label: 'Date', color: '#d97706' }, // amber
{ id: 'text', label: 'Text', color: '#64748b' }, // slate
{ id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' }, // red
];
// Draggable token in the palette

View File

@@ -0,0 +1,31 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { AgentSignaturePanel } from '../../_components/AgentSignaturePanel';
export default async function ProfilePage() {
const session = await auth();
if (!session?.user?.id) redirect('/agent/login');
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { agentSignatureData: true },
});
return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
<h1 className="text-2xl font-semibold text-gray-900">Profile</h1>
<section className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
<div>
<h2 className="text-lg font-medium text-gray-900">Agent Signature</h2>
<p className="text-sm text-gray-500 mt-1">
Draw your signature once. It will be embedded in any &quot;Agent Signature&quot; fields when you prepare a document.
</p>
</div>
<AgentSignaturePanel initialData={user?.agentSignatureData ?? null} />
</section>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import SignaturePad from 'signature_pad';
interface AgentSignaturePanelProps {
initialData: string | null;
}
export function AgentSignaturePanel({ initialData }: AgentSignaturePanelProps) {
const [savedData, setSavedData] = useState<string | null>(initialData);
const [isDrawing, setIsDrawing] = useState(!initialData);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sigPadRef = useRef<SignaturePad | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isDrawing || !canvasRef.current) return;
const canvas = canvasRef.current;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext('2d')?.scale(ratio, ratio);
sigPadRef.current = new SignaturePad(canvas, {
backgroundColor: 'rgba(0,0,0,0)',
penColor: '#1B2B4B',
});
return () => sigPadRef.current?.off();
}, [isDrawing]);
async function handleSave() {
if (!sigPadRef.current || sigPadRef.current.isEmpty()) {
setError('Please draw your signature first');
return;
}
const dataURL = sigPadRef.current.toDataURL('image/png');
setSaving(true);
setError(null);
try {
const res = await fetch('/api/agent/signature', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataURL }),
});
if (res.ok) {
setSavedData(dataURL);
setIsDrawing(false);
} else {
const data = await res.json().catch(() => ({ error: 'Save failed' }));
setError(data.error ?? 'Save failed');
}
} catch {
setError('Network error — please try again');
} finally {
setSaving(false);
}
}
if (!isDrawing && savedData) {
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">Your saved signature:</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={savedData}
alt="Saved agent signature"
className="max-h-20 border border-gray-200 rounded bg-white p-2"
/>
<button
onClick={() => { setIsDrawing(true); setError(null); }}
className="px-4 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Update Signature
</button>
</div>
);
}
return (
<div className="space-y-3">
<canvas
ref={canvasRef}
className="w-full border border-gray-300 rounded bg-white"
style={{ height: '140px', touchAction: 'none', display: 'block' }}
/>
<div className="flex gap-2">
<button
onClick={() => sigPadRef.current?.clear()}
className="px-3 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Clear
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : (savedData ? 'Save Updated Signature' : 'Save Signature')}
</button>
{savedData && (
<button
onClick={() => { setIsDrawing(false); setError(null); }}
className="px-3 py-2 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Cancel
</button>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}

View File

@@ -4,52 +4,58 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { LogoutButton } from "@/components/ui/LogoutButton";
interface PortalNavProps {
userEmail: string;
}
const navLinks = [
{ href: "/portal/dashboard", label: "Dashboard" },
{ href: "/portal/clients", label: "Clients" },
{ href: "/portal/profile", label: "Profile" },
];
export function PortalNav({ userEmail }: PortalNavProps) {
export function PortalNav({ userEmail }: { userEmail: string }) {
const pathname = usePathname();
return (
<nav className="bg-[var(--navy)] text-white">
<div className="flex items-center justify-between px-6 py-3">
{/* Left: site name + nav links */}
<div className="flex items-center gap-8">
<span className="text-base font-semibold tracking-wide">
Teressa Copeland
</span>
<div className="flex items-center gap-6">
{navLinks.map(({ href, label }) => {
const isActive = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`text-sm hover:opacity-80 transition-opacity pb-0.5 ${
isActive
? "border-b-2 border-[var(--gold)] font-medium"
: "border-b-2 border-transparent"
}`}
>
{label}
</Link>
);
})}
<nav style={{ position: "sticky", top: 0, zIndex: 50, backgroundColor: "#1B2B4B", color: "#FAF9F7" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem", height: "64px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
{/* Wordmark */}
<span style={{ fontFamily: "var(--font-serif), Georgia, serif", fontWeight: 600, fontSize: "1.25rem", color: "#FAF9F7", letterSpacing: "0.02em" }}>
Teressa Copeland
</span>
{/* Nav links + right side */}
<div style={{ display: "flex", alignItems: "center", gap: "2rem" }}>
{navLinks.map(({ href, label }) => {
const isActive = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
style={{
color: "#FAF9F7",
textDecoration: "none",
fontSize: "0.8125rem",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontWeight: 500,
opacity: isActive ? 1 : 0.8,
borderBottom: isActive ? "2px solid #C9A84C" : "2px solid transparent",
paddingBottom: "2px",
transition: "opacity 0.2s",
}}
>
{label}
</Link>
);
})}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginLeft: "0.5rem", borderLeft: "1px solid rgba(255,255,255,0.15)", paddingLeft: "1.25rem" }}>
<span style={{ fontSize: "0.75rem", color: "rgba(250,249,247,0.6)" }}>{userEmail}</span>
<LogoutButton className="text-white/70 hover:text-white" />
</div>
</div>
{/* Right: agent email + logout */}
<div className="flex items-center gap-4">
<span className="text-xs text-white/60">{userEmail}</span>
<LogoutButton />
</div>
</div>
{/* Gold gradient border — matches SiteNav */}
<div style={{ height: "1px", background: "linear-gradient(to right, transparent 0%, rgba(201,168,76,0.8) 30%, rgba(201,168,76,1) 50%, rgba(201,168,76,0.8) 70%, transparent 100%)" }} />
</nav>
);
}