@@ -8,8 +8,10 @@ import {
88} from '@stripe/react-stripe-js'
99import { useMutation , useQueryClient } from '@tanstack/react-query'
1010import { useRouter } from 'next/navigation'
11- import { type FormEvent , useEffect , useState } from 'react'
11+ import { parseAsString , useQueryStates } from 'nuqs'
12+ import { type FormEvent , useCallback , useEffect , useRef , useState } from 'react'
1213import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries'
14+ import type { VerificationPaymentResponse } from '@/core/modules/billing/models'
1315import type { TeamModel } from '@/core/modules/teams/models'
1416import { defaultErrorToast , useToast } from '@/lib/hooks/use-toast'
1517import { useTRPC } from '@/trpc/client'
@@ -30,13 +32,57 @@ import { useDashboard } from '../context'
3032const TEAM_UNBLOCK_POLL_ATTEMPTS = 15
3133const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000
3234const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...'
35+ const stripePaymentIntentParams = {
36+ payment_intent : parseAsString ,
37+ payment_intent_client_secret : parseAsString ,
38+ redirect_status : parseAsString ,
39+ }
3340
3441// Waits before retrying team status polling; 2000 -> resolves after 2 seconds.
3542const wait = ( ms : number ) =>
3643 new Promise < void > ( ( resolve ) => {
3744 window . setTimeout ( resolve , ms )
3845 } )
3946
47+ const useTeamUnblockPolling = ( ) => {
48+ const trpc = useTRPC ( )
49+ const queryClient = useQueryClient ( )
50+ const { team } = useDashboard ( )
51+
52+ const teamListQueryOptions = trpc . teams . list . queryOptions (
53+ undefined ,
54+ DASHBOARD_TEAMS_LIST_QUERY_OPTIONS
55+ )
56+ const teamListQueryKey = teamListQueryOptions . queryKey
57+
58+ const pollUntilTeamUnblocked = async ( ) => {
59+ for ( let attempt = 0 ; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS ; attempt += 1 ) {
60+ await queryClient . invalidateQueries ( { queryKey : teamListQueryKey } )
61+
62+ const teams = await queryClient . fetchQuery ( {
63+ ...teamListQueryOptions ,
64+ staleTime : 0 ,
65+ } )
66+ const activeTeam = teams . find (
67+ ( candidate : TeamModel ) =>
68+ candidate . id === team . id || candidate . slug === team . slug
69+ )
70+
71+ if ( activeTeam && ! activeTeam . isBlocked ) {
72+ await queryClient . invalidateQueries ( { queryKey : teamListQueryKey } )
73+ return true
74+ }
75+
76+ if ( attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1 )
77+ await wait ( TEAM_UNBLOCK_POLL_INTERVAL_MS )
78+ }
79+
80+ return false
81+ }
82+
83+ return pollUntilTeamUnblocked
84+ }
85+
4086interface VerificationRequiredDialogProps {
4187 open : boolean
4288 onOpenChange : ( open : boolean ) => void
@@ -49,18 +95,36 @@ export const VerificationRequiredDialog = ({
4995 return (
5096 < Dialog open = { open } onOpenChange = { onOpenChange } >
5197 < DialogContent >
52- < VerificationRequiredDialogContent onOpenChange = { onOpenChange } />
98+ < VerificationRequiredDialogContent
99+ open = { open }
100+ onOpenChange = { onOpenChange }
101+ />
53102 </ DialogContent >
54103 </ Dialog >
55104 )
56105}
57106
58107const VerificationRequiredDialogContent = ( {
108+ open,
59109 onOpenChange,
60- } : Pick < VerificationRequiredDialogProps , 'onOpenChange' > ) => {
110+ } : VerificationRequiredDialogProps ) => {
61111 const { team } = useDashboard ( )
62112 const { toast } = useToast ( )
63113 const trpc = useTRPC ( )
114+ const router = useRouter ( )
115+ const hasHandledPaymentIntent = useRef ( false )
116+ const pollUntilTeamUnblocked = useTeamUnblockPolling ( )
117+ const [ verificationPayment , setVerificationPayment ] =
118+ useState < VerificationPaymentResponse | null > ( null )
119+ const [ isLoadingVerificationPayment , setIsLoadingVerificationPayment ] =
120+ useState ( false )
121+ const [ paymentIntentParams , setPaymentIntentParams ] = useQueryStates (
122+ stripePaymentIntentParams ,
123+ {
124+ history : 'replace' ,
125+ shallow : true ,
126+ }
127+ )
64128
65129 const verificationPaymentMutation = useMutation (
66130 trpc . billing . createVerificationPayment . mutationOptions ( {
@@ -75,11 +139,147 @@ const VerificationRequiredDialogContent = ({
75139 } )
76140 )
77141
142+ const createVerificationPayment = useCallback ( async ( ) => {
143+ setIsLoadingVerificationPayment ( true )
144+
145+ try {
146+ const payment = await verificationPaymentMutation . mutateAsync ( {
147+ teamSlug : team . slug ,
148+ } )
149+ setVerificationPayment ( payment )
150+ } catch {
151+ // The mutation onError handler owns the user-facing toast and close.
152+ } finally {
153+ setIsLoadingVerificationPayment ( false )
154+ }
155+ } , [ verificationPaymentMutation . mutateAsync , team . slug ] )
156+
157+ useEffect ( ( ) => {
158+ if ( open ) return
159+
160+ hasHandledPaymentIntent . current = false
161+ setVerificationPayment ( null )
162+ setIsLoadingVerificationPayment ( false )
163+ verificationPaymentMutation . reset ( )
164+ } , [ open , verificationPaymentMutation . reset ] )
165+
78166 useEffect ( ( ) => {
79- verificationPaymentMutation . mutate ( { teamSlug : team . slug } )
80- } , [ team . slug , verificationPaymentMutation . mutate ] )
167+ if ( ! open ) return
168+
169+ const paymentIntentClientSecret =
170+ paymentIntentParams . payment_intent_client_secret
171+
172+ if ( hasHandledPaymentIntent . current ) return
173+ hasHandledPaymentIntent . current = true
174+
175+ if ( ! paymentIntentClientSecret ) {
176+ void createVerificationPayment ( )
177+ return
178+ }
179+
180+ setPaymentIntentParams ( {
181+ payment_intent : null ,
182+ payment_intent_client_secret : null ,
183+ redirect_status : null ,
184+ } )
185+
186+ const checkPaymentIntent = async ( ) => {
187+ const stripe = await stripePromise
188+
189+ if ( ! stripe ) {
190+ toast ( defaultErrorToast ( 'Failed to load Stripe.' ) )
191+ await createVerificationPayment ( )
192+ return
193+ }
81194
82- const verificationPayment = verificationPaymentMutation . data
195+ const { paymentIntent, error } = await stripe . retrievePaymentIntent (
196+ paymentIntentClientSecret
197+ )
198+
199+ if ( error ) {
200+ toast (
201+ defaultErrorToast (
202+ error . message ?? 'Failed to check verification payment status.'
203+ )
204+ )
205+ await createVerificationPayment ( )
206+ return
207+ }
208+
209+ if ( paymentIntent . status === 'succeeded' ) {
210+ toast ( {
211+ title : 'Verification payment submitted' ,
212+ description :
213+ 'We are checking whether your team has been verified and unblocked.' ,
214+ } )
215+
216+ try {
217+ const isTeamUnblocked = await pollUntilTeamUnblocked ( )
218+
219+ if ( ! isTeamUnblocked ) {
220+ toast (
221+ defaultErrorToast (
222+ 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.'
223+ )
224+ )
225+ router . refresh ( )
226+ onOpenChange ( false )
227+ return
228+ }
229+
230+ toast ( {
231+ variant : 'success' ,
232+ title : 'Team unblocked' ,
233+ description : 'Your team has been verified and unblocked.' ,
234+ } )
235+ } catch {
236+ toast (
237+ defaultErrorToast (
238+ 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.'
239+ )
240+ )
241+ }
242+
243+ router . refresh ( )
244+ onOpenChange ( false )
245+ return
246+ }
247+
248+ if ( paymentIntent . status === 'processing' ) {
249+ toast ( {
250+ title : 'Verification payment processing' ,
251+ description :
252+ 'Your bank is still processing this payment. Please check again in a moment.' ,
253+ } )
254+ router . refresh ( )
255+ onOpenChange ( false )
256+ return
257+ }
258+
259+ if ( paymentIntent . status === 'requires_payment_method' )
260+ toast (
261+ defaultErrorToast (
262+ 'Verification payment was not completed. Please try again.'
263+ )
264+ )
265+
266+ await createVerificationPayment ( )
267+ }
268+
269+ checkPaymentIntent ( ) . catch ( ( ) => {
270+ toast ( defaultErrorToast ( 'Failed to check verification payment status.' ) )
271+ void createVerificationPayment ( )
272+ } )
273+ } , [
274+ createVerificationPayment ,
275+ open ,
276+ paymentIntentParams . payment_intent_client_secret ,
277+ setPaymentIntentParams ,
278+ toast ,
279+ pollUntilTeamUnblocked ,
280+ router ,
281+ onOpenChange ,
282+ ] )
83283
84284 return (
85285 < >
@@ -91,7 +291,7 @@ const VerificationRequiredDialogContent = ({
91291 </ DialogDescription >
92292 </ DialogHeader >
93293
94- { verificationPaymentMutation . isPending ? (
294+ { isLoadingVerificationPayment ? (
95295 < LoadingState message = { VERIFICATION_PAYMENT_LOADING_MESSAGE } />
96296 ) : verificationPayment ? (
97297 < VerificationPaymentElements
@@ -143,45 +343,12 @@ const VerificationPaymentForm = ({
143343} : Pick < VerificationPaymentElementsProps , 'onOpenChange' > ) => {
144344 const stripe = useStripe ( )
145345 const elements = useElements ( )
146- const trpc = useTRPC ( )
147346 const router = useRouter ( )
148- const queryClient = useQueryClient ( )
149347 const { toast } = useToast ( )
150- const { team } = useDashboard ( )
151348 const [ isPaying , setIsPaying ] = useState ( false )
152349 const [ isCheckingTeamStatus , setIsCheckingTeamStatus ] = useState ( false )
153350 const [ isPaymentElementReady , setIsPaymentElementReady ] = useState ( false )
154-
155- const teamListQueryOptions = trpc . teams . list . queryOptions (
156- undefined ,
157- DASHBOARD_TEAMS_LIST_QUERY_OPTIONS
158- )
159- const teamListQueryKey = teamListQueryOptions . queryKey
160-
161- const pollUntilTeamUnblocked = async ( ) => {
162- for ( let attempt = 0 ; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS ; attempt += 1 ) {
163- await queryClient . invalidateQueries ( { queryKey : teamListQueryKey } )
164-
165- const teams = await queryClient . fetchQuery ( {
166- ...teamListQueryOptions ,
167- staleTime : 0 ,
168- } )
169- const activeTeam = teams . find (
170- ( candidate : TeamModel ) =>
171- candidate . id === team . id || candidate . slug === team . slug
172- )
173-
174- if ( activeTeam && ! activeTeam . isBlocked ) {
175- await queryClient . invalidateQueries ( { queryKey : teamListQueryKey } )
176- return true
177- }
178-
179- if ( attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1 )
180- await wait ( TEAM_UNBLOCK_POLL_INTERVAL_MS )
181- }
182-
183- return false
184- }
351+ const pollUntilTeamUnblocked = useTeamUnblockPolling ( )
185352
186353 const handleSubmit = async ( event : FormEvent < HTMLFormElement > ) => {
187354 event . preventDefault ( )
0 commit comments