From b6a5a2c1f5f47a318b4f922d9e2880f9eeb29ebb Mon Sep 17 00:00:00 2001
From: Ho Ngoc Hai
Date: Sat, 18 Apr 2026 00:32:37 +0700
Subject: [PATCH] feat(web): typed error states + Playwright e2e for AVM v2
valuation
- Map 429/402/503 API errors to Vietnamese rate-limit, quota-exhausted, and
model-unavailable banners on the /dashboard/valuation page.
- Mark the error banner with role=alert and data-testid for a11y + testing.
- Add e2e/web/valuation.spec.ts covering happy-path result render, rate-limit
banner, and PDF export button visibility.
Refs: TEC-2736
Co-Authored-By: Paperclip
---
.../(dashboard)/dashboard/valuation/page.tsx | 206 +++++++++++++-----
e2e/web/valuation.spec.ts | 129 +++++++++++
2 files changed, 276 insertions(+), 59 deletions(-)
create mode 100644 e2e/web/valuation.spec.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..6b7dd78 100644
--- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx
+++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx
@@ -5,9 +5,11 @@ import { useState } from 'react';
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 { ApiError } from '@/lib/api-client';
import {
useValuationPredict,
useValuationHistory,
@@ -15,23 +17,65 @@ import {
} from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
-// Lazy-load chart component (uses Recharts, no SSR)
+function getValuationErrorMessage(error: unknown): { title: string; detail: string } {
+ if (error instanceof ApiError) {
+ if (error.status === 429) {
+ return {
+ title: 'Quá nhiều yêu cầu',
+ detail: 'Bạn đã gửi quá nhiều yêu cầu định giá. Vui lòng đợi một lát rồi thử lại.',
+ };
+ }
+ if (error.status === 402 || /quota|subscription/i.test(error.message)) {
+ return {
+ title: 'Đã hết hạn mức',
+ detail:
+ 'Gói đăng ký hiện tại đã hết lượt định giá AI. Hãy nâng cấp hoặc thử lại vào chu kỳ sau.',
+ };
+ }
+ if (error.status === 503 || /model|unavailable/i.test(error.message)) {
+ return {
+ title: 'Dịch vụ AI tạm thời không khả dụng',
+ detail: 'Mô hình định giá đang bận hoặc bảo trì. Vui lòng thử lại sau vài phút.',
+ };
+ }
+ return {
+ title: 'Không thể định giá',
+ detail: error.message || 'Đã xảy ra lỗi không xác định. Vui lòng thử lại sau.',
+ };
+ }
+ return {
+ title: 'Không thể định giá',
+ detail: 'Vui lòng kiểm tra kết nối mạng và thử lại.',
+ };
+}
+
+// Lazy-load chart components (uses Recharts, no SSR)
+const chartLoading = () => (
+
+ Đang tải...
+
+);
+
const ValuationHistoryChart = dynamic(
() =>
import('@/components/valuation/valuation-history-chart').then(
(m) => m.ValuationHistoryChart,
),
- {
- ssr: false,
- loading: () => (
-
- Đang tải...
-
- ),
- },
+ { ssr: false, loading: chartLoading },
);
+const ValueDriversChart = dynamic(
+ () =>
+ import('@/components/valuation/value-drivers-chart').then(
+ (m) => m.ValueDriversChart,
+ ),
+ { ssr: false, loading: chartLoading },
+);
+
+type TabKey = 'single' | 'compare';
+
export default function ValuationPage() {
+ const [activeTab, setActiveTab] = useState('single');
const [historyPage, setHistoryPage] = useState(1);
const [selectedId, setSelectedId] = useState(null);
@@ -49,6 +93,7 @@ export default function ValuationPage() {
};
const handleSelectHistory = (id: string) => {
+ setActiveTab('single');
setSelectedId(id);
};
@@ -62,7 +107,7 @@ export default function ValuationPage() {
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 && (
+ {activeTab === 'single' && currentResult && (
-
- {/* Form + Results (left 2 cols) */}
-
-
-
- {predictMutation.isError && (
-
- Không thể định giá. Vui lòng thử lại sau.
-
- )}
-
- {currentResult && (
- <>
- {/* Main results with confidence badge + driver charts */}
-
-
- {/* Comparables table (TanStack Table) */}
- {currentResult.comparables.length > 0 && (
-
- )}
-
- {/* Market context card */}
- {currentResult.marketContext && (
-
- )}
-
- {/* Valuation history chart */}
- {currentResult.valuationHistory &&
- currentResult.valuationHistory.length >= 2 && (
-
- )}
- >
- )}
-
-
- {/* History sidebar (right col) */}
-
-
-
+ {/* Tab switcher */}
+
+
+
+
+ {activeTab === 'single' ? (
+
+ {/* Form + Results (left 2 cols) */}
+
+
+
+ {predictMutation.isError && (() => {
+ const { title, detail } = getValuationErrorMessage(predictMutation.error);
+ return (
+
+ );
+ })()}
+
+ {currentResult && (
+ <>
+ {/* Main results with confidence badge */}
+
+
+ {/* Value drivers waterfall chart */}
+ {currentResult.priceDrivers.length > 0 && (
+
+ )}
+
+ {/* Comparables table (TanStack Table) */}
+ {currentResult.comparables.length > 0 && (
+
+ )}
+
+ {/* Market context card */}
+ {currentResult.marketContext && (
+
+ )}
+
+ {/* Valuation history chart */}
+ {currentResult.valuationHistory &&
+ currentResult.valuationHistory.length >= 2 && (
+
+ )}
+ >
+ )}
+
+
+ {/* History sidebar (right col) */}
+
+
+
+
+ ) : (
+
+ )}
);
}
diff --git a/e2e/web/valuation.spec.ts b/e2e/web/valuation.spec.ts
new file mode 100644
index 0000000..0dfb4c1
--- /dev/null
+++ b/e2e/web/valuation.spec.ts
@@ -0,0 +1,129 @@
+import { test, expect } from '@playwright/test';
+
+const mockValuationResult = {
+ id: 'val-e2e-1',
+ propertyType: 'APARTMENT',
+ area: 75,
+ district: 'Quận 1',
+ city: 'Ho Chi Minh',
+ estimatedPriceVND: 5_500_000_000,
+ priceRangeLow: 5_100_000_000,
+ priceRangeHigh: 5_900_000_000,
+ pricePerM2: 73_333_333,
+ confidence: 0.82,
+ modelVersion: 'avm-v2.0',
+ priceDrivers: [
+ { feature: 'area_m2', impact: 24.5, direction: 'positive' as const },
+ { feature: 'distance_to_cbd_km', impact: 12.3, direction: 'negative' as const },
+ ],
+ comparables: [
+ {
+ id: 'c1',
+ listingId: 'l1',
+ propertyType: 'APARTMENT',
+ area: 72,
+ district: 'Quận 1',
+ pricePerM2: 74_500_000,
+ priceVnd: 5_364_000_000,
+ distanceKm: 0.8,
+ publishedAt: '2026-03-12T00:00:00Z',
+ },
+ ],
+ modelPredictions: [
+ { modelName: 'xgboost', weight: 0.6, predictedPriceVnd: 5_500_000_000, predictedPricePerM2Vnd: 73_333_333 },
+ { modelName: 'lightgbm', weight: 0.4, predictedPriceVnd: 5_480_000_000, predictedPricePerM2Vnd: 73_066_666 },
+ ],
+ ensembleMethod: 'weighted_average',
+ createdAt: '2026-04-18T00:00:00Z',
+};
+
+const mockHistory = { data: [], total: 0, page: 1, totalPages: 1, limit: 10 };
+
+async function setupMocks(page: import('@playwright/test').Page) {
+ await page.route('**/auth/me', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
+ }),
+ );
+ await page.route('**/analytics/valuation/history**', (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
+ );
+ await page.route('**/analytics/valuation', (route) => {
+ if (route.request().method() === 'POST') {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(mockValuationResult),
+ });
+ }
+ return route.continue();
+ });
+}
+
+test.describe('AVM v2 Valuation Page', () => {
+ test('submit form -> render result card with confidence + price range', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto('/vi/dashboard/valuation');
+
+ await page.locator('#propertyType').selectOption('APARTMENT');
+ await page.locator('#district').fill('Quận 1');
+ await page.locator('#area').fill('75');
+
+ await page.getByRole('button', { name: /Định giá ngay/i }).click();
+
+ const results = page.locator('#valuation-results');
+ await expect(results).toBeVisible();
+ await expect(results).toContainText('5.500.000.000');
+ await expect(results).toContainText('Độ tin cậy cao');
+ await expect(results).toContainText('avm-v2.0');
+ });
+
+ test('renders rate-limit error state on HTTP 429', async ({ page }) => {
+ await page.route('**/auth/me', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
+ }),
+ );
+ await page.route('**/analytics/valuation/history**', (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
+ );
+ await page.route('**/analytics/valuation', (route) => {
+ if (route.request().method() === 'POST') {
+ return route.fulfill({
+ status: 429,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Too many requests' }),
+ });
+ }
+ return route.continue();
+ });
+
+ await page.goto('/vi/dashboard/valuation');
+ await page.locator('#propertyType').selectOption('APARTMENT');
+ await page.locator('#district').fill('Quận 1');
+ await page.locator('#area').fill('75');
+ await page.getByRole('button', { name: /Định giá ngay/i }).click();
+
+ const alert = page.getByTestId('valuation-error');
+ await expect(alert).toBeVisible();
+ await expect(alert).toContainText('Quá nhiều yêu cầu');
+ });
+
+ test('export PDF button is visible after a successful valuation', async ({ page }) => {
+ await setupMocks(page);
+ await page.goto('/vi/dashboard/valuation');
+
+ await page.locator('#propertyType').selectOption('APARTMENT');
+ await page.locator('#district').fill('Quận 1');
+ await page.locator('#area').fill('75');
+ await page.getByRole('button', { name: /Định giá ngay/i }).click();
+
+ await expect(page.locator('#valuation-results')).toBeVisible();
+ // Export PDF button (uses Download icon + label)
+ await expect(page.getByRole('button', { name: /Xuất PDF|Export PDF|Tải PDF/i })).toBeVisible();
+ });
+});