feat(web): add i18n locale routes and language switcher component
Add locale-prefixed routes for admin, auth, dashboard, and public pages. Add error, loading, and not-found pages for locale context. Add language switcher UI component for Vietnamese/English toggle. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
239
apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx
Normal file
239
apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'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 { Select } from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useTransactions } from '@/lib/hooks/use-payments';
|
||||
|
||||
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 [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const { data: transactions, isLoading: loading } = useTransactions({
|
||||
status: statusFilter || undefined,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user