feat(web): AVM v2 upgraded valuation dashboard (TEC-2763)
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 <noreply@paperclip.ing>
This commit is contained in:
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal file
99
apps/web/lib/hooks/__tests__/use-avm-v2-flag.spec.tsx
Normal file
@@ -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<string, string>();
|
||||
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<string, string | undefined>)[
|
||||
'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);
|
||||
});
|
||||
});
|
||||
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal file
55
apps/web/lib/hooks/use-avm-v2-flag.ts
Normal file
@@ -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<boolean>(readEnvDefault());
|
||||
|
||||
useEffect(() => {
|
||||
const override = readOverride();
|
||||
setEnabled(override ?? readEnvDefault());
|
||||
}, []);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
Reference in New Issue
Block a user