feat: enhance billing management features and localization updates

- Added new localization keys for billing management in English and Chinese message files.
- Updated the sidebar to include a billing link.
- Refactored the billing card component to improve loading state handling and user session management.
- Enhanced subscription fetching logic to include better error handling and logging.
- Adjusted the upgrade card visibility based on user membership status.
This commit is contained in:
javayhu 2025-04-06 00:48:08 +08:00
parent b639a9e50a
commit 36e76ef080
11 changed files with 266 additions and 254 deletions

View File

@ -383,6 +383,7 @@
},
"avatar": {
"dashboard": "Dashboard",
"billing": "Billing",
"settings": "Settings"
}
},
@ -441,10 +442,12 @@
},
"nextBillingDate": "Next billing date:",
"trialEnds": "Trial ends:",
"manageSubscription": "Manage Subscription",
"freePlanMessage": "You are currently on the free plan with limited features",
"lifetimeMessage": "You have lifetime access to all premium features",
"viewBillingHistory": "View Billing History",
"manageSubscription": "Manage Subscription",
"billingHistory": "Billing History",
"manageBilling": "Manage Subscription and Billing",
"upgradePlan": "Upgrade Plan",
"retry": "Retry",
"errorMessage": "Failed to get data"
},

View File

@ -383,6 +383,7 @@
},
"avatar": {
"dashboard": "工作台",
"billing": "账单",
"settings": "设置"
}
},
@ -441,10 +442,11 @@
},
"nextBillingDate": "下次账单日期:",
"trialEnds": "试用结束日期:",
"manageSubscription": "管理订阅",
"freePlanMessage": "您当前使用的是功能有限的免费方案",
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
"viewBillingHistory": "查看账单历史",
"manageSubscription": "管理订阅",
"billingHistory": "账单历史",
"manageBilling": "管理订阅和账单",
"retry": "重试",
"errorMessage": "获取数据失败"
},

View File

