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", "creditsExpired": "Credits expired",
"creditsAdded": "Credits have been added to your account", "creditsAdded": "Credits have been added to your account",
"viewTransactions": "View Credit Transactions", "viewTransactions": "View Credit Transactions",
"retry": "Retry",
"subscriptionCredits": "{credits} credits from subscription this month", "subscriptionCredits": "{credits} credits from subscription this month",
"lifetimeCredits": "{credits} credits from lifetime plan this month", "lifetimeCredits": "{credits} credits from lifetime plan this month",
"expiringCredits": "{credits} credits expiring on {date}" "expiringCredits": "{credits} credits expiring on {date}"

View File

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

View File

@ -16,14 +16,13 @@ import { getPricePlans } from '@/config/price-config';
import { usePayment } from '@/hooks/use-payment'; import { usePayment } from '@/hooks/use-payment';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation'; import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { formatDate, formatPrice } from '@/lib/formatter'; import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { PlanIntervals } from '@/payment/types';
import { Routes } from '@/routes'; import { Routes } from '@/routes';
import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react'; import { CheckCircleIcon, ClockIcon, RefreshCwIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function BillingCard() { export default function BillingCard() {
@ -37,7 +36,7 @@ export default function BillingCard() {
error: loadPaymentError, error: loadPaymentError,
subscription, subscription,
currentPlan: currentPlanFromStore, currentPlan: currentPlanFromStore,
refetch, fetchPayment,
} = usePayment(); } = usePayment();
// Get user session for customer ID // Get user session for customer ID
@ -77,6 +76,12 @@ export default function BillingCard() {
const isPageLoading = isLoadingPayment || isLoadingSession; const isPageLoading = isLoadingPayment || isLoadingSession;
// console.log('billing card, isLoadingPayment', isLoadingPayment, 'isLoadingSession', 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 // Check for payment success and show success message
useEffect(() => { useEffect(() => {
const sessionId = searchParams.get('session_id'); const sessionId = searchParams.get('session_id');
@ -133,7 +138,7 @@ export default function BillingCard() {
<Button <Button
variant="outline" variant="outline"
className="cursor-pointer" className="cursor-pointer"
onClick={() => refetch()} onClick={handleRetry}
> >
<RefreshCwIcon className="size-4 mr-1" /> <RefreshCwIcon className="size-4 mr-1" />
{t('retry')} {t('retry')}

View File

@ -18,10 +18,10 @@ import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter'; import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Routes } from '@/routes'; import { Routes } from '@/routes';
import { Loader2Icon } from 'lucide-react'; import { RefreshCwIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function CreditsBalanceCard() { export default function CreditsBalanceCard() {
@ -35,7 +35,7 @@ export default function CreditsBalanceCard() {
balance, balance,
isLoading: isLoadingBalance, isLoading: isLoadingBalance,
error, error,
refresh: refreshBalance, fetchCredits,
} = useCredits(); } = useCredits();
// Get payment info to check plan type // Get payment info to check plan type
@ -57,22 +57,23 @@ export default function CreditsBalanceCard() {
return null; return null;
} }
// Function to fetch credit statistics // Fetch credit statistics
const fetchCreditStats = async () => { const fetchCreditStats = useCallback(async () => {
console.log('fetchCreditStats, fetch start');
setIsLoadingStats(true); setIsLoadingStats(true);
try { try {
const result = await getCreditStatsAction(); const result = await getCreditStatsAction();
if (result?.data?.success && result.data.data) { if (result?.data?.success && result.data.data) {
setCreditStats(result.data.data); setCreditStats(result.data.data);
} else { } else {
console.error('Failed to fetch credit stats:', result?.data?.error); console.error('fetchCreditStats, failed to fetch credit stats', result);
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch credit stats:', error); console.error('fetchCreditStats, error:', error);
} finally { } finally {
setIsLoadingStats(false); setIsLoadingStats(false);
} }
}; }, []);
// Fetch stats on component mount // Fetch stats on component mount
useEffect(() => { useEffect(() => {
@ -84,22 +85,35 @@ export default function CreditsBalanceCard() {
const sessionId = searchParams.get('session_id'); const sessionId = searchParams.get('session_id');
if (sessionId && !hasHandledSession.current) { if (sessionId && !hasHandledSession.current) {
hasHandledSession.current = true; hasHandledSession.current = true;
// Show success toast (delayed to avoid React lifecycle conflicts) // Show success toast (delayed to avoid React lifecycle conflicts)
setTimeout(() => { setTimeout(() => {
toast.success(t('creditsAdded')); toast.success(t('creditsAdded'));
}, 0); }, 0);
// Refresh credits data to show updated balance // Use setTimeout to ensure async operations complete properly
refreshBalance(); setTimeout(() => {
// Refresh credit stats // Force refresh credits data to show updated balance
fetchCreditStats(); fetchCredits(true);
// Refresh credit stats
fetchCreditStats();
}, 100);
// Clean up URL parameters // Clean up URL parameters
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete('session_id'); url.searchParams.delete('session_id');
localeRouter.replace(Routes.SettingsBilling + url.search); 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 // Render loading skeleton
const isPageLoading = isLoadingBalance || isLoadingStats; const isPageLoading = isLoadingBalance || isLoadingStats;
@ -138,10 +152,13 @@ export default function CreditsBalanceCard() {
<div className="text-destructive text-sm">{error}</div> <div className="text-destructive text-sm">{error}</div>
</CardContent> </CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none"> <CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button variant="outline" className="cursor-pointer" asChild> <Button
<LocaleLink href={Routes.SettingsCredits}> variant="outline"
{t('viewTransactions')} className="cursor-pointer"
</LocaleLink> onClick={handleRetry}
>
<RefreshCwIcon className="size-4 mr-1" />
{t('retry')}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@ -23,10 +23,6 @@ export function useCredits() {
(force = false) => { (force = false) => {
const currentUser = session?.user; const currentUser = session?.user;
if (currentUser) { if (currentUser) {
console.log(
`${force ? 'force fetch' : 'fetch'} credits for user`,
currentUser.id
);
fetchCreditsFromStore(currentUser, force); fetchCreditsFromStore(currentUser, force);
} }
}, },
@ -36,7 +32,6 @@ export function useCredits() {
useEffect(() => { useEffect(() => {
const currentUser = session?.user; const currentUser = session?.user;
if (currentUser) { if (currentUser) {
console.log('fetch credits info for user', currentUser.id);
fetchCreditsFromStore(currentUser); fetchCreditsFromStore(currentUser);
} }
}, [session?.user, fetchCreditsFromStore]); }, [session?.user, fetchCreditsFromStore]);

View File

@ -23,8 +23,7 @@ export function usePayment() {
(force = false) => { (force = false) => {
const currentUser = session?.user; const currentUser = session?.user;
if (currentUser) { if (currentUser) {
console.log('fetch payment info for user', currentUser.id); fetchPaymentFromStore(currentUser, force);
fetchPaymentFromStore(currentUser);
} }
}, },
[session?.user, fetchPaymentFromStore] [session?.user, fetchPaymentFromStore]
@ -33,7 +32,6 @@ export function usePayment() {
useEffect(() => { useEffect(() => {
const currentUser = session?.user; const currentUser = session?.user;
if (currentUser) { if (currentUser) {
console.log('fetch payment info for user', currentUser.id);
fetchPaymentFromStore(currentUser); fetchPaymentFromStore(currentUser);
} }
}, [session?.user, fetchPaymentFromStore]); }, [session?.user, fetchPaymentFromStore]);

View File

@ -3,6 +3,9 @@ import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import type { Session } from '@/lib/auth-types'; import type { Session } from '@/lib/auth-types';
import { create } from 'zustand'; import { create } from 'zustand';
// Cache duration: 30 seconds
const CACHE_DURATION = 30 * 1000;
/** /**
* Credits state interface * Credits state interface
*/ */
@ -17,15 +20,13 @@ export interface CreditsState {
lastFetchTime: number | null; lastFetchTime: number | null;
// Actions // 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>; 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 * Credits store using Zustand
* Manages the user's credit balance globally with caching and optimistic updates * 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, 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 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 // Skip if already loading
if (get().isLoading) return; if (get().isLoading) return;
@ -55,33 +57,43 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
return; return;
} }
// Check if we have recent data (within cache duration) // Check if we have recent data (within cache duration) unless force refresh
const { lastFetchTime } = get(); if (!force) {
const now = Date.now(); const { lastFetchTime } = get();
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) { const now = Date.now();
return; // Use cached data 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 { try {
const result = await getCreditBalanceAction(); const result = await getCreditBalanceAction();
if (result?.data?.success) { if (result?.data?.success) {
const newBalance = result.data.credits || 0;
console.log('fetchCredits, set new balance', newBalance);
set({ set({
balance: result.data.credits || 0, balance: newBalance,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: now, lastFetchTime: Date.now(),
}); });
} else { } else {
console.warn('fetchCredits, failed to fetch credit balance', result);
set({ set({
error: result?.data?.error || 'Failed to fetch credit balance', error: result?.data?.error || 'Failed to fetch credit balance',
isLoading: false, isLoading: false,
}); });
} }
} catch (error) { } catch (error) {
console.error('fetch credits error:', error); console.error('fetchCredits, error:', error);
set({ set({
error: 'Failed to fetch credit balance', error: 'Failed to fetch credit balance',
isLoading: false, isLoading: false,
@ -100,8 +112,9 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
// Check if we have enough credits // Check if we have enough credits
if (balance < amount) { if (balance < amount) {
console.log('consumeCredits, insufficient credits', balance, amount);
set({ set({
error: `Insufficient credits. You need ${amount} credits but only have ${balance}.`, error: 'Insufficient credits',
}); });
return false; return false;
} }
@ -128,6 +141,7 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
} }
// Revert optimistic update on failure // Revert optimistic update on failure
console.warn('consumeCredits, reverting optimistic update');
set({ set({
balance: balance, // Revert to original balance balance: balance, // Revert to original balance
error: result?.data?.error || 'Failed to consume credits', error: result?.data?.error || 'Failed to consume credits',
@ -135,7 +149,7 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
}); });
return false; return false;
} catch (error) { } catch (error) {
console.error('consume credits error:', error); console.error('consumeCredits, error:', error);
// Revert optimistic update on error // Revert optimistic update on error
set({ set({
balance: balance, // Revert to original balance balance: balance, // Revert to original balance
@ -145,60 +159,4 @@ export const useCreditsStore = create<CreditsState>((set, get) => ({
return false; 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 type { PricePlan, Subscription } from '@/payment/types';
import { create } from 'zustand'; import { create } from 'zustand';
const CACHE_DURATION = 30 * 1000;
/** /**
* Payment state interface * Payment state interface
*/ */
@ -17,9 +19,14 @@ export interface PaymentState {
isLoading: boolean; isLoading: boolean;
// Error state // Error state
error: string | null; error: string | null;
// Last fetch timestamp to avoid frequent requests
lastFetchTime: number | null;
// Actions // Actions
fetchPayment: (user: Session['user'] | null | undefined) => Promise<void>; fetchPayment: (
user: Session['user'] | null | undefined,
force?: boolean
) => Promise<void>;
resetState: () => void; resetState: () => void;
} }
@ -33,12 +40,13 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
subscription: null, subscription: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: null,
/** /**
* Fetch payment and subscription data for the current user * Fetch payment and subscription data for the current user
* @param user Current user from auth session * @param user Current user from auth session
*/ */
fetchPayment: async (user) => { fetchPayment: async (user, force = false) => {
// Skip if already loading // Skip if already loading
if (get().isLoading) return; if (get().isLoading) return;
@ -48,10 +56,21 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
currentPlan: null, currentPlan: null,
subscription: null, subscription: null,
error: null, error: null,
lastFetchTime: null,
}); });
return; 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 // Fetch subscription data
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
@ -66,30 +85,26 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
const result = await getLifetimeStatusAction({ userId: user.id }); const result = await getLifetimeStatusAction({ userId: user.id });
if (result?.data?.success) { if (result?.data?.success) {
isLifetimeMember = result.data.isLifetimeMember || false; isLifetimeMember = result.data.isLifetimeMember || false;
console.log('get lifetime status result', result); console.log('fetchPayment, lifetime status', isLifetimeMember);
} else { } else {
console.warn('get lifetime status failed', result?.data?.error); console.warn(
// set({ 'fetchPayment, lifetime status error',
// error: result?.data?.error || 'Failed to fetch payment data', result?.data?.error
// isLoading: false );
// });
} }
} catch (error) { } catch (error) {
console.error('get lifetime status error:', error); console.error('fetchPayment, lifetime status error:', error);
// set({
// error: 'Failed to fetch payment data',
// isLoading: false
// });
} }
// If lifetime member, set the lifetime plan // If lifetime member, set the lifetime plan
if (isLifetimeMember) { if (isLifetimeMember) {
console.log('set lifetime plan for user', user.id); console.log('fetchPayment, set lifetime plan');
set({ set({
currentPlan: lifetimePlan || null, currentPlan: lifetimePlan || null,
subscription: null, subscription: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: Date.now(),
}); });
return; return;
} }
@ -108,34 +123,29 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
(price) => price.priceId === activeSubscription.priceId (price) => price.priceId === activeSubscription.priceId
) )
) || null; ) || null;
console.log( console.log('fetchPayment, subscription found, set pro plan');
'subscription found, setting plan for user',
user.id,
plan?.id
);
set({ set({
currentPlan: plan, currentPlan: plan,
subscription: activeSubscription, subscription: activeSubscription,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: Date.now(),
}); });
} else { } else {
// No subscription found - set to free plan // No subscription found - set to free plan
console.log( console.log('fetchPayment, no subscription found, set free plan');
'no subscription found, setting free plan for user',
user.id
);
set({ set({
currentPlan: freePlan || null, currentPlan: freePlan || null,
subscription: null, subscription: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: Date.now(),
}); });
} }
} else { } else {
// Failed to fetch subscription // Failed to fetch subscription
console.error( console.error(
'fetch subscription for user failed', 'fetchPayment, subscription for user failed',
result?.data?.error result?.data?.error
); );
set({ set({
@ -144,7 +154,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
}); });
} }
} catch (error) { } catch (error) {
console.error('fetch payment data error:', error); console.error('fetchPayment, error:', error);
set({ set({
error: 'Failed to fetch payment data', error: 'Failed to fetch payment data',
isLoading: false, isLoading: false,
@ -163,6 +173,7 @@ export const usePaymentStore = create<PaymentState>((set, get) => ({
subscription: null, subscription: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastFetchTime: null,
}); });
}, },
})); }));