trading-platform-frontend-v2/src/modules/investment/components/KYCVerificationPanel.tsx
Adrian Flores Cortes c9e2727d3b [SPRINT-3] feat(investment,payments): Dashboard, KYC, transactions and alt payments
SUBTASK-005 (Investment):
- Rewrite Investment.tsx with summary cards and performance chart
- Add pagination to Transactions.tsx (10 items per page)
- Add PDF/CSV export dropdown to Reports.tsx
- Fix quick amount buttons in DepositForm.tsx
- Fix Max button in WithdrawForm.tsx
- Add full KYC verification system (3 steps)
- Add KYCVerification page with route /investment/kyc

SUBTASK-006 (Payments):
- Add AlternativePaymentMethods component (OXXO, SPEI, Card)
- Extend payment types for regional methods
- Update PaymentMethodsList exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:17:46 -06:00

612 lines
23 KiB
TypeScript

/**
* KYCVerificationPanel Component
* Complete KYC verification flow with document upload
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useState, useRef } from 'react';
import {
Shield,
User,
FileText,
Upload,
CheckCircle,
AlertCircle,
Camera,
X,
Loader2,
Clock,
MapPin,
Briefcase,
} from 'lucide-react';
import { KYCStatusBadge, KYCStatus } from './KYCStatusBadge';
interface PersonalData {
fullName: string;
dateOfBirth: string;
nationality: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
occupation: string;
incomeSource: string;
}
interface Document {
id: string;
type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie';
fileName: string;
status: 'pending' | 'approved' | 'rejected';
previewUrl?: string;
rejectionReason?: string;
}
interface KYCVerificationPanelProps {
status: KYCStatus;
personalData?: Partial<PersonalData>;
documents?: Document[];
rejectionReason?: string;
onSubmit?: (data: { personalData: PersonalData; documents: File[] }) => Promise<void>;
onCancel?: () => void;
}
type Step = 'personal' | 'documents' | 'review';
const DOCUMENT_TYPES = [
{ id: 'id_front', label: 'ID Frontal', description: 'INE, Pasaporte o Licencia de conducir (frente)', icon: FileText },
{ id: 'id_back', label: 'ID Reverso', description: 'Parte posterior del documento', icon: FileText },
{ id: 'proof_of_address', label: 'Comprobante de Domicilio', description: 'Recibo de luz, agua o estado de cuenta (no mayor a 3 meses)', icon: MapPin },
{ id: 'selfie', label: 'Selfie con ID', description: 'Foto tuya sosteniendo tu identificación', icon: Camera },
];
const NATIONALITY_OPTIONS = [
'Mexico', 'United States', 'Canada', 'Spain', 'Argentina',
'Colombia', 'Chile', 'Peru', 'Brazil', 'United Kingdom', 'Germany', 'France',
];
const OCCUPATION_OPTIONS = [
'Empleado', 'Empresario', 'Profesionista Independiente', 'Comerciante',
'Estudiante', 'Jubilado', 'Otro',
];
const INCOME_SOURCE_OPTIONS = [
'Salario', 'Negocio Propio', 'Inversiones', 'Herencia',
'Ahorros', 'Pensión', 'Otro',
];
export const KYCVerificationPanel: React.FC<KYCVerificationPanelProps> = ({
status,
personalData: initialPersonalData,
documents: existingDocuments = [],
rejectionReason,
onSubmit,
onCancel,
}) => {
const [currentStep, setCurrentStep] = useState<Step>(status === 'not_started' ? 'personal' : 'review');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentDocType, setCurrentDocType] = useState<string | null>(null);
const [personalData, setPersonalData] = useState<PersonalData>({
fullName: initialPersonalData?.fullName || '',
dateOfBirth: initialPersonalData?.dateOfBirth || '',
nationality: initialPersonalData?.nationality || '',
address: initialPersonalData?.address || '',
city: initialPersonalData?.city || '',
state: initialPersonalData?.state || '',
postalCode: initialPersonalData?.postalCode || '',
country: initialPersonalData?.country || '',
occupation: initialPersonalData?.occupation || '',
incomeSource: initialPersonalData?.incomeSource || '',
});
const [uploadedDocuments, setUploadedDocuments] = useState<Map<string, { file: File; preview: string }>>(new Map());
const handlePersonalDataChange = (field: keyof PersonalData, value: string) => {
setPersonalData(prev => ({ ...prev, [field]: value }));
};
const isPersonalDataValid = () => {
return (
personalData.fullName.length >= 3 &&
personalData.dateOfBirth !== '' &&
personalData.nationality !== '' &&
personalData.address.length >= 5 &&
personalData.city.length >= 2 &&
personalData.country !== '' &&
personalData.occupation !== '' &&
personalData.incomeSource !== ''
);
};
const handleFileSelect = (docType: string) => {
setCurrentDocType(docType);
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentDocType) return;
if (!file.type.startsWith('image/') && file.type !== 'application/pdf') {
setError('Solo se permiten imágenes o archivos PDF');
return;
}
if (file.size > 10 * 1024 * 1024) {
setError('El archivo no debe exceder 10MB');
return;
}
const preview = URL.createObjectURL(file);
setUploadedDocuments(prev => {
const newMap = new Map(prev);
const existing = prev.get(currentDocType);
if (existing?.preview) {
URL.revokeObjectURL(existing.preview);
}
newMap.set(currentDocType, { file, preview });
return newMap;
});
setError(null);
setCurrentDocType(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const removeDocument = (docType: string) => {
setUploadedDocuments(prev => {
const newMap = new Map(prev);
const existing = prev.get(docType);
if (existing?.preview) {
URL.revokeObjectURL(existing.preview);
}
newMap.delete(docType);
return newMap;
});
};
const isDocumentsComplete = () => {
return DOCUMENT_TYPES.every(doc => uploadedDocuments.has(doc.id) || existingDocuments.some(d => d.type === doc.id && d.status === 'approved'));
};
const handleSubmit = async () => {
if (!onSubmit) return;
setIsSubmitting(true);
setError(null);
try {
const files = Array.from(uploadedDocuments.values()).map(d => d.file);
await onSubmit({ personalData, documents: files });
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al enviar la verificación');
} finally {
setIsSubmitting(false);
}
};
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-4 mb-8">
{(['personal', 'documents', 'review'] as Step[]).map((step, index) => {
const isActive = step === currentStep;
const isCompleted =
(step === 'personal' && (currentStep === 'documents' || currentStep === 'review')) ||
(step === 'documents' && currentStep === 'review');
return (
<React.Fragment key={step}>
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${
isActive ? 'bg-blue-600 text-white' :
isCompleted ? 'bg-green-600 text-white' :
'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}
>
{isCompleted ? <CheckCircle className="w-5 h-5" /> : index + 1}
</div>
<span className={`text-xs mt-1 ${isActive ? 'text-white' : 'text-gray-400'}`}>
{step === 'personal' ? 'Datos' : step === 'documents' ? 'Documentos' : 'Revisión'}
</span>
</div>
{index < 2 && (
<div className={`w-16 h-0.5 ${isCompleted ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-700'}`} />
)}
</React.Fragment>
);
})}
</div>
);
const renderPersonalDataStep = () => (
<div className="space-y-4">
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
<span className="text-blue-400 font-medium">Información Personal</span>
</div>
<p className="text-slate-400 text-sm mt-2">
Proporciona tus datos personales como aparecen en tu identificación oficial.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-300 mb-1">Nombre Completo</label>
<input
type="text"
value={personalData.fullName}
onChange={(e) => handlePersonalDataChange('fullName', e.target.value)}
placeholder="Como aparece en tu identificación"
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Fecha de Nacimiento</label>
<input
type="date"
value={personalData.dateOfBirth}
onChange={(e) => handlePersonalDataChange('dateOfBirth', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Nacionalidad</label>
<select
value={personalData.nationality}
onChange={(e) => handlePersonalDataChange('nationality', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{NATIONALITY_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-300 mb-1">Dirección</label>
<input
type="text"
value={personalData.address}
onChange={(e) => handlePersonalDataChange('address', e.target.value)}
placeholder="Calle y número"
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Ciudad</label>
<input
type="text"
value={personalData.city}
onChange={(e) => handlePersonalDataChange('city', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Estado/Provincia</label>
<input
type="text"
value={personalData.state}
onChange={(e) => handlePersonalDataChange('state', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Código Postal</label>
<input
type="text"
value={personalData.postalCode}
onChange={(e) => handlePersonalDataChange('postalCode', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">País</label>
<select
value={personalData.country}
onChange={(e) => handlePersonalDataChange('country', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{NATIONALITY_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Ocupación</label>
<select
value={personalData.occupation}
onChange={(e) => handlePersonalDataChange('occupation', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{OCCUPATION_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">Fuente de Ingresos</label>
<select
value={personalData.incomeSource}
onChange={(e) => handlePersonalDataChange('incomeSource', e.target.value)}
className="w-full px-4 py-2.5 bg-slate-800 border border-slate-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Seleccionar</option>
{INCOME_SOURCE_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
</div>
);
const renderDocumentsStep = () => (
<div className="space-y-4">
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-amber-400" />
<span className="text-amber-400 font-medium">Documentos Requeridos</span>
</div>
<p className="text-slate-400 text-sm mt-2">
Sube fotos claras de tus documentos. Asegúrate de que toda la información sea legible.
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={handleFileChange}
className="hidden"
/>
<div className="grid gap-4">
{DOCUMENT_TYPES.map((docType) => {
const uploaded = uploadedDocuments.get(docType.id);
const existing = existingDocuments.find(d => d.type === docType.id);
const Icon = docType.icon;
return (
<div
key={docType.id}
className={`p-4 rounded-lg border transition-colors ${
uploaded || (existing?.status === 'approved')
? 'border-green-500/50 bg-green-900/10'
: existing?.status === 'rejected'
? 'border-red-500/50 bg-red-900/10'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${
uploaded || (existing?.status === 'approved')
? 'bg-green-900/30 text-green-400'
: existing?.status === 'rejected'
? 'bg-red-900/30 text-red-400'
: 'bg-slate-700 text-slate-400'
}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="font-medium text-white">{docType.label}</p>
<p className="text-sm text-slate-400">{docType.description}</p>
{existing?.status === 'rejected' && existing.rejectionReason && (
<p className="text-sm text-red-400 mt-1">
<AlertCircle className="w-3 h-3 inline mr-1" />
{existing.rejectionReason}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{uploaded && (
<>
<img
src={uploaded.preview}
alt="Preview"
className="w-12 h-12 rounded object-cover"
/>
<button
onClick={() => removeDocument(docType.id)}
className="p-1 text-red-400 hover:text-red-300"
>
<X className="w-4 h-4" />
</button>
</>
)}
{!uploaded && existing?.status === 'approved' && (
<CheckCircle className="w-5 h-5 text-green-400" />
)}
{!uploaded && (!existing || existing.status === 'rejected') && (
<button
onClick={() => handleFileSelect(docType.id)}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 transition-colors"
>
<Upload className="w-4 h-4" />
Subir
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
const renderReviewStep = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<KYCStatusBadge status={status} rejectionReason={rejectionReason} size="lg" />
<p className="text-slate-400 mt-3">
{status === 'pending' && 'Tu documentación ha sido enviada y está pendiente de revisión.'}
{status === 'in_review' && 'Nuestro equipo está revisando tu documentación.'}
{status === 'approved' && 'Tu identidad ha sido verificada exitosamente.'}
{status === 'rejected' && 'Por favor, corrige los documentos rechazados y vuelve a enviar.'}
</p>
</div>
{status === 'approved' && (
<div className="bg-green-900/20 border border-green-800/50 rounded-lg p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Verificación Completa</h3>
<p className="text-slate-400">
Ya puedes realizar retiros y acceder a todas las funcionalidades de inversión.
</p>
</div>
)}
{(status === 'pending' || status === 'in_review') && (
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-6 text-center">
<Clock className="w-12 h-12 text-blue-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">En Proceso</h3>
<p className="text-slate-400">
La verificación puede tomar entre 24 y 48 horas. Te notificaremos cuando esté lista.
</p>
</div>
)}
{status === 'rejected' && (
<div className="bg-red-900/20 border border-red-800/50 rounded-lg p-6">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2 text-center">Verificación Rechazada</h3>
{rejectionReason && (
<p className="text-red-400 text-center mb-4">{rejectionReason}</p>
)}
<button
onClick={() => setCurrentStep('personal')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Reiniciar Verificación
</button>
</div>
)}
{personalData.fullName && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<h4 className="text-sm font-medium text-slate-400 mb-3">Datos Personales</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-slate-500">Nombre:</span>
<span className="text-white ml-2">{personalData.fullName}</span>
</div>
<div>
<span className="text-slate-500">Nacionalidad:</span>
<span className="text-white ml-2">{personalData.nationality}</span>
</div>
<div className="col-span-2">
<span className="text-slate-500">Dirección:</span>
<span className="text-white ml-2">
{personalData.address}, {personalData.city}, {personalData.country}
</span>
</div>
</div>
</div>
)}
</div>
);
return (
<div className="bg-slate-900 rounded-xl border border-slate-800 max-w-2xl mx-auto">
<div className="p-6 border-b border-slate-800">
<h2 className="text-white text-xl font-semibold flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
Verificación de Identidad (KYC)
</h2>
<p className="text-slate-400 text-sm mt-1">
Completa la verificación para habilitar retiros y acceder a todas las funcionalidades
</p>
</div>
{status === 'not_started' || status === 'rejected' ? (
<>
<div className="p-6 border-b border-slate-800">
{renderStepIndicator()}
</div>
<div className="p-6">
{currentStep === 'personal' && renderPersonalDataStep()}
{currentStep === 'documents' && renderDocumentsStep()}
{currentStep === 'review' && renderReviewStep()}
</div>
{error && (
<div className="px-6 pb-4">
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
)}
<div className="p-6 border-t border-slate-800 flex justify-between">
<button
onClick={currentStep === 'personal' ? onCancel : () => setCurrentStep(currentStep === 'documents' ? 'personal' : 'documents')}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors"
>
{currentStep === 'personal' ? 'Cancelar' : 'Atrás'}
</button>
{currentStep === 'review' ? (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Enviando...
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
Enviar Verificación
</>
)}
</button>
) : (
<button
onClick={() => setCurrentStep(currentStep === 'personal' ? 'documents' : 'review')}
disabled={
(currentStep === 'personal' && !isPersonalDataValid()) ||
(currentStep === 'documents' && !isDocumentsComplete())
}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Continuar
</button>
)}
</div>
</>
) : (
<div className="p-6">
{renderReviewStep()}
</div>
)}
</div>
);
};
export default KYCVerificationPanel;