Signed-off-by: Rodrigo B. de Sousa Martins <rodrigo@martinsevieira.adv.br>
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import { toast } from 'react-toastify';
|
|
|
|
// Página pública: NÃO requer autenticação do usuário
|
|
|
|
export function ClientProvaUpload() {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
|
const [locationPermission, setLocationPermission] = useState<string>('pending');
|
|
const [filePreview, setFilePreview] = useState<string | null>(null);
|
|
const navigate = useNavigate();
|
|
|
|
// Tentar obter a localização do usuário quando o componente montar
|
|
useEffect(() => {
|
|
if (navigator.geolocation) {
|
|
setLocationPermission('requesting');
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
console.log('Localização obtida com sucesso');
|
|
setUserLocation({
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude
|
|
});
|
|
setLocationPermission('granted');
|
|
},
|
|
(error) => {
|
|
console.log('Erro ao obter localização:', error.message);
|
|
setLocationPermission('denied');
|
|
},
|
|
{
|
|
enableHighAccuracy: true,
|
|
timeout: 5000,
|
|
maximumAge: 0
|
|
}
|
|
);
|
|
} else {
|
|
console.log('Geolocalização não suportada pelo navegador');
|
|
setLocationPermission('unsupported');
|
|
}
|
|
}, []);
|
|
|
|
// Validação extra do arquivo antes do upload
|
|
function validateFile(file: File | null): string | null {
|
|
if (!file) return 'Nenhum arquivo selecionado.';
|
|
const allowedTypes = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'
|
|
];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
return 'Tipo de arquivo não suportado. Use JPG, PNG, GIF, WEBP ou PDF.';
|
|
}
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
return 'Arquivo excede o tamanho máximo de 10MB.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
if (acceptedFiles.length > 0) {
|
|
const selectedFile = acceptedFiles[0];
|
|
const validationError = validateFile(selectedFile);
|
|
if (validationError) {
|
|
toast.error(validationError);
|
|
setFile(null);
|
|
setFilePreview(null);
|
|
return;
|
|
}
|
|
setFile(selectedFile);
|
|
|
|
// Criar preview para imagens
|
|
if (selectedFile.type.startsWith('image/')) {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
setFilePreview(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(selectedFile);
|
|
} else {
|
|
setFilePreview(null);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept: {
|
|
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
|
'application/pdf': ['.pdf']
|
|
},
|
|
multiple: false,
|
|
maxSize: 10 * 1024 * 1024 // 10MB
|
|
});
|
|
|
|
const handleSubmit = async () => {
|
|
try {
|
|
if (!file) {
|
|
toast.error('Por favor, selecione um arquivo primeiro');
|
|
return;
|
|
}
|
|
// Validação extra antes do envio
|
|
const validationError = validateFile(file);
|
|
if (validationError) {
|
|
toast.error(validationError);
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
setUploadProgress(0);
|
|
setError(null);
|
|
|
|
const formData = new FormData();
|
|
|
|
// Adicionar arquivo com todas as informações preservadas
|
|
formData.append('files', file);
|
|
formData.append('modification_times', String(file.lastModified));
|
|
formData.append('file_names', file.name);
|
|
formData.append('file_sizes', String(file.size));
|
|
formData.append('file_types', file.type);
|
|
|
|
// Adicionar metadados de contexto
|
|
formData.append('user_agent', navigator.userAgent);
|
|
formData.append('language', navigator.language);
|
|
formData.append('viewport_width', String(window.innerWidth));
|
|
formData.append('viewport_height', String(window.innerHeight));
|
|
formData.append('referrer', document.referrer);
|
|
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
formData.append('timezone_offset', String(new Date().getTimezoneOffset()));
|
|
formData.append('platform', navigator.platform);
|
|
formData.append('cookies_enabled', String(navigator.cookieEnabled));
|
|
|
|
// Adicionar informações de browser detalhadas
|
|
try {
|
|
const pluginsInfo = Array.from(navigator.plugins).map(p => p.name).join(',');
|
|
formData.append('browser_plugins', pluginsInfo);
|
|
} catch (e) {
|
|
formData.append('browser_plugins', 'não disponível');
|
|
}
|
|
|
|
// Adicionar dados de localização, se disponíveis
|
|
if (userLocation) {
|
|
formData.append('latitude', String(userLocation.latitude));
|
|
formData.append('longitude', String(userLocation.longitude));
|
|
}
|
|
formData.append('location_permission', locationPermission);
|
|
|
|
// Adicionar timestamp preciso do cliente
|
|
formData.append('client_timestamp', new Date().toISOString());
|
|
|
|
// Simulação de progresso
|
|
const progressInterval = setInterval(() => {
|
|
setUploadProgress(prev => {
|
|
if (prev >= 95) {
|
|
clearInterval(progressInterval);
|
|
return prev;
|
|
}
|
|
return prev + 5;
|
|
});
|
|
}, 300);
|
|
|
|
// Upload sem autenticação (página pública)
|
|
let response: Response;
|
|
try {
|
|
response = await fetch('/api/provas/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
} catch (networkError) {
|
|
clearInterval(progressInterval);
|
|
setUploadProgress(0);
|
|
throw new Error('Erro de conexão. Verifique sua internet e tente novamente.');
|
|
}
|
|
|
|
clearInterval(progressInterval);
|
|
setUploadProgress(100);
|
|
|
|
// Verificar se a resposta foi bem-sucedida
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
let errorMessage;
|
|
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
errorMessage = errorJson.error || errorJson.details || 'Erro ao enviar arquivo';
|
|
} catch {
|
|
errorMessage = errorText || 'Erro ao enviar arquivo';
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Ler o corpo da resposta
|
|
let result: any;
|
|
try {
|
|
result = await response.json();
|
|
} catch {
|
|
throw new Error('Resposta inesperada do servidor.');
|
|
}
|
|
|
|
if (result && result.id) {
|
|
toast.success('Arquivo enviado com sucesso!');
|
|
// Navegar para a página de status
|
|
navigate(`/public/provas/status/${result.id}`);
|
|
} else {
|
|
throw new Error('ID do documento não encontrado na resposta');
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error('Erro detalhado no upload:', error);
|
|
setError(error.message || 'Erro ao enviar arquivo. Por favor, tente novamente.');
|
|
toast.error('Falha no upload: ' + (error.message || 'Erro desconhecido'));
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 flex flex-col justify-center">
|
|
<div className="max-w-3xl mx-auto w-full">
|
|
<div className="text-center mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900">Validação Digital de Documentos</h1>
|
|
<p className="mt-2 text-lg text-gray-600">
|
|
Valide e proteja a autenticidade de seus documentos e imagens com timestamping seguro
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white shadow overflow-hidden rounded-lg">
|
|
<div className="px-4 py-5 sm:px-6 bg-blue-700 text-white">
|
|
<h2 className="text-xl font-semibold">Upload de Documento para Validação</h2>
|
|
<p className="mt-1 text-sm text-blue-100">
|
|
Formatos aceitos: JPG, PNG, GIF, PDF (máx. 10MB)
|
|
</p>
|
|
</div>
|
|
<div className="px-4 py-5 sm:p-6">
|
|
<div
|
|
{...getRootProps()}
|
|
className={`border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors ${
|
|
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
|
|
}`}
|
|
>
|
|
<input {...getInputProps()} />
|
|
|
|
{filePreview ? (
|
|
<div className="space-y-4">
|
|
<div className="mx-auto max-w-sm">
|
|
<img
|
|
src={filePreview}
|
|
alt="Preview"
|
|
className="max-h-60 mx-auto object-contain rounded-lg shadow-sm"
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
{file?.name} ({(file?.size || 0) / 1024 < 1024
|
|
? `${Math.round((file?.size || 0) / 1024)} KB`
|
|
: `${Math.round((file?.size || 0) / 1024 / 1024 * 10) / 10} MB`})
|
|
</p>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setFile(null);
|
|
setFilePreview(null);
|
|
}}
|
|
className="text-sm text-red-600 hover:text-red-800"
|
|
>
|
|
Remover arquivo
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<svg className="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
|
<path d="M24 8v16m0 0v16m0-16h16m-16 0H8" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
<p className="text-lg font-medium text-gray-900">
|
|
{isDragActive
|
|
? "Solte o arquivo aqui..."
|
|
: "Arraste e solte um arquivo aqui, ou clique para selecionar"}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Os metadados originais e informações de contexto serão preservados para melhor validação
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{locationPermission === 'granted' && (
|
|
<div className="mt-4 text-sm text-green-600">
|
|
✓ Localização obtida: Esta informação ajudará na validação do documento
|
|
</div>
|
|
)}
|
|
|
|
{locationPermission === 'denied' && (
|
|
<div className="mt-4 text-sm text-yellow-600">
|
|
⚠️ Localização não permitida: A validação ainda funcionará, mas sem dados de geolocalização
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-4 text-sm text-red-600 p-3 bg-red-50 rounded-md">
|
|
Erro: {error}
|
|
</div>
|
|
)}
|
|
|
|
{uploading && (
|
|
<div className="mt-6">
|
|
<div className="relative pt-1">
|
|
<div className="flex mb-2 items-center justify-between">
|
|
<div>
|
|
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-blue-600 bg-blue-200">
|
|
Enviando arquivo
|
|
</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-xs font-semibold inline-block text-blue-600">
|
|
{uploadProgress}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-blue-200">
|
|
<div
|
|
style={{ width: `${uploadProgress}%` }}
|
|
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500 transition-all duration-300">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6 flex justify-end">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!file || uploading}
|
|
className={`px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white
|
|
${!file || uploading
|
|
? 'bg-gray-300 cursor-not-allowed'
|
|
: 'bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
|
|
}`}
|
|
>
|
|
{uploading ? 'Enviando...' : 'Enviar para Validação'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="px-4 py-4 sm:px-6 bg-gray-50 border-t border-gray-200">
|
|
<div className="text-xs text-gray-500">
|
|
<strong>Nota sobre privacidade:</strong> Este serviço coleta metadados como informações do dispositivo,
|
|
localização e timestamp para garantir a validação adequada do documento. Todos os dados são criptografados
|
|
e armazenados com segurança.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|