feat(web): centralise Vietnamese price formatting across all pages

Create a single `currency.ts` utility with `formatPrice`, `formatVND`,
`formatPricePerM2`, and `parseVND` to replace 9+ duplicated inline
formatters. This fixes inconsistent decimal handling (1.5M was truncated
to "1 triệu") and standardises price/m² display. Integrated across
property cards, listing detail, dashboard, analytics, payments, pricing,
and admin moderation pages with 19 new unit tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:33:31 +07:00
parent 18b5980f29
commit 55a01c5738
12 changed files with 285 additions and 107 deletions

View File

@@ -24,16 +24,7 @@ import {
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { adminApi, type ModerationQueueItem, type PaginatedResult } from '@/lib/admin-api';
function formatPrice(price: number): string {
if (price >= 1_000_000_000) {
return `${(price / 1_000_000_000).toFixed(1)} tỷ`;
}
if (price >= 1_000_000) {
return `${(price / 1_000_000).toFixed(0)} triệu`;
}
return price.toLocaleString('vi-VN');
}
import { formatPrice } from '@/lib/currency';
function moderationScoreBadge(score: number | null) {
if (score === null) return <Badge variant="secondary">N/A</Badge>;
@@ -159,14 +150,14 @@ export default function AdminModerationPage() {
</div>
)}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Kiểm duyệt tin đăng</h1>
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Kiểm duyệt tin đăng</h1>
<p className="text-sm text-muted-foreground">
Duyệt hoặc từ chối các tin đăng chờ phê duyệt
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{selected.size > 0 && (
<>
<Button
@@ -224,6 +215,7 @@ export default function AdminModerationPage() {
checked={selected.size === result.data.length && result.data.length > 0}
onChange={toggleSelectAll}
className="rounded border-input"
aria-label="Chọn tất cả tin đăng"
/>
</TableHead>
<TableHead>Tiêu đ</TableHead>
@@ -244,6 +236,7 @@ export default function AdminModerationPage() {
checked={selected.has(item.listingId)}
onChange={() => toggleSelect(item.listingId)}
className="rounded border-input"
aria-label={`Chọn tin: ${item.propertyTitle}`}
/>
</TableCell>
<TableCell>

View File

@@ -16,6 +16,7 @@ import { useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { adminApi, type DashboardStats, type RevenueStatsItem } from '@/lib/admin-api';
import { formatVND } from '@/lib/currency';
interface StatCardProps {
title: string;
@@ -72,7 +73,7 @@ function RevenueChart({ data }: { data: RevenueStatsItem[] }) {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{item.period}</span>
<span className="font-medium">
{item.totalRevenue.toLocaleString('vi-VN')} VND
{formatVND(item.totalRevenue)}
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
@@ -82,8 +83,8 @@ function RevenueChart({ data }: { data: RevenueStatsItem[] }) {
/>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>Subscription: {item.subscriptionRevenue.toLocaleString('vi-VN')}</span>
<span>Listing fee: {item.listingFeeRevenue.toLocaleString('vi-VN')}</span>
<span>Subscription: {formatVND(item.subscriptionRevenue)}</span>
<span>Listing fee: {formatVND(item.listingFeeRevenue)}</span>
<span>{item.transactionCount} GD</span>
</div>
</div>

View File

@@ -5,6 +5,7 @@ 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 { formatPrice, formatPricePerM2 } from '@/lib/currency';
import {
useMarketReport,
useHeatmap,
@@ -36,18 +37,6 @@ 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;
@@ -124,7 +113,7 @@ export default function AnalyticsPage() {
<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>
<h1 className="text-2xl font-bold sm:text-3xl">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>
@@ -159,7 +148,7 @@ export default function AnalyticsPage() {
<CardHeader className="pb-2">
<CardDescription>Giá TB/m²</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : formatPriceM2(avgPriceM2)}
{loading ? '...' : formatPricePerM2(avgPriceM2)}
</CardTitle>
</CardHeader>
</Card>
@@ -183,11 +172,11 @@ export default function AnalyticsPage() {
{/* 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 className="w-full justify-start overflow-x-auto">
<TabsTrigger value="overview" className="min-w-fit">Tổng quan</TabsTrigger>
<TabsTrigger value="trends" className="min-w-fit">Xu hướng giá</TabsTrigger>
<TabsTrigger value="districts" className="min-w-fit">Chi tiết quận</TabsTrigger>
<TabsTrigger value="performance" className="min-w-fit">Hiệu suất</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@@ -336,7 +325,7 @@ export default function AnalyticsPage() {
{formatPrice(stat.medianPrice)}
</td>
<td className="py-2 pr-4 text-right">
{formatPriceM2(stat.avgPriceM2)}
{formatPricePerM2(stat.avgPriceM2)}
</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">
@@ -384,7 +373,7 @@ export default function AnalyticsPage() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Giá/m²</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
<span>{formatPricePerM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin đăng</span>

View File

@@ -6,6 +6,7 @@ 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 { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
import { useListingsSearch } from '@/lib/hooks/use-listings';
@@ -17,18 +18,6 @@ const DistrictBarChart = dynamic(
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;
@@ -105,9 +94,9 @@ export default function DashboardPage() {
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Bảng điều khiển</h1>
<h1 className="text-2xl font-bold sm:text-3xl">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>
@@ -136,7 +125,7 @@ export default function DashboardPage() {
/>
<StatCard
title="Giá TB thị trường"
value={loading ? '...' : formatPriceM2(avgPriceM2)}
value={loading ? '...' : formatPricePerM2(avgPriceM2)}
trend={avgYoy}
description="YoY"
/>
@@ -186,7 +175,7 @@ export default function DashboardPage() {
<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)}
{loading ? '...' : formatPricePerM2(avgPriceM2)}
</span>
</div>
<div className="flex items-center justify-between">
@@ -214,7 +203,7 @@ export default function DashboardPage() {
{/* Recent listings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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>

View File

@@ -13,15 +13,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatVND } from '@/lib/currency';
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' },
@@ -66,14 +60,14 @@ export default function PaymentsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Thanh toán</h1>
<h1 className="text-2xl font-bold sm:text-3xl">Thanh toán</h1>
<p className="mt-2 text-muted-foreground">
Lịch sử giao dịch quản thanh toán
</p>
</div>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng giao dịch</CardDescription>
@@ -104,12 +98,12 @@ export default function PaymentsPage() {
{/* Transactions table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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">
<div className="w-full sm:w-40">
<Select
value={statusFilter}
onChange={(e) => {

View File

@@ -8,15 +8,10 @@ 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 { formatPrice } from '@/lib/currency';
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';
@@ -75,7 +70,7 @@ export default function ListingsPage() {
</div>
{/* Stats */}
<div className="grid gap-3 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng tin đăng</CardDescription>

View File

@@ -6,6 +6,7 @@ import {
generateBreadcrumbJsonLd,
generateListingJsonLd,
} from '@/components/seo/json-ld';
import { formatPrice } from '@/lib/currency';
import { fetchListingById } from '@/lib/listings-server';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
@@ -20,13 +21,6 @@ function getLabel(list: readonly { value: string; label: string }[], value: stri
return list.find((item) => item.value === value)?.label ?? value;
}
function formatPriceShort(priceVND: string): string {
const num = Number(priceVND);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} t\u1ef7`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} tri\u1ec7u`;
return num.toLocaleString('vi-VN');
}
// ---------------------------------------------------------------------------
// Metadata (runs server-side, provides <title>, <meta>, OG, canonical)
// ---------------------------------------------------------------------------
@@ -44,7 +38,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const { property } = listing;
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType);
const priceStr = formatPriceShort(listing.priceVND);
const priceStr = formatPrice(listing.priceVND);
const fullAddress = [property.address, property.ward, property.district, property.city]
.filter(Boolean)
.join(', ');

View File

@@ -13,6 +13,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import { formatVND } from '@/lib/currency';
import { usePlans } from '@/lib/hooks/use-subscription';
import type { PlanDto } from '@/lib/subscription-api';
import { cn } from '@/lib/utils';
@@ -134,16 +135,6 @@ const FALLBACK_PLANS: PlanDto[] = [
// Helpers
// ---------------------------------------------------------------------------
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) {
const millions = num / 1_000_000;
return `${millions % 1 === 0 ? millions.toFixed(0) : millions.toFixed(1)} triệu đ`;
}
return num.toLocaleString('vi-VN') + ' đ';
}
// Feature labels mapped for the comparison table
const FEATURE_LABELS: Record<string, string> = {
maxListings: 'Tin đăng',