- 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
-
+
{/* Stats */}
-
+
Tổng tin đăng
diff --git a/apps/web/app/[locale]/(public)/listings/[id]/page.tsx b/apps/web/app/[locale]/(public)/listings/[id]/page.tsx
index 759a8dd..6dc64ae 100644
--- a/apps/web/app/[locale]/(public)/listings/[id]/page.tsx
+++ b/apps/web/app/[locale]/(public)/listings/[id]/page.tsx
@@ -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 , , OG, canonical)
// ---------------------------------------------------------------------------
@@ -44,7 +38,7 @@ export async function generateMetadata({ params }: PageProps): Promise
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(', ');
diff --git a/apps/web/app/[locale]/(public)/pricing/page.tsx b/apps/web/app/[locale]/(public)/pricing/page.tsx
index c8c35fd..66ffa85 100644
--- a/apps/web/app/[locale]/(public)/pricing/page.tsx
+++ b/apps/web/app/[locale]/(public)/pricing/page.tsx
@@ -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 = {
maxListings: 'Tin đăng',
diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx
index 7f7b8cc..07ea425 100644
--- a/apps/web/components/listings/listing-detail-client.tsx
+++ b/apps/web/components/listings/listing-detail-client.tsx
@@ -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) {
{formatPrice(listing.priceVND)} VND
{listing.pricePerM2 != null && (
- ~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m²
+ ~{formatPricePerM2(listing.pricePerM2)}
)}
{listing.rentPriceMonthly && (
diff --git a/apps/web/components/search/property-card.tsx b/apps/web/components/search/property-card.tsx
index 79cd0ef..c873830 100644
--- a/apps/web/components/search/property-card.tsx
+++ b/apps/web/components/search/property-card.tsx
@@ -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 = {
APARTMENT: 'Căn hộ',
HOUSE: 'Nhà riêng',
diff --git a/apps/web/lib/__tests__/currency.spec.ts b/apps/web/lib/__tests__/currency.spec.ts
new file mode 100644
index 0000000..d1568c1
--- /dev/null
+++ b/apps/web/lib/__tests__/currency.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/currency.ts b/apps/web/lib/currency.ts
new file mode 100644
index 0000000..32af250
--- /dev/null
+++ b/apps/web/lib/currency.ts
@@ -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$/, '');
+}
|