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:
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