- 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>
562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
'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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; description: string }> = {
|
|
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<string, string> {
|
|
const headers: Record<string, string> = { '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<void> {
|
|
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<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
|
|
const [documentType, setDocumentType] = useState('CCCD');
|
|
const [documentNumber, setDocumentNumber] = useState('');
|
|
const [frontImage, setFrontImage] = useState<File | null>(null);
|
|
const [backImage, setBackImage] = 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 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 (
|
|
<div className="space-y-6" data-testid="kyc-page">
|
|
<div>
|
|
<h1 className="text-2xl font-bold sm:text-3xl">Xác minh danh tính (KYC)</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo
|
|
</p>
|
|
</div>
|
|
|
|
{/* KYC Status */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">Trạng thái xác minh</CardTitle>
|
|
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">{kycInfo.description}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<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}
|
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
|
Đóng
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{success && (
|
|
<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.
|
|
</div>
|
|
)}
|
|
|
|
{/* KYC Form */}
|
|
{canSubmit && !success && (
|
|
<>
|
|
{/* Step indicator */}
|
|
<div className="flex items-center justify-center gap-2">
|
|
{KYC_STEPS.map((s, i) => (
|
|
<div key={s.step} className="flex items-center">
|
|
<div
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
|
currentStep >= s.step
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{s.step}
|
|
</div>
|
|
<span className="ml-2 hidden text-sm sm:inline">{s.title}</span>
|
|
{i < KYC_STEPS.length - 1 && (
|
|
<div className="mx-1 h-px w-4 bg-border sm:mx-3 sm:w-16" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
{KYC_STEPS[currentStep - 1]?.title}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{KYC_STEPS[currentStep - 1]?.description}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Step 1: Document type */}
|
|
{currentStep === 1 && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="docType">Loại giấy tờ</Label>
|
|
<Select
|
|
id="docType"
|
|
value={documentType}
|
|
onChange={(e) => setDocumentType(e.target.value)}
|
|
>
|
|
{DOCUMENT_TYPES.map((dt) => (
|
|
<option key={dt.value} value={dt.value}>
|
|
{dt.label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="docNumber">Số giấy tờ</Label>
|
|
<Input
|
|
id="docNumber"
|
|
value={documentNumber}
|
|
onChange={(e) => setDocumentNumber(e.target.value)}
|
|
placeholder="Nhập số CCCD/CMND/Hộ chiếu"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Step 2: Upload images */}
|
|
{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">
|
|
<Label htmlFor="frontImg">Ảnh mặt trước *</Label>
|
|
<Input
|
|
id="frontImg"
|
|
data-testid="kyc-front-input"
|
|
type="file"
|
|
accept={ACCEPTED_ACCEPT_ATTR}
|
|
onChange={(e) => handleFileSelect('front', e.target.files?.[0] ?? null)}
|
|
/>
|
|
{frontImage && (
|
|
<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 className="space-y-2">
|
|
<Label htmlFor="backImg">Ảnh mặt sau</Label>
|
|
<Input
|
|
id="backImg"
|
|
data-testid="kyc-back-input"
|
|
type="file"
|
|
accept={ACCEPTED_ACCEPT_ATTR}
|
|
onChange={(e) => handleFileSelect('back', e.target.files?.[0] ?? null)}
|
|
/>
|
|
{backImage && (
|
|
<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 className="space-y-2">
|
|
<Label htmlFor="selfieImg">Ảnh selfie cầm giấy tờ</Label>
|
|
<Input
|
|
id="selfieImg"
|
|
data-testid="kyc-selfie-input"
|
|
type="file"
|
|
accept={ACCEPTED_ACCEPT_ATTR}
|
|
onChange={(e) => handleFileSelect('selfie', e.target.files?.[0] ?? null)}
|
|
/>
|
|
{selfieImage && (
|
|
<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>
|
|
</>
|
|
)}
|
|
|
|
{/* Step 3: Confirm */}
|
|
{currentStep === 3 && (
|
|
<div className="space-y-3 rounded-lg border bg-muted/50 p-4">
|
|
<h3 className="font-semibold">Kiểm tra thông tin</h3>
|
|
<div className="grid gap-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Loại giấy tờ</span>
|
|
<span>{DOCUMENT_TYPES.find((d) => d.value === documentType)?.label}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Số giấy tờ</span>
|
|
<span>{documentNumber}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Ảnh mặt trước</span>
|
|
<span>{frontImage ? frontImage.name : 'Chưa tải'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Ảnh mặt sau</span>
|
|
<span>{backImage ? backImage.name : 'Không có'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Ảnh selfie</span>
|
|
<span>{selfieImage ? selfieImage.name : 'Không có'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload progress */}
|
|
{submitting && Object.keys(uploadProgress).length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Đang tải ảnh lên...</span>
|
|
<span className="font-medium">{totalProgress()}%</span>
|
|
</div>
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all duration-300"
|
|
style={{ width: `${totalProgress()}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation buttons */}
|
|
<div className="flex justify-between pt-2">
|
|
{currentStep > 1 ? (
|
|
<Button
|
|
variant="outline"
|
|
data-testid="kyc-back-button"
|
|
onClick={() => setCurrentStep((s) => s - 1)}
|
|
disabled={submitting}
|
|
>
|
|
Quay lại
|
|
</Button>
|
|
) : (
|
|
<div />
|
|
)}
|
|
{currentStep < 3 ? (
|
|
<Button
|
|
data-testid="kyc-next-button"
|
|
onClick={() => {
|
|
if (currentStep === 1 && !documentNumber.trim()) {
|
|
setError('Vui lòng nhập số giấy tờ');
|
|
return;
|
|
}
|
|
if (currentStep === 2 && !frontImage) {
|
|
setError('Vui lòng tải ảnh mặt trước');
|
|
return;
|
|
}
|
|
setError(null);
|
|
setCurrentStep((s) => s + 1);
|
|
}}
|
|
>
|
|
Tiếp tục
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
data-testid="kyc-submit-button"
|
|
onClick={handleSubmit}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{/* Already verified */}
|
|
{kycStatus === 'VERIFIED' && (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-3xl">
|
|
✓
|
|
</div>
|
|
<h2 className="text-xl font-semibold">Danh tính đã được xác minh</h2>
|
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
|
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.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Pending status */}
|
|
{kycStatus === 'PENDING' && !success && (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100 text-3xl">
|
|
⏳
|
|
</div>
|
|
<h2 className="text-xl font-semibold">Đang xem xét hồ sơ</h2>
|
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
|
Độ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.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|