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>
This commit is contained in:
@@ -15,12 +15,12 @@ import {
|
|||||||
|
|
||||||
const DistrictBarChart = dynamic(
|
const DistrictBarChart = dynamic(
|
||||||
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
() => 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">Dang tai bieu do...</div> },
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
||||||
);
|
);
|
||||||
|
|
||||||
const PriceTrendChart = dynamic(
|
const PriceTrendChart = dynamic(
|
||||||
() => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart),
|
() => 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">Dang tai bieu do...</div> },
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
||||||
);
|
);
|
||||||
|
|
||||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||||
@@ -29,14 +29,14 @@ const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
|||||||
|
|
||||||
function formatPrice(priceStr: string): string {
|
function formatPrice(priceStr: string): string {
|
||||||
const num = Number(priceStr);
|
const num = Number(priceStr);
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
|
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)} trieu`;
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||||
return num.toLocaleString('vi-VN');
|
return num.toLocaleString('vi-VN');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPriceM2(price: number): string {
|
function formatPriceM2(price: number): string {
|
||||||
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`;
|
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
|
||||||
return `${price.toLocaleString('vi-VN')} d/m2`;
|
return `${price.toLocaleString('vi-VN')} đ/m²`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function YoYBadge({ value }: { value: number | null }) {
|
function YoYBadge({ value }: { value: number | null }) {
|
||||||
@@ -91,7 +91,7 @@ export default function AnalyticsPage() {
|
|||||||
setTrendDistrict(firstDistrict);
|
setTrendDistrict(firstDistrict);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setError('Khong the tai du lieu phan tich'))
|
.catch(() => setError('Không thể tải dữ liệu phân tích'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [city, period]);
|
}, [city, period]);
|
||||||
|
|
||||||
@@ -131,16 +131,16 @@ export default function AnalyticsPage() {
|
|||||||
const trendChartData = priceTrend.map((p) => ({
|
const trendChartData = priceTrend.map((p) => ({
|
||||||
period: p.period,
|
period: p.period,
|
||||||
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
|
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
|
||||||
'Tin dang': p.totalListings,
|
'Tin đăng': p.totalListings,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Phan tich thi truong</h1>
|
<h1 className="text-3xl font-bold">Phân tích thị trường</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Bao cao thi truong bat dong san - {period}
|
Báo cáo thị trường bất động sản - {period}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -163,7 +163,7 @@ export default function AnalyticsPage() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Tong tin dang</CardDescription>
|
<CardDescription>Tổng tin đăng</CardDescription>
|
||||||
<CardTitle className="text-2xl">
|
<CardTitle className="text-2xl">
|
||||||
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
|
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -171,7 +171,7 @@ export default function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Gia TB/m2</CardDescription>
|
<CardDescription>Giá TB/m²</CardDescription>
|
||||||
<CardTitle className="text-2xl">
|
<CardTitle className="text-2xl">
|
||||||
{loading ? '...' : formatPriceM2(avgPriceM2)}
|
{loading ? '...' : formatPriceM2(avgPriceM2)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -179,15 +179,15 @@ export default function AnalyticsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Ngay trung binh de ban</CardDescription>
|
<CardDescription>Ngày trung bình để bán</CardDescription>
|
||||||
<CardTitle className="text-2xl">
|
<CardTitle className="text-2xl">
|
||||||
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}
|
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>So quan/huyen</CardDescription>
|
<CardDescription>Số quận/huyện</CardDescription>
|
||||||
<CardTitle className="text-2xl">
|
<CardTitle className="text-2xl">
|
||||||
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
|
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -198,9 +198,9 @@ export default function AnalyticsPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Tong quan</TabsTrigger>
|
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
|
||||||
<TabsTrigger value="trends">Xu huong gia</TabsTrigger>
|
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
|
||||||
<TabsTrigger value="districts">Chi tiet quan</TabsTrigger>
|
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
@@ -209,17 +209,17 @@ export default function AnalyticsPage() {
|
|||||||
{/* Bar Chart - Price by District */}
|
{/* Bar Chart - Price by District */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Gia trung binh theo quan</CardTitle>
|
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
|
||||||
<CardDescription>Trieu VND/m2 tai {city}</CardDescription>
|
<CardDescription>Triệu VND/m² tại {city}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : barChartData.length === 0 ? (
|
) : barChartData.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
|
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
|
||||||
@@ -230,17 +230,17 @@ export default function AnalyticsPage() {
|
|||||||
{/* Heatmap - Card Grid */}
|
{/* Heatmap - Card Grid */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Ban do gia theo quan</CardTitle>
|
<CardTitle className="text-lg">Bản đồ giá theo quận</CardTitle>
|
||||||
<CardDescription>So sanh gia trung binh/m2 tai {city}</CardDescription>
|
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : heatmap.length === 0 ? (
|
) : heatmap.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
@@ -267,7 +267,7 @@ export default function AnalyticsPage() {
|
|||||||
{formatPriceM2(point.avgPriceM2)}
|
{formatPriceM2(point.avgPriceM2)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{point.totalListings} tin dang
|
{point.totalListings} tin đăng
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -299,20 +299,20 @@ export default function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">
|
||||||
Xu huong gia - {trendDistrict || 'Chon quan'}
|
Xu hướng giá - {trendDistrict || 'Chọn quận'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Bien dong gia trung binh/m2 qua cac quy (Can ho)
|
Biến động giá trung bình/m² qua các quý (Căn hộ)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{trendLoading ? (
|
{trendLoading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : trendChartData.length === 0 ? (
|
) : trendChartData.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu xu huong
|
Chưa có dữ liệu xu hướng
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PriceTrendChart data={trendChartData} height={350} />
|
<PriceTrendChart data={trendChartData} height={350} />
|
||||||
@@ -328,31 +328,31 @@ export default function AnalyticsPage() {
|
|||||||
{/* Stats Table */}
|
{/* Stats Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Thong ke chi tiet theo quan</CardTitle>
|
<CardTitle className="text-lg">Thống kê chi tiết theo quận</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Du lieu thi truong bat dong san tai {city} - {period}
|
Dữ liệu thị trường bất động sản tại {city} - {period}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : districtStats.length === 0 ? (
|
) : districtStats.length === 0 ? (
|
||||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left">
|
<tr className="border-b text-left">
|
||||||
<th className="pb-2 pr-4 font-medium">Quan</th>
|
<th className="pb-2 pr-4 font-medium">Quận</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Loai BDS</th>
|
<th className="pb-2 pr-4 font-medium">Loại BĐS</th>
|
||||||
<th className="pb-2 pr-4 font-medium text-right">Gia trung vi</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">Gia/m2</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 dang</th>
|
<th className="pb-2 pr-4 font-medium text-right">Tin đăng</th>
|
||||||
<th className="pb-2 pr-4 font-medium text-right">Ngay ban</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>
|
<th className="pb-2 font-medium text-right">YoY</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -391,17 +391,17 @@ export default function AnalyticsPage() {
|
|||||||
{/* Market Report Cards */}
|
{/* Market Report Cards */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Bao cao thi truong</CardTitle>
|
<CardTitle className="text-lg">Báo cáo thị trường</CardTitle>
|
||||||
<CardDescription>Tong hop chi so thi truong theo tung quan</CardDescription>
|
<CardDescription>Tổng hợp chỉ số thị trường theo từng quận</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : marketReport.length === 0 ? (
|
) : marketReport.length === 0 ? (
|
||||||
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
<div className="flex h-48 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -411,25 +411,25 @@ export default function AnalyticsPage() {
|
|||||||
<h3 className="font-semibold">{district.district}</h3>
|
<h3 className="font-semibold">{district.district}</h3>
|
||||||
<div className="mt-2 space-y-1 text-sm">
|
<div className="mt-2 space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Gia trung vi</span>
|
<span className="text-muted-foreground">Giá trung vị</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatPrice(district.medianPrice)}
|
{formatPrice(district.medianPrice)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Gia/m2</span>
|
<span className="text-muted-foreground">Giá/m²</span>
|
||||||
<span>{formatPriceM2(district.avgPriceM2)}</span>
|
<span>{formatPriceM2(district.avgPriceM2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Tin dang</span>
|
<span className="text-muted-foreground">Tin đăng</span>
|
||||||
<span>{district.totalListings}</span>
|
<span>{district.totalListings}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Ton kho</span>
|
<span className="text-muted-foreground">Tồn kho</span>
|
||||||
<span>{district.inventoryLevel}</span>
|
<span>{district.inventoryLevel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Thay doi YoY</span>
|
<span className="text-muted-foreground">Thay đổi YoY</span>
|
||||||
<YoYBadge value={district.yoyChange} />
|
<YoYBadge value={district.yoyChange} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/lis
|
|||||||
|
|
||||||
const DistrictBarChart = dynamic(
|
const DistrictBarChart = dynamic(
|
||||||
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
() => 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">Dang tai bieu do...</div> },
|
{ 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 CITY = 'Ho Chi Minh';
|
||||||
@@ -24,14 +24,14 @@ const PERIOD = '2026-Q1';
|
|||||||
|
|
||||||
function formatPrice(priceStr: string): string {
|
function formatPrice(priceStr: string): string {
|
||||||
const num = Number(priceStr);
|
const num = Number(priceStr);
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
|
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)} trieu`;
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||||
return num.toLocaleString('vi-VN');
|
return num.toLocaleString('vi-VN');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPriceM2(price: number): string {
|
function formatPriceM2(price: number): string {
|
||||||
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`;
|
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
|
||||||
return `${price.toLocaleString('vi-VN')} d/m2`;
|
return `${price.toLocaleString('vi-VN')} đ/m²`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
@@ -124,35 +124,35 @@ export default function DashboardPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Bang dieu khien</h1>
|
<h1 className="text-3xl font-bold">Bảng điều khiển</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Tong quan thi truong va tin dang cua ban
|
Tổng quan thị trường và tin đăng của bạn
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings/new">
|
<Link href="/listings/new">
|
||||||
<Button>Dang tin moi</Button>
|
<Button>Đăng tin mới</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats overview */}
|
{/* Stats overview */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Tin dang cua toi"
|
title="Tin đăng của tôi"
|
||||||
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
|
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
|
||||||
description="Tong so tin da dang"
|
description="Tổng số tin đã đăng"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Luot xem"
|
title="Lượt xem"
|
||||||
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
|
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
|
||||||
description="Tren tat ca tin dang"
|
description="Trên tất cả tin đăng"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Lien he"
|
title="Liên hệ"
|
||||||
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
|
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
|
||||||
description="Yeu cau tu khach hang"
|
description="Yêu cầu từ khách hàng"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Gia TB thi truong"
|
title="Giá TB thị trường"
|
||||||
value={loading ? '...' : formatPriceM2(avgPriceM2)}
|
value={loading ? '...' : formatPriceM2(avgPriceM2)}
|
||||||
trend={avgYoy}
|
trend={avgYoy}
|
||||||
description="YoY"
|
description="YoY"
|
||||||
@@ -164,24 +164,24 @@ export default function DashboardPage() {
|
|||||||
{/* Price chart */}
|
{/* Price chart */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Gia trung binh theo quan</CardTitle>
|
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
|
||||||
<CardDescription>{CITY} - {PERIOD} (trieu VND/m2)</CardDescription>
|
<CardDescription>{CITY} - {PERIOD} (triệu VND/m²)</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : chartData.length === 0 ? (
|
) : chartData.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Chua co du lieu
|
Chưa có dữ liệu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DistrictBarChart
|
<DistrictBarChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
height={280}
|
height={280}
|
||||||
dataKey="Gia/m2"
|
dataKey="Gia/m2"
|
||||||
tooltipFormatter={(value) => [`${value} tr/m2`, 'Gia']}
|
tooltipFormatter={(value) => [`${value} tr/m²`, 'Giá']}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -190,30 +190,30 @@ export default function DashboardPage() {
|
|||||||
{/* Market summary */}
|
{/* Market summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Thi truong {CITY}</CardTitle>
|
<CardTitle className="text-lg">Thị trường {CITY}</CardTitle>
|
||||||
<CardDescription>Chi so chinh - {PERIOD}</CardDescription>
|
<CardDescription>Chỉ số chính - {PERIOD}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Tong tin dang</span>
|
<span className="text-sm text-muted-foreground">Tổng tin đăng</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
|
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Gia TB/m2</span>
|
<span className="text-sm text-muted-foreground">Giá TB/m²</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{loading ? '...' : formatPriceM2(avgPriceM2)}
|
{loading ? '...' : formatPriceM2(avgPriceM2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Ngay TB de ban</span>
|
<span className="text-sm text-muted-foreground">Ngày TB để bán</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`}
|
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">So quan</span>
|
<span className="text-sm text-muted-foreground">Số quận</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
|
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
|
||||||
</span>
|
</span>
|
||||||
@@ -221,7 +221,7 @@ export default function DashboardPage() {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Link href="/analytics">
|
<Link href="/analytics">
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
Xem phan tich chi tiet
|
Xem phân tích chi tiết
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,26 +233,26 @@ export default function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">Tin dang gan day</CardTitle>
|
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
|
||||||
<CardDescription>Danh sach tin dang moi nhat cua ban</CardDescription>
|
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings">
|
<Link href="/listings">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Xem tat ca
|
Xem tất cả
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
Dang tai...
|
Đang tải...
|
||||||
</div>
|
</div>
|
||||||
) : !listings || listings.data.length === 0 ? (
|
) : !listings || listings.data.length === 0 ? (
|
||||||
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
||||||
<p>Chua co tin dang nao</p>
|
<p>Chưa có tin đăng nào</p>
|
||||||
<Link href="/listings/new" className="mt-2">
|
<Link href="/listings/new" className="mt-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Dang tin dau tien
|
Đăng tin đầu tiên
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,8 +292,8 @@ export default function DashboardPage() {
|
|||||||
<ListingStatusBadge status={listing.status} />
|
<ListingStatusBadge status={listing.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
|
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
|
||||||
<span>{listing.viewCount} luot xem</span>
|
<span>{listing.viewCount} lượt xem</span>
|
||||||
<span>{listing.inquiryCount} lien he</span>
|
<span>{listing.inquiryCount} liên hệ</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||||
function formatPrice(priceVND: string): string {
|
function formatPrice(priceVND: string): string {
|
||||||
const num = Number(priceVND);
|
const num = Number(priceVND);
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`;
|
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)} trieu`;
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||||
return num.toLocaleString('vi-VN');
|
return num.toLocaleString('vi-VN');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +77,13 @@ export default function ListingsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Quan ly tin dang</h1>
|
<h1 className="text-2xl font-bold">Quản lý tin đăng</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Quan ly, theo doi va cap nhat cac tin dang cua ban
|
Quản lý, theo dõi và cập nhật các tin đăng của bạn
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings/new">
|
<Link href="/listings/new">
|
||||||
<Button>Dang tin moi</Button>
|
<Button>Đăng tin mới</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,13 +91,13 @@ export default function ListingsPage() {
|
|||||||
<div className="grid gap-3 sm:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Tong tin dang</CardDescription>
|
<CardDescription>Tổng tin đăng</CardDescription>
|
||||||
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Dang hoat dong</CardDescription>
|
<CardDescription>Đang hoạt động</CardDescription>
|
||||||
<CardTitle className="text-xl text-green-600">
|
<CardTitle className="text-xl text-green-600">
|
||||||
{loading ? '...' : stats.active}
|
{loading ? '...' : stats.active}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -105,7 +105,7 @@ export default function ListingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Cho duyet</CardDescription>
|
<CardDescription>Chờ duyệt</CardDescription>
|
||||||
<CardTitle className="text-xl text-yellow-600">
|
<CardTitle className="text-xl text-yellow-600">
|
||||||
{loading ? '...' : stats.pending}
|
{loading ? '...' : stats.pending}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -113,7 +113,7 @@ export default function ListingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Tong luot xem</CardDescription>
|
<CardDescription>Tổng lượt xem</CardDescription>
|
||||||
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
|
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -128,7 +128,7 @@ export default function ListingsPage() {
|
|||||||
}
|
}
|
||||||
className="w-40"
|
className="w-40"
|
||||||
>
|
>
|
||||||
<option value="">Tat ca giao dich</option>
|
<option value="">Tất cả giao dịch</option>
|
||||||
{TRANSACTION_TYPES.map((t) => (
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -142,7 +142,7 @@ export default function ListingsPage() {
|
|||||||
}
|
}
|
||||||
className="w-44"
|
className="w-44"
|
||||||
>
|
>
|
||||||
<option value="">Tat ca loai BDS</option>
|
<option value="">Tất cả loại BĐS</option>
|
||||||
{PROPERTY_TYPES.map((t) => (
|
{PROPERTY_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -154,7 +154,7 @@ export default function ListingsPage() {
|
|||||||
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
|
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
|
||||||
className="w-40"
|
className="w-40"
|
||||||
>
|
>
|
||||||
<option value="">Tat ca trang thai</option>
|
<option value="">Tất cả trạng thái</option>
|
||||||
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
|
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
{label}
|
{label}
|
||||||
@@ -168,14 +168,14 @@ export default function ListingsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
>
|
>
|
||||||
Luoi
|
Lưới
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('table')}
|
onClick={() => setViewMode('table')}
|
||||||
>
|
>
|
||||||
Bang
|
Bảng
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,10 +187,10 @@ export default function ListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : !result || result.data.length === 0 ? (
|
) : !result || result.data.length === 0 ? (
|
||||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||||
<p>Chua co tin dang nao</p>
|
<p>Chưa có tin đăng nào</p>
|
||||||
<Link href="/listings/new" className="mt-2">
|
<Link href="/listings/new" className="mt-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Dang tin dau tien
|
Đăng tin đầu tiên
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +228,7 @@ export default function ListingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{listing.property.areaM2} m2
|
{listing.property.areaM2} m²
|
||||||
</Badge>
|
</Badge>
|
||||||
{listing.property.bedrooms != null && (
|
{listing.property.bedrooms != null && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
@@ -242,9 +242,9 @@ export default function ListingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
||||||
<span>{listing.viewCount} luot xem</span>
|
<span>{listing.viewCount} lượt xem</span>
|
||||||
<span>{listing.inquiryCount} lien he</span>
|
<span>{listing.inquiryCount} liên hệ</span>
|
||||||
<span>{listing.saveCount} da luu</span>
|
<span>{listing.saveCount} đã lưu</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -259,14 +259,14 @@ export default function ListingsPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left">
|
<tr className="border-b text-left">
|
||||||
<th className="p-3 font-medium">Tin dang</th>
|
<th className="p-3 font-medium">Tin đăng</th>
|
||||||
<th className="p-3 font-medium">Loai</th>
|
<th className="p-3 font-medium">Loại</th>
|
||||||
<th className="p-3 font-medium text-right">Gia</th>
|
<th className="p-3 font-medium text-right">Giá</th>
|
||||||
<th className="p-3 font-medium text-right">Dien tich</th>
|
<th className="p-3 font-medium text-right">Diện tích</th>
|
||||||
<th className="p-3 font-medium text-center">Trang thai</th>
|
<th className="p-3 font-medium text-center">Trạng thái</th>
|
||||||
<th className="p-3 font-medium text-right">Luot xem</th>
|
<th className="p-3 font-medium text-right">Lượt xem</th>
|
||||||
<th className="p-3 font-medium text-right">Lien he</th>
|
<th className="p-3 font-medium text-right">Liên hệ</th>
|
||||||
<th className="p-3 font-medium text-right">Ngay dang</th>
|
<th className="p-3 font-medium text-right">Ngày đăng</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -311,7 +311,7 @@ export default function ListingsPage() {
|
|||||||
<td className="p-3 text-right font-medium text-primary">
|
<td className="p-3 text-right font-medium text-primary">
|
||||||
{formatPrice(listing.priceVND)}
|
{formatPrice(listing.priceVND)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">{listing.property.areaM2} m2</td>
|
<td className="p-3 text-right">{listing.property.areaM2} m²</td>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
<ListingStatusBadge status={listing.status} />
|
<ListingStatusBadge status={listing.status} />
|
||||||
</td>
|
</td>
|
||||||
@@ -338,7 +338,7 @@ export default function ListingsPage() {
|
|||||||
disabled={filters.page <= 1}
|
disabled={filters.page <= 1}
|
||||||
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
|
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
|
||||||
>
|
>
|
||||||
Truoc
|
Trước
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Trang {result.page} / {result.totalPages}
|
Trang {result.page} / {result.totalPages}
|
||||||
@@ -349,7 +349,7 @@ export default function ListingsPage() {
|
|||||||
disabled={filters.page >= result.totalPages}
|
disabled={filters.page >= result.totalPages}
|
||||||
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
|
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
|
||||||
>
|
>
|
||||||
Tiep
|
Tiếp
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ListingMap = dynamic(
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
|
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
|
||||||
<p className="text-sm text-muted-foreground">Dang tai ban do...</p>
|
<p className="text-sm text-muted-foreground">Đang tải bản đồ...</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@ export default function PublicListingDetailPage() {
|
|||||||
listingsApi
|
listingsApi
|
||||||
.getById(id)
|
.getById(id)
|
||||||
.then(setListing)
|
.then(setListing)
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : 'Khong tai duoc tin dang'))
|
.catch((err) => setError(err instanceof Error ? err.message : 'Không tải được tin đăng'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -74,9 +74,9 @@ export default function PublicListingDetailPage() {
|
|||||||
<svg className="h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-destructive">{error || 'Khong tim thay tin dang'}</p>
|
<p className="text-destructive">{error || 'Không tìm thấy tin đăng'}</p>
|
||||||
<Link href="/search">
|
<Link href="/search">
|
||||||
<Button variant="outline">Quay lai tim kiem</Button>
|
<Button variant="outline">Quay lại tìm kiếm</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -90,9 +90,9 @@ export default function PublicListingDetailPage() {
|
|||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Link href="/" className="hover:text-foreground">Trang chu</Link>
|
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link href="/search" className="hover:text-foreground">Tim kiem</Link>
|
<Link href="/search" className="hover:text-foreground">Tìm kiếm</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="truncate text-foreground">{property.title}</span>
|
<span className="truncate text-foreground">{property.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -121,12 +121,12 @@ export default function PublicListingDetailPage() {
|
|||||||
<p className="text-2xl font-bold text-primary md:text-3xl">{formatPrice(listing.priceVND)} VND</p>
|
<p className="text-2xl font-bold text-primary md:text-3xl">{formatPrice(listing.priceVND)} VND</p>
|
||||||
{listing.pricePerM2 != null && (
|
{listing.pricePerM2 != null && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m2
|
~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m²
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{listing.rentPriceMonthly && (
|
{listing.rentPriceMonthly && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Thue: {formatPrice(listing.rentPriceMonthly)}/thang
|
Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,18 +137,18 @@ export default function PublicListingDetailPage() {
|
|||||||
|
|
||||||
{/* Quick specs bar */}
|
{/* Quick specs bar */}
|
||||||
<div className="my-6 flex flex-wrap gap-4 rounded-lg border bg-card p-4">
|
<div className="my-6 flex flex-wrap gap-4 rounded-lg border bg-card p-4">
|
||||||
<QuickStat icon="area" label="Dien tich" value={`${property.areaM2} m\u00B2`} />
|
<QuickStat icon="area" label="Diện tích" value={`${property.areaM2} m\u00B2`} />
|
||||||
{property.bedrooms != null && (
|
{property.bedrooms != null && (
|
||||||
<QuickStat icon="bed" label="Phong ngu" value={`${property.bedrooms}`} />
|
<QuickStat icon="bed" label="Phòng ngủ" value={`${property.bedrooms}`} />
|
||||||
)}
|
)}
|
||||||
{property.bathrooms != null && (
|
{property.bathrooms != null && (
|
||||||
<QuickStat icon="bath" label="Phong tam" value={`${property.bathrooms}`} />
|
<QuickStat icon="bath" label="Phòng tắm" value={`${property.bathrooms}`} />
|
||||||
)}
|
)}
|
||||||
{property.floors != null && (
|
{property.floors != null && (
|
||||||
<QuickStat icon="floors" label="So tang" value={`${property.floors}`} />
|
<QuickStat icon="floors" label="Số tầng" value={`${property.floors}`} />
|
||||||
)}
|
)}
|
||||||
{property.direction && (
|
{property.direction && (
|
||||||
<QuickStat icon="compass" label="Huong" value={getLabel(DIRECTIONS, property.direction) || ''} />
|
<QuickStat icon="compass" label="Hướng" value={getLabel(DIRECTIONS, property.direction) || ''} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export default function PublicListingDetailPage() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Mo ta</CardTitle>
|
<CardTitle>Mô tả</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
|
||||||
@@ -168,19 +168,19 @@ export default function PublicListingDetailPage() {
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Thong tin chi tiet</CardTitle>
|
<CardTitle>Thông tin chi tiết</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
<InfoItem label="Loai BDS" value={propertyTypeLabel || '---'} />
|
<InfoItem label="Loại BĐS" value={propertyTypeLabel || '---'} />
|
||||||
<InfoItem label="Dien tich" value={`${property.areaM2} m\u00B2`} />
|
<InfoItem label="Diện tích" value={`${property.areaM2} m\u00B2`} />
|
||||||
<InfoItem label="Phong ngu" value={property.bedrooms != null ? `${property.bedrooms}` : '---'} />
|
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '---'} />
|
||||||
<InfoItem label="Phong tam" value={property.bathrooms != null ? `${property.bathrooms}` : '---'} />
|
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '---'} />
|
||||||
<InfoItem label="So tang" value={property.floors != null ? `${property.floors}` : '---'} />
|
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '---'} />
|
||||||
<InfoItem label="Huong" value={getLabel(DIRECTIONS, property.direction) || '---'} />
|
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
|
||||||
<InfoItem label="Nam xay" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
|
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
|
||||||
<InfoItem label="Phap ly" value={property.legalStatus || '---'} />
|
<InfoItem label="Pháp lý" value={property.legalStatus || '---'} />
|
||||||
<InfoItem label="Du an" value={property.projectName || '---'} />
|
<InfoItem label="Dự án" value={property.projectName || '---'} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -189,7 +189,7 @@ export default function PublicListingDetailPage() {
|
|||||||
{property.amenities && property.amenities.length > 0 && (
|
{property.amenities && property.amenities.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Tien ich</CardTitle>
|
<CardTitle>Tiện ích</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -206,7 +206,7 @@ export default function PublicListingDetailPage() {
|
|||||||
{/* Map */}
|
{/* Map */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Vi tri tren ban do</CardTitle>
|
<CardTitle>Vị trí trên bản đồ</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ListingMap
|
<ListingMap
|
||||||
@@ -222,7 +222,7 @@ export default function PublicListingDetailPage() {
|
|||||||
{/* Contact card */}
|
{/* Contact card */}
|
||||||
<Card className="sticky top-20">
|
<Card className="sticky top-20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Lien he</CardTitle>
|
<CardTitle>Liên hệ</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -242,22 +242,22 @@ export default function PublicListingDetailPage() {
|
|||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Goi ngay
|
Gọi ngay
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<Button variant="outline" className="w-full gap-2">
|
<Button variant="outline" className="w-full gap-2">
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
</svg>
|
</svg>
|
||||||
Nhan tin
|
Nhắn tin
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{agent && (
|
{agent && (
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<p className="text-xs text-muted-foreground">Moi gioi</p>
|
<p className="text-xs text-muted-foreground">Môi giới</p>
|
||||||
{agent.agency && <p className="text-sm font-medium">{agent.agency}</p>}
|
{agent.agency && <p className="text-sm font-medium">{agent.agency}</p>}
|
||||||
{listing.commissionPct != null && (
|
{listing.commissionPct != null && (
|
||||||
<p className="text-xs text-muted-foreground">Hoa hong: {listing.commissionPct}%</p>
|
<p className="text-xs text-muted-foreground">Hoa hồng: {listing.commissionPct}%</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -270,20 +270,20 @@ export default function PublicListingDetailPage() {
|
|||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold">{listing.viewCount}</p>
|
<p className="text-lg font-bold">{listing.viewCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Luot xem</p>
|
<p className="text-xs text-muted-foreground">Lượt xem</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold">{listing.saveCount}</p>
|
<p className="text-lg font-bold">{listing.saveCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Luot luu</p>
|
<p className="text-xs text-muted-foreground">Lượt lưu</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold">{listing.inquiryCount}</p>
|
<p className="text-lg font-bold">{listing.inquiryCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Lien he</p>
|
<p className="text-xs text-muted-foreground">Liên hệ</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{listing.publishedAt && (
|
{listing.publishedAt && (
|
||||||
<p className="mt-3 border-t pt-3 text-center text-xs text-muted-foreground">
|
<p className="mt-3 border-t pt-3 text-center text-xs text-muted-foreground">
|
||||||
Dang ngay {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
|
Đăng ngày {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ListingMap = dynamic(
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-[calc(100vh-220px)] items-center justify-center rounded-lg bg-muted">
|
<div className="flex h-[calc(100vh-220px)] items-center justify-center rounded-lg bg-muted">
|
||||||
<p className="text-sm text-muted-foreground">Dang tai ban do...</p>
|
<p className="text-sm text-muted-foreground">Đang tải bản đồ...</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function DistrictBarChart({
|
|||||||
tooltipFormatter,
|
tooltipFormatter,
|
||||||
}: DistrictBarChartProps) {
|
}: DistrictBarChartProps) {
|
||||||
const defaultFormatter: TooltipFormatter = (value, name) => [
|
const defaultFormatter: TooltipFormatter = (value, name) => [
|
||||||
name === dataKey ? `${value} tr/m2` : String(value),
|
name === dataKey ? `${value} tr/m²` : String(value),
|
||||||
name === dataKey ? 'Gia' : 'Tin dang',
|
name === dataKey ? 'Giá' : 'Tin đăng',
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
interface PriceTrendChartProps {
|
interface PriceTrendChartProps {
|
||||||
data: { period: string; 'Gia/m2': number; 'Tin dang': number }[];
|
data: { period: string; 'Gia/m2': number; 'Tin đăng': number }[];
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function PriceTrendChart({ data, height = 350 }: PriceTrendChartProps) {
|
|||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
}}
|
}}
|
||||||
formatter={(value, name) => [
|
formatter={(value, name) => [
|
||||||
name === 'Gia/m2' ? `${value} tr/m2` : value,
|
name === 'Gia/m2' ? `${value} tr/m²` : value,
|
||||||
name,
|
name,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +54,7 @@ export function PriceTrendChart({ data, height = 350 }: PriceTrendChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="Tin dang"
|
dataKey="Tin đăng"
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--muted-foreground))"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
|
|||||||
Reference in New Issue
Block a user