From 78e46a024b2e576e812c60c9ccc3f6900eb60462 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 18 Apr 2026 00:06:13 +0700 Subject: [PATCH] feat(web): enhance KYC upload with validation, previews, test ids - Add file type (JPG/PNG/WEBP/PDF) and 5MB size validation - Show image previews with cleanup of object URLs - Add data-testid attributes on inputs, buttons, previews, alerts for E2E - Improve error messaging for expired/failed presigned uploads (403 vs other) - Guard step 2->3 advance when front image missing Co-Authored-By: Paperclip --- .../(dashboard)/dashboard/kyc/page.tsx | 136 ++++++++++++++++-- 1 file changed, 123 insertions(+), 13 deletions(-) diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx index 9e1fad8..46c1be7 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +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'; @@ -32,6 +32,20 @@ const KYC_STEPS = [ 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; @@ -71,8 +85,10 @@ function uploadFileWithProgress( 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(`Upload thất bại (${xhr.status})`)); + reject(new Error(`Tải ảnh thất bại (${xhr.status}). Vui lòng thử lại.`)); } }); @@ -96,6 +112,48 @@ export default function KycPage() { 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.' }; @@ -197,7 +255,7 @@ export default function KycPage() { }; return ( -
+

Xác minh danh tính (KYC)

@@ -219,7 +277,11 @@ export default function KycPage() { {error && ( -

+
{error} ) : ( @@ -394,11 +495,16 @@ export default function KycPage() { )} {currentStep < 3 ? ( ) : ( - )}