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

@@ -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');