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:
Ho Ngoc Hai
2026-04-09 09:44:18 +07:00
parent 2250e17a09
commit 7195064f12
43 changed files with 7418 additions and 1 deletions

View 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ồ 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 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ồ </h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Đi ngũ quản trị đang xem xét hồ của bạn. Thời gian dự kiến: 1-3 ngày làm việc.
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import Link from 'next/link';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
import { useListingsSearch } from '@/lib/hooks/use-listings';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const CITY = 'Ho Chi Minh';
const PERIOD = '2026-Q1';
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
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');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
}
interface StatCardProps {
title: string;
value: string;
description?: string;
trend?: number | null;
}
function StatCard({ title, value, description, trend }: StatCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{(description || trend != null) && (
<CardContent>
<div className="flex items-center gap-2">
{trend != null && (
<span
className={`text-xs font-medium ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}
>
{trend >= 0 ? '+' : ''}
{trend.toFixed(1)}%
</span>
)}
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
</CardContent>
)}
</Card>
);
}
export default function DashboardPage() {
const { data: reportData, isLoading: reportLoading } = useMarketReport(CITY, PERIOD);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(CITY, PERIOD);
const { data: listings, isLoading: listingsLoading } = useListingsSearch({ page: 1, limit: 6 });
const loading = reportLoading || heatmapLoading || listingsLoading;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgYoy =
marketReport.filter((d) => d.yoyChange != null).length > 0
? marketReport
.filter((d) => d.yoyChange != null)
.reduce((sum, d) => sum + (d.yoyChange ?? 0), 0) /
marketReport.filter((d) => d.yoyChange != null).length
: null;
const myListingsCount = listings?.total ?? 0;
const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0;
const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0;
const chartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Bảng điều khiển</h1>
<p className="mt-2 text-muted-foreground">
Tổng quan thị trường tin đăng của bạn
</p>
</div>
<Link href="/listings/new">
<Button>Đăng tin mới</Button>
</Link>
</div>
{/* Stats overview */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tin đăng của tôi"
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
description="Tổng số tin đã đăng"
/>
<StatCard
title="Lượt xem"
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
description="Trên tất cả tin đăng"
/>
<StatCard
title="Liên hệ"
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
description="Yêu cầu từ khách hàng"
/>
<StatCard
title="Giá TB thị trường"
value={loading ? '...' : formatPriceM2(avgPriceM2)}
trend={avgYoy}
description="YoY"
/>
</div>
{/* Market overview + quick stats */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Price chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
<CardDescription>{CITY} - {PERIOD} (triệu VND/m²)</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : chartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictBarChart
data={chartData}
height={280}
dataKey="Gia/m2"
tooltipFormatter={(value) => [`${value} tr/m²`, 'Giá']}
/>
)}
</CardContent>
</Card>
{/* Market summary */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thị trường {CITY}</CardTitle>
<CardDescription>Chỉ số chính - {PERIOD}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tổng tin đăng</span>
<span className="font-semibold">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Giá TB/m²</span>
<span className="font-semibold">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ngày TB đ bán</span>
<span className="font-semibold">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Số quận</span>
<span className="font-semibold">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</span>
</div>
<div className="pt-2">
<Link href="/analytics">
<Button variant="outline" size="sm" className="w-full">
Xem phân tích chi tiết
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
{/* Recent listings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
</div>
<Link href="/listings">
<Button variant="outline" size="sm">
Xem tất cả
</Button>
</Link>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : !listings || listings.data.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<p>Chưa tin đăng nào</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{listings.data.slice(0, 5).map((listing) => (
<Link
key={listing.id}
href={`/listings/${listing.id}`}
className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent"
>
<div className="relative h-12 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{listing.property.title}</p>
<p className="text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">
{formatPrice(listing.priceVND)}
</p>
<ListingStatusBadge status={listing.status} />
</div>
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View 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 quản 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ử </option>
<option value="PROCESSING">Đang xử </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 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> 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>
);
}

View 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ồ nhân</h1>
<p className="mt-2 text-muted-foreground">Quản 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 nhân</CardTitle>
<CardDescription>Thông tin bản trên hồ 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ọ 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"> 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>
);
}

View File

@@ -0,0 +1,371 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePlans, useBillingHistory, useQuota, subscriptionKeys } from '@/lib/hooks/use-subscription';
import {
subscriptionApi,
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 queryClient = useQueryClient();
const { data: plansData, isLoading: plansLoading } = usePlans();
const { data: billing, isLoading: billingLoading } = useBillingHistory();
const { data: listingsQuota } = useQuota('listings');
const { data: savedSearchesQuota } = useQuota('saved_searches');
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');
const loading = plansLoading || billingLoading;
const plans = (plansData ?? []).slice().sort(
(a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
);
const quotas = [listingsQuota, savedSearchesQuota].filter(
(q): q is QuotaCheckResult => q != null,
);
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);
}
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.billing() });
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 gói đăng 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 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>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { useState } from 'react';
import { ValuationForm } from '@/components/valuation/valuation-form';
import { ValuationHistory } from '@/components/valuation/valuation-history';
import { ValuationResults } from '@/components/valuation/valuation-results';
import {
useValuationPredict,
useValuationHistory,
useValuationDetail,
} from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
export default function ValuationPage() {
const [historyPage, setHistoryPage] = useState(1);
const [selectedId, setSelectedId] = useState<string | null>(null);
const predictMutation = useValuationPredict();
const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage);
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
const currentResult: ValuationResult | undefined =
predictMutation.data ?? selectedResult;
const handleSubmit = (data: ValuationRequest) => {
setSelectedId(null);
predictMutation.mutate(data);
};
const handleSelectHistory = (id: string) => {
setSelectedId(id);
};
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Dinh gia AI</h1>
<p className="mt-2 text-muted-foreground">
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Form + Results */}
<div className="space-y-6 lg:col-span-2">
<ValuationForm
onSubmit={handleSubmit}
isLoading={predictMutation.isPending}
/>
{predictMutation.isError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Khong the dinh gia. Vui long thu lai sau.
</div>
)}
{currentResult && <ValuationResults result={currentResult} />}
</div>
{/* History sidebar */}
<div>
<ValuationHistory
items={historyData?.data ?? []}
total={historyData?.total ?? 0}
page={historyPage}
onPageChange={setHistoryPage}
onSelect={handleSelectHistory}
isLoading={historyLoading}
/>
</div>
</div>
</div>
);
}