From 55a01c57388d068be60293cd1af5dc63dc4d5f1f Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 23:33:31 +0700 Subject: [PATCH] feat(web): centralise Vietnamese price formatting across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../(admin)/admin/moderation/page.tsx | 19 +-- apps/web/app/[locale]/(admin)/admin/page.tsx | 7 +- .../[locale]/(dashboard)/analytics/page.tsx | 31 ++--- .../[locale]/(dashboard)/dashboard/page.tsx | 23 +--- .../(dashboard)/dashboard/payments/page.tsx | 16 +-- .../[locale]/(dashboard)/listings/page.tsx | 9 +- .../[locale]/(public)/listings/[id]/page.tsx | 10 +- .../app/[locale]/(public)/pricing/page.tsx | 11 +- .../listings/listing-detail-client.tsx | 10 +- apps/web/components/search/property-card.tsx | 10 +- apps/web/lib/__tests__/currency.spec.ts | 125 ++++++++++++++++++ apps/web/lib/currency.ts | 121 +++++++++++++++++ 12 files changed, 285 insertions(+), 107 deletions(-) create mode 100644 apps/web/lib/__tests__/currency.spec.ts create mode 100644 apps/web/lib/currency.ts diff --git a/apps/web/app/[locale]/(admin)/admin/moderation/page.tsx b/apps/web/app/[locale]/(admin)/admin/moderation/page.tsx index 9848c78..81dbda2 100644 --- a/apps/web/app/[locale]/(admin)/admin/moderation/page.tsx +++ b/apps/web/app/[locale]/(admin)/admin/moderation/page.tsx @@ -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 N/A; @@ -159,14 +150,14 @@ export default function AdminModerationPage() { )} -
+
-

Kiểm duyệt tin đăng

+

Kiểm duyệt tin đăng

Duyệt hoặc từ chối các tin đăng chờ phê duyệt

-
+
{selected.size > 0 && ( <>
diff --git a/apps/web/app/[locale]/(dashboard)/analytics/page.tsx b/apps/web/app/[locale]/(dashboard)/analytics/page.tsx index de888cd..952c4aa 100644 --- a/apps/web/app/[locale]/(dashboard)/analytics/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/analytics/page.tsx @@ -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 N/A; const isPositive = value >= 0; @@ -124,7 +113,7 @@ export default function AnalyticsPage() {
-

Phân tích thị trường

+

Phân tích thị trường

Báo cáo thị trường bất động sản - {period}

@@ -159,7 +148,7 @@ export default function AnalyticsPage() { Giá TB/m² - {loading ? '...' : formatPriceM2(avgPriceM2)} + {loading ? '...' : formatPricePerM2(avgPriceM2)} @@ -183,11 +172,11 @@ export default function AnalyticsPage() { {/* Tabs */} - - Tổng quan - Xu hướng giá - Chi tiết quận - Hiệu suất + + Tổng quan + Xu hướng giá + Chi tiết quận + Hiệu suất {/* Overview Tab */} @@ -336,7 +325,7 @@ export default function AnalyticsPage() { {formatPrice(stat.medianPrice)} - {formatPriceM2(stat.avgPriceM2)} + {formatPricePerM2(stat.avgPriceM2)} {stat.totalListings} @@ -384,7 +373,7 @@ export default function AnalyticsPage() {
Giá/m² - {formatPriceM2(district.avgPriceM2)} + {formatPricePerM2(district.avgPriceM2)}
Tin đăng diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx index 53b6bb0..a31b46f 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx @@ -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 (
-
+
-

Bảng điều khiển

+

Bảng điều khiển

Tổng quan thị trường và tin đăng của bạn

@@ -136,7 +125,7 @@ export default function DashboardPage() { /> @@ -186,7 +175,7 @@ export default function DashboardPage() {
Giá TB/m² - {loading ? '...' : formatPriceM2(avgPriceM2)} + {loading ? '...' : formatPricePerM2(avgPriceM2)}
@@ -214,7 +203,7 @@ export default function DashboardPage() { {/* Recent listings */} - +
Tin đăng gần đây Danh sách tin đăng mới nhất của bạn diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx index e15e938..3a5c4c9 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx @@ -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 = { PENDING: { label: 'Chờ xử lý', variant: 'secondary' }, PROCESSING: { label: 'Đang xử lý', variant: 'secondary' }, @@ -66,14 +60,14 @@ export default function PaymentsPage() { return (
-

Thanh toán

+

Thanh toán

Lịch sử giao dịch và quản lý thanh toán

{/* Summary cards */} -
+
Tổng giao dịch @@ -104,12 +98,12 @@ export default function PaymentsPage() { {/* Transactions table */} - +
Lịch sử giao dịch Tất cả giao dịch thanh toán của bạn
-
+