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:
421
apps/web/app/[locale]/(dashboard)/analytics/page.tsx
Normal file
421
apps/web/app/[locale]/(dashboard)/analytics/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
useMarketReport,
|
||||
useHeatmap,
|
||||
useDistrictStats,
|
||||
usePriceTrend,
|
||||
} from '@/lib/hooks/use-analytics';
|
||||
|
||||
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 PriceTrendChart = dynamic(
|
||||
() => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart),
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
||||
);
|
||||
|
||||
const DistrictHeatmap = dynamic(
|
||||
() => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap),
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải bản đồ nhiệt...</div> },
|
||||
);
|
||||
|
||||
const AgentPerformance = dynamic(
|
||||
() => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance),
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải...</div> },
|
||||
);
|
||||
|
||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||
const CURRENT_PERIOD = '2026-Q1';
|
||||
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '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²`;
|
||||
}
|
||||
|
||||
function YoYBadge({ value }: { value: number | null }) {
|
||||
if (value === null) return <span className="text-xs text-muted-foreground">N/A</span>;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{value.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [city, setCity] = useState<string>(CITIES[0] ?? 'Ho Chi Minh');
|
||||
const period = CURRENT_PERIOD;
|
||||
const [tab, setTab] = useState('overview');
|
||||
const [trendDistrict, setTrendDistrict] = useState<string>('');
|
||||
|
||||
const { data: reportData, isLoading: reportLoading, error: reportError } = useMarketReport(city, period);
|
||||
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
|
||||
const { data: statsData, isLoading: statsLoading } = useDistrictStats(city, period);
|
||||
const { data: trendData, isLoading: trendLoading } = usePriceTrend(
|
||||
trendDistrict,
|
||||
city,
|
||||
'APARTMENT',
|
||||
TREND_PERIODS,
|
||||
);
|
||||
|
||||
const loading = reportLoading || heatmapLoading || statsLoading;
|
||||
const error = reportError ? 'Không thể tải dữ liệu phân tích' : null;
|
||||
const marketReport = reportData?.districts ?? [];
|
||||
const heatmap = heatmapData?.dataPoints ?? [];
|
||||
const districtStats = statsData?.districts ?? [];
|
||||
const priceTrend = trendData?.trend ?? [];
|
||||
|
||||
// Auto-select first district for trend
|
||||
const firstDistrict = marketReport[0]?.district ?? '';
|
||||
useEffect(() => {
|
||||
if (firstDistrict && !trendDistrict) {
|
||||
setTrendDistrict(firstDistrict);
|
||||
}
|
||||
}, [firstDistrict, trendDistrict]);
|
||||
|
||||
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
|
||||
const avgDaysOnMarket =
|
||||
marketReport.length > 0
|
||||
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
|
||||
: 0;
|
||||
const avgPriceM2 =
|
||||
marketReport.length > 0
|
||||
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
|
||||
: 0;
|
||||
|
||||
const uniqueDistricts = [...new Set(marketReport.map((d) => d.district))];
|
||||
|
||||
// Chart data for bar chart
|
||||
const barChartData = heatmap
|
||||
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
|
||||
.map((p) => ({
|
||||
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
|
||||
price: Math.round(p.avgPriceM2 / 1_000_000),
|
||||
listings: p.totalListings,
|
||||
}));
|
||||
|
||||
// Chart data for line chart
|
||||
const trendChartData = priceTrend.map((p) => ({
|
||||
period: p.period,
|
||||
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
|
||||
'Tin đăng': p.totalListings,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Phân tích thị trường</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Báo cáo thị trường bất động sản - {period}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{CITIES.map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
variant={city === c ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCity(c)}
|
||||
>
|
||||
{c}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md bg-red-50 p-4 text-red-700">{error}</div>}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tổng tin đăng</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Giá TB/m²</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : formatPriceM2(avgPriceM2)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Ngày trung bình để bán</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Số quận/huyện</CardDescription>
|
||||
<CardTitle className="text-2xl">
|
||||
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
|
||||
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
|
||||
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
|
||||
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview">
|
||||
<div className="mt-4 grid gap-6 lg:grid-cols-2">
|
||||
{/* Bar Chart - Price by District */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
|
||||
<CardDescription>Triệu VND/m² tại {city}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : barChartData.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Heatmap - Mapbox Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bản đồ nhiệt giá theo quận</CardTitle>
|
||||
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : heatmap.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<DistrictHeatmap
|
||||
data={heatmap}
|
||||
city={city}
|
||||
className="h-[350px]"
|
||||
onDistrictClick={(district) => {
|
||||
setTrendDistrict(district);
|
||||
setTab('trends');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Trends Tab */}
|
||||
<TabsContent value="trends">
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* District selector */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uniqueDistricts.map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant={trendDistrict === d ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTrendDistrict(d)}
|
||||
>
|
||||
{d}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Xu hướng giá - {trendDistrict || 'Chọn quận'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Biến động giá trung bình/m² qua các quý (Căn hộ)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendLoading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : trendChartData.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Chưa có dữ liệu xu hướng
|
||||
</div>
|
||||
) : (
|
||||
<PriceTrendChart data={trendChartData} height={350} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* District Stats Tab */}
|
||||
<TabsContent value="districts">
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Stats Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Thống kê chi tiết theo quận</CardTitle>
|
||||
<CardDescription>
|
||||
Dữ liệu thị trường bất động sản tại {city} - {period}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : districtStats.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-2 pr-4 font-medium">Quận</th>
|
||||
<th className="pb-2 pr-4 font-medium">Loại BĐS</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Giá trung vị</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Giá/m²</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Tin đăng</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Ngày bán</th>
|
||||
<th className="pb-2 font-medium text-right">YoY</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{districtStats.map((stat, i) => (
|
||||
<tr
|
||||
key={`${stat.district}-${stat.propertyType}-${i}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4">{stat.district}</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">
|
||||
{stat.propertyType}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-medium">
|
||||
{formatPrice(stat.medianPrice)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{formatPriceM2(stat.avgPriceM2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
|
||||
<td className="py-2 pr-4 text-right">
|
||||
{stat.daysOnMarket.toFixed(0)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<YoYBadge value={stat.yoyChange} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Market Report Cards */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Báo cáo thị trường</CardTitle>
|
||||
<CardDescription>Tổng hợp chỉ số thị trường theo từng quận</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : marketReport.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...new Map(marketReport.map((d) => [d.district, d])).values()].map(
|
||||
(district) => (
|
||||
<div key={district.district} className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">{district.district}</h3>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Giá trung vị</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(district.medianPrice)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Giá/m²</span>
|
||||
<span>{formatPriceM2(district.avgPriceM2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tin đăng</span>
|
||||
<span>{district.totalListings}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tồn kho</span>
|
||||
<span>{district.inventoryLevel}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Thay đổi YoY</span>
|
||||
<YoYBadge value={district.yoyChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Agent Performance Tab */}
|
||||
<TabsContent value="performance">
|
||||
<div className="mt-4">
|
||||
<AgentPerformance />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx
Normal file
315
apps/web/app/[locale]/(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>
|
||||
);
|
||||
}
|
||||
289
apps/web/app/[locale]/(dashboard)/dashboard/page.tsx
Normal file
289
apps/web/app/[locale]/(dashboard)/dashboard/page.tsx
Normal 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 và 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 có 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 có 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
283
apps/web/app/[locale]/(dashboard)/dashboard/profile/page.tsx
Normal file
283
apps/web/app/[locale]/(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>
|
||||
);
|
||||
}
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
96
apps/web/app/[locale]/(dashboard)/error.tsx
Normal file
96
apps/web/app/[locale]/(dashboard)/error.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [autoRetrying, setAutoRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Dashboard error:', error);
|
||||
}, [error]);
|
||||
|
||||
// Auto-retry once after 3 seconds
|
||||
useEffect(() => {
|
||||
if (retryCount > 0) return;
|
||||
setAutoRetrying(true);
|
||||
const timer = setTimeout(() => {
|
||||
setAutoRetrying(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, reset, retryCount]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center px-4">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-destructive"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Không thể tải bảng điều khiển</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{autoRetrying
|
||||
? 'Đang tự động thử lại...'
|
||||
: 'Đã xảy ra lỗi khi tải dữ liệu. Vui lòng thử lại sau.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Đã thử lại {retryCount} lần
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={autoRetrying}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{autoRetrying ? (
|
||||
<>
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Đang thử lại...
|
||||
</>
|
||||
) : (
|
||||
'Thử lại'
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Tải lại trang
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/app/[locale]/(dashboard)/layout.tsx
Normal file
93
apps/web/app/[locale]/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const t = useTranslations();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
|
||||
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
|
||||
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '➕' },
|
||||
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
|
||||
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
|
||||
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
|
||||
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
|
||||
{ href: '/dashboard/payments' as const, label: t('dashboard.payments'), icon: '💳' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header
|
||||
role="banner"
|
||||
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||
</Link>
|
||||
|
||||
<nav aria-label={t('nav.dashboardNav')} className="flex items-center space-x-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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 || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="sm:mr-1.5" aria-hidden="true">{item.icon}</span>
|
||||
<span className="hidden sm:inline">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
{user && (
|
||||
<span className="hidden text-sm text-muted-foreground sm:inline">
|
||||
{user.fullName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => logout()}>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" role="main" className="mx-auto max-w-7xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx
Normal file
131
apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
StepBasicInfo,
|
||||
StepLocation,
|
||||
StepDetails,
|
||||
StepPricing,
|
||||
} from '@/components/listings/listing-form-steps';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import {
|
||||
createListingSchema,
|
||||
type CreateListingFormData,
|
||||
} from '@/lib/validations/listings';
|
||||
|
||||
export default function EditListingPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [listing, setListing] = React.useState<ListingDetail | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [activeTab, setActiveTab] = React.useState('basic');
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListingFormData>({
|
||||
resolver: zodResolver(createListingSchema),
|
||||
mode: 'onTouched',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
listingsApi
|
||||
.getById(id)
|
||||
.then((data) => {
|
||||
setListing(data);
|
||||
const { property } = data;
|
||||
reset({
|
||||
transactionType: data.transactionType,
|
||||
propertyType: property.propertyType,
|
||||
title: property.title,
|
||||
description: property.description,
|
||||
address: property.address,
|
||||
ward: property.ward,
|
||||
district: property.district,
|
||||
city: property.city,
|
||||
areaM2: String(property.areaM2),
|
||||
bedrooms: property.bedrooms != null ? String(property.bedrooms) : '',
|
||||
bathrooms: property.bathrooms != null ? String(property.bathrooms) : '',
|
||||
floors: property.floors != null ? String(property.floors) : '',
|
||||
direction: property.direction ?? '',
|
||||
yearBuilt: property.yearBuilt != null ? String(property.yearBuilt) : '',
|
||||
legalStatus: property.legalStatus ?? '',
|
||||
projectName: property.projectName ?? '',
|
||||
amenities: property.amenities?.join(', ') ?? '',
|
||||
priceVND: data.priceVND,
|
||||
rentPriceMonthly: data.rentPriceMonthly ?? '',
|
||||
commissionPct: data.commissionPct != null ? String(data.commissionPct) : '',
|
||||
});
|
||||
})
|
||||
.catch(() => setListing(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!listing) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
|
||||
<p className="text-destructive">Không tìm thấy tin đăng</p>
|
||||
<Button variant="outline" onClick={() => router.push('/listings')}>
|
||||
Quay lại
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Chỉnh sửa tin đăng</h1>
|
||||
<Button variant="outline" onClick={() => router.push(`/listings/${id}`)}>
|
||||
Xem tin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chức năng chỉnh sửa sẽ được hoàn thiện khi backend API hỗ trợ PATCH /listings/:id.
|
||||
Hiện tại bạn có thể xem lại thông tin đã nhập.
|
||||
</p>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">Cơ bản</TabsTrigger>
|
||||
<TabsTrigger value="location">Vị trí</TabsTrigger>
|
||||
<TabsTrigger value="details">Chi tiết</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Giá cả</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="pt-6">
|
||||
<TabsContent value="basic">
|
||||
<StepBasicInfo register={register} errors={errors} />
|
||||
</TabsContent>
|
||||
<TabsContent value="location">
|
||||
<StepLocation register={register} errors={errors} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<StepDetails register={register} errors={errors} />
|
||||
</TabsContent>
|
||||
<TabsContent value="pricing">
|
||||
<StepPricing register={register} errors={errors} />
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/listings-api', () => ({
|
||||
listingsApi: {
|
||||
create: vi.fn(),
|
||||
uploadMedia: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/listings/image-upload', () => ({
|
||||
ImageUpload: ({ onChange }: { onChange: (imgs: unknown[]) => void }) => (
|
||||
<div data-testid="image-upload">
|
||||
<button type="button" onClick={() => onChange([])}>Upload Mock</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { listingsApi } from '@/lib/listings-api';
|
||||
import CreateListingPage from '../new/page';
|
||||
|
||||
const _mockedListingsApi = vi.mocked(listingsApi);
|
||||
|
||||
describe('CreateListingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the page title and step indicators', () => {
|
||||
render(<CreateListingPage />);
|
||||
|
||||
expect(screen.getByText('Đăng tin mới')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thông tin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vị trí')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chi tiết')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá cả')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hình ảnh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step 1 (basic info) initially', () => {
|
||||
render(<CreateListingPage />);
|
||||
|
||||
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/loại giao dịch/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/loại bất động sản/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tiêu đề/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/mô tả/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has back button disabled on first step', () => {
|
||||
render(<CreateListingPage />);
|
||||
expect(screen.getByRole('button', { name: /quay lại/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('navigates to step 2 when basic info is filled and next is clicked', async () => {
|
||||
render(<CreateListingPage />);
|
||||
|
||||
// Fill step 1
|
||||
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
|
||||
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
|
||||
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Bán căn hộ 2PN tại Quận 7');
|
||||
await userEvent.type(screen.getByLabelText(/mô tả/i), 'Căn hộ view sông tuyệt đẹp, nội thất cao cấp');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation errors when required fields are empty on step 1', async () => {
|
||||
render(<CreateListingPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
|
||||
|
||||
// Step should not advance - still showing basic info
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates back to previous step', async () => {
|
||||
render(<CreateListingPage />);
|
||||
|
||||
// Fill step 1 and go to step 2
|
||||
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
|
||||
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
|
||||
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Test listing title here');
|
||||
await userEvent.type(screen.getByLabelText(/mô tả/i), 'A detailed description of the property for sale');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Go back
|
||||
await userEvent.click(screen.getByRole('button', { name: /quay lại/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
221
apps/web/app/[locale]/(dashboard)/listings/new/page.tsx
Normal file
221
apps/web/app/[locale]/(dashboard)/listings/new/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ImageUpload, type ImageFile } from '@/components/listings/image-upload';
|
||||
import {
|
||||
StepBasicInfo,
|
||||
StepLocation,
|
||||
StepDetails,
|
||||
StepPricing,
|
||||
} from '@/components/listings/listing-form-steps';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createListingSchema,
|
||||
listingBasicSchema,
|
||||
listingLocationSchema,
|
||||
listingDetailsSchema,
|
||||
listingPricingSchema,
|
||||
type CreateListingFormData,
|
||||
} from '@/lib/validations/listings';
|
||||
|
||||
const STEPS = [
|
||||
{ title: 'Thông tin', schemaKeys: Object.keys(listingBasicSchema.shape) },
|
||||
{ title: 'Vị trí', schemaKeys: Object.keys(listingLocationSchema.shape) },
|
||||
{ title: 'Chi tiết', schemaKeys: Object.keys(listingDetailsSchema.shape) },
|
||||
{ title: 'Giá cả', schemaKeys: Object.keys(listingPricingSchema.shape) },
|
||||
{ title: 'Hình ảnh', schemaKeys: null },
|
||||
];
|
||||
|
||||
function toNum(val: string | undefined): number | undefined {
|
||||
if (!val) return undefined;
|
||||
const n = Number(val);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
export default function CreateListingPage() {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = React.useState(0);
|
||||
const [images, setImages] = React.useState<ImageFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<CreateListingFormData>({
|
||||
resolver: zodResolver(createListingSchema),
|
||||
mode: 'onTouched',
|
||||
});
|
||||
|
||||
const goNext = async () => {
|
||||
const step = STEPS[currentStep];
|
||||
if (step?.schemaKeys) {
|
||||
const valid = await trigger(step.schemaKeys as Array<keyof CreateListingFormData>);
|
||||
if (!valid) return;
|
||||
}
|
||||
setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
};
|
||||
|
||||
const goBack = () => setCurrentStep((s) => Math.max(s - 1, 0));
|
||||
|
||||
const onSubmit = async (data: CreateListingFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload: CreateListingPayload = {
|
||||
transactionType: data.transactionType,
|
||||
propertyType: data.propertyType,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
address: data.address,
|
||||
ward: data.ward,
|
||||
district: data.district,
|
||||
city: data.city,
|
||||
latitude: toNum(data.latitude) ?? 0,
|
||||
longitude: toNum(data.longitude) ?? 0,
|
||||
areaM2: Number(data.areaM2),
|
||||
priceVND: data.priceVND,
|
||||
};
|
||||
|
||||
const usableAreaM2 = toNum(data.usableAreaM2);
|
||||
if (usableAreaM2 != null) payload.usableAreaM2 = usableAreaM2;
|
||||
const bedrooms = toNum(data.bedrooms);
|
||||
if (bedrooms != null) payload.bedrooms = bedrooms;
|
||||
const bathrooms = toNum(data.bathrooms);
|
||||
if (bathrooms != null) payload.bathrooms = bathrooms;
|
||||
const floors = toNum(data.floors);
|
||||
if (floors != null) payload.floors = floors;
|
||||
const floor = toNum(data.floor);
|
||||
if (floor != null) payload.floor = floor;
|
||||
const totalFloors = toNum(data.totalFloors);
|
||||
if (totalFloors != null) payload.totalFloors = totalFloors;
|
||||
if (data.direction) payload.direction = data.direction as Direction;
|
||||
const yearBuilt = toNum(data.yearBuilt);
|
||||
if (yearBuilt != null) payload.yearBuilt = yearBuilt;
|
||||
if (data.legalStatus) payload.legalStatus = data.legalStatus;
|
||||
if (data.projectName) payload.projectName = data.projectName;
|
||||
if (data.amenities) {
|
||||
payload.amenities = data.amenities.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (data.rentPriceMonthly) payload.rentPriceMonthly = data.rentPriceMonthly;
|
||||
const commissionPct = toNum(data.commissionPct);
|
||||
if (commissionPct != null) payload.commissionPct = commissionPct;
|
||||
|
||||
const result = await listingsApi.create(payload);
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
await listingsApi.uploadMedia(result.listingId, img.file);
|
||||
} catch {
|
||||
// Continue with remaining images
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/listings/${result.listingId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Có lỗi xảy ra');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold">Đăng tin mới</h1>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
{STEPS.map((step, index) => (
|
||||
<div key={step.title} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => index < currentStep && setCurrentStep(index)}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors',
|
||||
index === currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: index < currentStep
|
||||
? 'bg-primary/20 text-primary cursor-pointer'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{index < currentStep ? '\u2713' : index + 1}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 hidden text-sm sm:inline',
|
||||
index === currentStep ? 'font-medium' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-3 h-px w-8 sm:w-12',
|
||||
index < currentStep ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button className="ml-2 font-medium underline" onClick={() => setError(null)}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{currentStep === 0 && <StepBasicInfo register={register} errors={errors} />}
|
||||
{currentStep === 1 && <StepLocation register={register} errors={errors} />}
|
||||
{currentStep === 2 && <StepDetails register={register} errors={errors} />}
|
||||
{currentStep === 3 && <StepPricing register={register} errors={errors} />}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Hình ảnh</h3>
|
||||
<ImageUpload images={images} onChange={setImages} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
Quay lại
|
||||
</Button>
|
||||
|
||||
{currentStep < STEPS.length - 1 ? (
|
||||
<Button type="button" onClick={goNext}>
|
||||
Tiếp theo
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Đang đăng...' : 'Đăng tin'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
apps/web/app/[locale]/(dashboard)/listings/page.tsx
Normal file
345
apps/web/app/[locale]/(dashboard)/listings/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import * as React from 'react';
|
||||
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||
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 { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||
import type { ListingDetail as _ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
function formatPrice(priceVND: string): string {
|
||||
const num = Number(priceVND);
|
||||
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 formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('vi-VN', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'table';
|
||||
|
||||
export default function ListingsPage() {
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
|
||||
const [filters, setFilters] = React.useState({
|
||||
transactionType: '',
|
||||
propertyType: '',
|
||||
status: '' as string,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const searchParams = React.useMemo(() => {
|
||||
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.status) params['status'] = filters.status;
|
||||
return params;
|
||||
}, [filters]);
|
||||
|
||||
const { data: result, isLoading: loading } = useListingsSearch(searchParams);
|
||||
|
||||
// Stats from current page data
|
||||
const stats = React.useMemo(() => {
|
||||
if (!result) return { total: 0, active: 0, pending: 0, views: 0 };
|
||||
return {
|
||||
total: result.total,
|
||||
active: result.data.filter((l) => l.status === 'ACTIVE').length,
|
||||
pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length,
|
||||
views: result.data.reduce((s, l) => s + l.viewCount, 0),
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Quản lý tin đăng</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quản lý, theo dõi và cập nhật các tin đăng của bạn
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/listings/new">
|
||||
<Button>Đăng tin mới</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tổng tin đăng</CardDescription>
|
||||
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Đang hoạt động</CardDescription>
|
||||
<CardTitle className="text-xl text-green-600">
|
||||
{loading ? '...' : stats.active}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Chờ duyệt</CardDescription>
|
||||
<CardTitle className="text-xl text-yellow-600">
|
||||
{loading ? '...' : stats.pending}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Tổng lượt xem</CardDescription>
|
||||
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters + View Toggle */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
value={filters.transactionType}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
|
||||
}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="">Tất cả giao dịch</option>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.propertyType}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
|
||||
}
|
||||
className="w-44"
|
||||
>
|
||||
<option value="">Tất cả loại BĐS</option>
|
||||
{PROPERTY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
|
||||
className="w-40"
|
||||
>
|
||||
<option value="">Tất cả trạng thái</option>
|
||||
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
Lưới
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
Bảng
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex min-h-[300px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||
<p>Chưa có 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>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{result.data.map((listing) => (
|
||||
<Link key={listing.id} href={`/listings/${listing.id}`}>
|
||||
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className="relative aspect-[4/3] bg-muted">
|
||||
{listing.property.media.length > 0 ? (
|
||||
<Image
|
||||
src={listing.property.media[0]?.url ?? ''}
|
||||
alt={listing.property.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Chưa có ảnh
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatPrice(listing.priceVND)} VND
|
||||
</p>
|
||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||
{listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.areaM2} m²
|
||||
</Badge>
|
||||
{listing.property.bedrooms != null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.bedrooms} PN
|
||||
</Badge>
|
||||
)}
|
||||
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{listing.property.bathrooms} PT
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{listing.viewCount} lượt xem</span>
|
||||
<span>{listing.inquiryCount} liên hệ</span>
|
||||
<span>{listing.saveCount} đã lưu</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Table View */
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="p-3 font-medium">Tin đăng</th>
|
||||
<th className="p-3 font-medium">Loại</th>
|
||||
<th className="p-3 font-medium text-right">Giá</th>
|
||||
<th className="p-3 font-medium text-right">Diện tích</th>
|
||||
<th className="p-3 font-medium text-center">Trạng thái</th>
|
||||
<th className="p-3 font-medium text-right">Lượt xem</th>
|
||||
<th className="p-3 font-medium text-right">Liên hệ</th>
|
||||
<th className="p-3 font-medium text-right">Ngày đăng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.map((listing) => (
|
||||
<tr
|
||||
key={listing.id}
|
||||
className="border-b last:border-0 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/listings/${listing.id}`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<div className="relative h-10 w-14 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="56px"
|
||||
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">
|
||||
<p className="truncate font-medium group-hover:text-primary">
|
||||
{listing.property.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">
|
||||
{listing.property.propertyType}
|
||||
</td>
|
||||
<td className="p-3 text-right font-medium text-primary">
|
||||
{formatPrice(listing.priceVND)}
|
||||
</td>
|
||||
<td className="p-3 text-right">{listing.property.areaM2} m²</td>
|
||||
<td className="p-3 text-center">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
</td>
|
||||
<td className="p-3 text-right">{listing.viewCount}</td>
|
||||
<td className="p-3 text-right">{listing.inquiryCount}</td>
|
||||
<td className="p-3 text-right text-xs text-muted-foreground">
|
||||
{formatDate(listing.publishedAt ?? listing.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{result && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page <= 1}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
|
||||
>
|
||||
Trước
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Trang {result.page} / {result.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={filters.page >= result.totalPages}
|
||||
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
|
||||
>
|
||||
Tiếp
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/web/app/[locale]/(dashboard)/loading.tsx
Normal file
71
apps/web/app/[locale]/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-72 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-10 w-28 animate-pulse rounded-md bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* Stats grid skeleton */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart + sidebar skeleton */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm lg:col-span-2">
|
||||
<div className="h-5 w-40 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-56 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-6 h-64 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="h-5 w-36 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-6 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent listings skeleton */}
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<div className="h-5 w-36 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-56 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-lg border p-3">
|
||||
<div className="h-12 w-16 flex-shrink-0 animate-pulse rounded bg-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-1/2 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-1 h-5 w-16 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user