diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx index 59f94cf..70aa964 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx @@ -10,6 +10,7 @@ import { useUpdateSavedSearch, } from '@/lib/hooks/use-saved-searches'; import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api'; +import { formatPrice } from '@/lib/currency'; const PROPERTY_TYPE_LABELS: Record = { APARTMENT: 'Chung cư', @@ -25,12 +26,6 @@ const TRANSACTION_TYPE_LABELS: Record = { RENT: 'Cho thuê', }; -function formatPrice(value: string): string { - const num = Number(value); - 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 formatFilters(filters: SavedSearchFilters): string[] { const parts: string[] = []; diff --git a/apps/web/app/[locale]/(public)/page.tsx b/apps/web/app/[locale]/(public)/page.tsx index dae4267..e12a504 100644 --- a/apps/web/app/[locale]/(public)/page.tsx +++ b/apps/web/app/[locale]/(public)/page.tsx @@ -18,27 +18,12 @@ import { useTrendingAreas, } from '@/lib/hooks/use-analytics'; import { listingsApi, type ListingDetail } from '@/lib/listings-api'; +import { formatPrice, formatPricePerM2 } from '@/lib/currency'; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -const vndFmt = new Intl.NumberFormat('vi-VN', { - style: 'currency', - currency: 'VND', - maximumFractionDigits: 0, -}); - -function formatVnd(value: number): string { - if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)} tỷ`; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)} tr`; - return vndFmt.format(value); -} - -function formatPriceM2(value: number): string { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} tr/m²`; - return `${Math.round(value / 1000)}k/m²`; -} function currentPeriod(): string { const now = new Date(); @@ -138,7 +123,7 @@ function KpiStrip({ city }: { city: string }) {
} @@ -146,7 +131,7 @@ function KpiStrip({ city }: { city: string }) { /> } @@ -154,7 +139,7 @@ function KpiStrip({ city }: { city: string }) { /> } loading={isLoading} @@ -352,7 +337,7 @@ function RecentListings() { const price = Number(r.priceVND); return ( - {formatVnd(price)} + {formatPrice(price)} ); }, @@ -367,7 +352,7 @@ function RecentListings() { cell: (r) => r.pricePerM2 ? ( - {formatPriceM2(r.pricePerM2)} + {formatPricePerM2(r.pricePerM2)} ) : ( @@ -457,7 +442,7 @@ export default function MarketDashboardPage() { { id: 'price', header: 'Giá TB/m²', - cell: (r) => formatPriceM2(r.avgPriceM2), + cell: (r) => formatPricePerM2(r.avgPriceM2), align: 'right' as const, numeric: true, sortable: true, diff --git a/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx b/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx index 224cd42..fab21fd 100644 --- a/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx +++ b/apps/web/components/chuyen-nhuong/chuyen-nhuong-detail-client.tsx @@ -19,14 +19,12 @@ import { STATUS_LABELS, } from '@/lib/chuyen-nhuong-api'; import { cn } from '@/lib/utils'; +import { formatVNDFull } from '@/lib/currency'; interface ChuyenNhuongDetailClientProps { listing: TransferListingDetail; } -function formatVND(value: string): string { - return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab'; -} export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) { const statusColor = @@ -69,13 +67,13 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
{listing.aiEstimatePriceVND && ( )} {listing.areaM2 && ( @@ -144,7 +142,7 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr {listing.monthlyRentVND && (
Tiền thuê hàng tháng - {formatVND(listing.monthlyRentVND)} + {formatVNDFull(listing.monthlyRentVND)}
)} {listing.depositMonths != null && ( @@ -181,14 +179,14 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
Giá yêu cầu - {formatVND(listing.askingPriceVND)} + {formatVNDFull(listing.askingPriceVND)}
{listing.aiEstimatePriceVND && (
Giá AI ước tính - {formatVND(listing.aiEstimatePriceVND)} + {formatVNDFull(listing.aiEstimatePriceVND)}
)} diff --git a/apps/web/components/chuyen-nhuong/transfer-item-table.tsx b/apps/web/components/chuyen-nhuong/transfer-item-table.tsx index 6ddf664..3616561 100644 --- a/apps/web/components/chuyen-nhuong/transfer-item-table.tsx +++ b/apps/web/components/chuyen-nhuong/transfer-item-table.tsx @@ -7,14 +7,12 @@ import { CONDITION_COLORS, CONDITION_LABELS, } from '@/lib/chuyen-nhuong-api'; +import { formatVNDFull } from '@/lib/currency'; interface TransferItemTableProps { items: TransferItemData[]; } -function formatVND(value: string): string { - return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab'; -} export function TransferItemTable({ items }: TransferItemTableProps) { if (items.length === 0) { @@ -59,12 +57,12 @@ export function TransferItemTable({ items }: TransferItemTableProps) { {item.quantity} - {formatVND(item.askingPriceVND)} + {formatVNDFull(item.askingPriceVND)} {item.aiEstimatePriceVND ? ( - {formatVND(item.aiEstimatePriceVND)} + {formatVNDFull(item.aiEstimatePriceVND)} ) : '—'} diff --git a/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx index 40c75bf..615d729 100644 --- a/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx +++ b/apps/web/components/chuyen-nhuong/transfer-listing-card.tsx @@ -10,14 +10,12 @@ import { CATEGORY_LABELS, STATUS_LABELS, } from '@/lib/chuyen-nhuong-api'; +import { formatVNDFull } from '@/lib/currency'; interface TransferListingCardProps { listing: TransferListingListItem; } -function formatVND(value: string): string { - return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab'; -} export function TransferListingCard({ listing }: TransferListingCardProps) { const statusColor = @@ -62,7 +60,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) { {/* Price */}

- {formatVND(listing.askingPriceVND)} + {formatVNDFull(listing.askingPriceVND)}

{listing.isNegotiable && ( Thương lượng diff --git a/apps/web/components/listings/price-history-chart.tsx b/apps/web/components/listings/price-history-chart.tsx index 99cc362..3664be6 100644 --- a/apps/web/components/listings/price-history-chart.tsx +++ b/apps/web/components/listings/price-history-chart.tsx @@ -11,21 +11,13 @@ import { } from 'recharts'; import type { PriceHistoryItem } from '@/lib/listings-api'; +import { formatPrice } from '@/lib/currency'; interface PriceHistoryChartProps { data: PriceHistoryItem[]; height?: number; } -function priceToMillions(priceStr: string): number { - return Math.round(Number(priceStr) / 1_000_000); -} - -function formatMillions(value: number): string { - if (value >= 1000) return `${(value / 1000).toFixed(1)} tỷ`; - return `${value} tr`; -} - export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps) { if (data.length === 0) return null; @@ -37,7 +29,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps month: '2-digit', year: 'numeric', }), - price: priceToMillions(item.newPrice), + price: Number(item.newPrice), })); return ( @@ -48,7 +40,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps formatMillions(v)} + tickFormatter={(v: number) => formatPrice(v)} /> [formatMillions(Number(value)), 'Giá']} + formatter={(value) => [formatPrice(Number(value)), 'Giá']} /> = 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)} tr`; - return num.toLocaleString('vi-VN'); -} - interface ListingMapProps { listings: ListingDetail[]; onMarkerClick?: (listing: ListingDetail) => void; diff --git a/apps/web/components/valuation/ai-estimate-button.tsx b/apps/web/components/valuation/ai-estimate-button.tsx index a62e9c7..6b7b2ac 100644 --- a/apps/web/components/valuation/ai-estimate-button.tsx +++ b/apps/web/components/valuation/ai-estimate-button.tsx @@ -4,17 +4,12 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useValuationPredictForListing } from '@/lib/hooks/use-valuation'; +import { formatPrice } from '@/lib/currency'; interface AiEstimateButtonProps { listingId: string; } -function formatPrice(num: number): string { - if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`; - if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; - return num.toLocaleString('vi-VN'); -} - export function AiEstimateButton({ listingId }: AiEstimateButtonProps) { const [showResult, setShowResult] = useState(false); const mutation = useValuationPredictForListing(); diff --git a/apps/web/lib/__tests__/currency.spec.ts b/apps/web/lib/__tests__/currency.spec.ts index d1568c1..bf431ed 100644 --- a/apps/web/lib/__tests__/currency.spec.ts +++ b/apps/web/lib/__tests__/currency.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { formatPrice, formatVND, + formatVNDFull, formatPricePerM2, parseVND, } from '../currency'; @@ -105,6 +106,31 @@ describe('formatPricePerM2', () => { }); }); +// --------------------------------------------------------------------------- +// formatVNDFull — full locale format, no compact notation +// --------------------------------------------------------------------------- + +describe('formatVNDFull', () => { + it('formats with full locale number + đ suffix', () => { + expect(formatVNDFull(4_990_000)).toMatch(/4.*990.*000.*đ/); + }); + + it('never uses compact notation', () => { + const result = formatVNDFull(1_500_000_000); + expect(result).not.toContain('tỷ'); + expect(result).toContain('đ'); + }); + + it('handles zero and negatives', () => { + expect(formatVNDFull(0)).toMatch(/0.*đ/); + expect(formatVNDFull(-1)).toBe('0 đ'); + }); + + it('accepts string input', () => { + expect(formatVNDFull('1500000')).toMatch(/1.*500.*000.*đ/); + }); +}); + // --------------------------------------------------------------------------- // parseVND — reverse parse // --------------------------------------------------------------------------- diff --git a/apps/web/lib/currency.ts b/apps/web/lib/currency.ts index 32af250..19c55e8 100644 --- a/apps/web/lib/currency.ts +++ b/apps/web/lib/currency.ts @@ -28,12 +28,12 @@ export function formatPrice(amount: string | number): string { if (num >= 1_000_000_000) { const billions = num / 1_000_000_000; - return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7`; + return `${stripTrailingZero(billions.toFixed(1))} tỷ`; } if (num >= 1_000_000) { const millions = num / 1_000_000; - return `${stripTrailingZero(millions.toFixed(1))} tri\u1ec7u`; + return `${stripTrailingZero(millions.toFixed(1))} triệu`; } return num.toLocaleString('vi-VN'); @@ -44,8 +44,8 @@ export function formatPrice(amount: string | number): string { // --------------------------------------------------------------------------- /** - * Format a VND amount with a " \u0111" currency suffix. - * Returns "Mi\u1ec5n ph\u00ed" for zero amounts. + * Format a VND amount with a " đ" currency suffix. + * Returns "Miễn phí" for zero amounts. * * @example * formatVND(4_990_000) // "4.99 trieu d" @@ -53,20 +53,20 @@ export function formatPrice(amount: string | number): string { */ 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 (!Number.isFinite(num) || num < 0) return '0 đ'; + if (num === 0) return 'Miễn phí'; if (num >= 1_000_000_000) { const billions = num / 1_000_000_000; - return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7 \u0111`; + return `${stripTrailingZero(billions.toFixed(1))} tỷ đ`; } if (num >= 1_000_000) { const millions = num / 1_000_000; - return `${stripTrailingZero(millions.toFixed(1))} tri\u1ec7u \u0111`; + return `${stripTrailingZero(millions.toFixed(1))} triệu đ`; } - return num.toLocaleString('vi-VN') + ' \u0111'; + return num.toLocaleString('vi-VN') + ' đ'; } // --------------------------------------------------------------------------- @@ -74,27 +74,49 @@ export function formatVND(amount: string | number): string { // --------------------------------------------------------------------------- /** - * Format a VND/m\u00b2 value. + * Format a VND/m² value. * * @example - * formatPricePerM2(50_500_000) // "50.5 tr/m\u00b2" - * formatPricePerM2(500_000) // "500.000 \u0111/m\u00b2" + * formatPricePerM2(50_500_000) // "50.5 tr/m²" + * formatPricePerM2(500_000) // "500k/m²" */ 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 (!Number.isFinite(num) || num < 0) return '0 đ/m²'; if (num >= 1_000_000_000) { const billions = num / 1_000_000_000; - return `${stripTrailingZero(billions.toFixed(1))} t\u1ef7/m\u00b2`; + return `${stripTrailingZero(billions.toFixed(1))} tỷ/m²`; } if (num >= 1_000_000) { const millions = num / 1_000_000; - return `${stripTrailingZero(millions.toFixed(1))} tr/m\u00b2`; + return `${stripTrailingZero(millions.toFixed(1))} tr/m²`; } - return `${num.toLocaleString('vi-VN')} \u0111/m\u00b2`; + if (num >= 1_000) { + return `${Math.round(num / 1_000)}k/m²`; + } + + return `${num.toLocaleString('vi-VN')} đ/m²`; +} + +// --------------------------------------------------------------------------- +// Variant: full locale format (no compact notation) +// --------------------------------------------------------------------------- + +/** + * Format a VND amount as a full locale number with " đ" suffix. + * Unlike formatVND, this never uses compact notation. + * + * @example + * formatVNDFull(4_990_000) // "4.990.000 đ" + * formatVNDFull(0) // "0 đ" + */ +export function formatVNDFull(amount: string | number): string { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (!Number.isFinite(num) || num < 0) return '0 đ'; + return new Intl.NumberFormat('vi-VN').format(num) + ' đ'; } // --------------------------------------------------------------------------- @@ -103,7 +125,7 @@ export function formatPricePerM2(price: string | number): string { /** * Parse a formatted Vietnamese price string back into a number. - * Returns `null` if the input cannot be parsed. + * Returns null if the input cannot be parsed. */ export function parseVND(formatted: string): number | null { const cleaned = formatted.replace(/[^\d]/g, '');