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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 và 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>
|
||||
|
||||
@@ -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 và quản lý 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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(', ');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
125
apps/web/lib/__tests__/currency.spec.ts
Normal file
125
apps/web/lib/__tests__/currency.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatPrice,
|
||||
formatVND,
|
||||
formatPricePerM2,
|
||||
parseVND,
|
||||
} from '../currency';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatPrice — compact notation without currency suffix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats billions as "X ty"', () => {
|
||||
expect(formatPrice(1_000_000_000)).toBe('1 t\u1ef7');
|
||||
expect(formatPrice(1_500_000_000)).toBe('1.5 t\u1ef7');
|
||||
expect(formatPrice(3_500_000_000)).toBe('3.5 t\u1ef7');
|
||||
expect(formatPrice(10_000_000_000)).toBe('10 t\u1ef7');
|
||||
});
|
||||
|
||||
it('formats millions as "X trieu"', () => {
|
||||
expect(formatPrice(1_000_000)).toBe('1 tri\u1ec7u');
|
||||
expect(formatPrice(1_500_000)).toBe('1.5 tri\u1ec7u');
|
||||
expect(formatPrice(150_000_000)).toBe('150 tri\u1ec7u');
|
||||
expect(formatPrice(800_000_000)).toBe('800 tri\u1ec7u');
|
||||
expect(formatPrice(999_000_000)).toBe('999 tri\u1ec7u');
|
||||
});
|
||||
|
||||
it('formats values below 1 million with locale separator', () => {
|
||||
expect(formatPrice(500_000)).toMatch(/500/);
|
||||
expect(formatPrice(1_000)).toMatch(/1/);
|
||||
expect(formatPrice(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts string inputs', () => {
|
||||
expect(formatPrice('3500000000')).toBe('3.5 t\u1ef7');
|
||||
expect(formatPrice('150000000')).toBe('150 tri\u1ec7u');
|
||||
});
|
||||
|
||||
it('handles edge cases gracefully', () => {
|
||||
expect(formatPrice(-1)).toBe('0');
|
||||
expect(formatPrice(NaN)).toBe('0');
|
||||
expect(formatPrice(Infinity)).toBe('0');
|
||||
expect(formatPrice('')).toBe('0');
|
||||
});
|
||||
|
||||
it('strips trailing .0', () => {
|
||||
expect(formatPrice(2_000_000_000)).toBe('2 t\u1ef7');
|
||||
expect(formatPrice(5_000_000)).toBe('5 tri\u1ec7u');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatVND — with currency suffix "d"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatVND', () => {
|
||||
it('returns "Mien phi" for zero', () => {
|
||||
expect(formatVND(0)).toBe('Mi\u1ec5n ph\u00ed');
|
||||
});
|
||||
|
||||
it('formats billions with suffix', () => {
|
||||
expect(formatVND(1_500_000_000)).toBe('1.5 t\u1ef7 \u0111');
|
||||
});
|
||||
|
||||
it('formats millions with suffix', () => {
|
||||
expect(formatVND(5_000_000)).toBe('5 tri\u1ec7u \u0111');
|
||||
expect(formatVND(4_990_000)).toBe('5 tri\u1ec7u \u0111');
|
||||
});
|
||||
|
||||
it('formats values below 1 million with suffix', () => {
|
||||
expect(formatVND(500_000)).toMatch(/\u0111$/);
|
||||
});
|
||||
|
||||
it('accepts string input', () => {
|
||||
expect(formatVND('1500000000')).toBe('1.5 t\u1ef7 \u0111');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatPricePerM2 — price per square metre
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatPricePerM2', () => {
|
||||
it('formats millions as "X tr/m\u00b2"', () => {
|
||||
expect(formatPricePerM2(50_500_000)).toBe('50.5 tr/m\u00b2');
|
||||
expect(formatPricePerM2(1_000_000)).toBe('1 tr/m\u00b2');
|
||||
});
|
||||
|
||||
it('formats billions as "X ty/m\u00b2"', () => {
|
||||
expect(formatPricePerM2(1_500_000_000)).toBe('1.5 t\u1ef7/m\u00b2');
|
||||
});
|
||||
|
||||
it('formats values below 1 million with "/m\u00b2" suffix', () => {
|
||||
expect(formatPricePerM2(500_000)).toMatch(/m\u00b2$/);
|
||||
});
|
||||
|
||||
it('accepts string input', () => {
|
||||
expect(formatPricePerM2('50500000')).toBe('50.5 tr/m\u00b2');
|
||||
});
|
||||
|
||||
it('handles edge cases', () => {
|
||||
expect(formatPricePerM2(0)).toBe('0 \u0111/m\u00b2');
|
||||
expect(formatPricePerM2(-1)).toBe('0 \u0111/m\u00b2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseVND — reverse parse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseVND', () => {
|
||||
it('parses formatted number back', () => {
|
||||
expect(parseVND('500.000')).toBe(500000);
|
||||
});
|
||||
|
||||
it('returns null for empty / non-numeric input', () => {
|
||||
expect(parseVND('')).toBeNull();
|
||||
expect(parseVND('abc')).toBeNull();
|
||||
});
|
||||
|
||||
it('strips non-digit characters', () => {
|
||||
expect(parseVND('1.500.000 \u0111')).toBe(1500000);
|
||||
});
|
||||
});
|
||||
121
apps/web/lib/currency.ts
Normal file
121
apps/web/lib/currency.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Vietnamese currency formatting utilities.
|
||||
*
|
||||
* Centralised formatter for all price displays across the platform.
|
||||
* Converts raw VND numbers into human-readable Vietnamese format:
|
||||
* 3,500,000,000 -> "3.5 ty"
|
||||
* 150,000,000 -> "150 trieu"
|
||||
* 800,000 -> "800.000"
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core formatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a VND amount into compact Vietnamese notation.
|
||||
*
|
||||
* @example
|
||||
* formatPrice(3_500_000_000) // "3.5 ty"
|
||||
* formatPrice(150_000_000) // "150 trieu"
|
||||
* formatPrice(1_500_000) // "1.5 trieu"
|
||||
* formatPrice(800_000) // "800.000"
|
||||
* formatPrice("3500000000") // "3.5 ty" (string input accepted)
|
||||
*/
|
||||
export function formatPrice(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (!Number.isFinite(num) || num < 0) return '0';
|
||||
|
||||
if (num >= 1_000_000_000) {
|
||||
const billions = num / 1_000_000_000;
|
||||
return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7`;
|
||||
}
|
||||
|
||||
if (num >= 1_000_000) {
|
||||
const millions = num / 1_000_000;
|
||||
return `${stripTrailingZero(millions.toFixed(1))} tri\u1ec7u`;
|
||||
}
|
||||
|
||||
return num.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant: with currency suffix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a VND amount with a " \u0111" currency suffix.
|
||||
* Returns "Mi\u1ec5n ph\u00ed" for zero amounts.
|
||||
*
|
||||
* @example
|
||||
* formatVND(4_990_000) // "4.99 trieu d"
|
||||
* formatVND(0) // "Mien phi"
|
||||
*/
|
||||
export function formatVND(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (!Number.isFinite(num) || num < 0) return '0 \u0111';
|
||||
if (num === 0) return 'Mi\u1ec5n ph\u00ed';
|
||||
|
||||
if (num >= 1_000_000_000) {
|
||||
const billions = num / 1_000_000_000;
|
||||
return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7 \u0111`;
|
||||
}
|
||||
|
||||
if (num >= 1_000_000) {
|
||||
const millions = num / 1_000_000;
|
||||
return `${stripTrailingZero(millions.toFixed(1))} tri\u1ec7u \u0111`;
|
||||
}
|
||||
|
||||
return num.toLocaleString('vi-VN') + ' \u0111';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant: price per square metre
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a VND/m\u00b2 value.
|
||||
*
|
||||
* @example
|
||||
* formatPricePerM2(50_500_000) // "50.5 tr/m\u00b2"
|
||||
* formatPricePerM2(500_000) // "500.000 \u0111/m\u00b2"
|
||||
*/
|
||||
export function formatPricePerM2(price: string | number): string {
|
||||
const num = typeof price === 'string' ? Number(price) : price;
|
||||
if (!Number.isFinite(num) || num < 0) return '0 \u0111/m\u00b2';
|
||||
|
||||
if (num >= 1_000_000_000) {
|
||||
const billions = num / 1_000_000_000;
|
||||
return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7/m\u00b2`;
|
||||
}
|
||||
|
||||
if (num >= 1_000_000) {
|
||||
const millions = num / 1_000_000;
|
||||
return `${stripTrailingZero(millions.toFixed(1))} tr/m\u00b2`;
|
||||
}
|
||||
|
||||
return `${num.toLocaleString('vi-VN')} \u0111/m\u00b2`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser (reverse direction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a formatted Vietnamese price string back into a number.
|
||||
* Returns `null` if the input cannot be parsed.
|
||||
*/
|
||||
export function parseVND(formatted: string): number | null {
|
||||
const cleaned = formatted.replace(/[^\d]/g, '');
|
||||
if (cleaned === '') return null;
|
||||
return Number(cleaned);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Remove a trailing ".0" so "3.0 ty" becomes "3 ty". */
|
||||
function stripTrailingZero(str: string): string {
|
||||
return str.replace(/\.0$/, '');
|
||||
}
|
||||
Reference in New Issue
Block a user