diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx index 9012094..5ebbe9a 100644 --- a/apps/web/app/(dashboard)/analytics/page.tsx +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -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(CITIES[0] ?? 'Ho Chi Minh'); - const [period] = useState(CURRENT_PERIOD); + const period = CURRENT_PERIOD; const [tab, setTab] = useState('overview'); - const [marketReport, setMarketReport] = useState([]); - const [heatmap, setHeatmap] = useState([]); - const [districtStats, setDistrictStats] = useState([]); - const [priceTrend, setPriceTrend] = useState([]); const [trendDistrict, setTrendDistrict] = useState(''); - const [loading, setLoading] = useState(true); - const [trendLoading, setTrendLoading] = useState(false); - const [error, setError] = useState(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 = diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx index 07e40f8..53b6bb0 100644 --- a/apps/web/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/page.tsx @@ -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([]); - const [heatmap, setHeatmap] = useState([]); - const [listings, setListings] = useState | 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 = diff --git a/apps/web/app/(dashboard)/dashboard/payments/page.tsx b/apps/web/app/(dashboard)/dashboard/payments/page.tsx index b104f4e..e15e938 100644 --- a/apps/web/app/(dashboard)/dashboard/payments/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/payments/page.tsx @@ -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 = { }; export default function PaymentsPage() { - const [transactions, setTransactions] = useState(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; diff --git a/apps/web/app/(dashboard)/dashboard/subscription/page.tsx b/apps/web/app/(dashboard)/dashboard/subscription/page.tsx index 2b9ebb1..7e29a82 100644 --- a/apps/web/app/(dashboard)/dashboard/subscription/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/subscription/page.tsx @@ -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([]); - const [billing, setBilling] = useState(null); - const [quotas, setQuotas] = useState([]); - 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(null); const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); const [processing, setProcessing] = useState(false); const [error, setError] = useState(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'); diff --git a/apps/web/app/(dashboard)/error.tsx b/apps/web/app/(dashboard)/error.tsx index cbdada6..07235c4 100644 --- a/apps/web/app/(dashboard)/error.tsx +++ b/apps/web/app/(dashboard)/error.tsx @@ -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 (
@@ -33,17 +53,35 @@ export default function DashboardError({

Không thể tải bảng điều khiển

- Đã 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.'}

{error.digest && (

Mã lỗi: {error.digest}

)} + {retryCount > 0 && ( +

+ Đã thử lại {retryCount} lần +

+ )}
@@ -53,6 +55,23 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {user.fullName} )} + diff --git a/apps/web/app/(dashboard)/listings/page.tsx b/apps/web/app/(dashboard)/listings/page.tsx index 9f66c1b..c127f78 100644 --- a/apps/web/app/(dashboard)/listings/page.tsx +++ b/apps/web/app/(dashboard)/listings/page.tsx @@ -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 | null>(null); - const [loading, setLoading] = React.useState(true); const [viewMode, setViewMode] = React.useState('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 = { 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(() => { diff --git a/apps/web/app/(public)/search/error.tsx b/apps/web/app/(public)/search/error.tsx index 61d3054..5debd84 100644 --- a/apps/web/app/(public)/search/error.tsx +++ b/apps/web/app/(public)/search/error.tsx @@ -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 (
@@ -34,17 +53,35 @@ export default function SearchError({

Lỗi tìm kiếm

- 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.'}

{error.digest && (

Mã lỗi: {error.digest}

)} + {retryCount > 0 && ( +

+ Đã thử lại {retryCount} lần +

+ )}
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 (
@@ -39,19 +59,37 @@ export default function GlobalError({ Đã xảy ra lỗi

- 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.'}

{error.digest && (

Mã lỗi: {error.digest}

)} + {retryCount > 0 && ( +

+ Đã thử lại {retryCount} lần +

+ )}
+ Chuyển đến nội dung chính - {children} + + + {children} + + ); diff --git a/apps/web/components/providers/query-provider.tsx b/apps/web/components/providers/query-provider.tsx new file mode 100644 index 0000000..0eec0dc --- /dev/null +++ b/apps/web/components/providers/query-provider.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { QueryClientProvider } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; + +export function QueryProvider({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + return {children}; +} diff --git a/apps/web/components/providers/theme-provider.tsx b/apps/web/components/providers/theme-provider.tsx new file mode 100644 index 0000000..2a80bde --- /dev/null +++ b/apps/web/components/providers/theme-provider.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextValue { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext({ + theme: 'light', + toggleTheme: () => {}, +}); + +export function useTheme() { + return useContext(ThemeContext); +} + +const STORAGE_KEY = 'goodgo-theme'; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState('light'); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === 'dark' || stored === 'light') { + setTheme(stored); + document.documentElement.classList.toggle('dark', stored === 'dark'); + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + setTheme('dark'); + document.documentElement.classList.add('dark'); + } + }, []); + + const toggleTheme = useCallback(() => { + setTheme((prev) => { + const next = prev === 'light' ? 'dark' : 'light'; + localStorage.setItem(STORAGE_KEY, next); + document.documentElement.classList.toggle('dark', next === 'dark'); + return next; + }); + }, []); + + return ( + + {children} + + ); +} diff --git a/apps/web/lib/hooks/use-analytics.ts b/apps/web/lib/hooks/use-analytics.ts new file mode 100644 index 0000000..706241c --- /dev/null +++ b/apps/web/lib/hooks/use-analytics.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import { analyticsApi } from '@/lib/analytics-api'; + +export const analyticsKeys = { + all: ['analytics'] as const, + marketReport: (city: string, period: string) => + ['analytics', 'market-report', city, period] as const, + heatmap: (city: string, period: string) => + ['analytics', 'heatmap', city, period] as const, + districtStats: (city: string, period: string) => + ['analytics', 'district-stats', city, period] as const, + priceTrend: (district: string, city: string, propertyType: string, periods: string[]) => + ['analytics', 'price-trend', district, city, propertyType, periods] as const, +}; + +export function useMarketReport(city: string, period: string) { + return useQuery({ + queryKey: analyticsKeys.marketReport(city, period), + queryFn: () => analyticsApi.getMarketReport(city, period), + }); +} + +export function useHeatmap(city: string, period: string) { + return useQuery({ + queryKey: analyticsKeys.heatmap(city, period), + queryFn: () => analyticsApi.getHeatmap(city, period), + }); +} + +export function useDistrictStats(city: string, period: string) { + return useQuery({ + queryKey: analyticsKeys.districtStats(city, period), + queryFn: () => analyticsApi.getDistrictStats(city, period), + }); +} + +export function usePriceTrend( + district: string, + city: string, + propertyType: string, + periods: string[], +) { + return useQuery({ + queryKey: analyticsKeys.priceTrend(district, city, propertyType, periods), + queryFn: () => analyticsApi.getPriceTrend(district, city, propertyType, periods), + enabled: !!district && !!city, + }); +} diff --git a/apps/web/lib/hooks/use-listings.ts b/apps/web/lib/hooks/use-listings.ts new file mode 100644 index 0000000..5a9e325 --- /dev/null +++ b/apps/web/lib/hooks/use-listings.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { listingsApi, type SearchListingsParams } from '@/lib/listings-api'; + +export const listingsKeys = { + all: ['listings'] as const, + search: (params: SearchListingsParams) => ['listings', 'search', params] as const, + detail: (id: string) => ['listings', 'detail', id] as const, +}; + +export function useListingsSearch(params: SearchListingsParams = {}) { + return useQuery({ + queryKey: listingsKeys.search(params), + queryFn: () => listingsApi.search(params), + }); +} + +export function useListingDetail(id: string) { + return useQuery({ + queryKey: listingsKeys.detail(id), + queryFn: () => listingsApi.getById(id), + enabled: !!id, + }); +} diff --git a/apps/web/lib/hooks/use-payments.ts b/apps/web/lib/hooks/use-payments.ts new file mode 100644 index 0000000..6d77660 --- /dev/null +++ b/apps/web/lib/hooks/use-payments.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { paymentApi } from '@/lib/payment-api'; + +export const paymentKeys = { + all: ['payments'] as const, + transactions: (params: { status?: string; limit?: number; offset?: number }) => + ['payments', 'transactions', params] as const, + status: (id: string) => ['payments', 'status', id] as const, +}; + +export function useTransactions(params: { + status?: string; + limit?: number; + offset?: number; +} = {}) { + return useQuery({ + queryKey: paymentKeys.transactions(params), + queryFn: () => paymentApi.getTransactions(params), + }); +} + +export function usePaymentStatus(id: string) { + return useQuery({ + queryKey: paymentKeys.status(id), + queryFn: () => paymentApi.getPaymentStatus(id), + enabled: !!id, + }); +} diff --git a/apps/web/lib/hooks/use-subscription.ts b/apps/web/lib/hooks/use-subscription.ts new file mode 100644 index 0000000..23f26c1 --- /dev/null +++ b/apps/web/lib/hooks/use-subscription.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { subscriptionApi } from '@/lib/subscription-api'; + +export const subscriptionKeys = { + all: ['subscription'] as const, + plans: () => ['subscription', 'plans'] as const, + billing: () => ['subscription', 'billing'] as const, + quota: (metric: string) => ['subscription', 'quota', metric] as const, +}; + +export function usePlans() { + return useQuery({ + queryKey: subscriptionKeys.plans(), + queryFn: () => subscriptionApi.getPlans(), + }); +} + +export function useBillingHistory() { + return useQuery({ + queryKey: subscriptionKeys.billing(), + queryFn: () => subscriptionApi.getBillingHistory(), + }); +} + +export function useQuota(metric: string) { + return useQuery({ + queryKey: subscriptionKeys.quota(metric), + queryFn: () => subscriptionApi.checkQuota(metric), + enabled: !!metric, + }); +} diff --git a/apps/web/lib/query-client.ts b/apps/web/lib/query-client.ts new file mode 100644 index 0000000..ec702dd --- /dev/null +++ b/apps/web/lib/query-client.ts @@ -0,0 +1,32 @@ +'use client'; + +import { QueryClient } from '@tanstack/react-query'; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + refetchOnWindowFocus: false, + }, + mutations: { + retry: 1, + }, + }, + }); +} + +let browserQueryClient: QueryClient | undefined; + +export function getQueryClient() { + if (typeof window === 'undefined') { + return makeQueryClient(); + } + if (!browserQueryClient) { + browserQueryClient = makeQueryClient(); + } + return browserQueryClient; +} diff --git a/apps/web/package.json b/apps/web/package.json index 621b146..561e963 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@sentry/nextjs": "^10.47.0", + "@tanstack/react-query": "^5.96.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46e6dcd..56df104 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: '@sentry/nextjs': specifier: ^10.47.0 version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) + '@tanstack/react-query': + specifier: ^5.96.2 + version: 5.96.2(react@18.3.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2424,6 +2427,14 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@tanstack/query-core@5.96.2': + resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} + + '@tanstack/react-query@5.96.2': + resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8794,6 +8805,13 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.1 + '@tanstack/query-core@5.96.2': {} + + '@tanstack/react-query@5.96.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.96.2 + react: 18.3.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0