222 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|