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:
parent
b639a9e50a
commit
36e76ef080
@ -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"
|
||||
},
|
||||
|
@ -383,6 +383,7 @@
|
||||
},
|
||||
"avatar": {
|
||||
"dashboard": "工作台",
|
||||
"billing": "账单",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
@ -441,10 +442,11 @@
|
||||
},
|
||||
"nextBillingDate": "下次账单日期:",
|
||||
"trialEnds": "试用结束日期:",
|
||||
"manageSubscription": "管理订阅",
|
||||
"freePlanMessage": "您当前使用的是功能有限的免费方案",
|
||||
"lifetimeMessage": "您拥有所有高级功能的终身使用权限",
|
||||
"viewBillingHistory": "查看账单历史",
|
||||
"manageSubscription": "管理订阅",
|
||||
"billingHistory": "账单历史",
|
||||
"manageBilling": "管理订阅和账单",
|
||||
"retry": "重试",
|
||||
"errorMessage": "获取数据失败"
|
||||
},
|
||||
|
@ -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',
|
||||
|
@ -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} />}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
@ -94,7 +94,6 @@ export interface Subscription {
|
||||
canceledAt?: Date;
|
||||
trialEndDate?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user