From 5d4ecdeb2f19cbefe82a5293c3ded684e8916db7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 18 Apr 2026 15:05:46 +0700 Subject: [PATCH] feat(web): AVM v2 upgraded valuation dashboard (TEC-2763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5.4 ships the upgraded AVM UI behind the `avm_v2` A/B flag. When the flag is on, the dashboard exposes: - Tab switch between single valuation and multi-property compare - Waterfall drivers chart (ValueDriversChart) alongside the existing horizontal bar breakdown - Mapbox comparables map with similarity-coloured markers and an optional highlighted subject pin - Confidence interval + range bar and PDF export remain available - Valuation history chart surface unchanged (still lazy-loaded) Flag plumbing (useAvmV2Flag): - NEXT_PUBLIC_FEATURE_AVM_V2=1 enables by default - `?avm_v2=1|0` URL param forces + persists to localStorage - safe localStorage handling (no throw when storage is blocked) Tests: comparables-map, value-drivers-chart, use-avm-v2-flag specs added. Pre-existing "Yếu tố chính" assertion in valuation-results.spec updated to match the current copy ("Yếu tố ảnh hưởng giá") so the valuation suite is green (7 files, 52 tests). Co-Authored-By: Paperclip --- .../(dashboard)/dashboard/valuation/page.tsx | 153 ++++++--- .../__tests__/comparables-map.spec.tsx | 149 +++++++++ .../__tests__/valuation-results.spec.tsx | 4 +- .../__tests__/value-drivers-chart.spec.tsx | 44 +++ .../components/valuation/comparables-map.tsx | 230 +++++++++++++ .../valuation/valuation-compare.tsx | 303 ++++++++++++++++++ .../valuation/value-drivers-chart.tsx | 147 +++++++++ .../hooks/__tests__/use-avm-v2-flag.spec.tsx | 99 ++++++ apps/web/lib/hooks/use-avm-v2-flag.ts | 55 ++++ 9 files changed, 1135 insertions(+), 49 deletions(-) create mode 100644 apps/web/components/valuation/__tests__/comparables-map.spec.tsx create mode 100644 apps/web/components/valuation/__tests__/value-drivers-chart.spec.tsx create mode 100644 apps/web/components/valuation/comparables-map.tsx create mode 100644 apps/web/components/valuation/valuation-compare.tsx create mode 100644 apps/web/components/valuation/value-drivers-chart.tsx create mode 100644 apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx create mode 100644 apps/web/lib/hooks/use-avm-v2-flag.ts diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx index 29b7210..c2cb097 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx @@ -2,12 +2,17 @@ import dynamic from 'next/dynamic'; import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { ComparablesMap } from '@/components/valuation/comparables-map'; import { ComparablesTable } from '@/components/valuation/comparables-table'; import { ExportPdfButton } from '@/components/valuation/export-pdf-button'; import { MarketContextCard } from '@/components/valuation/market-context-card'; +import { ValuationCompare } from '@/components/valuation/valuation-compare'; import { ValuationForm } from '@/components/valuation/valuation-form'; import { ValuationHistory } from '@/components/valuation/valuation-history'; import { ValuationResults } from '@/components/valuation/valuation-results'; +import { ValueDriversChart } from '@/components/valuation/value-drivers-chart'; +import { useAvmV2Flag } from '@/lib/hooks/use-avm-v2-flag'; import { useValuationPredict, useValuationHistory, @@ -15,7 +20,6 @@ import { } from '@/lib/hooks/use-valuation'; import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; -// Lazy-load chart component (uses Recharts, no SSR) const ValuationHistoryChart = dynamic( () => import('@/components/valuation/valuation-history-chart').then( @@ -31,9 +35,13 @@ const ValuationHistoryChart = dynamic( }, ); +type ViewMode = 'single' | 'compare'; + export default function ValuationPage() { + const avmV2 = useAvmV2Flag(); const [historyPage, setHistoryPage] = useState(1); const [selectedId, setSelectedId] = useState(null); + const [viewMode, setViewMode] = useState('single'); const predictMutation = useValuationPredict(); const { data: historyData, isLoading: historyLoading } = @@ -54,15 +62,21 @@ export default function ValuationPage() { return (
- {/* Page header */}
-

Định giá AI

+
+

Định giá AI

+ {avmV2 && ( + + AVM v2 + + )} +

Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường

- {currentResult && ( + {currentResult && viewMode === 'single' && ( -
- {/* Form + Results (left 2 cols) */} -
- + {avmV2 && ( +
+ + +
+ )} - {predictMutation.isError && ( -
- Không thể định giá. Vui lòng thử lại sau. -
- )} + {viewMode === 'compare' && avmV2 ? ( + + ) : ( +
+
+ - {currentResult && ( - <> - {/* Main results with confidence badge + driver charts */} - + {predictMutation.isError && ( +
+ Không thể định giá. Vui lòng thử lại sau. +
+ )} - {/* Comparables table (TanStack Table) */} - {currentResult.comparables.length > 0 && ( - - )} + {currentResult && ( + <> + - {/* Market context card */} - {currentResult.marketContext && ( - - )} - - {/* Valuation history chart */} - {currentResult.valuationHistory && - currentResult.valuationHistory.length >= 2 && ( - + {avmV2 && currentResult.priceDrivers.length > 0 && ( + )} - - )} -
- {/* History sidebar (right col) */} -
- + {currentResult.comparables.length > 0 && ( + + )} + + {avmV2 && currentResult.comparables.length > 0 && ( + + )} + + {currentResult.marketContext && ( + + )} + + {currentResult.valuationHistory && + currentResult.valuationHistory.length >= 2 && ( + + )} + + )} +
+ +
+ +
-
+ )}
); } diff --git a/apps/web/components/valuation/__tests__/comparables-map.spec.tsx b/apps/web/components/valuation/__tests__/comparables-map.spec.tsx new file mode 100644 index 0000000..85f1267 --- /dev/null +++ b/apps/web/components/valuation/__tests__/comparables-map.spec.tsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import type { ValuationComparable } from '@/lib/valuation-api'; +import { ComparablesMap } from '../comparables-map'; + +// Mapbox GL does not run cleanly in jsdom — mock with a minimal stand-in +// that records addTo calls so we can assert marker count. +const markerAddTo = vi.fn(); +const mapAddControl = vi.fn(); +const mapFitBounds = vi.fn(); +const mapFlyTo = vi.fn(); +const mapRemove = vi.fn(); + +vi.mock('mapbox-gl', () => { + class MockMap { + addControl = mapAddControl; + fitBounds = mapFitBounds; + flyTo = mapFlyTo; + remove = mapRemove; + } + class MockNavigationControl {} + class MockAttributionControl {} + + class MockMarker { + setLngLat() { + return this; + } + setPopup() { + return this; + } + addTo() { + markerAddTo(); + return this; + } + remove() { + // noop + } + } + + class MockPopup { + setHTML() { + return this; + } + } + + class MockLngLatBounds { + extend() { + return this; + } + isEmpty() { + return false; + } + } + + return { + default: { + accessToken: '', + Map: MockMap, + NavigationControl: MockNavigationControl, + AttributionControl: MockAttributionControl, + Marker: MockMarker, + Popup: MockPopup, + LngLatBounds: MockLngLatBounds, + }, + }; +}); + +vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})); + +const sampleComparables: ValuationComparable[] = [ + { + id: 'comp-1', + title: 'Căn hộ tương tự A', + address: '456 Nguyễn Hữu Thọ', + district: 'Quận 7', + priceVND: '4800000000', + areaM2: 78, + pricePerM2: 61_500_000, + similarity: 0.92, + latitude: 10.73, + longitude: 106.72, + }, + { + id: 'comp-2', + title: 'Căn hộ tương tự B', + address: '789 Phạm Viết Chánh', + district: 'Bình Thạnh', + priceVND: '5200000000', + areaM2: 82, + pricePerM2: 63_400_000, + similarity: 0.7, + latitude: 10.8, + longitude: 106.7, + }, +]; + +describe('ComparablesMap', () => { + beforeEach(() => { + markerAddTo.mockClear(); + mapAddControl.mockClear(); + mapFitBounds.mockClear(); + mapFlyTo.mockClear(); + mapRemove.mockClear(); + process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'pk.test'; + }); + + afterEach(() => { + delete (process.env as Record)[ + 'NEXT_PUBLIC_MAPBOX_TOKEN' + ]; + }); + + it('renders header and descriptor', () => { + render(); + expect(screen.getByText('Bản đồ so sánh')).toBeInTheDocument(); + expect(screen.getByText(/2 BĐS so sánh/)).toBeInTheDocument(); + }); + + it('renders prompt when mapbox token is missing', () => { + delete (process.env as Record)[ + 'NEXT_PUBLIC_MAPBOX_TOKEN' + ]; + render(); + expect( + screen.getByText(/Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN/), + ).toBeInTheDocument(); + }); + + it('shows empty state when no comparables have coordinates', () => { + const withoutCoords = sampleComparables.map( + ({ latitude: _lat, longitude: _lng, ...rest }) => rest, + ); + render(); + expect( + screen.getByText(/Không có toạ độ cho các BĐS so sánh/), + ).toBeInTheDocument(); + }); + + it('adds a marker for each geolocated comparable plus subject pin', () => { + render( + , + ); + expect(markerAddTo).toHaveBeenCalledTimes(3); + }); +}); diff --git a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx index 8f60cb7..9d5b4fe 100644 --- a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx @@ -64,7 +64,7 @@ describe('ValuationResults', () => { it('renders price drivers section', () => { render(); - expect(screen.getByText('Yếu tố chính')).toBeInTheDocument(); + expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument(); expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument(); expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument(); }); @@ -82,7 +82,7 @@ describe('ValuationResults', () => { it('hides drivers section when empty', () => { const noDrivers = { ...mockResult, priceDrivers: [] }; render(); - expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument(); + expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument(); }); }); diff --git a/apps/web/components/valuation/__tests__/value-drivers-chart.spec.tsx b/apps/web/components/valuation/__tests__/value-drivers-chart.spec.tsx new file mode 100644 index 0000000..1e10530 --- /dev/null +++ b/apps/web/components/valuation/__tests__/value-drivers-chart.spec.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { PriceDriver } from '@/lib/valuation-api'; +import { ValueDriversChart } from '../value-drivers-chart'; + +// Recharts uses ResizeObserver and SVG path measurements that jsdom does not +// implement. Stub ResponsiveContainer so child bars render in tests. +vi.mock('recharts', async () => { + const actual = (await vi.importActual('recharts')) as Record; + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+ ), + }; +}); + +const drivers: PriceDriver[] = [ + { feature: 'area_m2', impact: 20, direction: 'positive' }, + { feature: 'building_age_years', impact: -8, direction: 'negative' }, + { feature: 'distance_to_cbd_km', impact: -4.5, direction: 'negative' }, +]; + +describe('ValueDriversChart', () => { + it('renders header and description', () => { + render(); + expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument(); + expect( + screen.getByText(/Biểu đồ thác nước/), + ).toBeInTheDocument(); + }); + + it('renders nothing when drivers list is empty', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders chart container when drivers are provided', () => { + render(); + expect(screen.getByTestId('chart-container')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/valuation/comparables-map.tsx b/apps/web/components/valuation/comparables-map.tsx new file mode 100644 index 0000000..77b9093 --- /dev/null +++ b/apps/web/components/valuation/comparables-map.tsx @@ -0,0 +1,230 @@ +'use client'; + +/* eslint-disable import-x/no-named-as-default-member */ +import mapboxgl from 'mapbox-gl'; +import * as React from 'react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { formatPrice, formatPricePerM2 } from '@/lib/currency'; +import type { ValuationComparable } from '@/lib/valuation-api'; + +interface ComparablesMapProps { + comparables: ValuationComparable[]; + subjectLatitude?: number; + subjectLongitude?: number; + className?: string; +} + +const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; +const DEFAULT_ZOOM = 11; + +function similarityColor(sim: number): string { + if (sim >= 0.85) return '#16a34a'; + if (sim >= 0.7) return '#eab308'; + return '#dc2626'; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function ComparablesMap({ + comparables, + subjectLatitude, + subjectLongitude, + className, +}: ComparablesMapProps) { + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const markersRef = React.useRef([]); + + const geoComparables = React.useMemo( + () => + comparables.filter( + (c) => c.latitude != null && c.longitude != null, + ), + [comparables], + ); + + React.useEffect(() => { + if (!mapContainerRef.current) return; + + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) return; + + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: mapContainerRef.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl( + new mapboxgl.AttributionControl({ compact: true }), + 'bottom-right', + ); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + const bounds = new mapboxgl.LngLatBounds(); + let extended = false; + + if (subjectLatitude != null && subjectLongitude != null) { + const subjectEl = document.createElement('div'); + subjectEl.setAttribute('data-testid', 'comparables-map-subject'); + subjectEl.style.cssText = ` + width: 22px; + height: 22px; + border-radius: 50%; + background: hsl(221.2, 83.2%, 53.3%); + border: 3px solid white; + box-shadow: 0 0 0 2px hsl(221.2, 83.2%, 53.3%), 0 2px 6px rgba(0,0,0,0.3); + `; + const marker = new mapboxgl.Marker({ element: subjectEl }) + .setLngLat([subjectLongitude, subjectLatitude]) + .addTo(map); + markersRef.current.push(marker); + bounds.extend([subjectLongitude, subjectLatitude]); + extended = true; + } + + geoComparables.forEach((comp) => { + const color = similarityColor(comp.similarity); + const el = document.createElement('div'); + el.setAttribute('data-testid', 'comparables-map-marker'); + el.style.cssText = ` + background: white; + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + white-space: nowrap; + cursor: pointer; + border-left: 3px solid ${color}; + transition: transform 0.15s; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + `; + el.textContent = formatPrice(comp.priceVND); + el.addEventListener('mouseenter', () => { + el.style.transform = 'scale(1.08)'; + }); + el.addEventListener('mouseleave', () => { + el.style.transform = 'scale(1)'; + }); + + const popup = new mapboxgl.Popup({ + offset: 15, + maxWidth: '280px', + closeButton: false, + }).setHTML( + `
+

${escapeHtml(comp.title)}

+

${escapeHtml(comp.address)}

+

+ ${formatPrice(comp.priceVND)} VNĐ + ${formatPricePerM2(comp.pricePerM2)} +

+

+ ${comp.areaM2} m² · Tương đồng ${Math.round(comp.similarity * 100)}% +

+
`, + ); + + const marker = new mapboxgl.Marker({ element: el, anchor: 'left' }) + .setLngLat([comp.longitude!, comp.latitude!]) + .setPopup(popup) + .addTo(map); + + markersRef.current.push(marker); + bounds.extend([comp.longitude!, comp.latitude!]); + extended = true; + }); + + if (!extended) return; + + if (!bounds.isEmpty() && markersRef.current.length > 1) { + map.fitBounds(bounds, { padding: 60, maxZoom: 14 }); + } else if (markersRef.current.length === 1) { + const first = geoComparables[0] ?? { + latitude: subjectLatitude, + longitude: subjectLongitude, + }; + if (first.latitude != null && first.longitude != null) { + map.flyTo({ center: [first.longitude, first.latitude], zoom: 14 }); + } + } + }, [geoComparables, subjectLatitude, subjectLongitude]); + + const hasToken = + typeof process !== 'undefined' && + Boolean(process.env['NEXT_PUBLIC_MAPBOX_TOKEN']); + + const hasAnyGeo = + geoComparables.length > 0 || + (subjectLatitude != null && subjectLongitude != null); + + return ( + + + Bản đồ so sánh + + Vị trí các bất động sản tương tự được sử dụng trong mô hình AVM + + + +
+
+ + {!hasToken && ( +
+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ +
+ )} + + {hasToken && !hasAnyGeo && ( +
+ Không có toạ độ cho các BĐS so sánh +
+ )} + +
+ {geoComparables.length} BĐS so sánh +
+
+ + + ); +} diff --git a/apps/web/components/valuation/valuation-compare.tsx b/apps/web/components/valuation/valuation-compare.tsx new file mode 100644 index 0000000..a3d6353 --- /dev/null +++ b/apps/web/components/valuation/valuation-compare.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { Plus, Trash2, BarChart3 } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { formatPrice, formatPricePerM2 } from '@/lib/currency'; +import { useValuationBatch } from '@/lib/hooks/use-valuation'; +import { + VALUATION_PROPERTY_TYPES, + CITIES, +} from '@/lib/validations/valuation'; +import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; + +interface PropertySlot { + id: string; + propertyType: string; + area: string; + district: string; + city: string; + bedrooms: string; + label: string; +} + +function createEmptySlot(index: number): PropertySlot { + return { + id: crypto.randomUUID(), + propertyType: 'APARTMENT', + area: '', + district: '', + city: 'Ho Chi Minh', + bedrooms: '', + label: `BĐS ${index + 1}`, + }; +} + +function getConfidenceColor(c: number): string { + if (c >= 0.8) return 'text-green-600'; + if (c >= 0.5) return 'text-yellow-600'; + return 'text-red-600'; +} + +function getConfidenceVariant(c: number): 'success' | 'warning' | 'destructive' { + if (c >= 0.8) return 'success'; + if (c >= 0.5) return 'warning'; + return 'destructive'; +} + +export function ValuationCompare() { + const [slots, setSlots] = useState([ + createEmptySlot(0), + createEmptySlot(1), + ]); + const [results, setResults] = useState(null); + + const batchMutation = useValuationBatch(); + + const updateSlot = (id: string, field: keyof PropertySlot, value: string) => { + setSlots((prev) => + prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)), + ); + }; + + const addSlot = () => { + if (slots.length >= 5) return; + setSlots((prev) => [...prev, createEmptySlot(prev.length)]); + }; + + const removeSlot = (id: string) => { + if (slots.length <= 2) return; + setSlots((prev) => prev.filter((s) => s.id !== id)); + }; + + const handleCompare = () => { + const validSlots = slots.filter((s) => s.area && s.district); + if (validSlots.length < 2) return; + + const properties: ValuationRequest[] = validSlots.map((s) => ({ + propertyType: s.propertyType, + area: Number(s.area), + district: s.district, + city: s.city, + bedrooms: s.bedrooms ? Number(s.bedrooms) : undefined, + })); + + batchMutation.mutate( + { properties }, + { + onSuccess: (data) => { + setResults(data.results); + }, + }, + ); + }; + + const bestValue = + results && results.length > 0 + ? results.reduce((best, r) => + r.pricePerM2 < best.pricePerM2 ? r : best, + ) + : null; + + return ( +
+ + +
+ + So sánh định giá +
+ + So sánh giá trị ước tính của nhiều bất động sản cùng lúc (2-5 BĐS) + +
+ + {slots.map((slot) => ( +
+
+ + {slots.length > 2 && ( + + )} +
+
+ + updateSlot(slot.id, 'area', e.target.value)} + /> + + updateSlot(slot.id, 'district', e.target.value) + } + /> + + + updateSlot(slot.id, 'bedrooms', e.target.value) + } + /> +
+
+ ))} + +
+ {slots.length < 5 && ( + + )} + +
+
+
+ + {/* Comparison results */} + {results && results.length > 0 && ( +
+ {results.map((result, i) => { + const isBest = bestValue && result.id === bestValue.id; + return ( + + +
+ + {slots[i]?.label ?? `BĐS ${i + 1}`} + + {isBest && ( + + Giá/m² tốt nhất + + )} +
+ + {slots[i]?.district}, {slots[i]?.city} + +
+ +
+

+ {formatPrice(result.estimatedPriceVND)} VNĐ +

+

+ {formatPricePerM2(result.pricePerM2)}/m² +

+
+ +
+ Độ tin cậy + + {Math.round(result.confidence * 100)}% + +
+ +
+
= 0.8 + ? 'bg-green-500' + : result.confidence >= 0.5 + ? 'bg-yellow-500' + : 'bg-red-500' + }`} + style={{ width: `${result.confidence * 100}%` }} + /> +
+ +
+ Khoảng giá: {formatPrice(result.priceRangeLow)} -{' '} + {formatPrice(result.priceRangeHigh)} +
+ + {result.priceDrivers.length > 0 && ( +
+ {result.priceDrivers.slice(0, 3).map((d) => ( + + {d.direction === 'positive' ? '+' : '-'} + {Math.abs(d.impact).toFixed(0)}% {d.feature} + + ))} +
+ )} + + + ); + })} +
+ )} + + {batchMutation.isError && ( +
+ Không thể so sánh. Vui lòng thử lại sau. +
+ )} +
+ ); +} diff --git a/apps/web/components/valuation/value-drivers-chart.tsx b/apps/web/components/valuation/value-drivers-chart.tsx new file mode 100644 index 0000000..ca910ba --- /dev/null +++ b/apps/web/components/valuation/value-drivers-chart.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, + ReferenceLine, +} from 'recharts'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import type { PriceDriver } from '@/lib/valuation-api'; + +interface ValueDriversChartProps { + drivers: PriceDriver[]; +} + +const FEATURE_LABELS: Record = { + area_m2: 'Diện tích', + avg_price_district_3m_vnd_m2: 'Giá TB khu vực', + property_type_encoded: 'Loại BĐS', + distance_to_cbd_km: 'Khoảng cách trung tâm', + renovation_score: 'Cải tạo', + building_age_years: 'Tuổi công trình', + has_legal_paper: 'Giấy tờ pháp lý', + distance_to_metro_km: 'Khoảng cách metro', + interior_quality: 'Nội thất', + price_momentum_30d: 'Đà tăng giá 30 ngày', + view_quality: 'Chất lượng view', + natural_light: 'Ánh sáng tự nhiên', + noise_level: 'Mức ồn', + flood_zone_risk: 'Nguy cơ ngập', + park_occupancy_rate: 'Tỉ lệ lấp đầy', + logistics_connectivity_score: 'Kết nối logistics', + industry_demand_index: 'Nhu cầu CN', +}; + +function getFeatureLabel(feature: string): string { + return FEATURE_LABELS[feature] || feature.replace(/_/g, ' '); +} + +interface WaterfallItem { + name: string; + base: number; + value: number; + fill: string; + importance: number; + direction: 'positive' | 'negative'; +} + +function buildWaterfallData(drivers: PriceDriver[]): WaterfallItem[] { + const sorted = [...drivers].sort( + (a, b) => Math.abs(b.impact) - Math.abs(a.impact), + ); + + let cumulative = 0; + return sorted.map((driver) => { + const isPositive = driver.direction === 'positive'; + const absImpact = Math.abs(driver.impact); + const item: WaterfallItem = { + name: getFeatureLabel(driver.feature), + base: isPositive ? cumulative : cumulative - absImpact, + value: absImpact, + fill: isPositive ? '#22c55e' : '#ef4444', + importance: absImpact, + direction: driver.direction, + }; + cumulative += isPositive ? absImpact : -absImpact; + return item; + }); +} + +function CustomTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ payload: WaterfallItem }>; +}) { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ {data.direction === 'positive' ? '+' : '-'} + {data.importance.toFixed(1)}% +

+
+ ); +} + +export function ValueDriversChart({ drivers }: ValueDriversChartProps) { + if (drivers.length === 0) return null; + + const data = buildWaterfallData(drivers); + + return ( + + + Yếu tố ảnh hưởng giá + + Biểu đồ thác nước thể hiện mức ảnh hưởng của từng yếu tố + + + + + + `${v.toFixed(0)}%`} + domain={['dataMin', 'dataMax']} + /> + + } /> + + {/* Invisible base bar for waterfall offset */} + + {/* Visible value bar */} + + {data.map((entry, index) => ( + + ))} + + + + + + ); +} diff --git a/apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx b/apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx new file mode 100644 index 0000000..f59cdc7 --- /dev/null +++ b/apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx @@ -0,0 +1,99 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const LOCAL_KEY = 'goodgo:avm_v2'; + +function installMemoryStorage(): Storage { + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (k) => (store.has(k) ? store.get(k)! : null), + key: (i) => Array.from(store.keys())[i] ?? null, + removeItem: (k) => { + store.delete(k); + }, + setItem: (k, v) => { + store.set(k, String(v)); + }, + }; + vi.stubGlobal('localStorage', storage); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: storage, + }); + return storage; +} + +describe('useAvmV2Flag', () => { + let storage: Storage; + + beforeEach(() => { + vi.resetModules(); + storage = installMemoryStorage(); + window.history.replaceState({}, '', '/'); + delete (process.env as Record)[ + 'NEXT_PUBLIC_FEATURE_AVM_V2' + ]; + }); + + afterEach(() => { + storage.clear(); + vi.unstubAllGlobals(); + }); + + it('returns false by default when env flag is not set', async () => { + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + expect(result.current).toBe(false); + }); + + it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "1"', async () => { + process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1'; + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + expect(result.current).toBe(true); + }); + + it('returns true when NEXT_PUBLIC_FEATURE_AVM_V2 is "true"', async () => { + process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = 'true'; + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + expect(result.current).toBe(true); + }); + + it('query param ?avm_v2=1 forces on and persists to localStorage', async () => { + window.history.replaceState({}, '', '/?avm_v2=1'); + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toBe(true); + expect(storage.getItem(LOCAL_KEY)).toBe('1'); + }); + + it('query param ?avm_v2=0 forces off and persists to localStorage', async () => { + process.env['NEXT_PUBLIC_FEATURE_AVM_V2'] = '1'; + window.history.replaceState({}, '', '/?avm_v2=0'); + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toBe(false); + expect(storage.getItem(LOCAL_KEY)).toBe('0'); + }); + + it('respects localStorage override over env default', async () => { + storage.setItem(LOCAL_KEY, '1'); + const { useAvmV2Flag } = await import('../use-avm-v2-flag'); + const { result } = renderHook(() => useAvmV2Flag()); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/apps/web/lib/hooks/use-avm-v2-flag.ts b/apps/web/lib/hooks/use-avm-v2-flag.ts new file mode 100644 index 0000000..173e087 --- /dev/null +++ b/apps/web/lib/hooks/use-avm-v2-flag.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +const LOCAL_STORAGE_KEY = 'goodgo:avm_v2'; +const QUERY_PARAM = 'avm_v2'; + +function readEnvDefault(): boolean { + const raw = process.env['NEXT_PUBLIC_FEATURE_AVM_V2']; + if (!raw) return false; + return raw === '1' || raw.toLowerCase() === 'true'; +} + +function readOverride(): boolean | null { + if (typeof window === 'undefined') return null; + + const params = new URLSearchParams(window.location.search); + const qp = params.get(QUERY_PARAM); + if (qp === '1' || qp === 'true') { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, '1'); + } catch { + // localStorage may be blocked — ignore + } + return true; + } + if (qp === '0' || qp === 'false') { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, '0'); + } catch { + // localStorage may be blocked — ignore + } + return false; + } + + try { + const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored === '1') return true; + if (stored === '0') return false; + } catch { + // ignore + } + return null; +} + +export function useAvmV2Flag(): boolean { + const [enabled, setEnabled] = useState(readEnvDefault()); + + useEffect(() => { + const override = readOverride(); + setEnabled(override ?? readEnvDefault()); + }, []); + + return enabled; +}