1- import { convertCreditsToUsdCents } from '@codebuff/common/util/currency'
2- import { pluralize } from '@codebuff/common/util/string'
31import { Loader2 as Loader } from 'lucide-react'
42import { useState } from 'react'
53
64import { Button } from '@/components/ui/button'
7- import { Input } from '@/components/ui/input'
8- import { Label } from '@/components/ui/label'
9- import { NeonGradientButton } from '@/components/ui/neon-gradient-button'
10- import { formatDollars } from '@/lib/currency'
5+ import { dollarsToCredits } from '@/lib/currency'
116import { cn } from '@/lib/utils'
127
13- // Individual user credit options (starting from $10)
14- export const CREDIT_OPTIONS = [ 1000 , 2500 , 5000 , 10000 ] as const
15- export const CENTS_PER_CREDIT = 1
16- const MIN_CREDITS = 500
17- const MAX_CREDITS = 100000
18-
19- // Organization credit options (starting from $100)
20- export const ORG_CREDIT_OPTIONS = [ 10000 , 25000 , 50000 , 100000 ] as const
21- const MIN_CREDITS_ORG = 5000
22- const MAX_CREDITS_ORG = 1000000
8+ const DOLLAR_OPTIONS = [ 10 , 25 , 50 , 100 ] as const
9+ const ORG_DOLLAR_OPTIONS = [ 50 , 100 , 250 , 1000 ] as const
2310
2411export interface CreditPurchaseSectionProps {
2512 onPurchase : ( credits : number ) => void
2613 onSaveAutoTopupSettings ?: ( ) => Promise < boolean >
2714 isAutoTopupEnabled ?: boolean
28- isAutoTopupPending ?: boolean
2915 isPending ?: boolean
3016 isPurchasePending : boolean
3117 isOrganization ?: boolean
@@ -35,166 +21,57 @@ export function CreditPurchaseSection({
3521 onPurchase,
3622 onSaveAutoTopupSettings,
3723 isAutoTopupEnabled,
38- isAutoTopupPending,
3924 isPending,
4025 isPurchasePending,
4126 isOrganization = false ,
4227} : CreditPurchaseSectionProps ) {
43- const [ selectedCredits , setSelectedCredits ] = useState < number | null > ( null )
44- const [ customCredits , setCustomCredits ] = useState < string > ( '' )
45- const [ customError , setCustomError ] = useState < string > ( '' )
4628 const [ cooldownActive , setCooldownActive ] = useState ( false )
29+ const [ purchasingDollars , setPurchasingDollars ] = useState < number | null > (
30+ null ,
31+ )
4732
48- // Use organization-specific options if isOrganization is true
49- const creditOptions = isOrganization ? ORG_CREDIT_OPTIONS : CREDIT_OPTIONS
50- const minCredits = isOrganization ? MIN_CREDITS_ORG : MIN_CREDITS
51- const maxCredits = isOrganization ? MAX_CREDITS_ORG : MAX_CREDITS
33+ const dollarOptions = isOrganization ? ORG_DOLLAR_OPTIONS : DOLLAR_OPTIONS
34+ const isDisabled = isPending || isPurchasePending || cooldownActive
5235
53- const handlePurchaseClick = async ( ) => {
54- const credits = selectedCredits || parseInt ( customCredits )
55- if ( ! credits || isPurchasePending || isPending || cooldownActive ) return
36+ const handlePurchase = async ( dollars : number ) => {
37+ if ( isDisabled ) return
5638
5739 let canProceed = true
5840 if ( isAutoTopupEnabled && onSaveAutoTopupSettings ) {
5941 canProceed = await onSaveAutoTopupSettings ( )
6042 }
6143
6244 if ( canProceed ) {
45+ setPurchasingDollars ( dollars )
6346 setCooldownActive ( true )
64- setTimeout ( ( ) => setCooldownActive ( false ) , 3000 ) // 3 second cooldown
65- onPurchase ( credits )
47+ setTimeout ( ( ) => {
48+ setCooldownActive ( false )
49+ setPurchasingDollars ( null )
50+ } , 3000 )
51+ onPurchase ( dollarsToCredits ( dollars ) )
6652 }
6753 }
6854
69- const handleCreditSelection = ( credits : number ) => {
70- setSelectedCredits ( ( currentSelected ) =>
71- currentSelected === credits ? null : credits ,
72- )
73- setCustomCredits ( '' )
74- setCustomError ( '' )
75- }
76-
77- const handleCustomCreditsChange = ( value : string ) => {
78- setCustomCredits ( value )
79- setSelectedCredits ( null )
80-
81- if ( ! value ) {
82- setCustomError ( '' )
83- return
84- }
85-
86- const numCredits = parseInt ( value )
87- if ( isNaN ( numCredits ) ) {
88- setCustomError ( 'Please enter a valid number' )
89- } else if ( numCredits < minCredits ) {
90- setCustomError ( `Minimum ${ pluralize ( minCredits , 'credit' ) } ` )
91- } else if ( numCredits > maxCredits ) {
92- setCustomError ( `Maximum ${ pluralize ( maxCredits , 'credit' ) } ` )
93- } else {
94- setCustomError ( '' )
95- }
96- }
97-
98- const isValid = selectedCredits || ( customCredits && ! customError )
99- const effectiveCredits =
100- selectedCredits ||
101- ( customCredits && ! customError ? parseInt ( customCredits ) : null )
102- const costInCents = effectiveCredits
103- ? convertCreditsToUsdCents ( effectiveCredits , CENTS_PER_CREDIT )
104- : 0
105-
106- const costInDollars = formatDollars ( costInCents )
107-
10855 return (
109- < div className = "space-y-6" >
110- < div className = "grid grid-cols-2 sm:grid-cols-4 gap-3" >
111- { creditOptions . map ( ( credits ) => {
112- const optionCostInCents = convertCreditsToUsdCents (
113- credits ,
114- CENTS_PER_CREDIT ,
115- )
116- const optionCostInDollars = formatDollars ( optionCostInCents )
117-
118- return (
119- < Button
120- key = { credits }
121- variant = "outline"
122- onClick = { ( ) => handleCreditSelection ( credits ) }
123- className = { cn (
124- 'flex flex-col p-4 h-auto gap-1 transition-colors' ,
125- selectedCredits === credits
126- ? 'border-primary bg-accent'
127- : 'hover:bg-accent/50' ,
128- ) }
129- disabled = { isPending || isPurchasePending || cooldownActive }
130- >
131- < span className = "text-lg font-semibold" >
132- { credits . toLocaleString ( ) }
133- </ span >
134- < span className = "text-sm text-muted-foreground" >
135- ${ optionCostInDollars }
136- </ span >
137- </ Button >
138- )
139- } ) }
140- </ div >
141-
142- < div className = "flex flex-col md:flex-row gap-4 items-start md:items-end" >
143- < div className = "w-full flex-1 space-y-2" >
144- < Label htmlFor = "custom-credits" > Or enter a custom amount:</ Label >
145- < div >
146- < div className = "flex flex-col md:flex-row gap-4 items-start" >
147- < div className = "w-full flex-1" >
148- < Input
149- id = "custom-credits"
150- type = "number"
151- min = { minCredits }
152- max = { maxCredits }
153- value = { customCredits }
154- onChange = { ( e ) => handleCustomCreditsChange ( e . target . value ) }
155- placeholder = { `${ pluralize ( minCredits , 'credit' ) } - ${ pluralize ( maxCredits , 'credit' ) } ` }
156- className = { cn ( customError && 'border-destructive' ) }
157- disabled = { cooldownActive }
158- />
159- { customError && (
160- < p className = "text-xs text-destructive mt-2 pl-1" >
161- { customError }
162- </ p >
163- ) }
164- { customCredits && ! customError && (
165- < p className = "text-sm text-muted-foreground mt-2 pl-1" >
166- We'll charge you ${ costInDollars }
167- </ p >
168- ) }
169- </ div >
170-
171- < NeonGradientButton
172- onClick = { handlePurchaseClick }
173- disabled = {
174- ! isValid || isPending || isPurchasePending || cooldownActive
175- }
176- className = { cn (
177- 'w-full md:w-auto transition-opacity min-w-[120px]' ,
178- ( ! isValid ||
179- isPending ||
180- isPurchasePending ||
181- cooldownActive ) &&
182- 'opacity-50' ,
183- ) }
184- neonColors = { {
185- firstColor : '#4F46E5' ,
186- secondColor : '#06B6D4' ,
187- } }
188- >
189- { isPurchasePending ? (
190- < Loader className = "mr-2 size-4 animate-spin" />
191- ) : null }
192- Buy Credits
193- </ NeonGradientButton >
194- </ div >
195- </ div >
196- </ div >
197- </ div >
56+ < div className = "grid grid-cols-2 sm:grid-cols-4 gap-3" >
57+ { dollarOptions . map ( ( dollars ) => (
58+ < Button
59+ key = { dollars }
60+ variant = "outline"
61+ onClick = { ( ) => handlePurchase ( dollars ) }
62+ className = { cn (
63+ 'flex flex-col p-4 h-auto transition-all' ,
64+ 'hover:bg-accent/50 hover:border-primary' ,
65+ ) }
66+ disabled = { isDisabled }
67+ >
68+ { isPurchasePending && purchasingDollars === dollars ? (
69+ < Loader className = "size-5 animate-spin" />
70+ ) : (
71+ < span className = "text-xl font-bold" > ${ dollars } </ span >
72+ ) }
73+ </ Button >
74+ ) ) }
19875 </ div >
19976 )
20077}
0 commit comments