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

@@ -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',