refactor: replace usePayment hook and store with useCurrentPlan for improved payment state management

This commit is contained in:
javayhu 2025-08-21 00:50:12 +08:00
parent ff1e72df13
commit ac8d4dee4b
12 changed files with 156 additions and 300 deletions

View File

@ -1,7 +1,6 @@
'use client';
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { QueryProvider } from '@/components/providers/query-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
@ -63,9 +62,7 @@ export function Providers({ children, locale }: ProvidersProps) {
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>{children}</PaymentProvider>
</TooltipProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>

View File

@ -23,7 +23,6 @@ import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
import { LOCALES, routing } from '@/i18n/routing';
import { authClient } from '@/lib/auth-client';
import { useLocaleStore } from '@/stores/locale-store';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import {
ChevronsUpDown,
@ -55,7 +54,6 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
const pathname = useLocalePathname();
const params = useParams();
const { currentLocale, setCurrentLocale } = useLocaleStore();
const { resetState } = usePaymentStore();
const [, startTransition] = useTransition();
const t = useTranslations();
@ -81,8 +79,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
router.replace('/');
},
onError: (error) => {
@ -100,7 +97,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="cursor-pointer data-[state=open]:bg-sidebar-accent
className="cursor-pointer data-[state=open]:bg-sidebar-accent
data-[state=open]:text-sidebar-accent-foreground"
>
<UserAvatar

View File

@ -9,8 +9,9 @@ import {
CardTitle,
} from '@/components/ui/card';
import { websiteConfig } from '@/config/website';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { Routes } from '@/routes';
import { SparklesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -23,14 +24,16 @@ export function UpgradeCard() {
const t = useTranslations('Dashboard.upgrade');
const [mounted, setMounted] = useState(false);
const { isLoading, currentPlan, subscription } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData, isLoading } = useCurrentPlan(session?.user?.id);
useEffect(() => {
setMounted(true);
}, []);
// Don't show the upgrade card if the user has a lifetime membership or a subscription
const isMember = currentPlan?.isLifetime || !!subscription;
const isMember =
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
if (!mounted || isLoading || isMember) {
return null;

View File

@ -1,24 +0,0 @@
'use client';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import { useEffect } from 'react';
/**
* Payment provider component
*
* This component is responsible for initializing the payment state
* by fetching the current user's subscription and payment information when the app loads.
*/
export function PaymentProvider({ children }: { children: React.ReactNode }) {
const { fetchPayment } = usePaymentStore();
const { data: session } = authClient.useSession();
useEffect(() => {
if (session?.user) {
fetchPayment(session.user);
}
}, [session?.user, fetchPayment]);
return <>{children}</>;
}

View File

@ -13,7 +13,6 @@ import {
import { getAvatarLinks } from '@/config/avatar-config';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import { LogOutIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -29,8 +28,6 @@ export function UserButtonMobile({ user }: UserButtonProps) {
const avatarLinks = getAvatarLinks();
const localeRouter = useLocaleRouter();
const [open, setOpen] = useState(false);
const { resetState } = usePaymentStore();
const closeDrawer = () => {
setOpen(false);
};
@ -40,8 +37,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
localeRouter.replace('/');
},
onError: (error) => {
@ -64,7 +60,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
<DrawerContent
className="fixed inset-x-0 bottom-0 z-50 mt-24
className="fixed inset-x-0 bottom-0 z-50 mt-24
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm"
>
<DrawerHeader>

View File

@ -12,7 +12,6 @@ import { getAvatarLinks } from '@/config/avatar-config';
import { websiteConfig } from '@/config/website';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import { LogOutIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -29,15 +28,12 @@ export function UserButton({ user }: UserButtonProps) {
const avatarLinks = getAvatarLinks();
const localeRouter = useLocaleRouter();
const [open, setOpen] = useState(false);
const { resetState } = usePaymentStore();
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
localeRouter.replace('/');
},
onError: (error) => {

View File

@ -14,7 +14,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { getPricePlans } from '@/config/price-config';
import { useMounted } from '@/hooks/use-mounted';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';
@ -33,34 +33,37 @@ export default function BillingCard() {
const hasHandledSession = useRef(false);
const mounted = useMounted();
const {
isLoading: isLoadingPayment,
error: loadPaymentError,
subscription,
currentPlan: currentPlanFromStore,
fetchPayment,
} = usePayment();
// Get user session for customer ID
const { data: session, isPending: isLoadingSession } =
authClient.useSession();
const currentUser = session?.user;
// TanStack Query hook for current plan and subscription
const {
data: paymentData,
isLoading: isLoadingPayment,
error: loadPaymentError,
refetch: refetchPayment,
} = useCurrentPlan(currentUser?.id);
const currentPlan = paymentData?.currentPlan;
const subscription = paymentData?.subscription;
// Get price plans with translations - must be called here to maintain hook order
const pricePlans = getPricePlans();
const plans = Object.values(pricePlans);
// Convert current plan from store to a plan with translations
const currentPlan = currentPlanFromStore
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
// Convert current plan to a plan with translations
const currentPlanWithTranslations = currentPlan
? plans.find((plan) => plan.id === currentPlan?.id)
: null;
const isFreePlan = currentPlan?.isFree || false;
const isLifetimeMember = currentPlan?.isLifetime || false;
const isFreePlan = currentPlanWithTranslations?.isFree || false;
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
// Get subscription price details
const currentPrice =
subscription &&
currentPlan?.prices.find(
currentPlanWithTranslations?.prices.find(
(price) => price.priceId === subscription?.priceId
);
@ -77,8 +80,8 @@ export default function BillingCard() {
// Retry payment data fetching
const handleRetry = useCallback(() => {
// console.log('handleRetry, refetch payment info');
fetchPayment(true);
}, [fetchPayment]);
refetchPayment();
}, [refetchPayment]);
// Check for payment success and show success message
useEffect(() => {
@ -132,7 +135,9 @@ export default function BillingCard() {
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">{loadPaymentError}</div>
<div className="text-destructive text-sm">
{loadPaymentError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button
@ -149,7 +154,7 @@ export default function BillingCard() {
}
// currentPlan maybe null, so we need to check if it is null
if (!currentPlan) {
if (!currentPlanWithTranslations) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
@ -187,7 +192,9 @@ export default function BillingCard() {
<CardContent className="space-y-4 flex-1">
{/* Plan name and status */}
<div className="flex items-center justify-start space-x-4">
<div className="text-3xl font-medium">{currentPlan?.name}</div>
<div className="text-3xl font-medium">
{currentPlanWithTranslations?.name}
</div>
{subscription &&
(subscription.status === 'trialing' ||
subscription.status === 'active') && (

View File

@ -11,7 +11,8 @@ import {
import { getCreditPackages } from '@/config/credits-config';
import { websiteConfig } from '@/config/website';
import { useCurrentUser } from '@/hooks/use-current-user';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { authClient } from '@/lib/auth-client';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
@ -31,7 +32,9 @@ export function CreditPackages() {
// Get current user and payment info
const currentUser = useCurrentUser();
const { currentPlan } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData } = useCurrentPlan(session?.user?.id);
const currentPlan = paymentData?.currentPlan;
// Get credit packages with translations - must be called here to maintain hook order
const creditPackages = Object.values(getCreditPackages()).filter(

View File

@ -13,8 +13,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { websiteConfig } from '@/config/website';
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits-query';
import { useMounted } from '@/hooks/use-mounted';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
@ -48,7 +49,9 @@ export default function CreditsBalanceCard() {
} = useCreditBalance();
// Get payment info to check plan type
const { currentPlan } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData } = useCurrentPlan(session?.user?.id);
const currentPlan = paymentData?.currentPlan;
// TanStack Query hook for credit statistics
const {

View File

@ -0,0 +1,107 @@
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
import { getAllPricePlans } from '@/lib/price-plan';
import type { PricePlan, Subscription } from '@/payment/types';
import { useQuery } from '@tanstack/react-query';
// Query keys
export const paymentKeys = {
all: ['payment'] as const,
subscription: (userId: string) =>
[...paymentKeys.all, 'subscription', userId] as const,
lifetime: (userId: string) =>
[...paymentKeys.all, 'lifetime', userId] as const,
currentPlan: (userId: string) =>
[...paymentKeys.all, 'currentPlan', userId] as const,
};
// Hook to fetch active subscription
export function useActiveSubscription(userId: string | undefined) {
return useQuery({
queryKey: paymentKeys.subscription(userId || ''),
queryFn: async (): Promise<Subscription | null> => {
if (!userId) {
throw new Error('User ID is required');
}
const result = await getActiveSubscriptionAction({ userId });
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to fetch subscription');
}
return result.data.data || null;
},
enabled: !!userId,
});
}
// Hook to fetch lifetime status
export function useLifetimeStatus(userId: string | undefined) {
return useQuery({
queryKey: paymentKeys.lifetime(userId || ''),
queryFn: async (): Promise<boolean> => {
if (!userId) {
throw new Error('User ID is required');
}
const result = await getLifetimeStatusAction({ userId });
if (!result?.data?.success) {
throw new Error(
result?.data?.error || 'Failed to fetch lifetime status'
);
}
return result.data.isLifetimeMember || false;
},
enabled: !!userId,
});
}
// Hook to get current plan based on subscription and lifetime status
export function useCurrentPlan(userId: string | undefined) {
const {
data: subscription,
isLoading: isLoadingSubscription,
error: subscriptionError,
} = useActiveSubscription(userId);
const {
data: isLifetimeMember,
isLoading: isLoadingLifetime,
error: lifetimeError,
} = useLifetimeStatus(userId);
return useQuery({
queryKey: paymentKeys.currentPlan(userId || ''),
queryFn: async (): Promise<{
currentPlan: PricePlan | null;
subscription: Subscription | null;
}> => {
const plans: PricePlan[] = getAllPricePlans();
const freePlan = plans.find((plan) => plan.isFree);
const lifetimePlan = plans.find((plan) => plan.isLifetime);
// If lifetime member, return lifetime plan
if (isLifetimeMember) {
return {
currentPlan: lifetimePlan || null,
subscription: null,
};
}
// If has active subscription, find the corresponding plan
if (subscription) {
const plan =
plans.find((p) =>
p.prices.find((price) => price.priceId === subscription.priceId)
) || null;
return {
currentPlan: plan,
subscription,
};
}
// Default to free plan
return {
currentPlan: freePlan || null,
subscription: null,
};
},
enabled: !!userId && !isLoadingSubscription && !isLoadingLifetime,
});
}

View File

@ -1,49 +0,0 @@
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import { useCallback, useEffect } from 'react';
/**
* Hook for accessing and managing payment state
*
* This hook provides access to the payment state and methods to manage it.
* It also automatically fetches payment information when the user changes.
*/
export function usePayment() {
const {
currentPlan,
subscription,
isLoading,
error,
fetchPayment: fetchPaymentFromStore,
} = usePaymentStore();
const { data: session } = authClient.useSession();
const fetchPayment = useCallback(
(force = false) => {
const currentUser = session?.user;
if (currentUser) {
fetchPaymentFromStore(currentUser, force);
}
},
[session?.user, fetchPaymentFromStore]
);
useEffect(() => {
const currentUser = session?.user;
if (currentUser) {
fetchPaymentFromStore(currentUser);
}
}, [session?.user, fetchPaymentFromStore]);
return {
// State
currentPlan,
subscription,
isLoading,
error,
// Methods
fetchPayment,
};
}

View File

@ -1,180 +0,0 @@
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
import type { Session } from '@/lib/auth-types';
import { getAllPricePlans } from '@/lib/price-plan';
import type { PricePlan, Subscription } from '@/payment/types';
import { create } from 'zustand';
// Cache duration: 2 minutes (optimized for better UX)
const CACHE_DURATION = 2 * 60 * 1000;
/**
* Payment state interface
*/
export interface PaymentState {
// Current plan
currentPlan: PricePlan | null;
// Active subscription
subscription: Subscription | null;
// Loading state
isLoading: boolean;
// Error state
error: string | null;
// Last fetch timestamp to avoid frequent requests
lastFetchTime: number | null;
// Actions
fetchPayment: (
user: Session['user'] | null | undefined,
force?: boolean
) => Promise<void>;
resetState: () => void;
}
/**
* Payment store using Zustand
* Manages the user's payment and subscription data globally
*/
export const usePaymentStore = create<PaymentState>((set, get) => ({
// Initial state
currentPlan: null,
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, force = false) => {
// Skip if already loading
if (get().isLoading) return;
// Skip if no user is provided
if (!user) {
set({
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 });
// Get all price plans
const plans: PricePlan[] = getAllPricePlans();
const freePlan = plans.find((plan) => plan.isFree);
const lifetimePlan = plans.find((plan) => plan.isLifetime);
// Check if user is a lifetime member directly from the database
let isLifetimeMember = false;
try {
const result = await getLifetimeStatusAction({ userId: user.id });
if (result?.data?.success) {
isLifetimeMember = result.data.isLifetimeMember || false;
console.log('fetchPayment, lifetime status', isLifetimeMember);
} else {
console.warn(
'fetchPayment, lifetime status error',
result?.data?.error
);
}
} catch (error) {
console.error('fetchPayment, lifetime status error:', error);
}
// If lifetime member, set the lifetime plan
if (isLifetimeMember) {
console.log('fetchPayment, set lifetime plan');
set({
currentPlan: lifetimePlan || null,
subscription: null,
isLoading: false,
error: null,
lastFetchTime: Date.now(),
});
return;
}
try {
// Check if user has an active subscription
const result = await getActiveSubscriptionAction({ userId: user.id });
if (result?.data?.success) {
const activeSubscription = result.data.data;
// Set subscription state
if (activeSubscription) {
const plan =
plans.find((p) =>
p.prices.find(
(price) => price.priceId === activeSubscription.priceId
)
) || null;
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('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(
'fetchPayment, subscription for user failed',
result?.data?.error
);
set({
error: result?.data?.error || 'Failed to fetch payment data',
isLoading: false,
});
}
} catch (error) {
console.error('fetchPayment, error:', error);
set({
error: 'Failed to fetch payment data',
isLoading: false,
});
} finally {
set({ isLoading: false });
}
},
/**
* Reset payment state
*/
resetState: () => {
set({
currentPlan: null,
subscription: null,
isLoading: false,
error: null,
lastFetchTime: null,
});
},
}));