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
|
// Token color palette — each maps to a SignatureFieldType
|
||||||
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
|
const PALETTE_TOKENS: Array<{ id: SignatureFieldType; label: string; color: string }> = [
|
||||||
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
|
{ id: 'client-signature', label: 'Signature', color: '#2563eb' }, // blue
|
||||||
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
|
{ id: 'initials', label: 'Initials', color: '#7c3aed' }, // purple
|
||||||
{ id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green
|
{ id: 'checkbox', label: 'Checkbox', color: '#059669' }, // green
|
||||||
{ id: 'date', label: 'Date', color: '#d97706' }, // amber
|
{ id: 'date', label: 'Date', color: '#d97706' }, // amber
|
||||||
{ id: 'text', label: 'Text', color: '#64748b' }, // slate
|
{ id: 'text', label: 'Text', color: '#64748b' }, // slate
|
||||||
|
{ id: 'agent-signature', label: 'Agent Signature', color: '#dc2626' }, // red
|
||||||
];
|
];
|
||||||
|
|
||||||
// Draggable token in the palette
|
// 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 { usePathname } from "next/navigation";
|
||||||
import { LogoutButton } from "@/components/ui/LogoutButton";
|
import { LogoutButton } from "@/components/ui/LogoutButton";
|
||||||
|
|
||||||
interface PortalNavProps {
|
|
||||||
userEmail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/portal/dashboard", label: "Dashboard" },
|
{ href: "/portal/dashboard", label: "Dashboard" },
|
||||||
{ href: "/portal/clients", label: "Clients" },
|
{ href: "/portal/clients", label: "Clients" },
|
||||||
|
{ href: "/portal/profile", label: "Profile" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PortalNav({ userEmail }: PortalNavProps) {
|
export function PortalNav({ userEmail }: { userEmail: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-[var(--navy)] text-white">
|
<nav style={{ position: "sticky", top: 0, zIndex: 50, backgroundColor: "#1B2B4B", color: "#FAF9F7" }}>
|
||||||
<div className="flex items-center justify-between px-6 py-3">
|
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem", height: "64px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
{/* Left: site name + nav links */}
|
{/* Wordmark */}
|
||||||
<div className="flex items-center gap-8">
|
<span style={{ fontFamily: "var(--font-serif), Georgia, serif", fontWeight: 600, fontSize: "1.25rem", color: "#FAF9F7", letterSpacing: "0.02em" }}>
|
||||||
<span className="text-base font-semibold tracking-wide">
|
Teressa Copeland
|
||||||
Teressa Copeland
|
</span>
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-6">
|
{/* Nav links + right side */}
|
||||||
{navLinks.map(({ href, label }) => {
|
<div style={{ display: "flex", alignItems: "center", gap: "2rem" }}>
|
||||||
const isActive = pathname.startsWith(href);
|
{navLinks.map(({ href, label }) => {
|
||||||
return (
|
const isActive = pathname.startsWith(href);
|
||||||
<Link
|
return (
|
||||||
key={href}
|
<Link
|
||||||
href={href}
|
key={href}
|
||||||
className={`text-sm hover:opacity-80 transition-opacity pb-0.5 ${
|
href={href}
|
||||||
isActive
|
style={{
|
||||||
? "border-b-2 border-[var(--gold)] font-medium"
|
color: "#FAF9F7",
|
||||||
: "border-b-2 border-transparent"
|
textDecoration: "none",
|
||||||
}`}
|
fontSize: "0.8125rem",
|
||||||
>
|
letterSpacing: "0.08em",
|
||||||
{label}
|
textTransform: "uppercase",
|
||||||
</Link>
|
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>
|
||||||
</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>
|
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user