fix(web): consolidate inline currency formatters into shared lib (GOO-205)
Remove 8 inline formatPrice/formatVND/formatPriceM2 functions scattered across components and pages, replacing them with imports from @/lib/currency. Add formatVNDFull (full locale, no compact notation) for chuyen-nhuong pages. Fix price-history-chart off-by-1000 bug caused by double-dividing through priceToMillions then formatMillions. Add k/m² branch to formatPricePerM2 for sub-million values. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
useUpdateSavedSearch,
|
useUpdateSavedSearch,
|
||||||
} from '@/lib/hooks/use-saved-searches';
|
} from '@/lib/hooks/use-saved-searches';
|
||||||
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
|
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||||
APARTMENT: 'Chung cư',
|
APARTMENT: 'Chung cư',
|
||||||
@@ -25,12 +26,6 @@ const TRANSACTION_TYPE_LABELS: Record<string, string> = {
|
|||||||
RENT: 'Cho thuê',
|
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[] {
|
function formatFilters(filters: SavedSearchFilters): string[] {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|||||||
@@ -18,27 +18,12 @@ import {
|
|||||||
useTrendingAreas,
|
useTrendingAreas,
|
||||||
} from '@/lib/hooks/use-analytics';
|
} from '@/lib/hooks/use-analytics';
|
||||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||||
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers */
|
/* 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 {
|
function currentPeriod(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -138,7 +123,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="GGI HCM"
|
label="GGI HCM"
|
||||||
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
|
value={data ? formatPricePerM2(data.avgPricePerM2) : '—'}
|
||||||
delta={data?.priceChangePct?.d7}
|
delta={data?.priceChangePct?.d7}
|
||||||
footnote="Chỉ số giá TB/m²"
|
footnote="Chỉ số giá TB/m²"
|
||||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||||
@@ -146,7 +131,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Giá TB"
|
label="Giá TB"
|
||||||
value={data ? formatVnd(data.avgPrice) : '—'}
|
value={data ? formatPrice(data.avgPrice) : '—'}
|
||||||
delta={data?.priceChangePct?.d30}
|
delta={data?.priceChangePct?.d30}
|
||||||
footnote="Toàn thành phố"
|
footnote="Toàn thành phố"
|
||||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||||
@@ -154,7 +139,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Giá trung vị"
|
label="Giá trung vị"
|
||||||
value={data ? formatVnd(data.medianPrice) : '—'}
|
value={data ? formatPrice(data.medianPrice) : '—'}
|
||||||
footnote="Median price"
|
footnote="Median price"
|
||||||
icon={<Layers className="h-3.5 w-3.5" />}
|
icon={<Layers className="h-3.5 w-3.5" />}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -352,7 +337,7 @@ function RecentListings() {
|
|||||||
const price = Number(r.priceVND);
|
const price = Number(r.priceVND);
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||||
{formatVnd(price)}
|
{formatPrice(price)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -367,7 +352,7 @@ function RecentListings() {
|
|||||||
cell: (r) =>
|
cell: (r) =>
|
||||||
r.pricePerM2 ? (
|
r.pricePerM2 ? (
|
||||||
<span className="text-xs tabular-nums text-foreground-muted">
|
<span className="text-xs tabular-nums text-foreground-muted">
|
||||||
{formatPriceM2(r.pricePerM2)}
|
{formatPricePerM2(r.pricePerM2)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-foreground-dim">—</span>
|
<span className="text-foreground-dim">—</span>
|
||||||
@@ -457,7 +442,7 @@ export default function MarketDashboardPage() {
|
|||||||
{
|
{
|
||||||
id: 'price',
|
id: 'price',
|
||||||
header: 'Giá TB/m²',
|
header: 'Giá TB/m²',
|
||||||
cell: (r) => formatPriceM2(r.avgPriceM2),
|
cell: (r) => formatPricePerM2(r.avgPriceM2),
|
||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface ChuyenNhuongDetailClientProps {
|
interface ChuyenNhuongDetailClientProps {
|
||||||
listing: TransferListingDetail;
|
listing: TransferListingDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
|
export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientProps) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
@@ -69,13 +67,13 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="my-6 grid grid-cols-2 gap-4 rounded-lg border bg-card p-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
<QuickStat
|
<QuickStat
|
||||||
label="Giá yêu cầu"
|
label="Giá yêu cầu"
|
||||||
value={formatVND(listing.askingPriceVND)}
|
value={formatVNDFull(listing.askingPriceVND)}
|
||||||
valueClassName="text-primary"
|
valueClassName="text-primary"
|
||||||
/>
|
/>
|
||||||
{listing.aiEstimatePriceVND && (
|
{listing.aiEstimatePriceVND && (
|
||||||
<QuickStat
|
<QuickStat
|
||||||
label="Giá AI ước tính"
|
label="Giá AI ước tính"
|
||||||
value={formatVND(listing.aiEstimatePriceVND)}
|
value={formatVNDFull(listing.aiEstimatePriceVND)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{listing.areaM2 && (
|
{listing.areaM2 && (
|
||||||
@@ -144,7 +142,7 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
{listing.monthlyRentVND && (
|
{listing.monthlyRentVND && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
|
<span className="text-sm text-muted-foreground">Tiền thuê hàng tháng</span>
|
||||||
<span className="font-medium">{formatVND(listing.monthlyRentVND)}</span>
|
<span className="font-medium">{formatVNDFull(listing.monthlyRentVND)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{listing.depositMonths != null && (
|
{listing.depositMonths != null && (
|
||||||
@@ -181,14 +179,14 @@ export function ChuyenNhuongDetailClient({ listing }: ChuyenNhuongDetailClientPr
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
|
<span className="text-sm text-muted-foreground">Giá yêu cầu</span>
|
||||||
<span className="text-lg font-bold text-primary">
|
<span className="text-lg font-bold text-primary">
|
||||||
{formatVND(listing.askingPriceVND)}
|
{formatVNDFull(listing.askingPriceVND)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{listing.aiEstimatePriceVND && (
|
{listing.aiEstimatePriceVND && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Giá AI ước tính</span>
|
<span className="text-sm text-muted-foreground">Giá AI ước tính</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{formatVND(listing.aiEstimatePriceVND)}
|
{formatVNDFull(listing.aiEstimatePriceVND)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import {
|
|||||||
CONDITION_COLORS,
|
CONDITION_COLORS,
|
||||||
CONDITION_LABELS,
|
CONDITION_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface TransferItemTableProps {
|
interface TransferItemTableProps {
|
||||||
items: TransferItemData[];
|
items: TransferItemData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransferItemTable({ items }: TransferItemTableProps) {
|
export function TransferItemTable({ items }: TransferItemTableProps) {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -59,12 +57,12 @@ export function TransferItemTable({ items }: TransferItemTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||||
<td className="px-3 py-2 text-right font-medium">
|
<td className="px-3 py-2 text-right font-medium">
|
||||||
{formatVND(item.askingPriceVND)}
|
{formatVNDFull(item.askingPriceVND)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-muted-foreground">
|
<td className="px-3 py-2 text-right text-muted-foreground">
|
||||||
{item.aiEstimatePriceVND ? (
|
{item.aiEstimatePriceVND ? (
|
||||||
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
|
<span title={item.aiConfidence ? `Độ tin cậy: ${Math.round(item.aiConfidence * 100)}%` : undefined}>
|
||||||
{formatVND(item.aiEstimatePriceVND)}
|
{formatVNDFull(item.aiEstimatePriceVND)}
|
||||||
</span>
|
</span>
|
||||||
) : '—'}
|
) : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import {
|
|||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
} from '@/lib/chuyen-nhuong-api';
|
} from '@/lib/chuyen-nhuong-api';
|
||||||
|
import { formatVNDFull } from '@/lib/currency';
|
||||||
|
|
||||||
interface TransferListingCardProps {
|
interface TransferListingCardProps {
|
||||||
listing: TransferListingListItem;
|
listing: TransferListingListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVND(value: string): string {
|
|
||||||
return new Intl.NumberFormat('vi-VN').format(Number(value)) + ' \u20ab';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransferListingCard({ listing }: TransferListingCardProps) {
|
export function TransferListingCard({ listing }: TransferListingCardProps) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
@@ -62,7 +60,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) {
|
|||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-lg font-bold text-primary">
|
<p className="text-lg font-bold text-primary">
|
||||||
{formatVND(listing.askingPriceVND)}
|
{formatVNDFull(listing.askingPriceVND)}
|
||||||
</p>
|
</p>
|
||||||
{listing.isNegotiable && (
|
{listing.isNegotiable && (
|
||||||
<span className="text-xs text-muted-foreground">Thương lượng</span>
|
<span className="text-xs text-muted-foreground">Thương lượng</span>
|
||||||
|
|||||||
@@ -11,21 +11,13 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import type { PriceHistoryItem } from '@/lib/listings-api';
|
import type { PriceHistoryItem } from '@/lib/listings-api';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
interface PriceHistoryChartProps {
|
interface PriceHistoryChartProps {
|
||||||
data: PriceHistoryItem[];
|
data: PriceHistoryItem[];
|
||||||
height?: number;
|
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) {
|
export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps) {
|
||||||
if (data.length === 0) return null;
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
@@ -37,7 +29,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps
|
|||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
price: priceToMillions(item.newPrice),
|
price: Number(item.newPrice),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +40,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps
|
|||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
className="fill-muted-foreground"
|
className="fill-muted-foreground"
|
||||||
tickFormatter={(v: number) => formatMillions(v)}
|
tickFormatter={(v: number) => formatPrice(v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
@@ -57,7 +49,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps
|
|||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
}}
|
}}
|
||||||
formatter={(value) => [formatMillions(Number(value)), 'Giá']}
|
formatter={(value) => [formatPrice(Number(value)), 'Giá']}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|||||||
@@ -6,15 +6,9 @@ import * as React from 'react';
|
|||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
import { ComponentErrorBoundary } from '@/components/error-boundary';
|
import { ComponentErrorBoundary } from '@/components/error-boundary';
|
||||||
import type { ListingDetail } from '@/lib/listings-api';
|
import type { ListingDetail } from '@/lib/listings-api';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { useMapboxStyle } from '@/lib/mapbox-style';
|
import { useMapboxStyle } from '@/lib/mapbox-style';
|
||||||
|
|
||||||
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)} tr`;
|
|
||||||
return num.toLocaleString('vi-VN');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListingMapProps {
|
interface ListingMapProps {
|
||||||
listings: ListingDetail[];
|
listings: ListingDetail[];
|
||||||
onMarkerClick?: (listing: ListingDetail) => void;
|
onMarkerClick?: (listing: ListingDetail) => void;
|
||||||
|
|||||||
@@ -4,17 +4,12 @@ import { useState } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useValuationPredictForListing } from '@/lib/hooks/use-valuation';
|
import { useValuationPredictForListing } from '@/lib/hooks/use-valuation';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
|
||||||
interface AiEstimateButtonProps {
|
interface AiEstimateButtonProps {
|
||||||
listingId: string;
|
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) {
|
export function AiEstimateButton({ listingId }: AiEstimateButtonProps) {
|
||||||
const [showResult, setShowResult] = useState(false);
|
const [showResult, setShowResult] = useState(false);
|
||||||
const mutation = useValuationPredictForListing();
|
const mutation = useValuationPredictForListing();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
formatPrice,
|
formatPrice,
|
||||||
formatVND,
|
formatVND,
|
||||||
|
formatVNDFull,
|
||||||
formatPricePerM2,
|
formatPricePerM2,
|
||||||
parseVND,
|
parseVND,
|
||||||
} from '../currency';
|
} 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
|
// parseVND — reverse parse
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ export function formatPrice(amount: string | number): string {
|
|||||||
|
|
||||||
if (num >= 1_000_000_000) {
|
if (num >= 1_000_000_000) {
|
||||||
const billions = 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) {
|
if (num >= 1_000_000) {
|
||||||
const millions = 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');
|
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.
|
* Format a VND amount with a " đ" currency suffix.
|
||||||
* Returns "Mi\u1ec5n ph\u00ed" for zero amounts.
|
* Returns "Miễn phí" for zero amounts.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* formatVND(4_990_000) // "4.99 trieu d"
|
* 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 {
|
export function formatVND(amount: string | number): string {
|
||||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||||
if (!Number.isFinite(num) || num < 0) return '0 \u0111';
|
if (!Number.isFinite(num) || num < 0) return '0 đ';
|
||||||
if (num === 0) return 'Mi\u1ec5n ph\u00ed';
|
if (num === 0) return 'Miễn phí';
|
||||||
|
|
||||||
if (num >= 1_000_000_000) {
|
if (num >= 1_000_000_000) {
|
||||||
const billions = 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) {
|
if (num >= 1_000_000) {
|
||||||
const millions = 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
|
* @example
|
||||||
* formatPricePerM2(50_500_000) // "50.5 tr/m\u00b2"
|
* formatPricePerM2(50_500_000) // "50.5 tr/m²"
|
||||||
* formatPricePerM2(500_000) // "500.000 \u0111/m\u00b2"
|
* formatPricePerM2(500_000) // "500k/m²"
|
||||||
*/
|
*/
|
||||||
export function formatPricePerM2(price: string | number): string {
|
export function formatPricePerM2(price: string | number): string {
|
||||||
const num = typeof price === 'string' ? Number(price) : price;
|
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) {
|
if (num >= 1_000_000_000) {
|
||||||
const billions = 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) {
|
if (num >= 1_000_000) {
|
||||||
const millions = 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.
|
* 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 {
|
export function parseVND(formatted: string): number | null {
|
||||||
const cleaned = formatted.replace(/[^\d]/g, '');
|
const cleaned = formatted.replace(/[^\d]/g, '');
|
||||||
|
|||||||
Reference in New Issue
Block a user