refactor: add retry functionality for payment and credits data fetching with improved user experience

This commit is contained in:
javayhu 2025-07-13 15:15:44 +08:00
parent 2d2a85cd26
commit b27d8cc505
8 changed files with 117 additions and 131 deletions

View File

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

View File

@ -594,6 +594,7 @@
"creditsExpired": "积分已过期",
"creditsAdded": "积分已添加到您的账户",
"viewTransactions": "查看积分记录",
"retry": "重试",
"subscriptionCredits": "本月订阅获得 {credits} 积分",
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
"expiringCredits": "{credits} 积分将在 {date} 过期"

View File

@ -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')}

View File

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

View File

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

View File

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

View File

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

View File

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