603 lines
21 KiB
TypeScript
603 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { PenTool, RotateCcw, Check, Loader2, AlertCircle, FileText, Info, Upload, Image as ImageIcon } from 'lucide-react';
|
|
import dynamic from 'next/dynamic';
|
|
|
|
// Charger le PDFImageViewer côté client uniquement (conversion PDF vers images comme Docuseal)
|
|
const PDFImageViewer = dynamic(() => import('./PDFImageViewer'), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
|
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
|
|
<p className="text-slate-600 font-medium">Chargement du visualiseur...</p>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
interface SignatureCaptureProps {
|
|
signerId: string;
|
|
requestId: string;
|
|
signerName: string;
|
|
signerRole: string;
|
|
documentTitle: string;
|
|
sessionToken: string;
|
|
onCompleted: () => void;
|
|
}
|
|
|
|
interface SignPosition {
|
|
page: number;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
role: string;
|
|
}
|
|
|
|
export default function SignatureCapture({
|
|
signerId,
|
|
requestId,
|
|
signerName,
|
|
signerRole,
|
|
documentTitle,
|
|
sessionToken,
|
|
onCompleted,
|
|
}: SignatureCaptureProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [signatureMode, setSignatureMode] = useState<'draw' | 'upload'>('draw');
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [hasDrawn, setHasDrawn] = useState(false);
|
|
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
|
|
const [consentChecked, setConsentChecked] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastPoint, setLastPoint] = useState<{ x: number; y: number } | null>(null);
|
|
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
|
|
|
// PDF Viewer state
|
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
|
const [signaturePositions, setSignaturePositions] = useState<SignPosition[]>([]);
|
|
const [isPdfLoading, setIsPdfLoading] = useState(true);
|
|
|
|
// Load PDF and signature positions
|
|
useEffect(() => {
|
|
async function loadPdfAndPositions() {
|
|
try {
|
|
// Get PDF presigned URL
|
|
const pdfResponse = await fetch(`/api/odentas-sign/requests/${requestId}/pdf-url`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionToken}`,
|
|
},
|
|
});
|
|
|
|
if (!pdfResponse.ok) {
|
|
throw new Error('Impossible de charger le document PDF');
|
|
}
|
|
|
|
const pdfData = await pdfResponse.json();
|
|
setPdfUrl(pdfData.url);
|
|
|
|
// Get signature positions
|
|
const positionsResponse = await fetch(`/api/odentas-sign/requests/${requestId}/positions`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionToken}`,
|
|
},
|
|
});
|
|
|
|
if (!positionsResponse.ok) {
|
|
throw new Error('Impossible de charger les positions de signature');
|
|
}
|
|
|
|
const positionsData = await positionsResponse.json();
|
|
console.log('[SignatureCapture] Positions chargées:', {
|
|
total: positionsData.positions?.length || 0,
|
|
positions: positionsData.positions,
|
|
currentRole: signerRole,
|
|
});
|
|
setSignaturePositions(positionsData.positions || []);
|
|
} catch (err) {
|
|
console.error('[PDF] Erreur lors du chargement:', err);
|
|
setError(err instanceof Error ? err.message : 'Erreur de chargement');
|
|
} finally {
|
|
setIsPdfLoading(false);
|
|
}
|
|
}
|
|
|
|
loadPdfAndPositions();
|
|
}, [requestId, sessionToken]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Set canvas size to match display size
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = rect.width * window.devicePixelRatio;
|
|
canvas.height = rect.height * window.devicePixelRatio;
|
|
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
|
|
|
// Set drawing style
|
|
ctx.strokeStyle = '#1e293b';
|
|
ctx.lineWidth = 3;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
}, []);
|
|
|
|
function getCoordinates(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
if ('touches' in e) {
|
|
return {
|
|
x: e.touches[0].clientX - rect.left,
|
|
y: e.touches[0].clientY - rect.top,
|
|
};
|
|
}
|
|
|
|
return {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
};
|
|
}
|
|
|
|
function startDrawing(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
|
e.preventDefault();
|
|
setIsDrawing(true);
|
|
setHasDrawn(true);
|
|
|
|
const coords = getCoordinates(e);
|
|
setLastPoint(coords);
|
|
setPoints([coords]);
|
|
|
|
const ctx = canvasRef.current?.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(coords.x, coords.y);
|
|
}
|
|
|
|
function draw(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
|
e.preventDefault();
|
|
if (!isDrawing) return;
|
|
|
|
const coords = getCoordinates(e);
|
|
const ctx = canvasRef.current?.getContext('2d');
|
|
if (!ctx || !lastPoint) return;
|
|
|
|
// Ajouter le nouveau point
|
|
const newPoints = [...points, coords];
|
|
setPoints(newPoints);
|
|
|
|
// Dessiner une courbe de Bézier quadratique pour un tracé très fluide
|
|
if (newPoints.length >= 2) {
|
|
const p0 = lastPoint;
|
|
const p1 = coords;
|
|
|
|
// Point milieu pour lisser encore plus
|
|
const midX = (p0.x + p1.x) / 2;
|
|
const midY = (p0.y + p1.y) / 2;
|
|
|
|
// Dessiner une courbe de Bézier avec le point précédent comme contrôle
|
|
ctx.quadraticCurveTo(p0.x, p0.y, midX, midY);
|
|
ctx.stroke();
|
|
}
|
|
|
|
setLastPoint(coords);
|
|
}
|
|
|
|
function stopDrawing() {
|
|
const ctx = canvasRef.current?.getContext('2d');
|
|
if (ctx && points.length > 0) {
|
|
// Terminer la ligne jusqu'au dernier point
|
|
const lastPt = points[points.length - 1];
|
|
ctx.lineTo(lastPt.x, lastPt.y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
setIsDrawing(false);
|
|
setLastPoint(null);
|
|
setPoints([]);
|
|
}
|
|
|
|
function clearSignature() {
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext('2d');
|
|
if (!canvas || !ctx) return;
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
setHasDrawn(false);
|
|
setPoints([]);
|
|
setUploadedImage(null);
|
|
}
|
|
|
|
function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Vérifier le type de fichier
|
|
if (!file.type.startsWith('image/')) {
|
|
setError('Veuillez sélectionner une image (PNG, JPG, etc.)');
|
|
return;
|
|
}
|
|
|
|
// Vérifier la taille (max 5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
setError('L\'image est trop volumineuse (max 5MB)');
|
|
return;
|
|
}
|
|
|
|
// Lire le fichier et l'afficher dans le canvas
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext('2d');
|
|
if (!canvas || !ctx) return;
|
|
|
|
// Effacer le canvas
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Calculer les dimensions pour conserver le ratio
|
|
const canvasWidth = canvas.width / window.devicePixelRatio;
|
|
const canvasHeight = canvas.height / window.devicePixelRatio;
|
|
const imgRatio = img.width / img.height;
|
|
const canvasRatio = canvasWidth / canvasHeight;
|
|
|
|
let drawWidth, drawHeight, offsetX, offsetY;
|
|
|
|
if (imgRatio > canvasRatio) {
|
|
// Image plus large
|
|
drawWidth = canvasWidth;
|
|
drawHeight = canvasWidth / imgRatio;
|
|
offsetX = 0;
|
|
offsetY = (canvasHeight - drawHeight) / 2;
|
|
} else {
|
|
// Image plus haute
|
|
drawHeight = canvasHeight;
|
|
drawWidth = canvasHeight * imgRatio;
|
|
offsetX = (canvasWidth - drawWidth) / 2;
|
|
offsetY = 0;
|
|
}
|
|
|
|
// Dessiner l'image centrée
|
|
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
|
|
|
|
setHasDrawn(true);
|
|
setUploadedImage(event.target?.result as string);
|
|
setError(null);
|
|
};
|
|
img.src = event.target?.result as string;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function canvasToBase64(): string {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) {
|
|
throw new Error('Canvas not found');
|
|
}
|
|
|
|
// Get full data URL including the data:image/png;base64, prefix
|
|
return canvas.toDataURL('image/png');
|
|
}
|
|
|
|
async function submitSignature() {
|
|
if ((!hasDrawn && !uploadedImage) || !consentChecked) return;
|
|
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Convert canvas to base64 (fonctionne pour les deux modes)
|
|
const signatureImageBase64 = canvasToBase64();
|
|
|
|
const consentText = `Je consens à signer électroniquement le document "${documentTitle}" et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.`;
|
|
|
|
// Submit
|
|
const response = await fetch(`/api/odentas-sign/signers/${signerId}/sign`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${sessionToken}`,
|
|
},
|
|
body: JSON.stringify({
|
|
signatureImageBase64,
|
|
consentText,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || 'Erreur lors de la signature');
|
|
}
|
|
|
|
// Success!
|
|
onCompleted();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
>
|
|
{/* En-tête du document */}
|
|
<div className="bg-white rounded-lg border border-slate-200 mb-6">
|
|
<div className="border-b border-slate-200 px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center">
|
|
<FileText className="w-5 h-5 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">Document à signer</h2>
|
|
<p className="text-sm text-slate-600">{documentTitle}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs text-slate-500 mb-1">Signataire</p>
|
|
<p className="text-sm font-semibold text-slate-900">{signerName}</p>
|
|
<p className="text-xs text-slate-600">{signerRole}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visualiseur PDF */}
|
|
<div className="p-4">
|
|
{isPdfLoading ? (
|
|
<div className="h-[600px] bg-slate-50 rounded-lg flex flex-col items-center justify-center">
|
|
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin mb-3" />
|
|
<p className="text-sm text-slate-600">Chargement du document...</p>
|
|
</div>
|
|
) : pdfUrl ? (
|
|
<div className="h-[600px]">
|
|
<PDFImageViewer
|
|
pdfUrl={pdfUrl}
|
|
positions={signaturePositions}
|
|
currentSignerRole={signerRole}
|
|
requestId={requestId}
|
|
sessionToken={sessionToken}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="h-[600px] bg-slate-50 rounded-lg flex flex-col items-center justify-center">
|
|
<FileText className="w-12 h-12 text-slate-300 mb-3" />
|
|
<p className="text-sm text-slate-500">Aucun document à afficher</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section signature - Full width en dessous */}
|
|
<div className="bg-white rounded-lg border border-slate-200">
|
|
<div className="border-b border-slate-200 px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center">
|
|
<PenTool className="w-5 h-5 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">Votre signature</h2>
|
|
<p className="text-sm text-slate-600">Dessinez ou importez votre signature</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="max-w-3xl mx-auto space-y-6">
|
|
{/* Onglets */}
|
|
<div className="flex gap-2 p-1 bg-slate-100 rounded-lg">
|
|
<button
|
|
onClick={() => {
|
|
setSignatureMode('draw');
|
|
clearSignature();
|
|
}}
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
signatureMode === 'draw'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<PenTool className="w-4 h-4" />
|
|
Dessiner
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSignatureMode('upload');
|
|
clearSignature();
|
|
}}
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
signatureMode === 'upload'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Upload className="w-4 h-4" />
|
|
Importer une image
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Zone de signature */}
|
|
<div>
|
|
{signatureMode === 'draw' ? (
|
|
// Mode dessin
|
|
<div>
|
|
<div className="border-2 border-dashed border-slate-300 rounded-lg overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
|
|
<canvas
|
|
ref={canvasRef}
|
|
onMouseDown={startDrawing}
|
|
onMouseMove={draw}
|
|
onMouseUp={stopDrawing}
|
|
onMouseLeave={stopDrawing}
|
|
onTouchStart={startDrawing}
|
|
onTouchMove={draw}
|
|
onTouchEnd={stopDrawing}
|
|
className="w-full h-40 cursor-crosshair touch-none"
|
|
style={{ touchAction: 'none' }}
|
|
/>
|
|
|
|
{!hasDrawn && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="text-center">
|
|
<PenTool className="w-6 h-6 text-slate-400 mx-auto mb-2" />
|
|
<p className="text-slate-500 text-sm">Signez ici avec votre souris ou doigt</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bouton recommencer */}
|
|
{hasDrawn && (
|
|
<motion.button
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
onClick={clearSignature}
|
|
disabled={isSubmitting}
|
|
className="mt-3 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-50 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
Recommencer
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// Mode upload
|
|
<div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageUpload}
|
|
className="hidden"
|
|
/>
|
|
|
|
<div className="border-2 border-dashed border-slate-300 rounded-lg overflow-hidden bg-white relative">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-40"
|
|
/>
|
|
|
|
{!uploadedImage && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isSubmitting}
|
|
className="flex flex-col items-center gap-3 px-6 py-4 hover:bg-slate-50 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<div className="w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center">
|
|
<Upload className="w-6 h-6 text-indigo-600" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-slate-900 mb-1">
|
|
Cliquez pour importer une image
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
PNG, JPG, max 5MB
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Boutons pour mode upload */}
|
|
{uploadedImage && (
|
|
<div className="mt-3 flex gap-2">
|
|
<motion.button
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
onClick={clearSignature}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-50 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
Changer d'image
|
|
</motion.button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Consentement */}
|
|
<div className="bg-slate-50 rounded-lg border border-slate-200 p-4">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<div className="flex-shrink-0 pt-0.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={consentChecked}
|
|
onChange={(e) => setConsentChecked(e.target.checked)}
|
|
disabled={isSubmitting}
|
|
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
|
|
/>
|
|
</div>
|
|
<div className="text-sm text-slate-700 leading-relaxed">
|
|
<p>
|
|
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
|
|
</p>
|
|
<p className="mt-2 text-xs text-slate-500">
|
|
Conforme au règlement eIDAS - Signature horodatée et archivée 10 ans
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Erreur */}
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2"
|
|
>
|
|
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-700">{error}</p>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Bouton de validation */}
|
|
<button
|
|
onClick={submitSignature}
|
|
disabled={(!hasDrawn && !uploadedImage) || !consentChecked || isSubmitting}
|
|
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Signature en cours...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className="w-5 h-5" />
|
|
Valider ma signature
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|