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:
@@ -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
|
||||
|
||||
@@ -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 "Agent Signature" fields when you prepare a document.
|
||||
</p>
|
||||
</div>
|
||||
<AgentSignaturePanel initialData={user?.agentSignatureData ?? null} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user