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 <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -32,6 +32,20 @@ const KYC_STEPS = [
|
|||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
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 {
|
function getCsrfToken(): string | undefined {
|
||||||
const csrfMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
const csrfMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
||||||
return csrfMatch?.[1] ? decodeURIComponent(csrfMatch[1]) : undefined;
|
return csrfMatch?.[1] ? decodeURIComponent(csrfMatch[1]) : undefined;
|
||||||
@@ -71,8 +85,10 @@ function uploadFileWithProgress(
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
onProgress(100);
|
onProgress(100);
|
||||||
resolve();
|
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 {
|
} 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<File | null>(null);
|
const [frontImage, setFrontImage] = useState<File | null>(null);
|
||||||
const [backImage, setBackImage] = useState<File | null>(null);
|
const [backImage, setBackImage] = useState<File | null>(null);
|
||||||
const [selfieImage, setSelfieImage] = useState<File | null>(null);
|
const [selfieImage, setSelfieImage] = useState<File | null>(null);
|
||||||
|
const [frontPreview, setFrontPreview] = useState<string | null>(null);
|
||||||
|
const [backPreview, setBackPreview] = useState<string | null>(null);
|
||||||
|
const [selfiePreview, setSelfiePreview] = useState<string | null>(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 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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" data-testid="kyc-page">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold sm:text-3xl">Xác minh danh tính (KYC)</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Xác minh danh tính (KYC)</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
@@ -219,7 +277,11 @@ export default function KycPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="kyc-error"
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
||||||
Đóng
|
Đóng
|
||||||
@@ -228,7 +290,11 @@ export default function KycPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
<div
|
||||||
|
role="status"
|
||||||
|
data-testid="kyc-success"
|
||||||
|
className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700"
|
||||||
|
>
|
||||||
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.
|
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.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -299,41 +365,71 @@ export default function KycPage() {
|
|||||||
{/* Step 2: Upload images */}
|
{/* Step 2: Upload images */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<>
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Định dạng hỗ trợ: JPG, PNG, WEBP, PDF. Kích thước tối đa: 5MB.
|
||||||
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="frontImg">Ảnh mặt trước *</Label>
|
<Label htmlFor="frontImg">Ảnh mặt trước *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="frontImg"
|
id="frontImg"
|
||||||
|
data-testid="kyc-front-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={ACCEPTED_ACCEPT_ATTR}
|
||||||
onChange={(e) => setFrontImage(e.target.files?.[0] ?? null)}
|
onChange={(e) => handleFileSelect('front', e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{frontImage && (
|
{frontImage && (
|
||||||
<p className="text-xs text-muted-foreground">{frontImage.name}</p>
|
<p className="text-xs text-muted-foreground">{frontImage.name}</p>
|
||||||
)}
|
)}
|
||||||
|
{frontPreview && (
|
||||||
|
<img
|
||||||
|
src={frontPreview}
|
||||||
|
alt="Xem trước mặt trước"
|
||||||
|
data-testid="kyc-front-preview"
|
||||||
|
className="mt-2 max-h-48 rounded-md border object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backImg">Ảnh mặt sau</Label>
|
<Label htmlFor="backImg">Ảnh mặt sau</Label>
|
||||||
<Input
|
<Input
|
||||||
id="backImg"
|
id="backImg"
|
||||||
|
data-testid="kyc-back-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={ACCEPTED_ACCEPT_ATTR}
|
||||||
onChange={(e) => setBackImage(e.target.files?.[0] ?? null)}
|
onChange={(e) => handleFileSelect('back', e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{backImage && (
|
{backImage && (
|
||||||
<p className="text-xs text-muted-foreground">{backImage.name}</p>
|
<p className="text-xs text-muted-foreground">{backImage.name}</p>
|
||||||
)}
|
)}
|
||||||
|
{backPreview && (
|
||||||
|
<img
|
||||||
|
src={backPreview}
|
||||||
|
alt="Xem trước mặt sau"
|
||||||
|
data-testid="kyc-back-preview"
|
||||||
|
className="mt-2 max-h-48 rounded-md border object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="selfieImg">Ảnh selfie cầm giấy tờ</Label>
|
<Label htmlFor="selfieImg">Ảnh selfie cầm giấy tờ</Label>
|
||||||
<Input
|
<Input
|
||||||
id="selfieImg"
|
id="selfieImg"
|
||||||
|
data-testid="kyc-selfie-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept={ACCEPTED_ACCEPT_ATTR}
|
||||||
onChange={(e) => setSelfieImage(e.target.files?.[0] ?? null)}
|
onChange={(e) => handleFileSelect('selfie', e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{selfieImage && (
|
{selfieImage && (
|
||||||
<p className="text-xs text-muted-foreground">{selfieImage.name}</p>
|
<p className="text-xs text-muted-foreground">{selfieImage.name}</p>
|
||||||
)}
|
)}
|
||||||
|
{selfiePreview && (
|
||||||
|
<img
|
||||||
|
src={selfiePreview}
|
||||||
|
alt="Xem trước selfie"
|
||||||
|
data-testid="kyc-selfie-preview"
|
||||||
|
className="mt-2 max-h-48 rounded-md border object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -386,7 +482,12 @@ export default function KycPage() {
|
|||||||
{/* Navigation buttons */}
|
{/* Navigation buttons */}
|
||||||
<div className="flex justify-between pt-2">
|
<div className="flex justify-between pt-2">
|
||||||
{currentStep > 1 ? (
|
{currentStep > 1 ? (
|
||||||
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)} disabled={submitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="kyc-back-button"
|
||||||
|
onClick={() => setCurrentStep((s) => s - 1)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
Quay lại
|
Quay lại
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -394,11 +495,16 @@ export default function KycPage() {
|
|||||||
)}
|
)}
|
||||||
{currentStep < 3 ? (
|
{currentStep < 3 ? (
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="kyc-next-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentStep === 1 && !documentNumber.trim()) {
|
if (currentStep === 1 && !documentNumber.trim()) {
|
||||||
setError('Vui lòng nhập số giấy tờ');
|
setError('Vui lòng nhập số giấy tờ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentStep === 2 && !frontImage) {
|
||||||
|
setError('Vui lòng tải ảnh mặt trước');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setCurrentStep((s) => s + 1);
|
setCurrentStep((s) => s + 1);
|
||||||
}}
|
}}
|
||||||
@@ -406,7 +512,11 @@ export default function KycPage() {
|
|||||||
Tiếp tục
|
Tiếp tục
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleSubmit} disabled={submitting}>
|
<Button
|
||||||
|
data-testid="kyc-submit-button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
|
{submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user