diff --git a/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts
index 714a827..ef02ffa 100644
--- a/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts
+++ b/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts
@@ -6,7 +6,7 @@ import {
CacheTTL,
DomainException,
NotFoundException,
- type LoggerService,
+ LoggerService,
} from '@modules/shared';
import {
VALUATION_REPOSITORY,
diff --git a/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts b/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts
index a20ab99..ffc27d2 100644
--- a/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts
+++ b/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts
@@ -1,5 +1,5 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
-import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
+import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
diff --git a/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts b/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts
index 1017271..17ed115 100644
--- a/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts
+++ b/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts
@@ -1,5 +1,5 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
-import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
+import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx
index c2cb097..4712031 100644
--- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx
+++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx
@@ -12,6 +12,7 @@ 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 { ApiError } from '@/lib/api-client';
import { useAvmV2Flag } from '@/lib/hooks/use-avm-v2-flag';
import {
useValuationPredict,
@@ -20,6 +21,38 @@ import {
} from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
+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.',
+ };
+}
+
const ValuationHistoryChart = dynamic(
() =>
import('@/components/valuation/valuation-history-chart').then(
@@ -131,11 +164,19 @@ export default function ValuationPage() {
isLoading={predictMutation.isPending}
/>
- {predictMutation.isError && (
-
- Không thể định giá. Vui lòng thử lại sau.
-
- )}
+ {predictMutation.isError && (() => {
+ const { title, detail } = getValuationErrorMessage(predictMutation.error);
+ return (
+
+ );
+ })()}
{currentResult && (
<>
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();
+ });
+});