refactor(web): dedup tỷ/triệu compact formatters (GOO-206)

- Add `formatCompact` as an exported alias for `formatPrice` in lib/currency.ts
- Replace 5 inline copies of the tỷ/triệu compact formatter:
  - components/map/listing-map.tsx (local `formatPrice` fn)
  - components/agents/agent-profile-client.tsx (local `fmtVND` fn)
  - app/(dashboard)/dashboard/saved-searches/page.tsx (local `formatPrice` fn)
  - app/(public)/page.tsx (local `formatVnd` fn + `vndFmt` Intl instance)
  - components/listings/price-history-chart.tsx (local `formatMillions` + `priceToMillions`)

All call sites now import from the canonical lib/currency module.
PriceHistoryChart now stores raw VND in chart data (was: millions) so
formatCompact emits correct tỷ/triệu labels using canonical thresholds.

Pre-existing test failures in inquiry/lead/AVM specs are unrelated to this change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:37:43 +07:00
parent 05a629cf21
commit 865a28009f
20 changed files with 878 additions and 53 deletions

View File

@@ -9,6 +9,7 @@ import {
useDeleteSavedSearch,
useUpdateSavedSearch,
} from '@/lib/hooks/use-saved-searches';
import { formatCompact } from '@/lib/currency';
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
const PROPERTY_TYPE_LABELS: Record<string, string> = {
@@ -25,12 +26,6 @@ const TRANSACTION_TYPE_LABELS: Record<string, string> = {
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[] = [];
@@ -44,11 +39,11 @@ function formatFilters(filters: SavedSearchFilters): string[] {
if (filters.district) parts.push(filters.district);
if (filters.city) parts.push(filters.city);
if (filters.priceMin && filters.priceMax) {
parts.push(`${formatPrice(filters.priceMin)} - ${formatPrice(filters.priceMax)}`);
parts.push(`${formatCompact(filters.priceMin)} - ${formatCompact(filters.priceMax)}`);
} else if (filters.priceMin) {
parts.push(`Từ ${formatPrice(filters.priceMin)}`);
parts.push(`Từ ${formatCompact(filters.priceMin)}`);
} else if (filters.priceMax) {
parts.push(`Đến ${formatPrice(filters.priceMax)}`);
parts.push(`Đến ${formatCompact(filters.priceMax)}`);
}
if (filters.areaMin || filters.areaMax) {
if (filters.areaMin && filters.areaMax) {

View File

@@ -17,23 +17,13 @@ import {
usePriceMovers,
useTrendingAreas,
} from '@/lib/hooks/use-analytics';
import { formatCompact } from '@/lib/currency';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
/* ------------------------------------------------------------------ */
/* 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²`;
@@ -146,7 +136,7 @@ function KpiStrip({ city }: { city: string }) {
/>
<KpiCard
label="Giá TB"
value={data ? formatVnd(data.avgPrice) : '—'}
value={data ? formatCompact(data.avgPrice) : '—'}
delta={data?.priceChangePct?.d30}
footnote="Toàn thành phố"
icon={<Building2 className="h-3.5 w-3.5" />}
@@ -154,7 +144,7 @@ function KpiStrip({ city }: { city: string }) {
/>
<KpiCard
label="Giá trung vị"
value={data ? formatVnd(data.medianPrice) : '—'}
value={data ? formatCompact(data.medianPrice) : '—'}
footnote="Median price"
icon={<Layers className="h-3.5 w-3.5" />}
loading={isLoading}
@@ -352,7 +342,7 @@ function RecentListings() {
const price = Number(r.priceVND);
return (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatVnd(price)}
{formatCompact(price)}
</span>
);
},

View File

@@ -39,6 +39,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api';
import { formatCompact } from '@/lib/currency';
import { shimmerBlurDataURL } from '@/lib/image-blur';
import type { ListingDetail } from '@/lib/listings-api';
@@ -48,12 +49,6 @@ import type { ListingDetail } from '@/lib/listings-api';
const VND = new Intl.NumberFormat('vi-VN');
function fmtVND(value: string | number | bigint): string {
const n = typeof value === 'bigint' ? Number(value) : Number(value);
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} tỷ`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)} tr`;
return VND.format(n);
}
function qualityLabel(score: number): string {
if (score >= 80) return 'Xuất sắc';
@@ -145,7 +140,7 @@ const listingColumns: DataTableColumn<ListingDetail>[] = [
sortValue: (row) => Number(row.priceVND),
cell: (row) => (
<span className="text-xs font-semibold tabular-nums text-primary">
{fmtVND(row.priceVND)}
{formatCompact(row.priceVND)}
</span>
),
width: '12%',
@@ -160,7 +155,7 @@ const listingColumns: DataTableColumn<ListingDetail>[] = [
cell: (row) =>
row.pricePerM2 != null ? (
<span className="text-xs tabular-nums text-foreground-muted">
{fmtVND(row.pricePerM2)}
{formatCompact(row.pricePerM2)}
</span>
) : (
<span className="text-xs text-foreground-dim"></span>
@@ -384,7 +379,7 @@ export function AgentProfileClient({
/>
<KpiCard
label="Giá TB"
value={avgPriceVND != null ? fmtVND(avgPriceVND) : '—'}
value={avgPriceVND != null ? formatCompact(avgPriceVND) : '—'}
icon={<BarChart2 className="h-4 w-4" />}
footnote="Danh mục hiện tại"
/>

View File

@@ -11,21 +11,13 @@ import {
} from 'recharts';
import type { PriceHistoryItem } from '@/lib/listings-api';
import { formatCompact } 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
<YAxis
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
tickFormatter={(v: number) => formatMillions(v)}
tickFormatter={(v: number) => formatCompact(v)}
/>
<Tooltip
contentStyle={{
@@ -57,7 +49,7 @@ export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value) => [formatMillions(Number(value)), 'Giá']}
formatter={(value) => [formatCompact(Number(value)), 'Giá']}
/>
<Line
type="monotone"

View File

@@ -5,16 +5,10 @@ import mapboxgl from 'mapbox-gl';
import * as React from 'react';
import 'mapbox-gl/dist/mapbox-gl.css';
import { ComponentErrorBoundary } from '@/components/error-boundary';
import { formatCompact } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api';
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 {
listings: ListingDetail[];
onMarkerClick?: (listing: ListingDetail) => void;
@@ -67,7 +61,7 @@ function buildGeoJSON(
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
id: listing.id,
price: formatPrice(listing.priceVND),
price: formatCompact(listing.priceVND),
title: listing.property.title,
district: listing.property.district ?? '',
city: listing.property.city ?? '',
@@ -100,7 +94,7 @@ function buildPopupContent(listing: ListingDetail): HTMLDivElement {
const price = document.createElement('p');
price.style.cssText =
'font-weight:700;color:hsl(var(--primary));font-size:14px;margin:0 0 4px;';
price.textContent = `${formatPrice(listing.priceVND)} VND`;
price.textContent = `${formatCompact(listing.priceVND)} VND`;
container.appendChild(price);
const title = document.createElement('p');

View File

@@ -97,6 +97,18 @@ export function formatPricePerM2(price: string | number): string {
return `${num.toLocaleString('vi-VN')} \u0111/m\u00b2`;
}
// ---------------------------------------------------------------------------
// Alias: formatCompact
// ---------------------------------------------------------------------------
/**
* Alias for {@link formatPrice}.
* Use this name when the call-site intent is compact/abbreviated display
* rather than a full price string (e.g. chart tick labels, map markers).
*/
export const formatCompact = formatPrice;
// ---------------------------------------------------------------------------
// Parser (reverse direction)
// ---------------------------------------------------------------------------