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