'use client'; import { useState, useCallback, useEffect } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; import { useAuthStore } from '@/lib/auth-store'; const KYC_STATUS_MAP: Record = { NONE: { label: 'Chưa xác minh', variant: 'outline', description: 'Bạn chưa gửi hồ sơ xác minh danh tính. Hoàn tất KYC để mở khóa đầy đủ tính năng.' }, PENDING: { label: 'Đang chờ duyệt', variant: 'secondary', description: 'Hồ sơ của bạn đã được gửi và đang chờ đội ngũ quản trị xem xét. Vui lòng chờ 1-3 ngày làm việc.' }, VERIFIED: { label: 'Đã xác minh', variant: 'default', description: 'Danh tính của bạn đã được xác minh thành công. Bạn có thể sử dụng đầy đủ tính năng.' }, REJECTED: { label: 'Bị từ chối', variant: 'destructive', description: 'Hồ sơ xác minh bị từ chối. Vui lòng kiểm tra lại và gửi lại hồ sơ.' }, }; const DOCUMENT_TYPES = [ { value: 'CCCD', label: 'Căn cước công dân (CCCD)' }, { value: 'CMND', label: 'Chứng minh nhân dân (CMND)' }, { value: 'PASSPORT', label: 'Hộ chiếu' }, { value: 'BUSINESS_LICENSE', label: 'Giấy phép kinh doanh' }, ]; const KYC_STEPS = [ { step: 1, title: 'Loại giấy tờ', description: 'Chọn loại giấy tờ tùy thân' }, { step: 2, title: 'Tải ảnh', description: 'Tải ảnh mặt trước, mặt sau và ảnh selfie' }, { step: 3, title: 'Xác nhận', description: 'Kiểm tra và gửi hồ sơ' }, ]; const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB const ACCEPTED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; const ACCEPTED_ACCEPT_ATTR = 'image/jpeg,image/png,image/webp,application/pdf'; function validateFile(file: File): string | null { if (!ACCEPTED_MIME_TYPES.includes(file.type)) { return 'Định dạng không hợp lệ. Vui lòng chọn JPG, PNG, WEBP hoặc PDF.'; } if (file.size > MAX_FILE_SIZE_BYTES) { return 'Kích thước tệp vượt quá 5MB. Vui lòng chọn tệp nhỏ hơn.'; } return null; } function getCsrfToken(): string | undefined { const csrfMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); return csrfMatch?.[1] ? decodeURIComponent(csrfMatch[1]) : undefined; } function buildHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json' }; const csrfToken = getCsrfToken(); if (csrfToken) headers['X-CSRF-Token'] = csrfToken; return headers; } interface PresignedUrlResult { field: string; uploadUrl: string; publicUrl: string; objectKey: string; } function uploadFileWithProgress( url: string, file: File, onProgress: (percent: number) => void, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { onProgress(100); resolve(); } else if (xhr.status === 403) { reject(new Error('Liên kết tải lên đã hết hạn. Vui lòng thử lại.')); } else { reject(new Error(`Tải ảnh thất bại (${xhr.status}). Vui lòng thử lại.`)); } }); xhr.addEventListener('error', () => reject(new Error('Lỗi kết nối khi tải ảnh'))); xhr.addEventListener('abort', () => reject(new Error('Tải ảnh đã bị hủy'))); xhr.send(file); }); } export default function KycPage() { const { user, fetchProfile } = useAuthStore(); const [currentStep, setCurrentStep] = useState(1); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [uploadProgress, setUploadProgress] = useState>({}); const [documentType, setDocumentType] = useState('CCCD'); const [documentNumber, setDocumentNumber] = useState(''); const [frontImage, setFrontImage] = useState(null); const [backImage, setBackImage] = useState(null); const [selfieImage, setSelfieImage] = useState(null); const [frontPreview, setFrontPreview] = useState(null); const [backPreview, setBackPreview] = useState(null); const [selfiePreview, setSelfiePreview] = useState(null); // Revoke object URLs on cleanup to avoid memory leaks useEffect(() => { return () => { if (frontPreview) URL.revokeObjectURL(frontPreview); if (backPreview) URL.revokeObjectURL(backPreview); if (selfiePreview) URL.revokeObjectURL(selfiePreview); }; }, [frontPreview, backPreview, selfiePreview]); const handleFileSelect = useCallback( ( field: 'front' | 'back' | 'selfie', file: File | null, ) => { if (!file) return; const validationError = validateFile(file); if (validationError) { setError(validationError); return; } setError(null); const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null; if (field === 'front') { if (frontPreview) URL.revokeObjectURL(frontPreview); setFrontImage(file); setFrontPreview(previewUrl); } else if (field === 'back') { if (backPreview) URL.revokeObjectURL(backPreview); setBackImage(file); setBackPreview(previewUrl); } else { if (selfiePreview) URL.revokeObjectURL(selfiePreview); setSelfieImage(file); setSelfiePreview(previewUrl); } }, [frontPreview, backPreview, selfiePreview], ); const kycStatus = user?.kycStatus ?? 'NONE'; const kycInfo = KYC_STATUS_MAP[kycStatus] ?? { label: 'Chưa xác minh', variant: 'outline' as const, description: 'Bạn chưa gửi hồ sơ xác minh danh tính.' }; const canSubmit = kycStatus === 'NONE' || kycStatus === 'REJECTED'; const totalProgress = useCallback(() => { const fields = Object.values(uploadProgress); if (fields.length === 0) return 0; return Math.round(fields.reduce((sum, p) => sum + p, 0) / fields.length); }, [uploadProgress]); const handleSubmit = async () => { if (!documentNumber.trim()) { setError('Vui lòng nhập số giấy tờ'); return; } if (!frontImage) { setError('Vui lòng tải ảnh mặt trước'); return; } setSubmitting(true); setError(null); setUploadProgress({}); try { // Step 1: Build the list of files to upload const filesToUpload: { field: 'frontImage' | 'backImage' | 'selfieImage'; file: File }[] = [ { field: 'frontImage', file: frontImage }, ]; if (backImage) filesToUpload.push({ field: 'backImage', file: backImage }); if (selfieImage) filesToUpload.push({ field: 'selfieImage', file: selfieImage }); // Step 2: Request presigned URLs from the backend const uploadUrlsRes = await fetch(`${API_BASE_URL}/auth/kyc/upload-urls`, { method: 'POST', credentials: 'include', headers: buildHeaders(), body: JSON.stringify({ files: filesToUpload.map((f) => ({ field: f.field, mimeType: f.file.type, fileName: f.file.name, })), }), }); if (!uploadUrlsRes.ok) { const errData = await uploadUrlsRes.json().catch(() => ({ message: uploadUrlsRes.statusText })); throw new Error(errData.message || 'Không thể tạo URL tải lên'); } const presignedUrls: PresignedUrlResult[] = await uploadUrlsRes.json(); // Step 3: Upload each file directly to MinIO via presigned URL const urlMap = new Map(presignedUrls.map((p) => [p.field, p])); await Promise.all( filesToUpload.map(({ field, file }) => { const presigned = urlMap.get(field); if (!presigned) throw new Error(`Không tìm thấy URL tải lên cho ${field}`); return uploadFileWithProgress(presigned.uploadUrl, file, (percent) => { setUploadProgress((prev) => ({ ...prev, [field]: percent })); }); }), ); // Step 4: Submit KYC with the uploaded image URLs const frontUrl = urlMap.get('frontImage')!.publicUrl; const backUrl = urlMap.get('backImage')?.publicUrl; const selfieUrl = urlMap.get('selfieImage')?.publicUrl; const submitRes = await fetch(`${API_BASE_URL}/auth/kyc/submit`, { method: 'POST', credentials: 'include', headers: buildHeaders(), body: JSON.stringify({ documentType, documentNumber: documentNumber.trim(), frontImageUrl: frontUrl, backImageUrl: backUrl, selfieUrl, }), }); if (!submitRes.ok) { const errData = await submitRes.json().catch(() => ({ message: submitRes.statusText })); throw new Error(errData.message || 'Gửi hồ sơ thất bại'); } await fetchProfile(); setSuccess(true); } catch (e) { setError(e instanceof Error ? e.message : 'Gửi hồ sơ thất bại'); } finally { setSubmitting(false); } }; return (

