feat(web): add React Query, dark mode toggle, and error retry UX

- Install @tanstack/react-query with exponential backoff retry config
- Create QueryClientProvider and custom hooks for listings, analytics,
  payments, and subscription API calls
- Migrate 5 dashboard pages from useState/useEffect to React Query hooks
- Add dark mode CSS variables and ThemeProvider with localStorage persistence
- Add theme toggle button in dashboard header (sun/moon icon)
- Enhance error boundaries with auto-retry, retry count, and loading state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 23:02:44 +07:00
parent ccb82fddf8
commit 9d120dd21f
20 changed files with 481 additions and 155 deletions

View File

@@ -6,12 +6,11 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
type DistrictStats,
type PriceTrendPoint,
} from '@/lib/analytics-api';
useMarketReport,
useHeatmap,
useDistrictStats,
usePriceTrend,
} from '@/lib/hooks/use-analytics';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
@@ -54,57 +53,34 @@ function YoYBadge({ value }: { value: number | null }) {
export default function AnalyticsPage() {
const [city, setCity] = useState<string>(CITIES[0] ?? 'Ho Chi Minh');
const [period] = useState(CURRENT_PERIOD);
const period = CURRENT_PERIOD;
const [tab, setTab] = useState('overview');
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
const [priceTrend, setPriceTrend] = useState<PriceTrendPoint[]>([]);
const [trendDistrict, setTrendDistrict] = useState<string>('');
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: reportData, isLoading: reportLoading, error: reportError } = useMarketReport(city, period);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
const { data: statsData, isLoading: statsLoading } = useDistrictStats(city, period);
const { data: trendData, isLoading: trendLoading } = usePriceTrend(
trendDistrict,
city,
'APARTMENT',
TREND_PERIODS,
);
const loading = reportLoading || heatmapLoading || statsLoading;
const error = reportError ? 'Không thể tải dữ liệu phân tích' : null;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const districtStats = statsData?.districts ?? [];
const priceTrend = trendData?.trend ?? [];
// Auto-select first district for trend
const firstDistrict = marketReport[0]?.district ?? '';
useEffect(() => {
setLoading(true);
setError(null);
Promise.all([
analyticsApi
.getMarketReport(city, period)
.catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi
.getHeatmap(city, period)
.catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
analyticsApi
.getDistrictStats(city, period)
.catch(() => ({ districts: [] as DistrictStats[] })),
])
.then(([report, heatmapData, stats]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setDistrictStats(stats.districts);
// Auto-select first district for trend
const firstDistrict = report.districts[0]?.district ?? '';
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
})
.catch(() => setError('Không thể tải dữ liệu phân tích'))
.finally(() => setLoading(false));
}, [city, period]);
// Load price trend when district changes
useEffect(() => {
if (!trendDistrict || !city) return;
setTrendLoading(true);
analyticsApi
.getPriceTrend(trendDistrict, city, 'APARTMENT', TREND_PERIODS)
.then((res) => setPriceTrend(res.trend))
.catch(() => setPriceTrend([]))
.finally(() => setTrendLoading(false));
}, [trendDistrict, city]);
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
}, [firstDistrict, trendDistrict]);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgDaysOnMarket =