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 =
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
@@ -9,10 +9,30 @@ export default function DashboardError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [autoRetrying, setAutoRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Dashboard error:', error);
|
||||
}, [error]);
|
||||
|
||||
// Auto-retry once after 3 seconds
|
||||
useEffect(() => {
|
||||
if (retryCount > 0) return;
|
||||
setAutoRetrying(true);
|
||||
const timer = setTimeout(() => {
|
||||
setAutoRetrying(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, reset, retryCount]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center px-4">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
@@ -33,17 +53,35 @@ export default function DashboardError({
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Không thể tải bảng điều khiển</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Đã xảy ra lỗi khi tải dữ liệu. Vui lòng thử lại sau.
|
||||
{autoRetrying
|
||||
? 'Đang tự động thử lại...'
|
||||
: 'Đã xảy ra lỗi khi tải dữ liệu. Vui lòng thử lại sau.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Đã thử lại {retryCount} lần
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||
onClick={handleRetry}
|
||||
disabled={autoRetrying}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Thử lại
|
||||
{autoRetrying ? (
|
||||
<>
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Đang thử lại...
|
||||
</>
|
||||
) : (
|
||||
'Thử lại'
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -19,6 +20,7 @@ const navItems = [
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -53,6 +55,23 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
{user.fullName}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'light' ? 'Chuyển sang chế độ tối' : 'Chuyển sang chế độ sáng'}
|
||||
className="h-9 w-9 p-0"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => logout()}>
|
||||
Đăng xuất
|
||||
</Button>
|
||||
|
||||
@@ -8,11 +8,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import {
|
||||
listingsApi,
|
||||
type ListingDetail,
|
||||
type PaginatedResult,
|
||||
} from '@/lib/listings-api';
|
||||
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||
import { type ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
function formatPrice(priceVND: string): string {
|
||||
const num = Number(priceVND);
|
||||
@@ -33,8 +30,6 @@ function formatDate(dateStr: string | null): string {
|
||||
type ViewMode = 'grid' | 'table';
|
||||
|
||||
export default function ListingsPage() {
|
||||
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
|
||||
const [filters, setFilters] = React.useState({
|
||||
transactionType: '',
|
||||
@@ -43,23 +38,15 @@ export default function ListingsPage() {
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const fetchListings = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
const searchParams = React.useMemo(() => {
|
||||
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.status) params['status'] = filters.status;
|
||||
|
||||
listingsApi
|
||||
.search(params)
|
||||
.then(setResult)
|
||||
.catch(() => setResult(null))
|
||||
.finally(() => setLoading(false));
|
||||
return params;
|
||||
}, [filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
const { data: result, isLoading: loading } = useListingsSearch(searchParams);
|
||||
|
||||
// Stats from current page data
|
||||
const stats = React.useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function SearchError({
|
||||
error,
|
||||
@@ -9,10 +9,29 @@ export default function SearchError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [autoRetrying, setAutoRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Search error:', error);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (retryCount > 0) return;
|
||||
setAutoRetrying(true);
|
||||
const timer = setTimeout(() => {
|
||||
setAutoRetrying(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, reset, retryCount]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||
@@ -34,17 +53,35 @@ export default function SearchError({
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">Lỗi tìm kiếm</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Không thể thực hiện tìm kiếm. Vui lòng thử lại hoặc thay đổi bộ lọc.
|
||||
{autoRetrying
|
||||
? 'Đang tự động thử lại...'
|
||||
: 'Không thể thực hiện tìm kiếm. Vui lòng thử lại hoặc thay đổi bộ lọc.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Đã thử lại {retryCount} lần
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||
onClick={handleRetry}
|
||||
disabled={autoRetrying}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Thử lại
|
||||
{autoRetrying ? (
|
||||
<>
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Đang thử lại...
|
||||
</>
|
||||
) : (
|
||||
'Thử lại'
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
@@ -10,6 +10,9 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [autoRetrying, setAutoRetrying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
@@ -17,6 +20,23 @@ export default function GlobalError({
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Auto-retry once after 3 seconds
|
||||
useEffect(() => {
|
||||
if (retryCount > 0) return;
|
||||
setAutoRetrying(true);
|
||||
const timer = setTimeout(() => {
|
||||
setAutoRetrying(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, reset, retryCount]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((c) => c + 1);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
@@ -39,19 +59,37 @@ export default function GlobalError({
|
||||
Đã xảy ra lỗi
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Rất tiếc, đã có lỗi xảy ra. Vui lòng thử lại.
|
||||
{autoRetrying
|
||||
? 'Đang tự động thử lại...'
|
||||
: 'Rất tiếc, đã có lỗi xảy ra. Vui lòng thử lại.'}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Mã lỗi: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Đã thử lại {retryCount} lần
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||
onClick={handleRetry}
|
||||
disabled={autoRetrying}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Thử lại
|
||||
{autoRetrying ? (
|
||||
<>
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Đang thử lại...
|
||||
</>
|
||||
) : (
|
||||
'Thử lại'
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
|
||||
@@ -24,6 +24,26 @@
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 142.1 70.6% 45.3%;
|
||||
--primary-foreground: 144.9 80.4% 10%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 142.1 76.2% 36.3%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
|
||||
@@ -70,7 +72,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="vi">
|
||||
<html lang="vi" suppressHydrationWarning>
|
||||
<body>
|
||||
<a
|
||||
href="#main-content"
|
||||
@@ -78,7 +80,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
>
|
||||
Chuyển đến nội dung chính
|
||||
</a>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user