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

@@ -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 =

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

View File

@@ -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"> 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"

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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"> 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="/"

View File

@@ -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, đã 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">
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="/"

View File

@@ -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;
}

View File

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