@ -18,7 +18,7 @@ export const getUserSubscriptionAction = actionClient
headers: await headers(),
});
if (!session?.user) {
if (!session?.user || !session.user.customerId) {
return {
success: false,
error: 'Unauthorized',
@ -28,39 +28,37 @@ export const getUserSubscriptionAction = actionClient
try {
// Get the effective customer ID (from session or input)
const customerId = session.user.customerId;
const subscriptionId = session.user.subscriptionId;
// const subscriptionId = session.user.subscriptionId;
console.log('customerId:', customerId);
// If we have a subscription ID, fetch the subscription details directly
let subscriptionData = null;
if (subscriptionId) {
subscriptionData = await getSubscription({ subscriptionId });
}
// If we have a customer ID but no subscription ID, try to find the active subscription for this customer
else if (customerId) {
// Get the payment provider to access its methods
const provider = getPaymentProvider();
// Get the payment provider to access its methods
const provider = getPaymentProvider();
// Find the customer's most recent active subscription
const subscriptions = await provider.listCustomerSubscriptions({
customerId: customerId
});
// Find the customer's most recent active subscription
const subscriptions = await provider.listCustomerSubscriptions({
customerId: customerId
});
console.log('get user subscriptions:', subscriptions);
// Find the most recent active subscription (if any)
if (subscriptions && subscriptions.length > 0) {
// First try to find an active subscription
const activeSubscription = subscriptions.find(sub =>
sub.status === 'active' || sub.status === 'trialing'
);
// Find the most recent active subscription (if any)
if (subscriptions && subscriptions.length > 0) {
// First try to find an active subscription
const activeSubscription = subscriptions.find(sub =>
sub.status === 'active' || sub.status === 'trialing'
);
// If found, use it
if (activeSubscription) {
subscriptionData = activeSubscription;
}
// Otherwise, use the most recent subscription (first in the list, as they should be sorted by date)
else if (subscriptions.length > 0) {
subscriptionData = subscriptions[0];
}
// If found, use it
if (activeSubscription) {
subscriptionData = activeSubscription;
}
// Otherwise, use the most recent subscription (first in the list, as they should be sorted by date)
else if (subscriptions.length > 0) {
subscriptionData = subscriptions[0];
}
console.log('find subscription:', subscriptionData, 'for customerId:', customerId);
} else {
console.log('no subscriptions found for customerId:', customerId);
}
return {
@ -68,7 +66,7 @@ export const getUserSubscriptionAction = actionClient
data: subscriptionData,
};
} catch (error) {
console.error("fetch user subscription data error:", error);
console.error("get user subscription data error:", error);
return {
success: false,
error: error instanceof Error ? error.message : 'Something went wrong',

View File

@ -25,10 +25,7 @@ export function DashboardSidebar({ ...props }: React.ComponentProps<typeof Sideb
const sidebarLinks = getSidebarLinks();
const { data: session, isPending } = authClient.useSession();
const currentUser = session?.user;
// user is a member if they have a lifetime membership or an active subscription
const isMember = currentUser?.lifetimeMember ||
(currentUser?.subscriptionId && currentUser?.subscriptionStatus === 'active');
// console.log('sidebar currentUser:', currentUser);
return (
<Sidebar collapsible="icon" {...props}>
@ -59,7 +56,7 @@ export function DashboardSidebar({ ...props }: React.ComponentProps<typeof Sideb
{!isPending && (
<>
{/* show upgrade card if user is not a member */}
{!isMember && <SidebarUpgradeCard />}
{currentUser && <SidebarUpgradeCard user={currentUser} />}
{/* show user profile if user is logged in */}
{currentUser && <NavUser user={currentUser} />}

View File

@ -8,12 +8,26 @@ import {
} from "@/components/ui/card";
import { LocaleLink } from "@/i18n/navigation";
import { Routes } from "@/routes";
import { Session } from "@/lib/auth";
import { SparklesIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export function SidebarUpgradeCard() {
interface SidebarUpgradeCardProps {
user: Session['user'];
}
export function SidebarUpgradeCard({ user }: SidebarUpgradeCardProps) {
const t = useTranslations('Dashboard.upgrade');
// user is a member if they have a lifetime membership or an active/trialing subscription
const isMember = user?.lifetimeMember ||
(user?.subscriptionId && (user.subscriptionStatus === 'active' || user.subscriptionStatus === 'trialing'));
// if user is a member, don't show the upgrade card
if (isMember) {
return null;
}
return (
<Card className="shadow-none">
<CardHeader className="gap-2">

View File

@ -8,230 +8,230 @@ export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide'
export type PerType = 'word' | 'char' | 'line'
export type TextEffectProps = {
children: string
per?: PerType
as?: keyof React.JSX.IntrinsicElements
variants?: {
container?: Variants
item?: Variants
}
className?: string
preset?: PresetType
delay?: number
speedReveal?: number
speedSegment?: number
trigger?: boolean
onAnimationComplete?: () => void
onAnimationStart?: () => void
segmentWrapperClassName?: string
containerTransition?: Transition
segmentTransition?: Transition
style?: React.CSSProperties
children: string
per?: PerType
as?: keyof React.JSX.IntrinsicElements
variants?: {
container?: Variants
item?: Variants
}
className?: string
preset?: PresetType
delay?: number
speedReveal?: number
speedSegment?: number
trigger?: boolean
onAnimationComplete?: () => void
onAnimationStart?: () => void
segmentWrapperClassName?: string
containerTransition?: Transition
segmentTransition?: Transition
style?: React.CSSProperties
}
const defaultStaggerTimes: Record<PerType, number> = {
char: 0.03,
word: 0.05,
line: 0.1,
char: 0.03,
word: 0.05,
line: 0.1,
}
const defaultContainerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
}
const defaultItemVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
},
exit: { opacity: 0 },
hidden: { opacity: 0 },
visible: {
opacity: 1,
},
exit: { opacity: 0 },
}
const presetVariants: Record<PresetType, { container: Variants; item: Variants }> = {
blur: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: 'blur(12px)' },
visible: { opacity: 1, filter: 'blur(0px)' },
exit: { opacity: 0, filter: 'blur(12px)' },
},
blur: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: 'blur(12px)' },
visible: { opacity: 1, filter: 'blur(0px)' },
exit: { opacity: 0, filter: 'blur(12px)' },
},
'fade-in-blur': {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
},
},
'fade-in-blur': {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20, filter: 'blur(12px)' },
visible: { opacity: 1, y: 0, filter: 'blur(0px)' },
exit: { opacity: 0, y: 20, filter: 'blur(12px)' },
},
scale: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, scale: 0 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0 },
},
},
scale: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, scale: 0 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0 },
},
fade: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
},
fade: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
slide: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
},
},
slide: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
},
},
}
const AnimationComponent: React.FC<{
segment: string
variants: Variants
per: 'line' | 'word' | 'char'
segmentWrapperClassName?: string
segment: string
variants: Variants
per: 'line' | 'word' | 'char'
segmentWrapperClassName?: string
}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
const content =
per === 'line' ? (
<motion.span
variants={variants}
className="block">
{segment}
</motion.span>
) : per === 'word' ? (
<motion.span
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre">
{segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{segment.split('').map((char, charIndex) => (
<motion.span
key={`char-${charIndex}`}
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre">
{char}
</motion.span>
))}
</motion.span>
)
const content =
per === 'line' ? (
<motion.span
variants={variants}
className="block">
{segment}
</motion.span>
) : per === 'word' ? (
<motion.span
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre">
{segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{segment.split('').map((char, charIndex) => (
<motion.span
key={`char-${charIndex}`}
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre">
{char}
</motion.span>
))}
</motion.span>
)
if (!segmentWrapperClassName) {
return content
}
if (!segmentWrapperClassName) {
return content
}
const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block'
const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block'
return <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>{content}</span>
return <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>{content}</span>
})
AnimationComponent.displayName = 'AnimationComponent'
const splitText = (text: string, per: 'line' | 'word' | 'char') => {
if (per === 'line') return text.split('\n')
return text.split(/(\s+)/)
if (per === 'line') return text.split('\n')
return text.split(/(\s+)/)
}
const hasTransition = (variant: Variant): variant is TargetAndTransition & { transition?: Transition } => {
return typeof variant === 'object' && variant !== null && 'transition' in variant
return typeof variant === 'object' && variant !== null && 'transition' in variant
}
const createVariantsWithTransition = (baseVariants: Variants, transition?: Transition & { exit?: Transition }): Variants => {
if (!transition) return baseVariants
if (!transition) return baseVariants
const { ...mainTransition } = transition
const { ...mainTransition } = transition
return {
...baseVariants,
visible: {
...baseVariants.visible,
transition: {
...(hasTransition(baseVariants.visible) ? baseVariants.visible.transition : {}),
...mainTransition,
},
},
exit: {
...baseVariants.exit,
transition: {
...(hasTransition(baseVariants.exit) ? baseVariants.exit.transition : {}),
...mainTransition,
staggerDirection: -1,
},
},
}
return {
...baseVariants,
visible: {
...baseVariants.visible,
transition: {
...(hasTransition(baseVariants.visible) ? baseVariants.visible.transition : {}),
...mainTransition,
},
},
exit: {
...baseVariants.exit,
transition: {
...(hasTransition(baseVariants.exit) ? baseVariants.exit.transition : {}),
...mainTransition,
staggerDirection: -1,
},
},
}
}
export function TextEffect({ children, per = 'word', as = 'p', variants, className, preset = 'fade', delay = 0, speedReveal = 1, speedSegment = 1, trigger = true, onAnimationComplete, onAnimationStart, segmentWrapperClassName, containerTransition, segmentTransition, style }: TextEffectProps) {
const segments = splitText(children, per)
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
const segments = splitText(children, per)
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
const baseVariants = preset ? presetVariants[preset] : { container: defaultContainerVariants, item: defaultItemVariants }
const baseVariants = preset ? presetVariants[preset] : { container: defaultContainerVariants, item: defaultItemVariants }
const stagger = defaultStaggerTimes[per] / speedReveal
const stagger = defaultStaggerTimes[per] / speedReveal
const baseDuration = 0.3 / speedSegment
const baseDuration = 0.3 / speedSegment
const customStagger = hasTransition(variants?.container?.visible ?? {}) ? (variants?.container?.visible as TargetAndTransition).transition?.staggerChildren : undefined
const customStagger = hasTransition(variants?.container?.visible ?? {}) ? (variants?.container?.visible as TargetAndTransition).transition?.staggerChildren : undefined
const customDelay = hasTransition(variants?.container?.visible ?? {}) ? (variants?.container?.visible as TargetAndTransition).transition?.delayChildren : undefined
const customDelay = hasTransition(variants?.container?.visible ?? {}) ? (variants?.container?.visible as TargetAndTransition).transition?.delayChildren : undefined
const computedVariants = {
container: createVariantsWithTransition(variants?.container || baseVariants.container, {
staggerChildren: customStagger ?? stagger,
delayChildren: customDelay ?? delay,
...containerTransition,
exit: {
staggerChildren: customStagger ?? stagger,
staggerDirection: -1,
},
}),
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
duration: baseDuration,
...segmentTransition,
}),
}
const computedVariants = {
container: createVariantsWithTransition(variants?.container || baseVariants.container, {
staggerChildren: customStagger ?? stagger,
delayChildren: customDelay ?? delay,
...containerTransition,
exit: {
staggerChildren: customStagger ?? stagger,
staggerDirection: -1,
},
}),
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
duration: baseDuration,
...segmentTransition,
}),
}
return (
<AnimatePresence mode="popLayout">
{trigger && (
<MotionTag
initial="hidden"
animate="visible"
exit="exit"
variants={computedVariants.container}
className={className}
onAnimationComplete={onAnimationComplete}
onAnimationStart={onAnimationStart}
style={style}>
{per !== 'line' ? <span className="sr-only">{children}</span> : null}
{segments.map((segment, index) => (
<AnimationComponent
key={`${per}-${index}-${segment}`}
segment={segment}
variants={computedVariants.item}
per={per}
segmentWrapperClassName={segmentWrapperClassName}
/>
))}
</MotionTag>
)}
</AnimatePresence>
)
return (
<AnimatePresence mode="popLayout">
{trigger && (
<MotionTag
initial="hidden"
animate="visible"
exit="exit"
variants={computedVariants.container}
className={className}
onAnimationComplete={onAnimationComplete}
onAnimationStart={onAnimationStart}
style={style}>
{per !== 'line' ? <span className="sr-only">{children}</span> : null}
{segments.map((segment, index) => (
<AnimationComponent
key={`${per}-${index}-${segment}`}
segment={segment}
variants={computedVariants.item}
per={per}
segmentWrapperClassName={segmentWrapperClassName}
/>
))}
</MotionTag>
)}
</AnimatePresence>
)
}

