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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user