refactor: add retry functionality for payment and credits data fetching with improved user experience
This commit is contained in:
parent
2d2a85cd26
commit
b27d8cc505
@ -594,6 +594,7 @@
|
||||
"creditsExpired": "Credits expired",
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"viewTransactions": "View Credit Transactions",
|
||||
"retry": "Retry",
|
||||
"subscriptionCredits": "{credits} credits from subscription this month",
|
||||
"lifetimeCredits": "{credits} credits from lifetime plan this month",
|
||||
"expiringCredits": "{credits} credits expiring on {date}"
|
||||
|
@ -594,6 +594,7 @@
|
||||
"creditsExpired": "积分已过期",
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
"subscriptionCredits": "本月订阅获得 {credits} 积分",
|
||||
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
|
||||
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
||||
|
@ -16,14 +16,13 @@ import { getPricePlans } from '@/config/price-config';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatDate, formatPrice } from '@/lib/formatter';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PlanIntervals } from '@/payment/types';
|
||||
import { Routes } from '@/routes';
|
||||
import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function BillingCard() {
|
||||
@ -37,7 +36,7 @@ export default function BillingCard() {
|
||||
error: loadPaymentError,
|
||||
subscription,
|
||||
currentPlan: currentPlanFromStore,
|
||||
refetch,
|
||||
fetchPayment,
|
||||
} = usePayment();
|
||||
|
||||
// Get user session for customer ID
|
||||
@ -77,6 +76,12 @@ export default function BillingCard() {
|
||||
const isPageLoading = isLoadingPayment || isLoadingSession;
|
||||
// console.log('billing card, isLoadingPayment', isLoadingPayment, 'isLoadingSession', isLoadingSession);
|
||||
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
fetchPayment(true);
|
||||
}, [fetchPayment]);
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
const sessionId = searchParams.get('session_id');
|
||||
@ -133,7 +138,7 @@ export default function BillingCard() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => refetch()}
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<RefreshCwIcon className="size-4 mr-1" />
|
||||
{t('retry')}
|
||||
|
@ -18,10 +18,10 @@ import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Routes } from '@/routes';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function CreditsBalanceCard() {
|
||||
@ -35,7 +35,7 @@ export default function CreditsBalanceCard() {
|
||||
balance,
|
||||
isLoading: isLoadingBalance,
|
||||
error,
|
||||
refresh: refreshBalance,
|
||||
fetchCredits,
|
||||
} = useCredits();
|
||||
|
||||
// Get payment info to check plan type
|
||||
@ -57,22 +57,23 @@ export default function CreditsBalanceCard() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to fetch credit statistics
|
||||
const fetchCreditStats = async () => {
|
||||
// Fetch credit statistics
|
||||
const fetchCreditStats = useCallback(async () => {
|
||||
console.log('fetchCreditStats, fetch start');
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const result = await getCreditStatsAction();
|
||||
if (result?.data?.success && result.data.data) {
|
||||
setCreditStats(result.data.data);
|
||||
} else {
|
||||
console.error('Failed to fetch credit stats:', result?.data?.error);
|
||||
console.error('fetchCreditStats, failed to fetch credit stats', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credit stats:', error);
|
||||
console.error('fetchCreditStats, error:', error);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch stats on component mount
|
||||
useEffect(() => {
|
||||
@ -84,22 +85,35 @@ export default function CreditsBalanceCard() {
|
||||
const sessionId = searchParams.get('session_id');
|
||||
if (sessionId && !hasHandledSession.current) {
|
||||
hasHandledSession.current = true;
|
||||
|
||||
// Show success toast (delayed to avoid React lifecycle conflicts)
|
||||
setTimeout(() => {
|
||||
toast.success(t('creditsAdded'));
|
||||
}, 0);
|
||||
|
||||
// Refresh credits data to show updated balance
|
||||
refreshBalance();
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
// Use setTimeout to ensure async operations complete properly
|
||||
setTimeout(() => {
|
||||
// Force refresh credits data to show updated balance
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, 100);
|
||||
|
||||
// Clean up URL parameters
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('session_id');
|
||||
localeRouter.replace(Routes.SettingsBilling + url.search);
|
||||
}
|
||||
}, [searchParams, localeRouter, refreshBalance, fetchCreditStats]);
|
||||
}, [searchParams, localeRouter, fetchCredits, fetchCreditStats, t]);
|
||||
|
||||
// Retry all data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch credits data');
|
||||
// Force refresh credits balance (ignore cache)
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, [fetchCredits, fetchCreditStats]);
|
||||
|
||||
// Render loading skeleton
|
||||
const isPageLoading = isLoadingBalance || isLoadingStats;
|
||||
@ -138,10 +152,13 @@ export default function CreditsBalanceCard() {
|
||||
<div className="text-destructive text-sm">{error}</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<Button variant="outline" className="cursor-pointer" asChild>
|
||||
<LocaleLink href={Routes.SettingsCredits}>
|
||||
{t('viewTransactions')}
|
||||
</LocaleLink>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<RefreshCwIcon className="size-4 mr-1" />
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
@ -23,10 +23,6 @@ export function useCredits() {
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
console.log(
|
||||
`${force ? 'force fetch' : 'fetch'} credits for user`,
|
||||
currentUser.id
|
||||
);
|
||||
fetchCreditsFromStore(currentUser, force);
|
||||
}
|
||||
},
|
||||
@ -36,7 +32,6 @@ export function useCredits() {
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
console.log('fetch credits info for user', currentUser.id);
|
||||
fetchCreditsFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchCreditsFromStore]);
|
||||
|
@ -23,8 +23,7 @@ export function usePayment() {
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
console.log('fetch payment info for user', currentUser.id);
|
||||
fetchPaymentFromStore(currentUser);
|
||||
fetchPaymentFromStore(currentUser, force);
|
||||
}
|
||||
},
|
||||
[session?.user, fetchPaymentFromStore]
|
||||
@ -33,7 +32,6 @@ export function usePayment() {
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
console.log('fetch payment info for user', currentUser.id);
|
||||
fetchPaymentFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchPaymentFromStore]);
|
||||
|
@ -3,6 +3,9 @@ import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import type { Session } from '@/lib/auth-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Cache duration: 30 seconds
|
||||
const CACHE_DURATION = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Credits state interface
|
||||
*/
|
||||
@ -17,15 +20,13 @@ export interface CreditsState {
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
fetchCredits: (user: Session['user'] | null | undefined) => Promise<void>;
|
||||
fetchCredits: (
|
||||
user: Session['user'] | null | undefined,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
consumeCredits: (amount: number, description: string) => Promise<boolean>;
|
||||
refreshCredits: (user: Session['user'] | null | undefined) => Promise<void>;
|
||||
resetCreditsState: () => void;
|
||||
}
|
||||
|
||||
// Cache duration: 30 seconds
|
||||
const CACHE_DURATION = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Credits store using Zustand
|
||||
* Manages the user's credit balance globally with caching and optimistic updates
|
||||
@ -38,10 +39,11 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
lastFetchTime: null,
|
||||
|
||||
/**
|
||||
* Fetch credit balance for the current user with caching
|
||||
* Fetch credit balance for the current user with optional cache bypass
|
||||
* @param user Current user from auth session
|
||||
* @param force Whether to force refresh and ignore cache
|
||||
*/
|
||||
fetchCredits: async (user) => {
|
||||
fetchCredits: async (user, force = false) => {
|
||||
// Skip if already loading
|
||||
if (get().isLoading) return;
|
||||
|
||||
@ -55,33 +57,43 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recent data (within cache duration)
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
return; // Use cached data
|
||||
// Check if we have recent data (within cache duration) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
console.log(`fetchCredits, ${force ? 'force fetch' : 'fetch'} credits`);
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
// Clear cache if force refresh
|
||||
lastFetchTime: force ? null : get().lastFetchTime,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getCreditBalanceAction();
|
||||
|
||||
if (result?.data?.success) {
|
||||
const newBalance = result.data.credits || 0;
|
||||
console.log('fetchCredits, set new balance', newBalance);
|
||||
set({
|
||||
balance: result.data.credits || 0,
|
||||
balance: newBalance,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: now,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
console.warn('fetchCredits, failed to fetch credit balance', result);
|
||||
set({
|
||||
error: result?.data?.error || 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetch credits error:', error);
|
||||
console.error('fetchCredits, error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
@ -100,8 +112,9 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
|
||||
// Check if we have enough credits
|
||||
if (balance < amount) {
|
||||
console.log('consumeCredits, insufficient credits', balance, amount);
|
||||
set({
|
||||
error: `Insufficient credits. You need ${amount} credits but only have ${balance}.`,
|
||||
error: 'Insufficient credits',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@ -128,6 +141,7 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Revert optimistic update on failure
|
||||
console.warn('consumeCredits, reverting optimistic update');
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
error: result?.data?.error || 'Failed to consume credits',
|
||||
@ -135,7 +149,7 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('consume credits error:', error);
|
||||
console.error('consumeCredits, error:', error);
|
||||
// Revert optimistic update on error
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
@ -145,60 +159,4 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Force refresh credit balance (ignores cache)
|
||||
* @param user Current user from auth session
|
||||
*/
|
||||
refreshCredits: async (user) => {
|
||||
if (!user) {
|
||||
set({
|
||||
error: 'No user found',
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
lastFetchTime: null, // Clear cache to force refresh
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getCreditBalanceAction();
|
||||
|
||||
if (result?.data?.success) {
|
||||
set({
|
||||
balance: result.data.credits || 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
error: result?.data?.error || 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('refresh credits error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset credits state
|
||||
*/
|
||||
resetCreditsState: () => {
|
||||
set({
|
||||
balance: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
@ -5,6 +5,8 @@ import { getAllPricePlans } from '@/lib/price-plan';
|
||||
import type { PricePlan, Subscription } from '@/payment/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const CACHE_DURATION = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Payment state interface
|
||||
*/
|
||||
@ -17,9 +19,14 @@ export interface PaymentState {
|
||||
isLoading: boolean;
|
||||
// Error state
|
||||
error: string | null;
|
||||
// Last fetch timestamp to avoid frequent requests
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
fetchPayment: (user: Session['user'] | null | undefined) => Promise<void>;
|
||||
fetchPayment: (
|
||||
user: Session['user'] | null | undefined,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
@ -33,12 +40,13 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
|
||||
/**
|
||||
* Fetch payment and subscription data for the current user
|
||||
* @param user Current user from auth session
|
||||
*/
|
||||
fetchPayment: async (user) => {
|
||||
fetchPayment: async (user, force = false) => {
|
||||
// Skip if already loading
|
||||
if (get().isLoading) return;
|
||||
|
||||
@ -48,10 +56,21 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
currentPlan: null,
|
||||
subscription: null,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recent data (within cache duration) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
console.log('fetchPayment, use cached data');
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch subscription data
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
@ -66,30 +85,26 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
const result = await getLifetimeStatusAction({ userId: user.id });
|
||||
if (result?.data?.success) {
|
||||
isLifetimeMember = result.data.isLifetimeMember || false;
|
||||
console.log('get lifetime status result', result);
|
||||
console.log('fetchPayment, lifetime status', isLifetimeMember);
|
||||
} else {
|
||||
console.warn('get lifetime status failed', result?.data?.error);
|
||||
// set({
|
||||
// error: result?.data?.error || 'Failed to fetch payment data',
|
||||
// isLoading: false
|
||||
// });
|
||||
console.warn(
|
||||
'fetchPayment, lifetime status error',
|
||||
result?.data?.error
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get lifetime status error:', error);
|
||||
// set({
|
||||
// error: 'Failed to fetch payment data',
|
||||
// isLoading: false
|
||||
// });
|
||||
console.error('fetchPayment, lifetime status error:', error);
|
||||
}
|
||||
|
||||
// If lifetime member, set the lifetime plan
|
||||
if (isLifetimeMember) {
|
||||
console.log('set lifetime plan for user', user.id);
|
||||
console.log('fetchPayment, set lifetime plan');
|
||||
set({
|
||||
currentPlan: lifetimePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -108,34 +123,29 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
(price) => price.priceId === activeSubscription.priceId
|
||||
)
|
||||
) || null;
|
||||
console.log(
|
||||
'subscription found, setting plan for user',
|
||||
user.id,
|
||||
plan?.id
|
||||
);
|
||||
console.log('fetchPayment, subscription found, set pro plan');
|
||||
set({
|
||||
currentPlan: plan,
|
||||
subscription: activeSubscription,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// No subscription found - set to free plan
|
||||
console.log(
|
||||
'no subscription found, setting free plan for user',
|
||||
user.id
|
||||
);
|
||||
console.log('fetchPayment, no subscription found, set free plan');
|
||||
set({
|
||||
currentPlan: freePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Failed to fetch subscription
|
||||
console.error(
|
||||
'fetch subscription for user failed',
|
||||
'fetchPayment, subscription for user failed',
|
||||
result?.data?.error
|
||||
);
|
||||
set({
|
||||
@ -144,7 +154,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetch payment data error:', error);
|
||||
console.error('fetchPayment, error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch payment data',
|
||||
isLoading: false,
|
||||
@ -163,6 +173,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
Loading…
Reference in New Issue
Block a user