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

@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api';
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
@@ -23,13 +24,6 @@ const ListingMap = dynamic(
},
);
function formatPrice(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');
}
function getLabel(list: readonly { value: string; label: string }[], value: string | null) {
if (!value) return null;
return list.find((item) => item.value === value)?.label ?? value;
@@ -79,7 +73,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
<p className="text-2xl font-bold text-primary md:text-3xl">{formatPrice(listing.priceVND)} VND</p>
{listing.pricePerM2 != null && (
<p className="text-sm text-muted-foreground">
~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m²
~{formatPricePerM2(listing.pricePerM2)}
</p>
)}
{listing.rentPriceMonthly && (

View File

@@ -1,18 +1,10 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatPrice } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api';
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');
}
const PROPERTY_TYPE_LABELS: Record<string, string> = {
APARTMENT: 'Căn hộ',
HOUSE: 'Nhà riêng',