feat(web): add Agent Profile, KYC, Subscription & Payment dashboard pages
Implement four new dashboard pages with full UI: - /dashboard/profile: view/edit profile, agent details, KYC status - /dashboard/kyc: multi-step KYC document submission flow - /dashboard/subscription: plan comparison, quota usage, billing history - /dashboard/payments: transaction history with filters and pagination Also adds API client modules (profile-api, subscription-api, payment-api) and updates dashboard navigation with new page links. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
315
apps/web/app/(dashboard)/dashboard/kyc/page.tsx
Normal file
315
apps/web/app/(dashboard)/dashboard/kyc/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState } 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 { apiClient } from '@/lib/api-client';
|
||||
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ơ' },
|
||||
];
|
||||
|
||||
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 [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 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 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);
|
||||
try {
|
||||
await apiClient.patch('/auth/profile', {
|
||||
kycData: {
|
||||
documentType,
|
||||
documentNumber: documentNumber.trim(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">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 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 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-3 h-px w-8 bg-border 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 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frontImg">Ảnh mặt trước *</Label>
|
||||
<Input
|
||||
id="frontImg"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFrontImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{frontImage && (
|
||||
<p className="text-xs text-muted-foreground">{frontImage.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backImg">Ảnh mặt sau</Label>
|
||||
<Input
|
||||
id="backImg"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setBackImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{backImage && (
|
||||
<p className="text-xs text-muted-foreground">{backImage.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="selfieImg">Ảnh selfie cầm giấy tờ</Label>
|
||||
<Input
|
||||
id="selfieImg"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setSelfieImage(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{selfieImage && (
|
||||
<p className="text-xs text-muted-foreground">{selfieImage.name}</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between pt-2">
|
||||
{currentStep > 1 ? (
|
||||
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)}>
|
||||
Quay lại
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (currentStep === 1 && !documentNumber.trim()) {
|
||||
setError('Vui lòng nhập số giấy tờ');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setCurrentStep((s) => s + 1);
|
||||
}}
|
||||
>
|
||||
Tiếp tục
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
248
apps/web/app/(dashboard)/dashboard/payments/page.tsx
Normal file
248
apps/web/app/(dashboard)/dashboard/payments/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } 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 { Select } from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { paymentApi, type TransactionListDto } from '@/lib/payment-api';
|
||||
|
||||
function formatVND(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ đ`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
|
||||
return num.toLocaleString('vi-VN') + ' đ';
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
PENDING: { label: 'Chờ xử lý', variant: 'secondary' },
|
||||
PROCESSING: { label: 'Đang xử lý', variant: 'secondary' },
|
||||
COMPLETED: { label: 'Thành công', variant: 'default' },
|
||||
FAILED: { label: 'Thất bại', variant: 'destructive' },
|
||||
REFUNDED: { label: 'Hoàn tiền', variant: 'outline' },
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
SUBSCRIPTION: 'Gói dịch vụ',
|
||||
LISTING_FEE: 'Phí đăng tin',
|
||||
DEPOSIT: 'Đặt cọc',
|
||||
FEATURED_LISTING: 'Tin nổi bật',
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
VNPAY: 'VNPay',
|
||||
MOMO: 'MoMo',
|
||||
ZALOPAY: 'ZaloPay',
|
||||
BANK_TRANSFER: 'Chuyển khoản',
|
||||
};
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [transactions, setTransactions] = useState<TransactionListDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
paymentApi
|
||||
.getTransactions({
|
||||
status: statusFilter || undefined,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
})
|
||||
.then((data) => setTransactions(data))
|
||||
.catch(() => setTransactions(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [statusFilter, page]);
|
||||
|
||||
const totalPages = transactions ? Math.ceil(transactions.total / limit) : 0;
|
||||
|
||||
// Summary stats
|
||||
const completedTotal =
|
||||
transactions?.items
|
||||
.filter((t) => t.status === 'COMPLETED')
|
||||
.reduce((sum, t) => sum + Number(t.amountVND), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Thanh toán</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Lịch sử giao dịch và quản lý thanh toán
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tổng giao dịch</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : (transactions?.total ?? 0)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Đã thanh toán</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : formatVND(completedTotal)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Đang chờ</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading
|
||||
? '...'
|
||||
: (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Transactions table */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Lịch sử giao dịch</CardTitle>
|
||||
<CardDescription>Tất cả giao dịch thanh toán của bạn</CardDescription>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="PENDING">Chờ xử lý</option>
|
||||
<option value="PROCESSING">Đang xử lý</option>
|
||||
<option value="COMPLETED">Thành công</option>
|
||||
<option value="FAILED">Thất bại</option>
|
||||
<option value="REFUNDED">Hoàn tiền</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : !transactions || transactions.items.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
Chưa có giao dịch nào
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ngày</TableHead>
|
||||
<TableHead>Loại</TableHead>
|
||||
<TableHead>Nhà cung cấp</TableHead>
|
||||
<TableHead className="text-right">Số tiền</TableHead>
|
||||
<TableHead>Trạng thái</TableHead>
|
||||
<TableHead>Mã GD</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.items.map((tx) => {
|
||||
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
||||
return (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell className="text-sm">
|
||||
{new Date(tx.createdAt).toLocaleDateString('vi-VN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{TYPE_LABELS[tx.type] ?? tx.type}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{formatVND(tx.amountVND)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{transactions.items.map((tx) => {
|
||||
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
||||
return (
|
||||
<div key={tx.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{TYPE_LABELS[tx.type] ?? tx.type}
|
||||
</span>
|
||||
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(tx.createdAt).toLocaleDateString('vi-VN')} —{' '}
|
||||
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
||||
</span>
|
||||
<span className="font-semibold">{formatVND(tx.amountVND)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Trang {page + 1}/{totalPages} ({transactions.total} giao dịch)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page + 1 >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
apps/web/app/(dashboard)/dashboard/profile/page.tsx
Normal file
283
apps/web/app/(dashboard)/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } 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 { useAuthStore } from '@/lib/auth-store';
|
||||
import { profileApi, type AgentProfile } from '@/lib/profile-api';
|
||||
|
||||
const KYC_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
NONE: { label: 'Chưa xác minh', variant: 'outline' },
|
||||
PENDING: { label: 'Đang chờ duyệt', variant: 'secondary' },
|
||||
VERIFIED: { label: 'Đã xác minh', variant: 'default' },
|
||||
REJECTED: { label: 'Bị từ chối', variant: 'destructive' },
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, fetchProfile } = useAuthStore();
|
||||
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
profileApi
|
||||
.getAgentProfile()
|
||||
.then((agent) => setAgentProfile(agent))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
fullName: user.fullName,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await profileApi.updateProfile({
|
||||
fullName: formData.fullName,
|
||||
email: formData.email || undefined,
|
||||
});
|
||||
await fetchProfile();
|
||||
setSuccess('Cập nhật hồ sơ thành công');
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Cập nhật thất bại');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const kycInfo = KYC_STATUS_MAP[user?.kycStatus ?? 'NONE'] ?? { label: 'Chưa xác minh', variant: 'outline' as const };
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Hồ sơ cá nhân</h1>
|
||||
<p className="mt-2 text-muted-foreground">Quản lý thông tin tài khoản của bạn</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div 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 className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
||||
{success}
|
||||
<button onClick={() => setSuccess(null)} className="ml-2 font-medium underline">
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Profile info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Thông tin cá nhân</CardTitle>
|
||||
<CardDescription>Thông tin cơ bản trên hồ sơ của bạn</CardDescription>
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
Chỉnh sửa
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fullName">Họ và tên</Label>
|
||||
{editing ? (
|
||||
<Input
|
||||
id="fullName"
|
||||
value={formData.fullName}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, fullName: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||
{user?.fullName ?? '—'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Số điện thoại</Label>
|
||||
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||
{user?.phone ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Số điện thoại không thể thay đổi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
{editing ? (
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||
{user?.email ?? 'Chưa cập nhật'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Vai trò</Label>
|
||||
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||
{user?.role === 'AGENT' ? 'Môi giới' : user?.role === 'ADMIN' ? 'Quản trị viên' : user?.role === 'SELLER' ? 'Người bán' : 'Người mua'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu thay đổi'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
if (user) {
|
||||
setFormData({
|
||||
fullName: user.fullName,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Trạng thái tài khoản</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tài khoản</span>
|
||||
<Badge variant={user?.isActive ? 'default' : 'destructive'}>
|
||||
{user?.isActive ? 'Hoạt động' : 'Bị khóa'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Xác minh KYC</span>
|
||||
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
|
||||
</div>
|
||||
{user?.kycStatus !== 'VERIFIED' && (
|
||||
<a href="/dashboard/kyc">
|
||||
<Button variant="outline" size="sm" className="mt-2 w-full">
|
||||
{user?.kycStatus === 'NONE' ? 'Bắt đầu xác minh' : 'Xem trạng thái KYC'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Tham gia</span>
|
||||
<span className="text-sm">
|
||||
{user?.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString('vi-VN')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent details */}
|
||||
{agentProfile && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Thông tin môi giới</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Mã chứng chỉ</span>
|
||||
<span className="text-sm font-medium">
|
||||
{agentProfile.licenseNumber ?? 'Chưa có'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Công ty</span>
|
||||
<span className="text-sm font-medium">
|
||||
{agentProfile.agency ?? 'Độc lập'}
|
||||
</span>
|
||||
</div>
|
||||
{agentProfile.qualityScore != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Điểm chất lượng</span>
|
||||
<span className="text-sm font-semibold text-primary">
|
||||
{agentProfile.qualityScore}/100
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Xác minh</span>
|
||||
<Badge variant={agentProfile.isVerified ? 'default' : 'outline'}>
|
||||
{agentProfile.isVerified ? 'Đã xác minh' : 'Chưa xác minh'}
|
||||
</Badge>
|
||||
</div>
|
||||
{agentProfile.serviceAreas.length > 0 && (
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Khu vực hoạt động</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{agentProfile.serviceAreas.map((area) => (
|
||||
<Badge key={area} variant="secondary">
|
||||
{area}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
apps/web/app/(dashboard)/dashboard/subscription/page.tsx
Normal file
381
apps/web/app/(dashboard)/dashboard/subscription/page.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
subscriptionApi,
|
||||
type BillingHistoryDto,
|
||||
type PlanDto,
|
||||
type QuotaCheckResult,
|
||||
} from '@/lib/subscription-api';
|
||||
|
||||
function formatVND(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (num === 0) return 'Miễn phí';
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
|
||||
return num.toLocaleString('vi-VN') + ' đ';
|
||||
}
|
||||
|
||||
const PLAN_TIER_ORDER = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
|
||||
const PLAN_TIER_LABELS: Record<string, string> = {
|
||||
FREE: 'Miễn phí',
|
||||
AGENT_PRO: 'Môi giới Pro',
|
||||
INVESTOR: 'Nhà đầu tư',
|
||||
ENTERPRISE: 'Doanh nghiệp',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
ACTIVE: { label: 'Đang hoạt động', variant: 'default' },
|
||||
PAST_DUE: { label: 'Quá hạn', variant: 'destructive' },
|
||||
CANCELLED: { label: 'Đã hủy', variant: 'outline' },
|
||||
EXPIRED: { label: 'Hết hạn', variant: 'secondary' },
|
||||
};
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [plans, setPlans] = useState<PlanDto[]>([]);
|
||||
const [billing, setBilling] = useState<BillingHistoryDto | null>(null);
|
||||
const [quotas, setQuotas] = useState<QuotaCheckResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [upgradeTarget, setUpgradeTarget] = useState<PlanDto | null>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('plan');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
subscriptionApi.getPlans().catch(() => []),
|
||||
subscriptionApi.getBillingHistory().catch(() => null),
|
||||
Promise.all([
|
||||
subscriptionApi.checkQuota('listings').catch(() => null),
|
||||
subscriptionApi.checkQuota('saved_searches').catch(() => null),
|
||||
]),
|
||||
])
|
||||
.then(([plansData, billingData, quotaResults]) => {
|
||||
setPlans(plansData.sort((a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier)));
|
||||
setBilling(billingData);
|
||||
setQuotas(quotaResults.filter((q): q is QuotaCheckResult => q !== null));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const currentTier = billing?.subscription?.planTier ?? 'FREE';
|
||||
const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier);
|
||||
const subStatus = billing?.subscription?.status
|
||||
? STATUS_MAP[billing.subscription.status] ?? { label: 'Đang hoạt động', variant: 'default' as const }
|
||||
: null;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!upgradeTarget) return;
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (billing?.subscription) {
|
||||
await subscriptionApi.upgradeSubscription(upgradeTarget.tier);
|
||||
} else {
|
||||
await subscriptionApi.createSubscription(upgradeTarget.tier, billingCycle);
|
||||
}
|
||||
// Reload billing data
|
||||
const newBilling = await subscriptionApi.getBillingHistory().catch(() => null);
|
||||
setBilling(newBilling);
|
||||
setUpgradeTarget(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Nâng cấp thất bại');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Gói dịch vụ</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Quản lý gói đăng ký và theo dõi hạn mức sử dụng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div 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>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="plan">Gói hiện tại</TabsTrigger>
|
||||
<TabsTrigger value="plans">So sánh gói</TabsTrigger>
|
||||
<TabsTrigger value="history">Lịch sử thanh toán</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Current plan tab */}
|
||||
<TabsContent value="plan" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{billing?.subscription
|
||||
? `Kỳ hiện tại: ${new Date(billing.subscription.currentPeriodStart).toLocaleDateString('vi-VN')} — ${new Date(billing.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')}`
|
||||
: 'Bạn đang sử dụng gói miễn phí'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Quota usage */}
|
||||
{quotas.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Hạn mức sử dụng</h3>
|
||||
{quotas.map((q) => {
|
||||
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0;
|
||||
return (
|
||||
<div key={q.metric} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{q.metric === 'listings' ? 'Tin đăng' : q.metric === 'saved_searches' ? 'Tìm kiếm đã lưu' : q.metric}
|
||||
</span>
|
||||
<span>
|
||||
{q.used}/{q.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Plan comparison tab */}
|
||||
<TabsContent value="plans" className="space-y-6">
|
||||
{/* Billing cycle toggle */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant={billingCycle === 'monthly' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
>
|
||||
Theo tháng
|
||||
</Button>
|
||||
<Button
|
||||
variant={billingCycle === 'yearly' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
>
|
||||
Theo năm
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
-17%
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{plans.map((plan) => {
|
||||
const tierIndex = PLAN_TIER_ORDER.indexOf(plan.tier);
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
const isUpgrade = tierIndex > currentTierIndex;
|
||||
const price = billingCycle === 'monthly' ? plan.priceMonthlyVND : plan.priceYearlyVND;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={isCurrent ? 'border-primary ring-1 ring-primary' : ''}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{formatVND(price)}
|
||||
</span>
|
||||
{Number(price) > 0 && (
|
||||
<span className="text-sm">
|
||||
/{billingCycle === 'monthly' ? 'tháng' : 'năm'}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tin đăng</span>
|
||||
<span className="font-medium">
|
||||
{plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tìm kiếm lưu</span>
|
||||
<span className="font-medium">
|
||||
{plan.maxSavedSearches === -1
|
||||
? 'Không giới hạn'
|
||||
: plan.maxSavedSearches}
|
||||
</span>
|
||||
</div>
|
||||
{plan.features &&
|
||||
Object.entries(plan.features).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium">
|
||||
{typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Gói hiện tại
|
||||
</Button>
|
||||
) : isUpgrade ? (
|
||||
<Button className="w-full" onClick={() => setUpgradeTarget(plan)}>
|
||||
Nâng cấp
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
—
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Payment history tab */}
|
||||
<TabsContent value="history">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Lịch sử thanh toán</CardTitle>
|
||||
<CardDescription>Các giao dịch liên quan đến gói dịch vụ</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!billing || billing.payments.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||
Chưa có giao dịch nào
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{billing.payments.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.type}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{formatVND(p.amountVND)}</p>
|
||||
<Badge
|
||||
variant={
|
||||
p.status === 'COMPLETED'
|
||||
? 'default'
|
||||
: p.status === 'FAILED'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{p.status === 'COMPLETED'
|
||||
? 'Thành công'
|
||||
: p.status === 'FAILED'
|
||||
? 'Thất bại'
|
||||
: p.status === 'PENDING'
|
||||
? 'Chờ xử lý'
|
||||
: p.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Upgrade dialog */}
|
||||
<Dialog open={!!upgradeTarget} onOpenChange={(o) => !o && setUpgradeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Xác nhận nâng cấp gói dịch vụ. Bạn sẽ được chuyển hướng đến trang thanh toán.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Gói</span>
|
||||
<span className="font-medium">
|
||||
{PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Chu kỳ</span>
|
||||
<span className="font-medium">
|
||||
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Giá</span>
|
||||
<span className="font-semibold text-primary">
|
||||
{upgradeTarget &&
|
||||
formatVND(
|
||||
billingCycle === 'monthly'
|
||||
? upgradeTarget.priceMonthlyVND
|
||||
: upgradeTarget.priceYearlyVND,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUpgradeTarget(null)}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={processing}>
|
||||
{processing ? 'Đang xử lý...' : 'Xác nhận nâng cấp'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const navItems = [
|
||||
{ href: '/listings', label: 'Tin đăng', icon: '📋' },
|
||||
{ href: '/listings/new', label: 'Đăng tin', icon: '➕' },
|
||||
{ href: '/analytics', label: 'Phân tích', icon: '📊' },
|
||||
{ href: '/dashboard/profile', label: 'Hồ sơ', icon: '👤' },
|
||||
{ href: '/dashboard/subscription', label: 'Gói dịch vụ', icon: '💎' },
|
||||
{ href: '/dashboard/payments', label: 'Thanh toán', icon: '💳' },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -33,7 +36,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
aria-label={item.label}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground sm:px-3',
|
||||
pathname === item.href
|
||||
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user