Files
goodgo-platform/apps/web/app/(dashboard)/dashboard/page.tsx
Ho Ngoc Hai 36c1e3b39a fix(web): add proper Vietnamese diacritics to all dashboard and listing pages
Vietnamese text throughout the frontend was missing accent marks (diacritics),
using plain ASCII instead of proper Unicode characters. Fixed all user-visible
text across dashboard, analytics, listings, search, and chart components.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 13:21:37 +07:00

307 lines
12 KiB
TypeScript

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