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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user