WORKFLOW-APP/app/sign/[token]/page.tsx

222 lines
7.4 KiB
TypeScript

// app/sign/[token]/page.tsx
// Public page — director draws signature and submits
// Accessible without login via /sign/<token>
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
interface AppointmentData {
id: string;
status: string;
expand?: {
director?: { first_names?: string; surname?: string };
company?: { enterprise_name?: string };
};
}
export default function SignPage() {
const { token } = useParams<{ token: string }>();
const [appt, setAppt] = useState<AppointmentData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
useEffect(() => {
fetch(`/api/appointments/sign/${token}`)
.then((r) => r.json())
.then((d) => {
if (d.error) { setError("Appointment not found."); }
else { setAppt(d); }
})
.catch(() => setError("Failed to load appointment."))
.finally(() => setLoading(false));
}, [token]);
// Canvas drawing handlers
const getPos = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
// Scale from display CSS pixels to canvas internal pixels
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
if ("touches" in e) {
return {
x: (e.touches[0].clientX - rect.left) * scaleX,
y: (e.touches[0].clientY - rect.top) * scaleY,
};
}
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
const startDraw = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
drawing.current = true;
lastPos.current = getPos(e);
};
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!drawing.current) return;
e.preventDefault();
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
const pos = getPos(e);
ctx.strokeStyle = "#1a1a2e";
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(lastPos.current.x, lastPos.current.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPos.current = pos;
};
const stopDraw = (e?: React.MouseEvent | React.TouchEvent) => {
if (e) e.preventDefault();
drawing.current = false;
};
const clearCanvas = () => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const handleSubmit = async () => {
const canvas = canvasRef.current!;
const dataUrl = canvas.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
setSubmitting(true);
try {
const res = await fetch(`/api/appointments/${appt!.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signature_base64: base64 }),
});
if (!res.ok) throw new Error("Failed to submit");
setSubmitted(true);
} catch {
alert("Failed to submit signature. Please try again.");
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="text-slate-500">Loading...</div>
</div>
);
}
if (error || !appt) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="bg-white rounded-xl shadow p-8 text-center max-w-sm">
<h1 className="text-xl font-bold text-slate-800 mb-2">Appointment Not Found</h1>
<p className="text-slate-500">{error || "This link may be invalid or has expired."}</p>
</div>
</div>
);
}
const isSigned = submitted || appt.status === "signature_received" || appt.status === "mail_sent_to_all";
if (isSigned) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="bg-white rounded-xl shadow p-8 text-center max-w-sm">
<div className="text-5xl mb-4">✓</div>
<h1 className="text-xl font-bold text-slate-800 mb-2">Signature Submitted</h1>
<p className="text-slate-500">
Your acceptance has been recorded. The company will be notified.
</p>
</div>
</div>
);
}
const directorName = [appt.expand?.director?.first_names, appt.expand?.director?.surname]
.filter(Boolean).join(" ");
const companyName = appt.expand?.company?.enterprise_name || "the Company";
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="bg-indigo-600 px-8 py-6">
<h1 className="text-white text-xl font-bold">Accept Director Appointment</h1>
<p className="text-indigo-200 mt-1 text-sm">
{directorName} — {companyName}
</p>
</div>
{/* Body */}
<div className="px-8 py-6 space-y-5">
<p className="text-slate-600 text-sm">
Please draw your signature below to accept your appointment as director of{" "}
<strong>{companyName}</strong>.
</p>
{/* Signature canvas */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Your Signature
</label>
<div className="relative border-2 border-indigo-200 rounded-xl overflow-hidden bg-white">
<canvas
ref={canvasRef}
width={560}
height={200}
className="w-full cursor-crosshair touch-none select-none"
style={{ height: 200, display: "block" }}
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={stopDraw}
/>
<div className="absolute bottom-2 right-3 text-xs text-slate-400">
Draw with mouse or finger
</div>
</div>
<button
onClick={clearCanvas}
className="mt-2 text-xs text-slate-400 hover:text-slate-600 underline"
>
Clear signature
</button>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={submitting}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 text-white rounded-xl font-semibold text-base transition-colors"
>
{submitting ? "Submitting..." : "Submit Signature"}
</button>
</div>
{/* Footer */}
<div className="px-8 py-4 bg-slate-50 border-t border-slate-100">
<p className="text-xs text-slate-400 text-center">
By submitting, you accept your appointment as director in terms of section 70 of the Companies Act 71 of 2008.
</p>
</div>
</div>
</div>
);
}