11import { PermissionAction } from '@supabase/shared-types/out/constants'
2- import { useEffect , useState } from 'react'
2+ import {
3+ forwardRef ,
4+ useCallback ,
5+ useEffect ,
6+ useImperativeHandle ,
7+ useMemo ,
8+ useRef ,
9+ useState ,
10+ } from 'react'
311import { toast } from 'sonner'
412
513import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
614import { ButtonTooltip } from 'components/ui/ButtonTooltip'
715import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
816import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
917import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
10- import { BASE_PATH } from 'lib/constants'
18+ import { BASE_PATH , STRIPE_PUBLIC_KEY } from 'lib/constants'
1119import { getURL } from 'lib/helpers'
1220import { AlertCircle , CreditCard , Loader , Plus } from 'lucide-react'
1321import { Listbox } from 'ui'
22+ import HCaptcha from '@hcaptcha/react-hcaptcha'
23+ import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
24+ import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
25+ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
26+ import { loadStripe , PaymentMethod , StripeElementsOptions } from '@stripe/stripe-js'
27+ import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
28+ import { useTheme } from 'next-themes'
29+ import { Elements } from '@stripe/react-stripe-js'
30+ import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement'
31+ import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
32+
33+ const stripePromise = loadStripe ( STRIPE_PUBLIC_KEY )
1434
1535export interface PaymentMethodSelectionProps {
1636 selectedPaymentMethod ?: string
1737 onSelectPaymentMethod : ( id : string ) => void
1838 layout ?: 'vertical' | 'horizontal'
39+ createPaymentMethodInline : boolean
40+ readOnly : boolean
1941}
2042
21- const PaymentMethodSelection = ( {
22- selectedPaymentMethod,
23- onSelectPaymentMethod,
24- layout = 'vertical' ,
25- } : PaymentMethodSelectionProps ) => {
43+ const PaymentMethodSelection = forwardRef ( function PaymentMethodSelection (
44+ {
45+ selectedPaymentMethod,
46+ onSelectPaymentMethod,
47+ layout = 'vertical' ,
48+ createPaymentMethodInline = false ,
49+ readOnly,
50+ } : PaymentMethodSelectionProps ,
51+ ref
52+ ) {
2653 const selectedOrganization = useSelectedOrganization ( )
2754 const slug = selectedOrganization ?. slug
2855 const [ showAddNewPaymentMethodModal , setShowAddNewPaymentMethodModal ] = useState ( false )
56+ const captchaLoaded = useIsHCaptchaLoaded ( )
57+ const [ captchaToken , setCaptchaToken ] = useState < string | null > ( null )
58+ const [ captchaRef , setCaptchaRef ] = useState < HCaptcha | null > ( null )
59+ const [ setupIntent , setSetupIntent ] = useState < SetupIntentResponse | undefined > ( undefined )
60+ const { resolvedTheme } = useTheme ( )
61+ const paymentRef = useRef < { createPaymentMethod : ( ) => Promise < PaymentMethod | undefined > } > ( null )
62+ const [ setupNewPaymentMethod , setSetupNewPaymentMethod ] = useState < boolean | null > ( null )
2963
3064 const {
3165 data : paymentMethods ,
3266 isLoading,
3367 refetch : refetchPaymentMethods ,
3468 } = useOrganizationPaymentMethodsQuery ( { slug } )
3569
70+ const captchaRefCallback = useCallback ( ( node : any ) => {
71+ setCaptchaRef ( node )
72+ } , [ ] )
73+
74+ const { mutate : initSetupIntent , isLoading : setupIntentLoading } =
75+ useOrganizationPaymentMethodSetupIntent ( {
76+ onSuccess : ( intent ) => {
77+ setSetupIntent ( intent )
78+ } ,
79+ onError : ( error ) => {
80+ toast . error ( `Failed to setup intent: ${ error . message } ` )
81+ } ,
82+ } )
83+
84+ useEffect ( ( ) => {
85+ if ( paymentMethods ?. data && paymentMethods . data . length === 0 && setupNewPaymentMethod == null ) {
86+ setSetupNewPaymentMethod ( true )
87+ }
88+ } , [ paymentMethods ] )
89+
90+ useEffect ( ( ) => {
91+ const loadSetupIntent = async ( hcaptchaToken : string | undefined ) => {
92+ const slug = selectedOrganization ?. slug
93+ if ( ! slug ) return console . error ( 'Slug is required' )
94+ if ( ! hcaptchaToken ) return console . error ( 'HCaptcha token required' )
95+
96+ setSetupIntent ( undefined )
97+ initSetupIntent ( { slug : slug ! , hcaptchaToken } )
98+ }
99+
100+ const loadPaymentForm = async ( ) => {
101+ if ( setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded ) {
102+ let token = captchaToken
103+
104+ try {
105+ if ( ! token ) {
106+ const captchaResponse = await captchaRef . execute ( { async : true } )
107+ token = captchaResponse ?. response ?? null
108+ }
109+ } catch ( error ) {
110+ return
111+ }
112+
113+ await loadSetupIntent ( token ?? undefined )
114+ resetCaptcha ( )
115+ }
116+ }
117+
118+ loadPaymentForm ( )
119+ } , [ createPaymentMethodInline , captchaRef , captchaLoaded , setupNewPaymentMethod ] )
120+
121+ const resetCaptcha = ( ) => {
122+ setCaptchaToken ( null )
123+ captchaRef ?. resetCaptcha ( )
124+ }
125+
36126 const canUpdatePaymentMethods = useCheckPermissions (
37127 PermissionAction . BILLING_WRITE ,
38128 'stripe.payment_methods'
39129 )
40130
131+ const stripeOptionsPaymentMethod : StripeElementsOptions = useMemo (
132+ ( ) =>
133+ ( {
134+ clientSecret : setupIntent ? setupIntent . client_secret ! : '' ,
135+ appearance : getStripeElementsAppearanceOptions ( resolvedTheme ) ,
136+ paymentMethodCreation : 'manual' ,
137+ } ) as const ,
138+ [ setupIntent , resolvedTheme ]
139+ )
140+
41141 useEffect ( ( ) => {
42142 if ( paymentMethods ?. data && paymentMethods . data . length > 0 ) {
43143 const selectedPaymentMethodExists = paymentMethods . data . some (
@@ -55,15 +155,49 @@ const PaymentMethodSelection = ({
55155 }
56156 } , [ selectedPaymentMethod , paymentMethods , onSelectPaymentMethod ] )
57157
158+ // If createPaymentMethod already exists, use it. Otherwise, define it here.
159+ const createPaymentMethod = async ( ) => {
160+ if ( setupNewPaymentMethod || ( paymentMethods ?. data && paymentMethods . data . length === 0 ) ) {
161+ return paymentRef . current ?. createPaymentMethod ( )
162+ } else {
163+ return { id : selectedPaymentMethod }
164+ }
165+ }
166+
167+ useImperativeHandle ( ref , ( ) => ( {
168+ createPaymentMethod,
169+ } ) )
170+
58171 return (
59172 < >
173+ < HCaptcha
174+ ref = { captchaRefCallback }
175+ sitekey = { process . env . NEXT_PUBLIC_HCAPTCHA_SITE_KEY ! }
176+ size = "invisible"
177+ onOpen = { ( ) => {
178+ // [Joshen] This is to ensure that hCaptcha popup remains clickable
179+ if ( document !== undefined ) document . body . classList . add ( '!pointer-events-auto' )
180+ } }
181+ onClose = { ( ) => {
182+ setSetupIntent ( undefined )
183+ if ( document !== undefined ) document . body . classList . remove ( '!pointer-events-auto' )
184+ } }
185+ onVerify = { ( token ) => {
186+ setCaptchaToken ( token )
187+ if ( document !== undefined ) document . body . classList . remove ( '!pointer-events-auto' )
188+ } }
189+ onExpire = { ( ) => {
190+ setCaptchaToken ( null )
191+ } }
192+ />
193+
60194 < div >
61195 { isLoading ? (
62196 < div className = "flex items-center px-4 py-2 space-x-4 border rounded-md border-strong bg-surface-200" >
63197 < Loader className = "animate-spin" size = { 14 } />
64198 < p className = "text-sm text-foreground-light" > Retrieving payment methods</ p >
65199 </ div >
66- ) : paymentMethods ?. data . length === 0 ? (
200+ ) : paymentMethods ?. data . length === 0 && ! createPaymentMethodInline ? (
67201 < div className = "flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative" >
68202 < div className = "flex items-center space-x-4 text-foreground-light" >
69203 < AlertCircle size = { 16 } strokeWidth = { 1.5 } />
@@ -74,7 +208,13 @@ const PaymentMethodSelection = ({
74208 type = "default"
75209 disabled = { ! canUpdatePaymentMethods }
76210 icon = { < CreditCard /> }
77- onClick = { ( ) => setShowAddNewPaymentMethodModal ( true ) }
211+ onClick = { ( ) => {
212+ if ( createPaymentMethodInline ) {
213+ setSetupNewPaymentMethod ( true )
214+ } else {
215+ setShowAddNewPaymentMethodModal ( true )
216+ }
217+ } }
78218 htmlType = "button"
79219 tooltip = { {
80220 content : {
@@ -93,7 +233,7 @@ const PaymentMethodSelection = ({
93233 Add new
94234 </ ButtonTooltip >
95235 </ div >
96- ) : (
236+ ) : paymentMethods ?. data && paymentMethods ?. data . length > 0 && ! setupNewPaymentMethod ? (
97237 < Listbox
98238 layout = { layout }
99239 label = "Payment method"
@@ -126,14 +266,42 @@ const PaymentMethodSelection = ({
126266 } ) }
127267 < div
128268 className = "flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
129- onClick = { ( ) => setShowAddNewPaymentMethodModal ( true ) }
269+ onClick = { ( ) => {
270+ if ( createPaymentMethodInline ) {
271+ setSetupNewPaymentMethod ( true )
272+ } else {
273+ setShowAddNewPaymentMethodModal ( true )
274+ }
275+ } }
130276 >
131277 < Plus size = { 16 } />
132278 < p className = "transition text-foreground-light group-hover:text-foreground" >
133279 Add new payment method
134280 </ p >
135281 </ div >
136282 </ Listbox >
283+ ) : null }
284+
285+ { stripePromise && setupIntent && (
286+ < Elements stripe = { stripePromise } options = { stripeOptionsPaymentMethod } >
287+ < NewPaymentMethodElement
288+ ref = { paymentRef }
289+ pending_subscription_flow_enabled = { true }
290+ email = { selectedOrganization ?. billing_email }
291+ readOnly = { readOnly }
292+ />
293+ </ Elements >
294+ ) }
295+
296+ { setupIntentLoading && (
297+ < div className = "space-y-2" >
298+ < ShimmeringLoader className = "h-10" />
299+ < div className = "grid grid-cols-2 gap-4" >
300+ < ShimmeringLoader className = "h-10" />
301+ < ShimmeringLoader className = "h-10" />
302+ </ div >
303+ < ShimmeringLoader className = "h-10" />
304+ </ div >
137305 ) }
138306 </ div >
139307
@@ -158,6 +326,8 @@ const PaymentMethodSelection = ({
158326 />
159327 </ >
160328 )
161- }
329+ } )
330+
331+ PaymentMethodSelection . displayName = 'PaymentMethodSelection'
162332
163333export default PaymentMethodSelection
0 commit comments