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>
);
},