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

@@ -3,16 +3,11 @@
import dynamic from 'next/dynamic';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
analyticsApi,
type MarketReportDistrict,
type HeatmapDataPoint,
} from '@/lib/analytics-api';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
import { useListingsSearch } from '@/lib/hooks/use-listings';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
@@ -70,25 +65,13 @@ function StatCard({ title, value, description, trend }: StatCardProps) {
}
export default function DashboardPage() {
const [marketReport, setMarketReport] = useState<MarketReportDistrict[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapDataPoint[]>([]);
const [listings, setListings] = useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = useState(true);
const { data: reportData, isLoading: reportLoading } = useMarketReport(CITY, PERIOD);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(CITY, PERIOD);
const { data: listings, isLoading: listingsLoading } = useListingsSearch({ page: 1, limit: 6 });
useEffect(() => {
setLoading(true);
Promise.all([
analyticsApi.getMarketReport(CITY, PERIOD).catch(() => ({ districts: [] as MarketReportDistrict[] })),
analyticsApi.getHeatmap(CITY, PERIOD).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })),
listingsApi.search({ page: 1, limit: 6 }).catch(() => null),
])
.then(([report, heatmapData, listingsResult]) => {
setMarketReport(report.districts);
setHeatmap(heatmapData.dataPoints);
setListings(listingsResult);
})
.finally(() => setLoading(false));
}, []);
const loading = reportLoading || heatmapLoading || listingsLoading;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgPriceM2 =

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,7 +13,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { paymentApi, type TransactionListDto } from '@/lib/payment-api';
import { useTransactions } from '@/lib/hooks/use-payments';
function formatVND(amount: string | number): string {
const num = typeof amount === 'string' ? Number(amount) : amount;
@@ -45,24 +45,15 @@ const PROVIDER_LABELS: Record<string, string> = {
};
export default function PaymentsPage() {
const [transactions, setTransactions] = useState<TransactionListDto | null>(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
const limit = 20;
useEffect(() => {
setLoading(true);
paymentApi
.getTransactions({
status: statusFilter || undefined,
limit,
offset: page * limit,
})
.then((data) => setTransactions(data))
.catch(() => setTransactions(null))
.finally(() => setLoading(false));
}, [statusFilter, page]);
const { data: transactions, isLoading: loading } = useTransactions({
status: statusFilter || undefined,
limit,
offset: page * limit,
});
const totalPages = transactions ? Math.ceil(transactions.total / limit) : 0;

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -13,9 +14,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePlans, useBillingHistory, useQuota, subscriptionKeys } from '@/lib/hooks/use-subscription';
import {
subscriptionApi,
type BillingHistoryDto,
type PlanDto,
type QuotaCheckResult,
} from '@/lib/subscription-api';
@@ -43,33 +44,24 @@ const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondar
};
export default function SubscriptionPage() {
const [plans, setPlans] = useState<PlanDto[]>([]);
const [billing, setBilling] = useState<BillingHistoryDto | null>(null);
const [quotas, setQuotas] = useState<QuotaCheckResult[]>([]);
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const { data: plansData, isLoading: plansLoading } = usePlans();
const { data: billing, isLoading: billingLoading } = useBillingHistory();
const { data: listingsQuota } = useQuota('listings');
const { data: savedSearchesQuota } = useQuota('saved_searches');
const [upgradeTarget, setUpgradeTarget] = useState<PlanDto | null>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('plan');
useEffect(() => {
setLoading(true);
Promise.all([
subscriptionApi.getPlans().catch(() => []),
subscriptionApi.getBillingHistory().catch(() => null),
Promise.all([
subscriptionApi.checkQuota('listings').catch(() => null),
subscriptionApi.checkQuota('saved_searches').catch(() => null),
]),
])
.then(([plansData, billingData, quotaResults]) => {
setPlans(plansData.sort((a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier)));
setBilling(billingData);
setQuotas(quotaResults.filter((q): q is QuotaCheckResult => q !== null));
})
.finally(() => setLoading(false));
}, []);
const loading = plansLoading || billingLoading;
const plans = (plansData ?? []).slice().sort(
(a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
);
const quotas = [listingsQuota, savedSearchesQuota].filter(
(q): q is QuotaCheckResult => q != null,
);
const currentTier = billing?.subscription?.planTier ?? 'FREE';
const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier);
@@ -87,9 +79,7 @@ export default function SubscriptionPage() {
} else {
await subscriptionApi.createSubscription(upgradeTarget.tier, billingCycle);
}
// Reload billing data
const newBilling = await subscriptionApi.getBillingHistory().catch(() => null);
setBilling(newBilling);
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.billing() });
setUpgradeTarget(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Nâng cấp thất bại');