Skip to content

Commit 9e2fcec

Browse files
committed
Implement team unblock polling and enhance verification payment handling in VerificationRequiredDialog
- Introduced a custom hook for polling team status until unblocked, improving user experience during verification. - Enhanced payment intent handling with state management and error handling for better feedback on payment status. - Updated component structure to manage loading states and payment intent parameters effectively. - Improved toast notifications to provide clearer user feedback on verification payment outcomes.
1 parent 6e58d37 commit 9e2fcec

1 file changed

Lines changed: 208 additions & 41 deletions

File tree

src/features/dashboard/sidebar/verification-required-dialog.tsx

Lines changed: 208 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
} from '@stripe/react-stripe-js'
99
import { useMutation, useQueryClient } from '@tanstack/react-query'
1010
import { 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'
1213
import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries'
14+
import type { VerificationPaymentResponse } from '@/core/modules/billing/models'
1315
import type { TeamModel } from '@/core/modules/teams/models'
1416
import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast'
1517
import { useTRPC } from '@/trpc/client'
@@ -30,13 +32,57 @@ import { useDashboard } from '../context'
3032
const TEAM_UNBLOCK_POLL_ATTEMPTS = 15
3133
const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000
3234
const 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.
3542
const 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+
4086
interface 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

58107
const 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

Comments
 (0)