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

@@ -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');