View File

@ -6,8 +6,8 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/use-current-user';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate, formatPrice } from '@/lib/formatter';
import { getAllPlans } from '@/payment';
import { PlanIntervals, Subscription } from '@/payment/types';
@ -17,11 +17,13 @@ import { useEffect, useMemo, useState } from 'react';
export default function BillingCard() {
const t = useTranslations('Dashboard.settings.billing');
const [loading, setLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | undefined>('');
const [subscription, setSubscription] = useState<Subscription | null>(null);
const currentUser = useCurrentUser();
// const currentUser = useCurrentUser();
const { data: session, isPending } = authClient.useSession();
const currentUser = session?.user;
const isLifetimeMember = currentUser?.lifetimeMember === true;
// Get all available plans
@ -29,7 +31,7 @@ export default function BillingCard() {
// Fetch user subscription data if user has a subscription ID
const fetchUserSubscription = async () => {
setLoading(true);
setIsLoading(true);
setError('');
try {
@ -49,7 +51,7 @@ export default function BillingCard() {
console.error('fetch subscription data error:', error);
setError(t('errorMessage'));
} finally {
setLoading(false);
setIsLoading(false);
}
};
@ -77,7 +79,9 @@ export default function BillingCard() {
: null;
// Determine if the user can upgrade (not a lifetime member)
const canUpgrade = !isLifetimeMember;
// const canUpgrade = !isLifetimeMember;
// Determine if the user can upgrade (free plan)
const canUpgrade = currentPlan?.isFree;
return { currentPlan, currentPrice, nextBillingDate, canUpgrade };
}, [isLifetimeMember, plans, subscription]);
@ -91,7 +95,7 @@ export default function BillingCard() {
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
{isPending || isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-full" />
@ -154,7 +158,7 @@ export default function BillingCard() {
)}
</CardContent>
<CardFooter>
{loading ? (
{isPending || isLoading ? (
<Skeleton className="h-10 w-full" />
) : error ? (
<Button
@ -166,14 +170,14 @@ export default function BillingCard() {
{t('retry')}
</Button>
) : (
<div className="grid w-full gap-3 lg:grid-cols-2">
<div className="grid w-full gap-3">
{/* Manage subscription button */}
{subscription && currentUser?.customerId && (
{currentUser?.customerId && (
<CustomerPortalButton
customerId={currentUser.customerId}
className="w-full"
>
{t('manageSubscription')}
{t('manageBilling')}
</CustomerPortalButton>
)}
@ -186,20 +190,10 @@ export default function BillingCard() {
>
<LocaleLink href="/pricing">
{/* <RocketIcon className="size-4 mr-2" /> */}
{'Change Plan'}
{t('upgradePlan')}
</LocaleLink>
</Button>
)}
{/* Lifetime member billing history button */}
{isLifetimeMember && currentUser?.customerId && (
<CustomerPortalButton
customerId={currentUser.customerId}
className="w-full"
>
{t('viewBillingHistory')}
</CustomerPortalButton>
)}
</div>
)}
</CardFooter>

View File

@ -418,6 +418,11 @@ export function getAvatarLinks(): MenuItem[] {
href: Routes.Dashboard,
icon: <LayoutDashboardIcon className="size-4 shrink-0" />,
},
{
title: t('Marketing.avatar.billing'),
href: Routes.SettingsBilling,
icon: <CreditCardIcon className="size-4 shrink-0" />,
},
{
title: t('Marketing.avatar.settings'),
href: Routes.SettingsProfile,

View File

@ -16,7 +16,6 @@ const freePlan: PricePlan = {
prices: [],
isFree: true,
isLifetime: false,
// isSubscription: true,
};
/**
@ -41,7 +40,6 @@ const proPlan: PricePlan = {
amount: 990,
currency: "USD",
interval: PlanIntervals.MONTH,
trialPeriodDays: 7,
},
{
type: PaymentTypes.RECURRING,
@ -49,12 +47,10 @@ const proPlan: PricePlan = {
amount: 9900,
currency: "USD",
interval: PlanIntervals.YEAR,
trialPeriodDays: 7,
},
],
isFree: false,
isLifetime: false,
// isSubscription: true,
recommended: true,
};
@ -84,7 +80,6 @@ const lifetimePlan: PricePlan = {
],
isFree: false,
isLifetime: true,
// isSubscription: false,
};
/**

View File

@ -166,17 +166,21 @@ export class StripeProvider implements PaymentProvider {
// Add customer to checkout session
checkoutParams.customer = customerId;
// Add trial period if it's a subscription and has trial days
if (price.type === PaymentTypes.RECURRING
&& price.trialPeriodDays && price.trialPeriodDays > 0) {
// Add subscription data for recurring payments
if (price.type === PaymentTypes.RECURRING) {
// Initialize subscription_data with metadata
checkoutParams.subscription_data = {
trial_period_days: price.trialPeriodDays,
metadata: {
planId,
priceId,
...metadata,
},
};
// Add trial period if applicable
if (price.trialPeriodDays && price.trialPeriodDays > 0) {
checkoutParams.subscription_data.trial_period_days = price.trialPeriodDays;
}
}
// Create the checkout session
@ -280,10 +284,9 @@ export class StripeProvider implements PaymentProvider {
? new Date(subscription.trial_end * 1000)
: undefined,
createdAt: new Date(subscription.created * 1000),
updatedAt: new Date(),
};
} catch (error) {
console.error('Get subscription failed:', error);
console.error('Get subscription error:', error);
return null;
}
}
@ -305,12 +308,14 @@ export class StripeProvider implements PaymentProvider {
// Sort by creation date, newest first
status: status as any, // Type cast to handle our custom status types
});
console.log('list customer subscriptions:', subscriptions);
// Map to our subscription model
return subscriptions.data.map(subscription => {
// Determine the interval if available
let interval: PlanInterval | undefined = undefined;
if (subscription.items.data[0]?.plan.interval === 'month' || subscription.items.data[0]?.plan.interval === 'year') {
if (subscription.items.data[0]?.plan.interval === 'month'
|| subscription.items.data[0]?.plan.interval === 'year') {
interval = subscription.items.data[0]?.plan.interval as PlanInterval;
}
@ -339,7 +344,7 @@ export class StripeProvider implements PaymentProvider {
};
});
} catch (error) {
console.error('List customer subscriptions failed:', error);
console.error('List customer subscriptions error:', error);
return [];
}
}

View File

@ -94,7 +94,6 @@ export interface Subscription {
canceledAt?: Date;
trialEndDate?: Date;
createdAt: Date;
updatedAt: Date;
}
/**