Xác minh danh tính (KYC)

Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo

{/* KYC Status */}
Trạng thái xác minh {kycInfo.label}

{kycInfo.description}

{error && (
{error}
)} {success && (
Hồ sơ KYC đã được gửi thành công. Vui lòng chờ 1-3 ngày làm việc để được xem xét.
)} {/* KYC Form */} {canSubmit && !success && ( <> {/* Step indicator */}
{KYC_STEPS.map((s, i) => (
= s.step ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground' }`} > {s.step}
{s.title} {i < KYC_STEPS.length - 1 && (
)}
))}
{KYC_STEPS[currentStep - 1]?.title} {KYC_STEPS[currentStep - 1]?.description} {/* Step 1: Document type */} {currentStep === 1 && ( <>
setDocumentNumber(e.target.value)} placeholder="Nhập số CCCD/CMND/Hộ chiếu" />
)} {/* Step 2: Upload images */} {currentStep === 2 && ( <>

Định dạng hỗ trợ: JPG, PNG, WEBP, PDF. Kích thước tối đa: 5MB.

handleFileSelect('front', e.target.files?.[0] ?? null)} /> {frontImage && (

{frontImage.name}

)} {frontPreview && ( Xem trước mặt trước )}
handleFileSelect('back', e.target.files?.[0] ?? null)} /> {backImage && (

{backImage.name}

)} {backPreview && ( Xem trước mặt sau )}
handleFileSelect('selfie', e.target.files?.[0] ?? null)} /> {selfieImage && (

{selfieImage.name}

)} {selfiePreview && ( Xem trước selfie )}
)} {/* Step 3: Confirm */} {currentStep === 3 && (

Kiểm tra thông tin

Loại giấy tờ {DOCUMENT_TYPES.find((d) => d.value === documentType)?.label}
Số giấy tờ {documentNumber}
Ảnh mặt trước {frontImage ? frontImage.name : 'Chưa tải'}
Ảnh mặt sau {backImage ? backImage.name : 'Không có'}
Ảnh selfie {selfieImage ? selfieImage.name : 'Không có'}
)} {/* Upload progress */} {submitting && Object.keys(uploadProgress).length > 0 && (
Đang tải ảnh lên... {totalProgress()}%
)} {/* Navigation buttons */}
{currentStep > 1 ? ( ) : (
)} {currentStep < 3 ? ( ) : ( )}
)} {/* Already verified */} {kycStatus === 'VERIFIED' && (

Danh tính đã được xác minh

Tài khoản của bạn đã được xác minh đầy đủ. Bạn có thể sử dụng tất cả tính năng của GoodGo.

)} {/* Pending status */} {kycStatus === 'PENDING' && !success && (

Đang xem xét hồ sơ

Đội ngũ quản trị đang xem xét hồ sơ của bạn. Thời gian dự kiến: 1-3 ngày làm việc.

)}
); }