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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user