Skip to content

Commit 810d33f

Browse files
committed
refactor: consolidate billing portal buttons into single button in UsageSection title
1 parent 5617bac commit 810d33f

File tree

3 files changed

+73
-130
lines changed

3 files changed

+73
-130
lines changed

web/src/app/profile/components/subscription-section.tsx

Lines changed: 18 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
'use client'
22

33
import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans'
4-
import { env } from '@codebuff/common/env'
54
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
65
import {
76
AlertTriangle,
8-
ExternalLink,
97
Loader2,
108
} from 'lucide-react'
119
import Link from 'next/link'
@@ -51,37 +49,11 @@ function ProgressBar({ percentAvailable, label }: { percentAvailable: number; la
5149
)
5250
}
5351

54-
function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; email: string }) {
52+
function SubscriptionActive({ data }: { data: ActiveSubscriptionResponse }) {
5553
const { subscription, rateLimit, fallbackToALaCarte } = data
5654
const isCanceling = subscription.cancelAtPeriodEnd
57-
const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}`
5855
const queryClient = useQueryClient()
5956

60-
const billingPortalMutation = useMutation({
61-
mutationFn: async () => {
62-
const res = await fetch('/api/user/billing-portal', {
63-
method: 'POST',
64-
})
65-
if (!res.ok) {
66-
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
67-
throw new Error(error.error || 'Failed to open billing portal')
68-
}
69-
const data = await res.json()
70-
return data.url as string
71-
},
72-
onSuccess: (url) => {
73-
window.open(url, '_blank', 'noopener,noreferrer')
74-
},
75-
onError: (err: Error) => {
76-
// Fall back to the prefilled email portal URL on error
77-
window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer')
78-
toast({
79-
title: 'Note',
80-
description: 'Opened billing portal - you may need to sign in.',
81-
})
82-
},
83-
})
84-
8557
const updatePreferenceMutation = useMutation({
8658
mutationFn: async (newValue: boolean) => {
8759
const res = await fetch('/api/user/preferences', {
@@ -122,42 +94,23 @@ function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse;
12294
return (
12395
<Card>
12496
<CardHeader className="pb-5">
125-
<div className="flex items-center justify-between">
126-
<CardTitle className="flex items-baseline gap-2 text-lg">
127-
<span>💪</span>
128-
{SUBSCRIPTION_DISPLAY_NAME}
129-
<span className="text-sm font-normal text-muted-foreground">
130-
${subscription.tier}/mo
97+
<CardTitle className="flex items-baseline gap-2 text-lg">
98+
<span>💪</span>
99+
{SUBSCRIPTION_DISPLAY_NAME}
100+
<span className="text-sm font-normal text-muted-foreground">
101+
${subscription.tier}/mo
102+
</span>
103+
{isCanceling && (
104+
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground">
105+
Canceling
131106
</span>
132-
{isCanceling && (
133-
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground">
134-
Canceling
135-
</span>
136-
)}
137-
{subscription.scheduledTier != null && (
138-
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground">
139-
Renewing at ${subscription.scheduledTier}/mo
140-
</span>
141-
)}
142-
</CardTitle>
143-
<button
144-
onClick={() => billingPortalMutation.mutate()}
145-
disabled={billingPortalMutation.isPending}
146-
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 disabled:opacity-50"
147-
>
148-
{billingPortalMutation.isPending ? (
149-
<>
150-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
151-
Opening...
152-
</>
153-
) : (
154-
<>
155-
Manage
156-
<ExternalLink className="h-3.5 w-3.5" />
157-
</>
158-
)}
159-
</button>
160-
</div>
107+
)}
108+
{subscription.scheduledTier != null && (
109+
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground">
110+
Renewing at ${subscription.scheduledTier}/mo
111+
</span>
112+
)}
113+
</CardTitle>
161114
</CardHeader>
162115
<CardContent className="space-y-5">
163116
{rateLimit.limited && (
@@ -276,7 +229,5 @@ export function SubscriptionSection() {
276229
return <SubscriptionCta />
277230
}
278231

279-
const email = session?.user?.email || ''
280-
281-
return <SubscriptionActive data={data} email={email} />
232+
return <SubscriptionActive data={data} />
282233
}

web/src/app/profile/components/usage-section.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,21 @@
33
import { env } from '@codebuff/common/env'
44
import { loadStripe } from '@stripe/stripe-js'
55
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
6+
import { ExternalLink, Loader2 } from 'lucide-react'
67
import { useSession } from 'next-auth/react'
78
import { useState } from 'react'
89

910
import { SubscriptionSection } from './subscription-section'
1011
import { UsageDisplay } from './usage-display'
1112

1213
import { CreditManagementSection } from '@/components/credits/CreditManagementSection'
14+
import { Button } from '@/components/ui/button'
1315
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
1416
import { CreditConfetti } from '@/components/ui/credit-confetti'
1517
import { toast } from '@/components/ui/use-toast'
1618

1719
const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => {
1820
const { data: session } = useSession()
19-
const email = session?.user?.email || ''
2021
const queryClient = useQueryClient()
2122
const [showConfetti, setShowConfetti] = useState(false)
2223
const [purchasedAmount, setPurchasedAmount] = useState(0)
@@ -84,7 +85,6 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => {
8485
isPurchasePending={buyCreditsMutation.isPending}
8586
showAutoTopup={true}
8687
isLoading={isLoading}
87-
email={email}
8888
/>
8989
</div>
9090
</CardContent>
@@ -120,13 +120,63 @@ export function UsageSection() {
120120
const isUsageOrProfileLoading =
121121
isLoadingUsage || (status === 'authenticated' && !usageData)
122122

123+
const email = session?.user?.email || ''
124+
const fallbackPortalUrl = email
125+
? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}`
126+
: env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL
127+
128+
const billingPortalMutation = useMutation({
129+
mutationFn: async () => {
130+
const res = await fetch('/api/user/billing-portal', {
131+
method: 'POST',
132+
})
133+
if (!res.ok) {
134+
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
135+
throw new Error(error.error || 'Failed to open billing portal')
136+
}
137+
const data = await res.json()
138+
return data.url as string
139+
},
140+
onSuccess: (url) => {
141+
window.open(url, '_blank', 'noopener,noreferrer')
142+
},
143+
onError: () => {
144+
// Fall back to the prefilled email portal URL on error
145+
window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer')
146+
toast({
147+
title: 'Note',
148+
description: 'Opened billing portal - you may need to sign in.',
149+
})
150+
},
151+
})
152+
123153
return (
124154
<div className="space-y-6">
125-
{' '}
126-
<div className="space-y-1 mb-6">
155+
<div className="flex items-start justify-between gap-4 mb-6">
127156
<p className="text-muted-foreground">
128157
Track your credit usage and purchase additional credits as needed.
129158
</p>
159+
{status === 'authenticated' && (
160+
<Button
161+
variant="outline"
162+
size="sm"
163+
onClick={() => billingPortalMutation.mutate()}
164+
disabled={billingPortalMutation.isPending}
165+
className="flex-shrink-0"
166+
>
167+
{billingPortalMutation.isPending ? (
168+
<>
169+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
170+
Opening...
171+
</>
172+
) : (
173+
<>
174+
Billing Portal
175+
<ExternalLink className="ml-2 h-4 w-4" />
176+
</>
177+
)}
178+
</Button>
179+
)}
130180
</div>
131181
{status === 'authenticated' && <SubscriptionSection />}
132182
{isUsageError && (

web/src/components/credits/CreditManagementSection.tsx

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import { env } from '@codebuff/common/env'
2-
import { useMutation } from '@tanstack/react-query'
3-
import { ExternalLink, Loader2 } from 'lucide-react'
4-
51
import { CreditManagementSkeleton } from './CreditManagementSkeleton'
62
import { CreditPurchaseSection } from './CreditPurchaseSection'
73

84
import { AutoTopupSettings } from '@/components/auto-topup/AutoTopupSettings'
95
import { OrgAutoTopupSettings } from '@/components/auto-topup/OrgAutoTopupSettings'
10-
import { Button } from '@/components/ui/button'
11-
import { toast } from '@/components/ui/use-toast'
126

137
export interface CreditManagementSectionProps {
148
onPurchase: (credits: number) => void
@@ -19,7 +13,6 @@ export interface CreditManagementSectionProps {
1913
organizationId?: string
2014
isOrganization?: boolean // Keep for backward compatibility
2115
isLoading?: boolean
22-
email?: string
2316
}
2417

2518
export { CreditManagementSkeleton }
@@ -33,69 +26,18 @@ export function CreditManagementSection({
3326
organizationId,
3427
isOrganization = false,
3528
isLoading = false,
36-
email,
3729
}: CreditManagementSectionProps) {
3830
// Determine if we're in organization context
3931
const isOrgContext = context === 'organization' || isOrganization
4032

41-
const fallbackPortalUrl = email
42-
? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}`
43-
: env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL
44-
45-
const billingPortalMutation = useMutation({
46-
mutationFn: async () => {
47-
const res = await fetch('/api/user/billing-portal', {
48-
method: 'POST',
49-
})
50-
if (!res.ok) {
51-
const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' }))
52-
throw new Error(error.error || 'Failed to open billing portal')
53-
}
54-
const data = await res.json()
55-
return data.url as string
56-
},
57-
onSuccess: (url) => {
58-
window.open(url, '_blank', 'noopener,noreferrer')
59-
},
60-
onError: () => {
61-
// Fall back to the prefilled email portal URL on error
62-
window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer')
63-
toast({
64-
title: 'Note',
65-
description: 'Opened billing portal - you may need to sign in.',
66-
})
67-
},
68-
})
69-
7033
if (isLoading) {
7134
return <CreditManagementSkeleton />
7235
}
7336

7437
return (
7538
<div className={className}>
7639
<div className="space-y-8">
77-
<div className="flex items-center justify-between">
78-
<h3 className="text-2xl font-bold">Buy Credits</h3>
79-
{/* Only show billing portal button for user context - orgs have their own button */}
80-
{!isOrgContext && (
81-
<Button
82-
variant="link"
83-
size="sm"
84-
onClick={() => billingPortalMutation.mutate()}
85-
disabled={billingPortalMutation.isPending}
86-
className="text-sm text-primary underline underline-offset-4 hover:text-primary/90 p-0 h-auto"
87-
>
88-
{billingPortalMutation.isPending ? (
89-
<>
90-
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
91-
Opening...
92-
</>
93-
) : (
94-
<>Billing Portal <ExternalLink className="ml-1 h-3 w-3" /></>
95-
)}
96-
</Button>
97-
)}
98-
</div>
40+
<h3 className="text-2xl font-bold">Buy Credits</h3>
9941
<CreditPurchaseSection
10042
onPurchase={onPurchase}
10143
isPurchasePending={isPurchasePending}

0 commit comments

Comments
 (0)