From 6b896ea62f5a5f65f3dd5900d6592dfe8e2b770c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 27 Apr 2026 14:26:22 -0400 Subject: [PATCH 01/39] Add Payment Methods Session functionality - Introduces a new `PaymentMethodsSession` interface to handle payment method sessions. - Updates the `BillingRepository` to include a method for creating payment methods sessions. - Implements a new `MissingPaymentMethodDialog` component to prompt users for payment method input when their team is blocked due to missing payment methods. - Enhances the `TeamBlockageAlert` to open the payment method dialog when appropriate. - Adds utility functions to check for missing payment method blockage reasons. This update improves the user experience by allowing teams to add payment methods directly from the dashboard when blocked. --- src/core/modules/billing/models.ts | 5 + src/core/modules/billing/repository.server.ts | 45 +- src/core/server/api/routers/billing.ts | 15 +- src/features/dashboard/billing/hooks.ts | 21 +- .../dashboard/sidebar/blocked-banner.tsx | 99 +++-- .../sidebar/missing-payment-method-dialog.tsx | 398 ++++++++++++++++++ .../dashboard/sidebar/team-blockage.ts | 17 + 7 files changed, 552 insertions(+), 48 deletions(-) create mode 100644 src/features/dashboard/sidebar/missing-payment-method-dialog.tsx create mode 100644 src/features/dashboard/sidebar/team-blockage.ts diff --git a/src/core/modules/billing/models.ts b/src/core/modules/billing/models.ts index e79e3233e..7b29b7578 100644 --- a/src/core/modules/billing/models.ts +++ b/src/core/modules/billing/models.ts @@ -59,6 +59,11 @@ export interface PaymentMethodsCustomerSession { client_secret: string } +export interface PaymentMethodsSession { + client_secret: string + setup_intent_client_secret: string +} + export interface TierLimits { sandbox_concurrency: number max_cpu: number diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index a0b5b9277..d64ad4c19 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -1,5 +1,6 @@ import 'server-only' +import { z } from 'zod' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import type { AddOnOrderConfirmResponse, @@ -8,6 +9,7 @@ import type { CustomerPortalResponse, Invoice, PaymentMethodsCustomerSession, + PaymentMethodsSession, TeamItems, UsageResponse, } from '@/core/modules/billing/models' @@ -35,12 +37,35 @@ export interface BillingRepository { createOrder(itemId: string): Promise> confirmOrder(orderId: string): Promise> getCustomerSession(): Promise> + createPaymentMethodsSession(): Promise> } async function parseText(response: Response): Promise { return (await response.text()) || 'Request failed' } +const PaymentMethodsSessionResponseSchema = z.object({ + client_secret: z.string().min(1), + setup_intent_client_secret: z.string().min(1), +}) + +// Parses payment session JSON; { client_secret: "cs_123", setup_intent_client_secret: "seti_123" } -> typed secrets. +const parsePaymentMethodsSession = async ( + response: Response +): Promise> => { + const parseResult = PaymentMethodsSessionResponseSchema.safeParse( + await response.json() + ) + + if (!parseResult.success) { + return err( + repoErrorFromHttp(500, 'Invalid payment methods session response') + ) + } + + return ok(parseResult.data) +} + export function createBillingRepository( scope: BillingScope, deps: BillingRepositoryDeps = { @@ -245,7 +270,7 @@ export function createBillingRepository( }, async getCustomerSession() { const res = await fetch( - `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`, + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, { method: 'POST', headers: { @@ -261,5 +286,23 @@ export function createBillingRepository( return ok((await res.json()) as PaymentMethodsCustomerSession) }, + async createPaymentMethodsSession() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + return parsePaymentMethodsSession(res) + }, } } diff --git a/src/core/server/api/routers/billing.ts b/src/core/server/api/routers/billing.ts index 2419c7ef1..1a2904fef 100644 --- a/src/core/server/api/routers/billing.ts +++ b/src/core/server/api/routers/billing.ts @@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server' import { headers } from 'next/headers' import { z } from 'zod' import { createBillingRepository } from '@/core/modules/billing/repository.server' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' @@ -25,12 +24,6 @@ const billingRepositoryProcedure = protectedTeamProcedure.use( ) ) -const billingAndTeamsRepositoryProcedure = billingRepositoryProcedure.use( - withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({ - teamsRepository, - })) -) - export const billingRouter = createTRPCRouter({ createCheckout: billingRepositoryProcedure .input(z.object({ tierId: z.string() })) @@ -136,4 +129,12 @@ export const billingRouter = createTRPCRouter({ if (!result.ok) throwTRPCErrorFromRepoError(result.error) return result.data }), + + createPaymentMethodsSession: billingRepositoryProcedure.mutation( + async ({ ctx }) => { + const result = await ctx.billingRepository.createPaymentMethodsSession() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + } + ), }) diff --git a/src/features/dashboard/billing/hooks.ts b/src/features/dashboard/billing/hooks.ts index de0d1f4c4..df09ebaf4 100644 --- a/src/features/dashboard/billing/hooks.ts +++ b/src/features/dashboard/billing/hooks.ts @@ -10,9 +10,9 @@ import { useTRPC } from '@/trpc/client' import { ADDON_PURCHASE_MESSAGES } from './constants' import { extractAddonData, extractTierData } from './utils' -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! -) +const stripePublishableKey = + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '' +const stripePromise = loadStripe(stripePublishableKey) /** * Provides themed appearance configuration for Stripe Payment Element @@ -140,6 +140,21 @@ export function usePaymentElementAppearance() { '.AccordionButton--selected:hover': { backgroundColor: isDark ? '#1f1f1f' : '#f2f2f2', }, + '.CheckboxInput': { + backgroundColor: isDark ? '#1f1f1f' : '#ffffff', + border: isDark ? '1px solid #424242' : '1px solid #707070', + boxShadow: 'none', + }, + '.CheckboxInput:hover': { + border: isDark ? '1px solid #848484' : '1px solid #333333', + }, + '.CheckboxInput--checked': { + backgroundColor: isDark ? '#ff8800' : '#e56f00', + border: isDark ? '1px solid #ff8800' : '1px solid #e56f00', + }, + '.CheckboxLabel': { + color: isDark ? '#e6e6e6' : '#333333', + }, '.Spinner': { color: isDark ? '#ff8800' : '#e56f00', // accent-main-highlight borderColor: isDark diff --git a/src/features/dashboard/sidebar/blocked-banner.tsx b/src/features/dashboard/sidebar/blocked-banner.tsx index 63d9f1ff6..0e0be667f 100644 --- a/src/features/dashboard/sidebar/blocked-banner.tsx +++ b/src/features/dashboard/sidebar/blocked-banner.tsx @@ -2,12 +2,14 @@ import { AnimatePresence, motion } from 'motion/react' import { useRouter } from 'next/navigation' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { cn, exponentialSmoothing } from '@/lib/utils' import { WarningIcon } from '@/ui/primitives/icons' import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar' import { useDashboard } from '../context' +import { MissingPaymentMethodDialog } from './missing-payment-method-dialog' +import { isMissingPaymentMethodBlockReason } from './team-blockage' interface TeamBlockageAlertProps { className?: string @@ -18,59 +20,82 @@ export default function TeamBlockageAlert({ }: TeamBlockageAlertProps) { const { team } = useDashboard() const router = useRouter() + const [isPaymentMethodDialogOpen, setIsPaymentMethodDialogOpen] = + useState(false) const isBillingLimit = useMemo( () => team.blockedReason?.toLowerCase().includes('billing limit'), [team.blockedReason] ) + const isMissingPaymentMethod = useMemo( + () => + team.isBlocked && isMissingPaymentMethodBlockReason(team.blockedReason), + [team.blockedReason, team.isBlocked] + ) + + useEffect(() => { + if (isMissingPaymentMethod) setIsPaymentMethodDialogOpen(true) + }, [isMissingPaymentMethod]) + const handleClick = () => { if (isBillingLimit) { router.push(PROTECTED_URLS.LIMITS(team.slug)) return } + if (isMissingPaymentMethod) { + setIsPaymentMethodDialogOpen(true) + return + } + router.push('mailto:hello@e2b.dev') } return ( - - {team.isBlocked && ( - - - + + {team.isBlocked && ( + + - -
- - Team is Blocked - - {team.blockedReason && ( - - {team.blockedReason} + + +
+ + Team is Blocked - )} -
-
- - - )} - + {team.blockedReason && ( + + {team.blockedReason} + + )} +
+
+
+
+ )} +
+ + ) } diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx new file mode 100644 index 000000000..eb5df1480 --- /dev/null +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -0,0 +1,398 @@ +'use client' + +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { type FormEvent, useEffect, useState } from 'react' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { TeamModel } from '@/core/modules/teams/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { AlertIcon, ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' +import { useDashboard } from '../context' +import { isMissingPaymentMethodBlockReason } from './team-blockage' + +const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 +const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 + +// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. +const wait = (ms: number) => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) + +interface MissingPaymentMethodDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function MissingPaymentMethodDialog({ + open, + onOpenChange, +}: MissingPaymentMethodDialogProps) { + return ( + + + + + + ) +} + +function MissingPaymentMethodDialogContent({ + onOpenChange, +}: Pick) { + const { team } = useDashboard() + const { toast } = useToast() + const trpc = useTRPC() + + const paymentMethodsSessionMutation = useMutation( + trpc.billing.createPaymentMethodsSession.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load payment method form.' + ) + ) + }, + }) + ) + + useEffect(() => { + paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) + }, [paymentMethodsSessionMutation.mutate, team.slug]) + + const session = paymentMethodsSessionMutation.data + + return ( + <> + + Add payment method + + This team is blocked because there is no payment method on file. Add a + card to continue using E2B. + + + + {paymentMethodsSessionMutation.isPending ? ( + + ) : paymentMethodsSessionMutation.isError ? ( + + paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) + } + /> + ) : session ? ( + + paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) + } + onOpenChange={onOpenChange} + /> + ) : null} + + ) +} + +function PaymentMethodsSessionError({ onRetry }: { onRetry: () => void }) { + return ( +
+ + + + We could not load the payment form. Please try again. + + + +
+ ) +} + +function LoadingState({ message }: { message: string }) { + return ( +
+ + {message} +
+ ) +} + +interface PaymentMethodsSetupElementsProps { + customerSessionClientSecret: string + setupIntentClientSecret: string + onRefreshSession: () => void + onOpenChange: (open: boolean) => void +} + +function PaymentMethodsSetupElements({ + customerSessionClientSecret, + setupIntentClientSecret, + onRefreshSession, + onOpenChange, +}: PaymentMethodsSetupElementsProps) { + const appearance = usePaymentElementAppearance() + + return ( + + + + ) +} + +function PaymentMethodsSetupForm({ + onRefreshSession, + onOpenChange, +}: Pick< + PaymentMethodsSetupElementsProps, + 'onRefreshSession' | 'onOpenChange' +>) { + const stripe = useStripe() + const elements = useElements() + const trpc = useTRPC() + const router = useRouter() + const queryClient = useQueryClient() + const { toast } = useToast() + const { team } = useDashboard() + const [isSaving, setIsSaving] = useState(false) + const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) + const [teamRecoveryError, setTeamRecoveryError] = useState( + null + ) + const [paymentConfirmationError, setPaymentConfirmationError] = useState< + string | null + >(null) + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) + const [paymentElementLoadError, setPaymentElementLoadError] = useState< + string | null + >(null) + + const teamListQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + const teamListQueryKey = teamListQueryOptions.queryKey + + const pollUntilTeamUnblocked = async () => { + for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + + const teams = await queryClient.fetchQuery({ + ...teamListQueryOptions, + staleTime: 0, + }) + const activeTeam = teams.find( + (candidate: TeamModel) => + candidate.id === team.id || candidate.slug === team.slug + ) + + if (activeTeam && !isTeamMissingPaymentMethodBlocked(activeTeam)) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + return true + } + + if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) + await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) + } + + return false + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + setTeamRecoveryError(null) + setPaymentConfirmationError(null) + + if (!stripe || !elements || !isPaymentElementReady) { + toast(defaultErrorToast('Payment form is still loading.')) + return + } + + setIsSaving(true) + + const { error } = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) { + toast( + defaultErrorToast( + error.message ?? 'Failed to save payment method. Please try again.' + ) + ) + setPaymentConfirmationError( + error.message ?? + 'Failed to save payment method. Reload the form and try again.' + ) + setIsSaving(false) + return + } + + toast({ + title: 'Payment method added', + description: 'We are checking whether your team has been unblocked.', + }) + + setIsCheckingTeamStatus(true) + const isTeamUnblocked = await pollUntilTeamUnblocked() + setIsCheckingTeamStatus(false) + + if (!isTeamUnblocked) { + setTeamRecoveryError( + 'Payment method saved, but your team is still blocked. Please wait a moment and try checking again.' + ) + setIsSaving(false) + return + } + + setIsSaving(false) + router.refresh() + onOpenChange(false) + } + + const isProcessing = isSaving || isCheckingTeamStatus + const paymentSubmitLoadingLabel = isCheckingTeamStatus + ? 'Checking team status...' + : 'Saving...' + + return ( +
+ + + + Your payment method will be saved to your team billing account. + + + + {!isPaymentElementReady && !paymentElementLoadError && ( + + )} + + {paymentElementLoadError && ( +
+ + + + {paymentElementLoadError} + + + +
+ )} + + {paymentConfirmationError && ( +
+ + + + {paymentConfirmationError} + + + +
+ )} + + {teamRecoveryError && ( + + + + {teamRecoveryError} + + + )} + + { + setPaymentElementLoadError(null) + setIsPaymentElementReady(true) + }} + onLoadError={(event) => { + setIsPaymentElementReady(false) + setPaymentElementLoadError( + event.error.message ?? + 'Failed to load payment details. Please refresh and try again.' + ) + }} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: false, + }, + }} + /> + + + + ) +} + +// Checks active team recovery status; { isBlocked: true, blockedReason: "payment_method_missing" } -> true. +const isTeamMissingPaymentMethodBlocked = (team: TeamModel) => + team.isBlocked && isMissingPaymentMethodBlockReason(team.blockedReason) diff --git a/src/features/dashboard/sidebar/team-blockage.ts b/src/features/dashboard/sidebar/team-blockage.ts new file mode 100644 index 000000000..2ddad8c94 --- /dev/null +++ b/src/features/dashboard/sidebar/team-blockage.ts @@ -0,0 +1,17 @@ +// Normalizes a block reason; "payment_method_missing" -> "payment method missing". +const normalizeBlockReason = (reason: string) => + reason.toLowerCase().replace(/[_-]+/g, ' ').trim() + +// Checks for missing payment method blockage; "payment_method_missing" -> true. +const isMissingPaymentMethodBlockReason = (reason?: string | null) => { + if (!reason) return false + + const normalizedReason = normalizeBlockReason(reason) + + return ( + normalizedReason.includes('payment method missing') || + normalizedReason.includes('missing payment method') + ) +} + +export { isMissingPaymentMethodBlockReason } From d8aba175d77ebd565fe6e3c02565ba6e08a8e44e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 10:55:59 -0400 Subject: [PATCH 02/39] Refactor loading messages in MissingPaymentMethodDialog - Introduces a constant for the loading message in the MissingPaymentMethodDialog component to improve consistency and maintainability. - Updates the LoadingState component to use the new constant for loading payment method messages, enhancing clarity in the user interface. --- .../dashboard/sidebar/missing-payment-method-dialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index eb5df1480..e4890769f 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -30,6 +30,7 @@ import { isMissingPaymentMethodBlockReason } from './team-blockage' const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 +const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' // Waits before retrying team status polling; 2000 -> resolves after 2 seconds. const wait = (ms: number) => @@ -91,7 +92,7 @@ function MissingPaymentMethodDialogContent({ {paymentMethodsSessionMutation.isPending ? ( - + ) : paymentMethodsSessionMutation.isError ? ( @@ -304,7 +305,7 @@ function PaymentMethodsSetupForm({ {!isPaymentElementReady && !paymentElementLoadError && ( - + )} {paymentElementLoadError && ( From f33b1b3ba6fc6842b13c9d169eb4fa14fd45fc62 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:01:32 -0400 Subject: [PATCH 03/39] Refactor payment methods session parsing in BillingRepository - Removes the standalone `parsePaymentMethodsSession` function and integrates its logic directly into the `getCustomerSession` method. - Updates the API endpoint for fetching the customer session to align with the new structure. - Enhances error handling for invalid payment methods session responses, ensuring consistent error reporting. --- src/core/modules/billing/repository.server.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index d64ad4c19..e1807cc33 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -49,23 +49,6 @@ const PaymentMethodsSessionResponseSchema = z.object({ setup_intent_client_secret: z.string().min(1), }) -// Parses payment session JSON; { client_secret: "cs_123", setup_intent_client_secret: "seti_123" } -> typed secrets. -const parsePaymentMethodsSession = async ( - response: Response -): Promise> => { - const parseResult = PaymentMethodsSessionResponseSchema.safeParse( - await response.json() - ) - - if (!parseResult.success) { - return err( - repoErrorFromHttp(500, 'Invalid payment methods session response') - ) - } - - return ok(parseResult.data) -} - export function createBillingRepository( scope: BillingScope, deps: BillingRepositoryDeps = { @@ -270,7 +253,7 @@ export function createBillingRepository( }, async getCustomerSession() { const res = await fetch( - `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`, { method: 'POST', headers: { @@ -302,7 +285,17 @@ export function createBillingRepository( return err(repoErrorFromHttp(res.status, await parseText(res))) } - return parsePaymentMethodsSession(res) + const parseResult = PaymentMethodsSessionResponseSchema.safeParse( + await res.json() + ) + + if (!parseResult.success) { + return err( + repoErrorFromHttp(500, 'Invalid payment methods session response') + ) + } + + return ok(parseResult.data) }, } } From 296f39dc300c9cb72bd446a8c193af985a9920bb Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:08:06 -0400 Subject: [PATCH 04/39] Remove unused code --- src/features/dashboard/billing/hooks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/billing/hooks.ts b/src/features/dashboard/billing/hooks.ts index df09ebaf4..8fb42f41d 100644 --- a/src/features/dashboard/billing/hooks.ts +++ b/src/features/dashboard/billing/hooks.ts @@ -10,9 +10,9 @@ import { useTRPC } from '@/trpc/client' import { ADDON_PURCHASE_MESSAGES } from './constants' import { extractAddonData, extractTierData } from './utils' -const stripePublishableKey = - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '' -const stripePromise = loadStripe(stripePublishableKey) +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! +) /** * Provides themed appearance configuration for Stripe Payment Element From 521b47d77ad1e0e94178a68ba882c3de962c6d9e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:19:52 -0400 Subject: [PATCH 05/39] Refactor team blockage handling in dashboard components - Introduces a new `capitalize` utility function for consistent string formatting. - Updates `TeamBlockageAlert` to use the new `capitalize` function for displaying blocked reasons, improving readability. - Replaces the deprecated `isMissingPaymentMethodBlockReason` function with a new implementation that utilizes the `capitalize` function. - Removes the unused `team-blockage.ts` file, streamlining the codebase. --- .../dashboard/sidebar/blocked-banner.tsx | 26 ++++++++++++++----- .../sidebar/missing-payment-method-dialog.tsx | 14 +++++++--- .../dashboard/sidebar/team-blockage.ts | 17 ------------ src/lib/utils/formatting.ts | 9 +++++++ 4 files changed, 40 insertions(+), 26 deletions(-) delete mode 100644 src/features/dashboard/sidebar/team-blockage.ts diff --git a/src/features/dashboard/sidebar/blocked-banner.tsx b/src/features/dashboard/sidebar/blocked-banner.tsx index 0e0be667f..74b1ac44d 100644 --- a/src/features/dashboard/sidebar/blocked-banner.tsx +++ b/src/features/dashboard/sidebar/blocked-banner.tsx @@ -5,11 +5,11 @@ import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { cn, exponentialSmoothing } from '@/lib/utils' +import { capitalize } from '@/lib/utils/formatting' import { WarningIcon } from '@/ui/primitives/icons' import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar' import { useDashboard } from '../context' import { MissingPaymentMethodDialog } from './missing-payment-method-dialog' -import { isMissingPaymentMethodBlockReason } from './team-blockage' interface TeamBlockageAlertProps { className?: string @@ -29,10 +29,13 @@ export default function TeamBlockageAlert({ ) const isMissingPaymentMethod = useMemo( - () => - team.isBlocked && isMissingPaymentMethodBlockReason(team.blockedReason), + () => team.isBlocked && isMissingPaymentMethodReason(team.blockedReason), [team.blockedReason, team.isBlocked] ) + const displayedBlockReason = useMemo( + () => (team.blockedReason ? capitalize(team.blockedReason) : null), + [team.blockedReason] + ) useEffect(() => { if (isMissingPaymentMethod) setIsPaymentMethodDialogOpen(true) @@ -60,7 +63,7 @@ export default function TeamBlockageAlert({ Team is Blocked - {team.blockedReason && ( + {displayedBlockReason && ( - {team.blockedReason} + {displayedBlockReason} )} @@ -99,3 +102,14 @@ export default function TeamBlockageAlert({ ) } + +const isMissingPaymentMethodReason = (reason?: string | null) => { + if (!reason) return false + + const formattedReason = capitalize(reason).toLowerCase() + + return ( + formattedReason.includes('payment method missing') || + formattedReason.includes('missing payment method') + ) +} diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index e4890769f..65b8affaf 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -12,6 +12,7 @@ import { type FormEvent, useEffect, useState } from 'react' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import type { TeamModel } from '@/core/modules/teams/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { capitalize } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription } from '@/ui/primitives/alert' import { Button } from '@/ui/primitives/button' @@ -26,7 +27,6 @@ import { AlertIcon, ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' import { useDashboard } from '../context' -import { isMissingPaymentMethodBlockReason } from './team-blockage' const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 @@ -395,5 +395,13 @@ function PaymentMethodsSetupForm({ } // Checks active team recovery status; { isBlocked: true, blockedReason: "payment_method_missing" } -> true. -const isTeamMissingPaymentMethodBlocked = (team: TeamModel) => - team.isBlocked && isMissingPaymentMethodBlockReason(team.blockedReason) +const isTeamMissingPaymentMethodBlocked = (team: TeamModel) => { + if (!team.isBlocked || !team.blockedReason) return false + + const formattedReason = capitalize(team.blockedReason).toLowerCase() + + return ( + formattedReason.includes('payment method missing') || + formattedReason.includes('missing payment method') + ) +} diff --git a/src/features/dashboard/sidebar/team-blockage.ts b/src/features/dashboard/sidebar/team-blockage.ts deleted file mode 100644 index 2ddad8c94..000000000 --- a/src/features/dashboard/sidebar/team-blockage.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Normalizes a block reason; "payment_method_missing" -> "payment method missing". -const normalizeBlockReason = (reason: string) => - reason.toLowerCase().replace(/[_-]+/g, ' ').trim() - -// Checks for missing payment method blockage; "payment_method_missing" -> true. -const isMissingPaymentMethodBlockReason = (reason?: string | null) => { - if (!reason) return false - - const normalizedReason = normalizeBlockReason(reason) - - return ( - normalizedReason.includes('payment method missing') || - normalizedReason.includes('missing payment method') - ) -} - -export { isMissingPaymentMethodBlockReason } diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 20f0b04d3..2c12cf969 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -7,6 +7,15 @@ import * as chrono from 'chrono-node' import { format, isThisYear, isValid } from 'date-fns' import { formatInTimeZone } from 'date-fns-tz' +// Capitalizes a readable string; "payment_method_missing" -> "Payment method missing". +export const capitalize = (value: string) => { + const formattedValue = value.toLowerCase().replace(/[_-]+/g, ' ').trim() + + if (!formattedValue) return formattedValue + + return formattedValue.charAt(0).toUpperCase() + formattedValue.slice(1) +} + const LOCAL_LOG_STYLE_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', From 4f8789427ba46a94486dc1f8b6f482dcac41c8e4 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:24:51 -0400 Subject: [PATCH 06/39] Refactor MissingPaymentMethodDialog components to use arrow function syntax - Changes function declarations to arrow function expressions for `MissingPaymentMethodDialog`, `MissingPaymentMethodDialogContent`, `PaymentMethodsSessionError`, `LoadingState`, `PaymentMethodsSetupElements`, and `PaymentMethodsSetupForm`. - This update enhances consistency in the codebase and aligns with modern React practices. --- .../sidebar/missing-payment-method-dialog.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 65b8affaf..8a4b266bf 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -43,10 +43,10 @@ interface MissingPaymentMethodDialogProps { onOpenChange: (open: boolean) => void } -export function MissingPaymentMethodDialog({ +export const MissingPaymentMethodDialog = ({ open, onOpenChange, -}: MissingPaymentMethodDialogProps) { +}: MissingPaymentMethodDialogProps) => { return ( @@ -56,9 +56,9 @@ export function MissingPaymentMethodDialog({ ) } -function MissingPaymentMethodDialogContent({ +const MissingPaymentMethodDialogContent = ({ onOpenChange, -}: Pick) { +}: Pick) => { const { team } = useDashboard() const { toast } = useToast() const trpc = useTRPC() @@ -113,7 +113,7 @@ function MissingPaymentMethodDialogContent({ ) } -function PaymentMethodsSessionError({ onRetry }: { onRetry: () => void }) { +const PaymentMethodsSessionError = ({ onRetry }: { onRetry: () => void }) => { return (
@@ -133,7 +133,7 @@ function PaymentMethodsSessionError({ onRetry }: { onRetry: () => void }) { ) } -function LoadingState({ message }: { message: string }) { +const LoadingState = ({ message }: { message: string }) => { return (
@@ -149,12 +149,12 @@ interface PaymentMethodsSetupElementsProps { onOpenChange: (open: boolean) => void } -function PaymentMethodsSetupElements({ +const PaymentMethodsSetupElements = ({ customerSessionClientSecret, setupIntentClientSecret, onRefreshSession, onOpenChange, -}: PaymentMethodsSetupElementsProps) { +}: PaymentMethodsSetupElementsProps) => { const appearance = usePaymentElementAppearance() return ( @@ -176,13 +176,13 @@ function PaymentMethodsSetupElements({ ) } -function PaymentMethodsSetupForm({ +const PaymentMethodsSetupForm = ({ onRefreshSession, onOpenChange, }: Pick< PaymentMethodsSetupElementsProps, 'onRefreshSession' | 'onOpenChange' ->) { +>) => { const stripe = useStripe() const elements = useElements() const trpc = useTRPC() From 1af0c55108bf8348f68f9aa34d92628884cee8c4 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:27:40 -0400 Subject: [PATCH 07/39] Refactor MissingPaymentMethodDialog to streamline error handling and remove unused components - Eliminates the `PaymentMethodsSessionError` component and associated error handling logic, simplifying the dialog's structure. - Updates the `PaymentMethodsSetupForm` to remove unnecessary state variables and error messages, enhancing clarity and maintainability. - Integrates toast notifications for error handling, improving user feedback during payment method interactions. - Adjusts the loading state management to ensure a smoother user experience when loading payment elements. --- .../sidebar/missing-payment-method-dialog.tsx | 126 +++--------------- 1 file changed, 15 insertions(+), 111 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 8a4b266bf..7ea29bebd 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -23,7 +23,7 @@ import { DialogHeader, DialogTitle, } from '@/ui/primitives/dialog' -import { AlertIcon, ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' +import { ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' import { useDashboard } from '../context' @@ -71,6 +71,7 @@ const MissingPaymentMethodDialogContent = ({ error.message || 'Failed to load payment method form.' ) ) + onOpenChange(false) }, }) ) @@ -93,19 +94,10 @@ const MissingPaymentMethodDialogContent = ({ {paymentMethodsSessionMutation.isPending ? ( - ) : paymentMethodsSessionMutation.isError ? ( - - paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) - } - /> ) : session ? ( - paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) - } onOpenChange={onOpenChange} /> ) : null} @@ -113,26 +105,6 @@ const MissingPaymentMethodDialogContent = ({ ) } -const PaymentMethodsSessionError = ({ onRetry }: { onRetry: () => void }) => { - return ( -
- - - - We could not load the payment form. Please try again. - - - -
- ) -} - const LoadingState = ({ message }: { message: string }) => { return (
@@ -145,14 +117,12 @@ const LoadingState = ({ message }: { message: string }) => { interface PaymentMethodsSetupElementsProps { customerSessionClientSecret: string setupIntentClientSecret: string - onRefreshSession: () => void onOpenChange: (open: boolean) => void } const PaymentMethodsSetupElements = ({ customerSessionClientSecret, setupIntentClientSecret, - onRefreshSession, onOpenChange, }: PaymentMethodsSetupElementsProps) => { const appearance = usePaymentElementAppearance() @@ -168,21 +138,14 @@ const PaymentMethodsSetupElements = ({ loader: 'never', }} > - + ) } const PaymentMethodsSetupForm = ({ - onRefreshSession, onOpenChange, -}: Pick< - PaymentMethodsSetupElementsProps, - 'onRefreshSession' | 'onOpenChange' ->) => { +}: Pick) => { const stripe = useStripe() const elements = useElements() const trpc = useTRPC() @@ -192,16 +155,7 @@ const PaymentMethodsSetupForm = ({ const { team } = useDashboard() const [isSaving, setIsSaving] = useState(false) const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) - const [teamRecoveryError, setTeamRecoveryError] = useState( - null - ) - const [paymentConfirmationError, setPaymentConfirmationError] = useState< - string | null - >(null) const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) - const [paymentElementLoadError, setPaymentElementLoadError] = useState< - string | null - >(null) const teamListQueryOptions = trpc.teams.list.queryOptions( undefined, @@ -236,8 +190,6 @@ const PaymentMethodsSetupForm = ({ const handleSubmit = async (event: FormEvent) => { event.preventDefault() - setTeamRecoveryError(null) - setPaymentConfirmationError(null) if (!stripe || !elements || !isPaymentElementReady) { toast(defaultErrorToast('Payment form is still loading.')) @@ -260,10 +212,6 @@ const PaymentMethodsSetupForm = ({ error.message ?? 'Failed to save payment method. Please try again.' ) ) - setPaymentConfirmationError( - error.message ?? - 'Failed to save payment method. Reload the form and try again.' - ) setIsSaving(false) return } @@ -278,8 +226,10 @@ const PaymentMethodsSetupForm = ({ setIsCheckingTeamStatus(false) if (!isTeamUnblocked) { - setTeamRecoveryError( - 'Payment method saved, but your team is still blocked. Please wait a moment and try checking again.' + toast( + defaultErrorToast( + 'Payment method saved, but your team is still blocked. Please wait a moment and try again.' + ) ) setIsSaving(false) return @@ -304,68 +254,23 @@ const PaymentMethodsSetupForm = ({ - {!isPaymentElementReady && !paymentElementLoadError && ( + {!isPaymentElementReady && ( )} - {paymentElementLoadError && ( -
- - - - {paymentElementLoadError} - - - -
- )} - - {paymentConfirmationError && ( -
- - - - {paymentConfirmationError} - - - -
- )} - - {teamRecoveryError && ( - - - - {teamRecoveryError} - - - )} - { - setPaymentElementLoadError(null) setIsPaymentElementReady(true) }} onLoadError={(event) => { setIsPaymentElementReady(false) - setPaymentElementLoadError( - event.error.message ?? - 'Failed to load payment details. Please refresh and try again.' + toast( + defaultErrorToast( + event.error.message ?? + 'Failed to load payment details. Please refresh and try again.' + ) ) + onOpenChange(false) }} options={{ layout: { @@ -382,7 +287,6 @@ const PaymentMethodsSetupForm = ({ !stripe || !elements || !isPaymentElementReady || - !!paymentElementLoadError || isProcessing } loading={isProcessing ? paymentSubmitLoadingLabel : undefined} From 8e09eebd291a394776c2d585897aa6ece55b11f6 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 28 Apr 2026 14:29:58 -0400 Subject: [PATCH 08/39] Run biome format --- .../dashboard/sidebar/missing-payment-method-dialog.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 7ea29bebd..4817cf667 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -284,10 +284,7 @@ const PaymentMethodsSetupForm = ({ type="submit" className="w-full justify-center" disabled={ - !stripe || - !elements || - !isPaymentElementReady || - isProcessing + !stripe || !elements || !isPaymentElementReady || isProcessing } loading={isProcessing ? paymentSubmitLoadingLabel : undefined} > From e9221af047e38bb7d29b2caa946fda61736eb9ac Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 30 Apr 2026 16:12:02 -0400 Subject: [PATCH 09/39] feat: add verification payment functionality - Introduced a new `VerificationPaymentResponse` interface to handle verification payment responses. - Updated `BillingRepository` to include a `createVerificationPayment` method for initiating verification payments. - Implemented the `createVerificationPayment` method in the billing repository, which fetches the verification payment from the API and handles errors appropriately. - Added a new `VerificationRequiredDialog` component to prompt users for verification payments, including loading states and error handling. - Updated the `TeamBlockedIndicator` to open the verification dialog when the team is blocked due to verification requirements. This change enhances the billing process by allowing teams to verify their accounts through a payment mechanism. --- src/core/modules/billing/models.ts | 4 + src/core/modules/billing/repository.server.ts | 34 ++ src/core/server/api/routers/billing.ts | 7 + .../layouts/team-blocked-indicator.tsx | 83 +++-- .../sidebar/missing-payment-method-dialog.tsx | 15 +- .../sidebar/verification-required-dialog.tsx | 294 ++++++++++++++++++ 6 files changed, 405 insertions(+), 32 deletions(-) create mode 100644 src/features/dashboard/sidebar/verification-required-dialog.tsx diff --git a/src/core/modules/billing/models.ts b/src/core/modules/billing/models.ts index 7b29b7578..180367416 100644 --- a/src/core/modules/billing/models.ts +++ b/src/core/modules/billing/models.ts @@ -55,6 +55,10 @@ export interface AddOnOrderConfirmResponse { client_secret: string } +export interface VerificationPaymentResponse { + client_secret: string +} + export interface PaymentMethodsCustomerSession { client_secret: string } diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index ca58b093e..720afb9b1 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -12,6 +12,7 @@ import type { PaymentMethodsSession, TeamItems, UsageResponse, + VerificationPaymentResponse, } from '@/core/modules/billing/models' import { repoErrorFromHttp } from '@/core/shared/errors' import type { TeamRequestScope } from '@/core/shared/repository-scope' @@ -38,6 +39,7 @@ export interface BillingRepository { confirmOrder(orderId: string): Promise> getCustomerSession(): Promise> createPaymentMethodsSession(): Promise> + createVerificationPayment(): Promise> } async function parseText(response: Response): Promise { @@ -49,6 +51,10 @@ const PaymentMethodsSessionResponseSchema = z.object({ setup_intent_client_secret: z.string().min(1), }) +const VerificationPaymentResponseSchema = z.object({ + client_secret: z.string().min(1), +}) + export function createBillingRepository( scope: BillingScope, deps: BillingRepositoryDeps = { @@ -299,6 +305,34 @@ export function createBillingRepository( ) } + return ok(parseResult.data) + }, + async createVerificationPayment() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/verification-payment`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + const parseResult = VerificationPaymentResponseSchema.safeParse( + await res.json() + ) + + if (!parseResult.success) { + return err( + repoErrorFromHttp(500, 'Invalid verification payment response') + ) + } + return ok(parseResult.data) }, } diff --git a/src/core/server/api/routers/billing.ts b/src/core/server/api/routers/billing.ts index 1a2904fef..c7bdf0154 100644 --- a/src/core/server/api/routers/billing.ts +++ b/src/core/server/api/routers/billing.ts @@ -137,4 +137,11 @@ export const billingRouter = createTRPCRouter({ return result.data } ), + createVerificationPayment: billingRepositoryProcedure.mutation( + async ({ ctx }) => { + const result = await ctx.billingRepository.createVerificationPayment() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + } + ), }) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index d691f8866..2feca5214 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -1,10 +1,12 @@ 'use client' import Link from 'next/link' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { useDashboard } from '@/features/dashboard/context' import { BlockIcon } from '@/ui/primitives/icons' +import { MissingPaymentMethodDialog } from '../sidebar/missing-payment-method-dialog' +import { VerificationRequiredDialog } from '../sidebar/verification-required-dialog' function useBlockedMessage(slug: string, blockedReason: string | null) { return useMemo(() => { @@ -22,15 +24,15 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { return { text: 'Missing payment method.', cta: 'Add payment method.', - href: PROTECTED_URLS.BILLING(slug), + href: null, } } if (reason.includes('verification required')) { return { text: 'Verification required.', - cta: 'Add payment method.', - href: PROTECTED_URLS.BILLING(slug), + cta: 'Complete verification.', + href: null, } } @@ -44,25 +46,70 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { export default function TeamBlockedIndicator() { const { team } = useDashboard() + const [ + isMissingPaymentMethodDialogOpen, + setIsMissingPaymentMethodDialogOpen, + ] = useState(false) + const [ + isVerificationRequiredDialogOpen, + setIsVerificationRequiredDialogOpen, + ] = useState(false) const message = useBlockedMessage(team.slug, team.blockedReason) + const reason = team.blockedReason?.toLowerCase() ?? '' + const isMissingPaymentMethod = reason.includes('missing payment method') + const isVerificationRequired = reason.includes('verification required') + + useEffect(() => { + if (isMissingPaymentMethod) setIsMissingPaymentMethodDialogOpen(true) + if (isVerificationRequired) setIsVerificationRequiredDialogOpen(true) + }, [isMissingPaymentMethod, isVerificationRequired]) + + const handleDialogAction = () => { + if (isMissingPaymentMethod) { + setIsMissingPaymentMethodDialogOpen(true) + return + } + + if (isVerificationRequired) setIsVerificationRequiredDialogOpen(true) + } if (!team.isBlocked) return null return ( -
- - - {message.text} - {message.cta && message.href && ( - <> - {' '} - - {message.cta} - - - )} - -
+ <> +
+ + + {message.text} + {message.cta && ( + <> + {' '} + {message.href ? ( + + {message.cta} + + ) : ( + + )} + + )} + +
+ + + ) } diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 4817cf667..dbc95d59f 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -12,7 +12,6 @@ import { type FormEvent, useEffect, useState } from 'react' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import type { TeamModel } from '@/core/modules/teams/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' -import { capitalize } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription } from '@/ui/primitives/alert' import { Button } from '@/ui/primitives/button' @@ -176,7 +175,7 @@ const PaymentMethodsSetupForm = ({ candidate.id === team.id || candidate.slug === team.slug ) - if (activeTeam && !isTeamMissingPaymentMethodBlocked(activeTeam)) { + if (activeTeam && !activeTeam.isBlocked) { await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) return true } @@ -294,15 +293,3 @@ const PaymentMethodsSetupForm = ({ ) } - -// Checks active team recovery status; { isBlocked: true, blockedReason: "payment_method_missing" } -> true. -const isTeamMissingPaymentMethodBlocked = (team: TeamModel) => { - if (!team.isBlocked || !team.blockedReason) return false - - const formattedReason = capitalize(team.blockedReason).toLowerCase() - - return ( - formattedReason.includes('payment method missing') || - formattedReason.includes('missing payment method') - ) -} diff --git a/src/features/dashboard/sidebar/verification-required-dialog.tsx b/src/features/dashboard/sidebar/verification-required-dialog.tsx new file mode 100644 index 000000000..2bf8ae3db --- /dev/null +++ b/src/features/dashboard/sidebar/verification-required-dialog.tsx @@ -0,0 +1,294 @@ +'use client' + +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { type FormEvent, useEffect, useState } from 'react' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { TeamModel } from '@/core/modules/teams/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' +import { useDashboard } from '../context' + +const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 +const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 +const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' + +// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. +const wait = (ms: number) => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) + +interface VerificationRequiredDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const VerificationRequiredDialog = ({ + open, + onOpenChange, +}: VerificationRequiredDialogProps) => { + return ( + + + + + + ) +} + +const VerificationRequiredDialogContent = ({ + onOpenChange, +}: Pick) => { + const { team } = useDashboard() + const { toast } = useToast() + const trpc = useTRPC() + + const verificationPaymentMutation = useMutation( + trpc.billing.createVerificationPayment.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load verification payment form.' + ) + ) + onOpenChange(false) + }, + }) + ) + + useEffect(() => { + verificationPaymentMutation.mutate({ teamSlug: team.slug }) + }, [team.slug, verificationPaymentMutation.mutate]) + + const verificationPayment = verificationPaymentMutation.data + + return ( + <> + + Verify account + + This team requires payment verification. Make a $5 card payment to + verify your account and continue using E2B. + + + + {verificationPaymentMutation.isPending ? ( + + ) : verificationPayment ? ( + + ) : null} + + ) +} + +const LoadingState = ({ message }: { message: string }) => { + return ( +
+ + {message} +
+ ) +} + +interface VerificationPaymentElementsProps { + clientSecret: string + onOpenChange: (open: boolean) => void +} + +const VerificationPaymentElements = ({ + clientSecret, + onOpenChange, +}: VerificationPaymentElementsProps) => { + const appearance = usePaymentElementAppearance() + + return ( + + + + ) +} + +const VerificationPaymentForm = ({ + onOpenChange, +}: Pick) => { + const stripe = useStripe() + const elements = useElements() + const trpc = useTRPC() + const router = useRouter() + const queryClient = useQueryClient() + const { toast } = useToast() + const { team } = useDashboard() + const [isPaying, setIsPaying] = useState(false) + const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) + + const teamListQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + const teamListQueryKey = teamListQueryOptions.queryKey + + const pollUntilTeamUnblocked = async () => { + for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + + const teams = await queryClient.fetchQuery({ + ...teamListQueryOptions, + staleTime: 0, + }) + const activeTeam = teams.find( + (candidate: TeamModel) => + candidate.id === team.id || candidate.slug === team.slug + ) + + if (activeTeam && !activeTeam.isBlocked) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + return true + } + + if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) + await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) + } + + return false + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + if (!stripe || !elements || !isPaymentElementReady) { + toast(defaultErrorToast('Payment form is still loading.')) + return + } + + setIsPaying(true) + + const { error } = await stripe.confirmPayment({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) { + toast( + defaultErrorToast( + error.message ?? + 'Failed to process verification payment. Please try again.' + ) + ) + setIsPaying(false) + return + } + + toast({ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }) + + setIsCheckingTeamStatus(true) + const isTeamUnblocked = await pollUntilTeamUnblocked() + setIsCheckingTeamStatus(false) + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + ) + ) + setIsPaying(false) + return + } + + setIsPaying(false) + router.refresh() + onOpenChange(false) + } + + const isProcessing = isPaying || isCheckingTeamStatus + const paymentSubmitLoadingLabel = isCheckingTeamStatus + ? 'Checking team status...' + : 'Processing...' + + return ( +
+ + + + A $5 card payment will be charged and added back to your team as + credits. + + + + {!isPaymentElementReady && ( + + )} + + { + setIsPaymentElementReady(true) + }} + onLoadError={(event) => { + setIsPaymentElementReady(false) + toast( + defaultErrorToast( + event.error.message ?? + 'Failed to load payment details. Please refresh and try again.' + ) + ) + onOpenChange(false) + }} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: false, + }, + }} + /> + + + + ) +} From 9a4451db20a074dbf88d0b561434381eaa7782e8 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 15:32:26 -0400 Subject: [PATCH 10/39] Enhance payment method dialogs with success toasts and update verification messaging - Updated the success toast message in the PaymentMethodsSetupForm to clarify that a payment method was added. - Added a success toast in the VerificationPaymentForm to notify users when their team has been verified and unblocked. - Changed the dialog title and description in the VerificationRequiredDialogContent to reflect that the team requires verification instead of the account. --- .../sidebar/missing-payment-method-dialog.tsx | 9 ++++++++- .../sidebar/verification-required-dialog.tsx | 12 +++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index dbc95d59f..d13651c13 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -227,13 +227,20 @@ const PaymentMethodsSetupForm = ({ if (!isTeamUnblocked) { toast( defaultErrorToast( - 'Payment method saved, but your team is still blocked. Please wait a moment and try again.' + 'Payment method added, but your team is still blocked. Please wait a moment and try again.' ) ) setIsSaving(false) return } + toast({ + variant: 'success', + title: 'Team unblocked', + description: + 'Your payment method was added and your team has been unblocked.', + }) + setIsSaving(false) router.refresh() onOpenChange(false) diff --git a/src/features/dashboard/sidebar/verification-required-dialog.tsx b/src/features/dashboard/sidebar/verification-required-dialog.tsx index 2bf8ae3db..aca68773d 100644 --- a/src/features/dashboard/sidebar/verification-required-dialog.tsx +++ b/src/features/dashboard/sidebar/verification-required-dialog.tsx @@ -84,10 +84,10 @@ const VerificationRequiredDialogContent = ({ return ( <> - Verify account + Verify team - This team requires payment verification. Make a $5 card payment to - verify your account and continue using E2B. + This team is blocked because verification is required. Make a $5 + payment to verify and continue using E2B. @@ -232,6 +232,12 @@ const VerificationPaymentForm = ({ return } + toast({ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }) + setIsPaying(false) router.refresh() onOpenChange(false) From 5e8ec1df9b88cb69e1585330d257c215ae506524 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 15:38:52 -0400 Subject: [PATCH 11/39] Add constants for blocked reasons in team module - Introduced a new constants file to define BLOCKED_REASONS for better clarity and maintainability. - Updated the TeamBlockedIndicator component to utilize the new BLOCKED_REASONS constants for checking blocked reasons, replacing the previous string checks. --- src/core/modules/teams/constants.ts | 6 ++++++ .../layouts/team-blocked-indicator.tsx | 21 +++++-------------- 2 files changed, 11 insertions(+), 16 deletions(-) create mode 100644 src/core/modules/teams/constants.ts diff --git a/src/core/modules/teams/constants.ts b/src/core/modules/teams/constants.ts new file mode 100644 index 000000000..00ae46bbd --- /dev/null +++ b/src/core/modules/teams/constants.ts @@ -0,0 +1,6 @@ +const BLOCKED_REASONS = { + missingPayment: 'missing payment method', + verification: 'verification required', +} as const + +export { BLOCKED_REASONS } diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 939ca1366..0ca7e1af1 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -3,21 +3,12 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' +import { BLOCKED_REASONS } from '@/core/modules/teams/constants' import { useDashboard } from '@/features/dashboard/context' import { BlockIcon } from '@/ui/primitives/icons' import { MissingPaymentMethodDialog } from '../sidebar/missing-payment-method-dialog' import { VerificationRequiredDialog } from '../sidebar/verification-required-dialog' -// Detects missing payment method block reasons; "Payment method missing" -> true. -const isMissingPaymentMethodReason = (reason: string | null) => { - const formattedReason = reason?.toLowerCase() ?? '' - - return ( - formattedReason.includes('payment method missing') || - formattedReason.includes('missing payment method') - ) -} - function useBlockedMessage(slug: string, blockedReason: string | null) { return useMemo(() => { const reason = blockedReason?.toLowerCase() ?? '' @@ -30,7 +21,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (isMissingPaymentMethodReason(blockedReason)) { + if (reason === BLOCKED_REASONS.missingPayment) { return { text: 'Missing payment method.', cta: 'Add payment method.', @@ -38,7 +29,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (reason.includes('verification required')) { + if (reason === BLOCKED_REASONS.verification) { return { text: 'Verification required.', cta: 'Complete verification.', @@ -67,10 +58,8 @@ export default function TeamBlockedIndicator() { const message = useBlockedMessage(team.slug, team.blockedReason) const reason = team.blockedReason?.toLowerCase() ?? '' - const isMissingPaymentMethod = isMissingPaymentMethodReason( - team.blockedReason - ) - const isVerificationRequired = reason.includes('verification required') + const isMissingPaymentMethod = reason === BLOCKED_REASONS.missingPayment + const isVerificationRequired = reason === BLOCKED_REASONS.verification useEffect(() => { if (isMissingPaymentMethod) setIsMissingPaymentMethodDialogOpen(true) From 6918c6b1fdc18cc944cedfcdcd458f76bb2ad937 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 15:44:04 -0400 Subject: [PATCH 12/39] Refactor TeamBlockedIndicator to streamline dialog management - Consolidated state management for dialog visibility into a single state variable, improving readability and maintainability. - Updated the logic to determine which dialog to open based on the blocked reason, utilizing the existing BLOCKED_REASONS constants. - Simplified the dialog open/close handlers for better clarity and reduced redundancy. --- .../layouts/team-blocked-indicator.tsx | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 0ca7e1af1..e12605c68 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -9,6 +9,10 @@ import { BlockIcon } from '@/ui/primitives/icons' import { MissingPaymentMethodDialog } from '../sidebar/missing-payment-method-dialog' import { VerificationRequiredDialog } from '../sidebar/verification-required-dialog' +type BlockedReasonDialog = + | (typeof BLOCKED_REASONS)[keyof typeof BLOCKED_REASONS] + | null + function useBlockedMessage(slug: string, blockedReason: string | null) { return useMemo(() => { const reason = blockedReason?.toLowerCase() ?? '' @@ -47,32 +51,22 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { export default function TeamBlockedIndicator() { const { team } = useDashboard() - const [ - isMissingPaymentMethodDialogOpen, - setIsMissingPaymentMethodDialogOpen, - ] = useState(false) - const [ - isVerificationRequiredDialogOpen, - setIsVerificationRequiredDialogOpen, - ] = useState(false) + const [openDialog, setOpenDialog] = useState(null) const message = useBlockedMessage(team.slug, team.blockedReason) const reason = team.blockedReason?.toLowerCase() ?? '' - const isMissingPaymentMethod = reason === BLOCKED_REASONS.missingPayment - const isVerificationRequired = reason === BLOCKED_REASONS.verification + const blockedReasonDialog: BlockedReasonDialog = + reason === BLOCKED_REASONS.missingPayment || + reason === BLOCKED_REASONS.verification + ? reason + : null useEffect(() => { - if (isMissingPaymentMethod) setIsMissingPaymentMethodDialogOpen(true) - if (isVerificationRequired) setIsVerificationRequiredDialogOpen(true) - }, [isMissingPaymentMethod, isVerificationRequired]) + setOpenDialog(blockedReasonDialog) + }, [blockedReasonDialog]) const handleDialogAction = () => { - if (isMissingPaymentMethod) { - setIsMissingPaymentMethodDialogOpen(true) - return - } - - if (isVerificationRequired) setIsVerificationRequiredDialogOpen(true) + setOpenDialog(blockedReasonDialog) } if (!team.isBlocked) return null @@ -104,12 +98,16 @@ export default function TeamBlockedIndicator() {
{ + setOpenDialog(open ? BLOCKED_REASONS.missingPayment : null) + }} /> { + setOpenDialog(open ? BLOCKED_REASONS.verification : null) + }} /> ) From 11a1d6833a85f4018a002506c19d37ce78cfd5db Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 15:47:42 -0400 Subject: [PATCH 13/39] Remove unused capitalize function from formatting utility - Deleted the capitalize function that formatted strings by capitalizing the first letter and replacing underscores or hyphens with spaces. - This change simplifies the formatting utility by removing unnecessary code, improving maintainability. --- src/lib/utils/formatting.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 671539473..eed13d295 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -7,15 +7,6 @@ import * as chrono from 'chrono-node' import { format, isThisYear, isValid } from 'date-fns' import { formatInTimeZone } from 'date-fns-tz' -// Capitalizes a readable string; "payment_method_missing" -> "Payment method missing". -export const capitalize = (value: string) => { - const formattedValue = value.toLowerCase().replace(/[_-]+/g, ' ').trim() - - if (!formattedValue) return formattedValue - - return formattedValue.charAt(0).toUpperCase() + formattedValue.slice(1) -} - const LOCAL_LOG_STYLE_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', From ec810dfe31598d323f30a32cc82eb6ad1264e0b4 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 16:16:23 -0400 Subject: [PATCH 14/39] Enhance payment method and verification dialogs with improved error handling and success messaging - Refactored the PaymentMethodsSetupForm and VerificationPaymentForm to include try-catch blocks for better error handling when checking team status. - Updated toast messages to provide clearer feedback to users regarding team status and actions taken. - Ensured that loading states are properly managed during the team status check process. --- .../sidebar/missing-payment-method-dialog.tsx | 41 +++++++++++-------- .../sidebar/verification-required-dialog.tsx | 39 +++++++++++------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index d13651c13..68511ba41 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -221,29 +221,38 @@ const PaymentMethodsSetupForm = ({ }) setIsCheckingTeamStatus(true) - const isTeamUnblocked = await pollUntilTeamUnblocked() - setIsCheckingTeamStatus(false) - if (!isTeamUnblocked) { + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Payment method added, but your team is still blocked. Please wait a moment and try again.' + ) + ) + return + } + + toast({ + variant: 'success', + title: 'Team unblocked', + description: + 'Your payment method was added and your team has been unblocked.', + }) + + router.refresh() + onOpenChange(false) + } catch { toast( defaultErrorToast( - 'Payment method added, but your team is still blocked. Please wait a moment and try again.' + 'Payment method added, but we could not check your team status. Please refresh or try again in a moment.' ) ) + } finally { + setIsCheckingTeamStatus(false) setIsSaving(false) - return } - - toast({ - variant: 'success', - title: 'Team unblocked', - description: - 'Your payment method was added and your team has been unblocked.', - }) - - setIsSaving(false) - router.refresh() - onOpenChange(false) } const isProcessing = isSaving || isCheckingTeamStatus diff --git a/src/features/dashboard/sidebar/verification-required-dialog.tsx b/src/features/dashboard/sidebar/verification-required-dialog.tsx index aca68773d..1f05480fe 100644 --- a/src/features/dashboard/sidebar/verification-required-dialog.tsx +++ b/src/features/dashboard/sidebar/verification-required-dialog.tsx @@ -219,28 +219,37 @@ const VerificationPaymentForm = ({ }) setIsCheckingTeamStatus(true) - const isTeamUnblocked = await pollUntilTeamUnblocked() - setIsCheckingTeamStatus(false) - if (!isTeamUnblocked) { + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + ) + ) + return + } + + toast({ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }) + + router.refresh() + onOpenChange(false) + } catch { toast( defaultErrorToast( - 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' ) ) + } finally { + setIsCheckingTeamStatus(false) setIsPaying(false) - return } - - toast({ - variant: 'success', - title: 'Team unblocked', - description: 'Your team has been verified and unblocked.', - }) - - setIsPaying(false) - router.refresh() - onOpenChange(false) } const isProcessing = isPaying || isCheckingTeamStatus From 3ba3b46813f936f62caa13553b0cbffb8742b150 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 16:32:37 -0400 Subject: [PATCH 15/39] Implement setup intent handling in MissingPaymentMethodDialog - Added logic to manage setup intent parameters using useQueryStates for better state management. - Enhanced the useEffect hook to check the status of the setup intent and provide appropriate user feedback through toasts. - Improved error handling for Stripe integration, ensuring users are informed of payment method status and actions taken. - Refactored payment methods session mutation to streamline the process of creating a session based on setup intent status. --- .../sidebar/missing-payment-method-dialog.tsx | 108 +++++++++++++++++- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 68511ba41..d2dd74f9e 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -8,7 +8,8 @@ import { } from '@stripe/react-stripe-js' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { type FormEvent, useEffect, useState } from 'react' +import { parseAsString, useQueryStates } from 'nuqs' +import { type FormEvent, useEffect, useRef, useState } from 'react' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import type { TeamModel } from '@/core/modules/teams/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' @@ -30,6 +31,11 @@ import { useDashboard } from '../context' const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' +const stripeSetupIntentParams = { + setup_intent: parseAsString, + setup_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} // Waits before retrying team status polling; 2000 -> resolves after 2 seconds. const wait = (ms: number) => @@ -61,6 +67,15 @@ const MissingPaymentMethodDialogContent = ({ const { team } = useDashboard() const { toast } = useToast() const trpc = useTRPC() + const router = useRouter() + const hasHandledSetupIntent = useRef(false) + const [setupIntentParams, setSetupIntentParams] = useQueryStates( + stripeSetupIntentParams, + { + history: 'replace', + shallow: true, + } + ) const paymentMethodsSessionMutation = useMutation( trpc.billing.createPaymentMethodsSession.mutationOptions({ @@ -76,8 +91,95 @@ const MissingPaymentMethodDialogContent = ({ ) useEffect(() => { - paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) - }, [paymentMethodsSessionMutation.mutate, team.slug]) + const setupIntentClientSecret = + setupIntentParams.setup_intent_client_secret + + const createPaymentMethodsSession = () => { + paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) + } + + if (hasHandledSetupIntent.current) return + + if (!setupIntentClientSecret) { + createPaymentMethodsSession() + return + } + + hasHandledSetupIntent.current = true + setSetupIntentParams({ + setup_intent: null, + setup_intent_client_secret: null, + redirect_status: null, + }) + + const checkSetupIntent = async () => { + const stripe = await stripePromise + + if (!stripe) { + toast(defaultErrorToast('Failed to load Stripe.')) + createPaymentMethodsSession() + return + } + + const { setupIntent, error } = await stripe.retrieveSetupIntent( + setupIntentClientSecret + ) + + if (error) { + toast( + defaultErrorToast( + error.message ?? 'Failed to check payment method status.' + ) + ) + createPaymentMethodsSession() + return + } + + if (setupIntent.status === 'succeeded') { + toast({ + variant: 'success', + title: 'Payment method added', + description: 'Your payment method was added successfully.', + }) + router.refresh() + onOpenChange(false) + return + } + + if (setupIntent.status === 'processing') { + toast({ + title: 'Payment method processing', + description: + 'Your bank is still processing this payment method. Please check again in a moment.', + }) + router.refresh() + onOpenChange(false) + return + } + + if (setupIntent.status === 'requires_payment_method') + toast( + defaultErrorToast( + 'Payment method setup was not completed. Please try again.' + ) + ) + + createPaymentMethodsSession() + } + + checkSetupIntent().catch(() => { + toast(defaultErrorToast('Failed to check payment method status.')) + createPaymentMethodsSession() + }) + }, [ + paymentMethodsSessionMutation.mutate, + router, + setupIntentParams.setup_intent_client_secret, + setSetupIntentParams, + team.slug, + toast, + onOpenChange, + ]) const session = paymentMethodsSessionMutation.data From 674fdfe68ee2e7ccbcb733428d2a72a17782ad50 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 16:34:18 -0400 Subject: [PATCH 16/39] Run biome format --- .../dashboard/sidebar/missing-payment-method-dialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index d2dd74f9e..030980c5c 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -91,8 +91,7 @@ const MissingPaymentMethodDialogContent = ({ ) useEffect(() => { - const setupIntentClientSecret = - setupIntentParams.setup_intent_client_secret + const setupIntentClientSecret = setupIntentParams.setup_intent_client_secret const createPaymentMethodsSession = () => { paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) From 304113c4749413b5b788dac8cd3c696669fd653b Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 17:43:53 -0400 Subject: [PATCH 17/39] Refactor useBlockedMessage logic in TeamBlockedIndicator to handle multiple blocked reasons - Updated the condition checks for BLOCKED_REASONS to use includes() for better flexibility in matching blocked reasons. - Simplified the logic for determining the blocked reason dialog by utilizing Object.values() to find the appropriate reason, enhancing code readability and maintainability. --- .../dashboard/layouts/team-blocked-indicator.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index e12605c68..0ab3c3f6c 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -25,7 +25,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (reason === BLOCKED_REASONS.missingPayment) { + if (reason.includes(BLOCKED_REASONS.missingPayment)) { return { text: 'Missing payment method.', cta: 'Add payment method.', @@ -33,7 +33,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (reason === BLOCKED_REASONS.verification) { + if (reason.includes(BLOCKED_REASONS.verification)) { return { text: 'Verification required.', cta: 'Complete verification.', @@ -56,10 +56,9 @@ export default function TeamBlockedIndicator() { const message = useBlockedMessage(team.slug, team.blockedReason) const reason = team.blockedReason?.toLowerCase() ?? '' const blockedReasonDialog: BlockedReasonDialog = - reason === BLOCKED_REASONS.missingPayment || - reason === BLOCKED_REASONS.verification - ? reason - : null + Object.values(BLOCKED_REASONS).find((blockedReason) => + reason.includes(blockedReason) + ) ?? null useEffect(() => { setOpenDialog(blockedReasonDialog) From 5b7bfc11c23d052e96bed422a4b0d7cbd9632459 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 17:46:14 -0400 Subject: [PATCH 18/39] Refactor setup intent handling in MissingPaymentMethodDialog - Removed redundant assignment of hasHandledSetupIntent.current to improve code clarity. - Ensured that setup intent parameters are set correctly when creating a payment methods session. --- .../dashboard/sidebar/missing-payment-method-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 030980c5c..8666082ec 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -98,13 +98,13 @@ const MissingPaymentMethodDialogContent = ({ } if (hasHandledSetupIntent.current) return + hasHandledSetupIntent.current = true if (!setupIntentClientSecret) { createPaymentMethodsSession() return } - hasHandledSetupIntent.current = true setSetupIntentParams({ setup_intent: null, setup_intent_client_secret: null, From 6e58d37e2e44d7c0053d33efbcbbdff9b39b0ec5 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 18:05:14 -0400 Subject: [PATCH 19/39] Refactor payment methods session handling in MissingPaymentMethodDialog - Introduced useCallback for creating payment methods session to optimize performance and prevent unnecessary re-renders. - Enhanced state management for payment methods session and loading state, improving user experience during session creation. - Updated useEffect hooks to better handle setup intent parameters and session resets based on dialog visibility. --- .../sidebar/missing-payment-method-dialog.tsx | 71 +++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx index 8666082ec..e91bda542 100644 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx @@ -9,8 +9,9 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' -import { type FormEvent, useEffect, useRef, useState } from 'react' +import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { PaymentMethodsSession } from '@/core/modules/billing/models' import type { TeamModel } from '@/core/modules/teams/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' @@ -55,20 +56,28 @@ export const MissingPaymentMethodDialog = ({ return ( - + ) } const MissingPaymentMethodDialogContent = ({ + open, onOpenChange, -}: Pick) => { +}: MissingPaymentMethodDialogProps) => { const { team } = useDashboard() const { toast } = useToast() const trpc = useTRPC() const router = useRouter() const hasHandledSetupIntent = useRef(false) + const [paymentMethodsSession, setPaymentMethodsSession] = + useState(null) + const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = + useState(false) const [setupIntentParams, setSetupIntentParams] = useQueryStates( stripeSetupIntentParams, { @@ -90,18 +99,40 @@ const MissingPaymentMethodDialogContent = ({ }) ) - useEffect(() => { - const setupIntentClientSecret = setupIntentParams.setup_intent_client_secret + const createPaymentMethodsSession = useCallback(async () => { + setIsLoadingPaymentMethodsSession(true) - const createPaymentMethodsSession = () => { - paymentMethodsSessionMutation.mutate({ teamSlug: team.slug }) + try { + const session = await paymentMethodsSessionMutation.mutateAsync({ + teamSlug: team.slug, + }) + setPaymentMethodsSession(session) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingPaymentMethodsSession(false) } + }, [paymentMethodsSessionMutation.mutateAsync, team.slug]) + + useEffect(() => { + if (open) return + + hasHandledSetupIntent.current = false + setPaymentMethodsSession(null) + setIsLoadingPaymentMethodsSession(false) + paymentMethodsSessionMutation.reset() + }, [open, paymentMethodsSessionMutation.reset]) + + useEffect(() => { + if (!open) return + + const setupIntentClientSecret = setupIntentParams.setup_intent_client_secret if (hasHandledSetupIntent.current) return hasHandledSetupIntent.current = true if (!setupIntentClientSecret) { - createPaymentMethodsSession() + void createPaymentMethodsSession() return } @@ -116,7 +147,7 @@ const MissingPaymentMethodDialogContent = ({ if (!stripe) { toast(defaultErrorToast('Failed to load Stripe.')) - createPaymentMethodsSession() + await createPaymentMethodsSession() return } @@ -130,7 +161,7 @@ const MissingPaymentMethodDialogContent = ({ error.message ?? 'Failed to check payment method status.' ) ) - createPaymentMethodsSession() + await createPaymentMethodsSession() return } @@ -163,25 +194,23 @@ const MissingPaymentMethodDialogContent = ({ ) ) - createPaymentMethodsSession() + await createPaymentMethodsSession() } checkSetupIntent().catch(() => { toast(defaultErrorToast('Failed to check payment method status.')) - createPaymentMethodsSession() + void createPaymentMethodsSession() }) }, [ - paymentMethodsSessionMutation.mutate, + createPaymentMethodsSession, + open, router, setupIntentParams.setup_intent_client_secret, setSetupIntentParams, - team.slug, toast, onOpenChange, ]) - const session = paymentMethodsSessionMutation.data - return ( <> @@ -192,12 +221,14 @@ const MissingPaymentMethodDialogContent = ({ - {paymentMethodsSessionMutation.isPending ? ( + {isLoadingPaymentMethodsSession ? ( - ) : session ? ( + ) : paymentMethodsSession ? ( ) : null} From 9e2fceccbd537faeae16f1e31c8559dc8b67fe73 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 5 May 2026 18:14:56 -0400 Subject: [PATCH 20/39] 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. --- .../sidebar/verification-required-dialog.tsx | 249 +++++++++++++++--- 1 file changed, 208 insertions(+), 41 deletions(-) diff --git a/src/features/dashboard/sidebar/verification-required-dialog.tsx b/src/features/dashboard/sidebar/verification-required-dialog.tsx index 1f05480fe..9e4f004a3 100644 --- a/src/features/dashboard/sidebar/verification-required-dialog.tsx +++ b/src/features/dashboard/sidebar/verification-required-dialog.tsx @@ -8,8 +8,10 @@ import { } from '@stripe/react-stripe-js' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { type FormEvent, useEffect, useState } from 'react' +import { parseAsString, useQueryStates } from 'nuqs' +import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { VerificationPaymentResponse } from '@/core/modules/billing/models' import type { TeamModel } from '@/core/modules/teams/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' @@ -30,6 +32,11 @@ import { useDashboard } from '../context' const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' +const stripePaymentIntentParams = { + payment_intent: parseAsString, + payment_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} // Waits before retrying team status polling; 2000 -> resolves after 2 seconds. const wait = (ms: number) => @@ -37,6 +44,45 @@ const wait = (ms: number) => window.setTimeout(resolve, ms) }) +const useTeamUnblockPolling = () => { + const trpc = useTRPC() + const queryClient = useQueryClient() + const { team } = useDashboard() + + const teamListQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + const teamListQueryKey = teamListQueryOptions.queryKey + + const pollUntilTeamUnblocked = async () => { + for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + + const teams = await queryClient.fetchQuery({ + ...teamListQueryOptions, + staleTime: 0, + }) + const activeTeam = teams.find( + (candidate: TeamModel) => + candidate.id === team.id || candidate.slug === team.slug + ) + + if (activeTeam && !activeTeam.isBlocked) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + return true + } + + if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) + await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) + } + + return false + } + + return pollUntilTeamUnblocked +} + interface VerificationRequiredDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -49,18 +95,36 @@ export const VerificationRequiredDialog = ({ return ( - + ) } const VerificationRequiredDialogContent = ({ + open, onOpenChange, -}: Pick) => { +}: VerificationRequiredDialogProps) => { const { team } = useDashboard() const { toast } = useToast() const trpc = useTRPC() + const router = useRouter() + const hasHandledPaymentIntent = useRef(false) + const pollUntilTeamUnblocked = useTeamUnblockPolling() + const [verificationPayment, setVerificationPayment] = + useState(null) + const [isLoadingVerificationPayment, setIsLoadingVerificationPayment] = + useState(false) + const [paymentIntentParams, setPaymentIntentParams] = useQueryStates( + stripePaymentIntentParams, + { + history: 'replace', + shallow: true, + } + ) const verificationPaymentMutation = useMutation( trpc.billing.createVerificationPayment.mutationOptions({ @@ -75,11 +139,147 @@ const VerificationRequiredDialogContent = ({ }) ) + const createVerificationPayment = useCallback(async () => { + setIsLoadingVerificationPayment(true) + + try { + const payment = await verificationPaymentMutation.mutateAsync({ + teamSlug: team.slug, + }) + setVerificationPayment(payment) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingVerificationPayment(false) + } + }, [verificationPaymentMutation.mutateAsync, team.slug]) + + useEffect(() => { + if (open) return + + hasHandledPaymentIntent.current = false + setVerificationPayment(null) + setIsLoadingVerificationPayment(false) + verificationPaymentMutation.reset() + }, [open, verificationPaymentMutation.reset]) + useEffect(() => { - verificationPaymentMutation.mutate({ teamSlug: team.slug }) - }, [team.slug, verificationPaymentMutation.mutate]) + if (!open) return + + const paymentIntentClientSecret = + paymentIntentParams.payment_intent_client_secret + + if (hasHandledPaymentIntent.current) return + hasHandledPaymentIntent.current = true + + if (!paymentIntentClientSecret) { + void createVerificationPayment() + return + } + + setPaymentIntentParams({ + payment_intent: null, + payment_intent_client_secret: null, + redirect_status: null, + }) + + const checkPaymentIntent = async () => { + const stripe = await stripePromise + + if (!stripe) { + toast(defaultErrorToast('Failed to load Stripe.')) + await createVerificationPayment() + return + } - const verificationPayment = verificationPaymentMutation.data + const { paymentIntent, error } = await stripe.retrievePaymentIntent( + paymentIntentClientSecret + ) + + if (error) { + toast( + defaultErrorToast( + error.message ?? 'Failed to check verification payment status.' + ) + ) + await createVerificationPayment() + return + } + + if (paymentIntent.status === 'succeeded') { + toast({ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }) + + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + ) + ) + router.refresh() + onOpenChange(false) + return + } + + toast({ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }) + } catch { + toast( + defaultErrorToast( + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' + ) + ) + } + + router.refresh() + onOpenChange(false) + return + } + + if (paymentIntent.status === 'processing') { + toast({ + title: 'Verification payment processing', + description: + 'Your bank is still processing this payment. Please check again in a moment.', + }) + router.refresh() + onOpenChange(false) + return + } + + if (paymentIntent.status === 'requires_payment_method') + toast( + defaultErrorToast( + 'Verification payment was not completed. Please try again.' + ) + ) + + await createVerificationPayment() + } + + checkPaymentIntent().catch(() => { + toast(defaultErrorToast('Failed to check verification payment status.')) + void createVerificationPayment() + }) + }, [ + createVerificationPayment, + open, + paymentIntentParams.payment_intent_client_secret, + setPaymentIntentParams, + toast, + pollUntilTeamUnblocked, + router, + onOpenChange, + ]) return ( <> @@ -91,7 +291,7 @@ const VerificationRequiredDialogContent = ({ - {verificationPaymentMutation.isPending ? ( + {isLoadingVerificationPayment ? ( ) : verificationPayment ? ( ) => { const stripe = useStripe() const elements = useElements() - const trpc = useTRPC() const router = useRouter() - const queryClient = useQueryClient() const { toast } = useToast() - const { team } = useDashboard() const [isPaying, setIsPaying] = useState(false) const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) - - const teamListQueryOptions = trpc.teams.list.queryOptions( - undefined, - DASHBOARD_TEAMS_LIST_QUERY_OPTIONS - ) - const teamListQueryKey = teamListQueryOptions.queryKey - - const pollUntilTeamUnblocked = async () => { - for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - - const teams = await queryClient.fetchQuery({ - ...teamListQueryOptions, - staleTime: 0, - }) - const activeTeam = teams.find( - (candidate: TeamModel) => - candidate.id === team.id || candidate.slug === team.slug - ) - - if (activeTeam && !activeTeam.isBlocked) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - return true - } - - if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) - await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) - } - - return false - } + const pollUntilTeamUnblocked = useTeamUnblockPolling() const handleSubmit = async (event: FormEvent) => { event.preventDefault() From 238610f84bb96c712738ca96305af8e8820caa3c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 15:49:31 -0400 Subject: [PATCH 21/39] Refactor team-blocked dialogs and consolidate payment handling - Moved MissingPaymentMethodDialog and VerificationRequiredDialog to a new team-blocked directory for better organization. - Introduced a shared recovery component to handle payment processing logic, improving code reusability. - Enhanced state management and error handling for payment sessions, ensuring clearer user feedback during payment processes. - Updated imports in team-blocked indicator to reflect new dialog locations, streamlining the component structure. --- .../layouts/team-blocked-indicator.tsx | 6 +- .../sidebar/missing-payment-method-dialog.tsx | 443 ---------------- .../sidebar/verification-required-dialog.tsx | 476 ------------------ src/features/dashboard/team-blocked/index.ts | 2 + .../missing-payment-method-dialog.tsx | 216 ++++++++ .../team-blocked/team-blocked-recovery.tsx | 430 ++++++++++++++++ .../verification-required-dialog.tsx | 246 +++++++++ 7 files changed, 898 insertions(+), 921 deletions(-) delete mode 100644 src/features/dashboard/sidebar/missing-payment-method-dialog.tsx delete mode 100644 src/features/dashboard/sidebar/verification-required-dialog.tsx create mode 100644 src/features/dashboard/team-blocked/index.ts create mode 100644 src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx create mode 100644 src/features/dashboard/team-blocked/team-blocked-recovery.tsx create mode 100644 src/features/dashboard/team-blocked/verification-required-dialog.tsx diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 0ab3c3f6c..298746b31 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -6,8 +6,10 @@ import { PROTECTED_URLS } from '@/configs/urls' import { BLOCKED_REASONS } from '@/core/modules/teams/constants' import { useDashboard } from '@/features/dashboard/context' import { BlockIcon } from '@/ui/primitives/icons' -import { MissingPaymentMethodDialog } from '../sidebar/missing-payment-method-dialog' -import { VerificationRequiredDialog } from '../sidebar/verification-required-dialog' +import { + MissingPaymentMethodDialog, + VerificationRequiredDialog, +} from '../team-blocked' type BlockedReasonDialog = | (typeof BLOCKED_REASONS)[keyof typeof BLOCKED_REASONS] diff --git a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx b/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx deleted file mode 100644 index e91bda542..000000000 --- a/src/features/dashboard/sidebar/missing-payment-method-dialog.tsx +++ /dev/null @@ -1,443 +0,0 @@ -'use client' - -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' -import { parseAsString, useQueryStates } from 'nuqs' -import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react' -import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' -import type { PaymentMethodsSession } from '@/core/modules/billing/models' -import type { TeamModel } from '@/core/modules/teams/models' -import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' -import { useTRPC } from '@/trpc/client' -import { Alert, AlertDescription } from '@/ui/primitives/alert' -import { Button } from '@/ui/primitives/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/ui/primitives/dialog' -import { ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' -import { Loader } from '@/ui/primitives/loader' -import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' -import { useDashboard } from '../context' - -const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 -const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 -const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' -const stripeSetupIntentParams = { - setup_intent: parseAsString, - setup_intent_client_secret: parseAsString, - redirect_status: parseAsString, -} - -// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. -const wait = (ms: number) => - new Promise((resolve) => { - window.setTimeout(resolve, ms) - }) - -interface MissingPaymentMethodDialogProps { - open: boolean - onOpenChange: (open: boolean) => void -} - -export const MissingPaymentMethodDialog = ({ - open, - onOpenChange, -}: MissingPaymentMethodDialogProps) => { - return ( - - - - - - ) -} - -const MissingPaymentMethodDialogContent = ({ - open, - onOpenChange, -}: MissingPaymentMethodDialogProps) => { - const { team } = useDashboard() - const { toast } = useToast() - const trpc = useTRPC() - const router = useRouter() - const hasHandledSetupIntent = useRef(false) - const [paymentMethodsSession, setPaymentMethodsSession] = - useState(null) - const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = - useState(false) - const [setupIntentParams, setSetupIntentParams] = useQueryStates( - stripeSetupIntentParams, - { - history: 'replace', - shallow: true, - } - ) - - const paymentMethodsSessionMutation = useMutation( - trpc.billing.createPaymentMethodsSession.mutationOptions({ - onError: (error) => { - toast( - defaultErrorToast( - error.message || 'Failed to load payment method form.' - ) - ) - onOpenChange(false) - }, - }) - ) - - const createPaymentMethodsSession = useCallback(async () => { - setIsLoadingPaymentMethodsSession(true) - - try { - const session = await paymentMethodsSessionMutation.mutateAsync({ - teamSlug: team.slug, - }) - setPaymentMethodsSession(session) - } catch { - // The mutation onError handler owns the user-facing toast and close. - } finally { - setIsLoadingPaymentMethodsSession(false) - } - }, [paymentMethodsSessionMutation.mutateAsync, team.slug]) - - useEffect(() => { - if (open) return - - hasHandledSetupIntent.current = false - setPaymentMethodsSession(null) - setIsLoadingPaymentMethodsSession(false) - paymentMethodsSessionMutation.reset() - }, [open, paymentMethodsSessionMutation.reset]) - - useEffect(() => { - if (!open) return - - const setupIntentClientSecret = setupIntentParams.setup_intent_client_secret - - if (hasHandledSetupIntent.current) return - hasHandledSetupIntent.current = true - - if (!setupIntentClientSecret) { - void createPaymentMethodsSession() - return - } - - setSetupIntentParams({ - setup_intent: null, - setup_intent_client_secret: null, - redirect_status: null, - }) - - const checkSetupIntent = async () => { - const stripe = await stripePromise - - if (!stripe) { - toast(defaultErrorToast('Failed to load Stripe.')) - await createPaymentMethodsSession() - return - } - - const { setupIntent, error } = await stripe.retrieveSetupIntent( - setupIntentClientSecret - ) - - if (error) { - toast( - defaultErrorToast( - error.message ?? 'Failed to check payment method status.' - ) - ) - await createPaymentMethodsSession() - return - } - - if (setupIntent.status === 'succeeded') { - toast({ - variant: 'success', - title: 'Payment method added', - description: 'Your payment method was added successfully.', - }) - router.refresh() - onOpenChange(false) - return - } - - if (setupIntent.status === 'processing') { - toast({ - title: 'Payment method processing', - description: - 'Your bank is still processing this payment method. Please check again in a moment.', - }) - router.refresh() - onOpenChange(false) - return - } - - if (setupIntent.status === 'requires_payment_method') - toast( - defaultErrorToast( - 'Payment method setup was not completed. Please try again.' - ) - ) - - await createPaymentMethodsSession() - } - - checkSetupIntent().catch(() => { - toast(defaultErrorToast('Failed to check payment method status.')) - void createPaymentMethodsSession() - }) - }, [ - createPaymentMethodsSession, - open, - router, - setupIntentParams.setup_intent_client_secret, - setSetupIntentParams, - toast, - onOpenChange, - ]) - - return ( - <> - - Add payment method - - This team is blocked because there is no payment method on file. Add a - card to continue using E2B. - - - - {isLoadingPaymentMethodsSession ? ( - - ) : paymentMethodsSession ? ( - - ) : null} - - ) -} - -const LoadingState = ({ message }: { message: string }) => { - return ( -
- - {message} -
- ) -} - -interface PaymentMethodsSetupElementsProps { - customerSessionClientSecret: string - setupIntentClientSecret: string - onOpenChange: (open: boolean) => void -} - -const PaymentMethodsSetupElements = ({ - customerSessionClientSecret, - setupIntentClientSecret, - onOpenChange, -}: PaymentMethodsSetupElementsProps) => { - const appearance = usePaymentElementAppearance() - - return ( - - - - ) -} - -const PaymentMethodsSetupForm = ({ - onOpenChange, -}: Pick) => { - const stripe = useStripe() - const elements = useElements() - const trpc = useTRPC() - const router = useRouter() - const queryClient = useQueryClient() - const { toast } = useToast() - const { team } = useDashboard() - const [isSaving, setIsSaving] = useState(false) - const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) - const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) - - const teamListQueryOptions = trpc.teams.list.queryOptions( - undefined, - DASHBOARD_TEAMS_LIST_QUERY_OPTIONS - ) - const teamListQueryKey = teamListQueryOptions.queryKey - - const pollUntilTeamUnblocked = async () => { - for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - - const teams = await queryClient.fetchQuery({ - ...teamListQueryOptions, - staleTime: 0, - }) - const activeTeam = teams.find( - (candidate: TeamModel) => - candidate.id === team.id || candidate.slug === team.slug - ) - - if (activeTeam && !activeTeam.isBlocked) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - return true - } - - if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) - await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) - } - - return false - } - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault() - - if (!stripe || !elements || !isPaymentElementReady) { - toast(defaultErrorToast('Payment form is still loading.')) - return - } - - setIsSaving(true) - - const { error } = await stripe.confirmSetup({ - elements, - redirect: 'if_required', - confirmParams: { - return_url: window.location.href, - }, - }) - - if (error) { - toast( - defaultErrorToast( - error.message ?? 'Failed to save payment method. Please try again.' - ) - ) - setIsSaving(false) - return - } - - toast({ - title: 'Payment method added', - description: 'We are checking whether your team has been unblocked.', - }) - - setIsCheckingTeamStatus(true) - - try { - const isTeamUnblocked = await pollUntilTeamUnblocked() - - if (!isTeamUnblocked) { - toast( - defaultErrorToast( - 'Payment method added, but your team is still blocked. Please wait a moment and try again.' - ) - ) - return - } - - toast({ - variant: 'success', - title: 'Team unblocked', - description: - 'Your payment method was added and your team has been unblocked.', - }) - - router.refresh() - onOpenChange(false) - } catch { - toast( - defaultErrorToast( - 'Payment method added, but we could not check your team status. Please refresh or try again in a moment.' - ) - ) - } finally { - setIsCheckingTeamStatus(false) - setIsSaving(false) - } - } - - const isProcessing = isSaving || isCheckingTeamStatus - const paymentSubmitLoadingLabel = isCheckingTeamStatus - ? 'Checking team status...' - : 'Saving...' - - return ( -
- - - - Your payment method will be saved to your team billing account. - - - - {!isPaymentElementReady && ( - - )} - - { - setIsPaymentElementReady(true) - }} - onLoadError={(event) => { - setIsPaymentElementReady(false) - toast( - defaultErrorToast( - event.error.message ?? - 'Failed to load payment details. Please refresh and try again.' - ) - ) - onOpenChange(false) - }} - options={{ - layout: { - type: 'tabs', - defaultCollapsed: false, - }, - }} - /> - - - - ) -} diff --git a/src/features/dashboard/sidebar/verification-required-dialog.tsx b/src/features/dashboard/sidebar/verification-required-dialog.tsx deleted file mode 100644 index 9e4f004a3..000000000 --- a/src/features/dashboard/sidebar/verification-required-dialog.tsx +++ /dev/null @@ -1,476 +0,0 @@ -'use client' - -import { - Elements, - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' -import { parseAsString, useQueryStates } from 'nuqs' -import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react' -import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' -import type { VerificationPaymentResponse } from '@/core/modules/billing/models' -import type { TeamModel } from '@/core/modules/teams/models' -import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' -import { useTRPC } from '@/trpc/client' -import { Alert, AlertDescription } from '@/ui/primitives/alert' -import { Button } from '@/ui/primitives/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/ui/primitives/dialog' -import { ArrowRightIcon, CardIcon } from '@/ui/primitives/icons' -import { Loader } from '@/ui/primitives/loader' -import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' -import { useDashboard } from '../context' - -const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 -const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 -const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' -const stripePaymentIntentParams = { - payment_intent: parseAsString, - payment_intent_client_secret: parseAsString, - redirect_status: parseAsString, -} - -// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. -const wait = (ms: number) => - new Promise((resolve) => { - window.setTimeout(resolve, ms) - }) - -const useTeamUnblockPolling = () => { - const trpc = useTRPC() - const queryClient = useQueryClient() - const { team } = useDashboard() - - const teamListQueryOptions = trpc.teams.list.queryOptions( - undefined, - DASHBOARD_TEAMS_LIST_QUERY_OPTIONS - ) - const teamListQueryKey = teamListQueryOptions.queryKey - - const pollUntilTeamUnblocked = async () => { - for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - - const teams = await queryClient.fetchQuery({ - ...teamListQueryOptions, - staleTime: 0, - }) - const activeTeam = teams.find( - (candidate: TeamModel) => - candidate.id === team.id || candidate.slug === team.slug - ) - - if (activeTeam && !activeTeam.isBlocked) { - await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) - return true - } - - if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) - await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) - } - - return false - } - - return pollUntilTeamUnblocked -} - -interface VerificationRequiredDialogProps { - open: boolean - onOpenChange: (open: boolean) => void -} - -export const VerificationRequiredDialog = ({ - open, - onOpenChange, -}: VerificationRequiredDialogProps) => { - return ( - - - - - - ) -} - -const VerificationRequiredDialogContent = ({ - open, - onOpenChange, -}: VerificationRequiredDialogProps) => { - const { team } = useDashboard() - const { toast } = useToast() - const trpc = useTRPC() - const router = useRouter() - const hasHandledPaymentIntent = useRef(false) - const pollUntilTeamUnblocked = useTeamUnblockPolling() - const [verificationPayment, setVerificationPayment] = - useState(null) - const [isLoadingVerificationPayment, setIsLoadingVerificationPayment] = - useState(false) - const [paymentIntentParams, setPaymentIntentParams] = useQueryStates( - stripePaymentIntentParams, - { - history: 'replace', - shallow: true, - } - ) - - const verificationPaymentMutation = useMutation( - trpc.billing.createVerificationPayment.mutationOptions({ - onError: (error) => { - toast( - defaultErrorToast( - error.message || 'Failed to load verification payment form.' - ) - ) - onOpenChange(false) - }, - }) - ) - - const createVerificationPayment = useCallback(async () => { - setIsLoadingVerificationPayment(true) - - try { - const payment = await verificationPaymentMutation.mutateAsync({ - teamSlug: team.slug, - }) - setVerificationPayment(payment) - } catch { - // The mutation onError handler owns the user-facing toast and close. - } finally { - setIsLoadingVerificationPayment(false) - } - }, [verificationPaymentMutation.mutateAsync, team.slug]) - - useEffect(() => { - if (open) return - - hasHandledPaymentIntent.current = false - setVerificationPayment(null) - setIsLoadingVerificationPayment(false) - verificationPaymentMutation.reset() - }, [open, verificationPaymentMutation.reset]) - - useEffect(() => { - if (!open) return - - const paymentIntentClientSecret = - paymentIntentParams.payment_intent_client_secret - - if (hasHandledPaymentIntent.current) return - hasHandledPaymentIntent.current = true - - if (!paymentIntentClientSecret) { - void createVerificationPayment() - return - } - - setPaymentIntentParams({ - payment_intent: null, - payment_intent_client_secret: null, - redirect_status: null, - }) - - const checkPaymentIntent = async () => { - const stripe = await stripePromise - - if (!stripe) { - toast(defaultErrorToast('Failed to load Stripe.')) - await createVerificationPayment() - return - } - - const { paymentIntent, error } = await stripe.retrievePaymentIntent( - paymentIntentClientSecret - ) - - if (error) { - toast( - defaultErrorToast( - error.message ?? 'Failed to check verification payment status.' - ) - ) - await createVerificationPayment() - return - } - - if (paymentIntent.status === 'succeeded') { - toast({ - title: 'Verification payment submitted', - description: - 'We are checking whether your team has been verified and unblocked.', - }) - - try { - const isTeamUnblocked = await pollUntilTeamUnblocked() - - if (!isTeamUnblocked) { - toast( - defaultErrorToast( - 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' - ) - ) - router.refresh() - onOpenChange(false) - return - } - - toast({ - variant: 'success', - title: 'Team unblocked', - description: 'Your team has been verified and unblocked.', - }) - } catch { - toast( - defaultErrorToast( - 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' - ) - ) - } - - router.refresh() - onOpenChange(false) - return - } - - if (paymentIntent.status === 'processing') { - toast({ - title: 'Verification payment processing', - description: - 'Your bank is still processing this payment. Please check again in a moment.', - }) - router.refresh() - onOpenChange(false) - return - } - - if (paymentIntent.status === 'requires_payment_method') - toast( - defaultErrorToast( - 'Verification payment was not completed. Please try again.' - ) - ) - - await createVerificationPayment() - } - - checkPaymentIntent().catch(() => { - toast(defaultErrorToast('Failed to check verification payment status.')) - void createVerificationPayment() - }) - }, [ - createVerificationPayment, - open, - paymentIntentParams.payment_intent_client_secret, - setPaymentIntentParams, - toast, - pollUntilTeamUnblocked, - router, - onOpenChange, - ]) - - return ( - <> - - Verify team - - This team is blocked because verification is required. Make a $5 - payment to verify and continue using E2B. - - - - {isLoadingVerificationPayment ? ( - - ) : verificationPayment ? ( - - ) : null} - - ) -} - -const LoadingState = ({ message }: { message: string }) => { - return ( -
- - {message} -
- ) -} - -interface VerificationPaymentElementsProps { - clientSecret: string - onOpenChange: (open: boolean) => void -} - -const VerificationPaymentElements = ({ - clientSecret, - onOpenChange, -}: VerificationPaymentElementsProps) => { - const appearance = usePaymentElementAppearance() - - return ( - - - - ) -} - -const VerificationPaymentForm = ({ - onOpenChange, -}: Pick) => { - const stripe = useStripe() - const elements = useElements() - const router = useRouter() - const { toast } = useToast() - const [isPaying, setIsPaying] = useState(false) - const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) - const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) - const pollUntilTeamUnblocked = useTeamUnblockPolling() - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault() - - if (!stripe || !elements || !isPaymentElementReady) { - toast(defaultErrorToast('Payment form is still loading.')) - return - } - - setIsPaying(true) - - const { error } = await stripe.confirmPayment({ - elements, - redirect: 'if_required', - confirmParams: { - return_url: window.location.href, - }, - }) - - if (error) { - toast( - defaultErrorToast( - error.message ?? - 'Failed to process verification payment. Please try again.' - ) - ) - setIsPaying(false) - return - } - - toast({ - title: 'Verification payment submitted', - description: - 'We are checking whether your team has been verified and unblocked.', - }) - - setIsCheckingTeamStatus(true) - - try { - const isTeamUnblocked = await pollUntilTeamUnblocked() - - if (!isTeamUnblocked) { - toast( - defaultErrorToast( - 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' - ) - ) - return - } - - toast({ - variant: 'success', - title: 'Team unblocked', - description: 'Your team has been verified and unblocked.', - }) - - router.refresh() - onOpenChange(false) - } catch { - toast( - defaultErrorToast( - 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' - ) - ) - } finally { - setIsCheckingTeamStatus(false) - setIsPaying(false) - } - } - - const isProcessing = isPaying || isCheckingTeamStatus - const paymentSubmitLoadingLabel = isCheckingTeamStatus - ? 'Checking team status...' - : 'Processing...' - - return ( -
- - - - A $5 card payment will be charged and added back to your team as - credits. - - - - {!isPaymentElementReady && ( - - )} - - { - setIsPaymentElementReady(true) - }} - onLoadError={(event) => { - setIsPaymentElementReady(false) - toast( - defaultErrorToast( - event.error.message ?? - 'Failed to load payment details. Please refresh and try again.' - ) - ) - onOpenChange(false) - }} - options={{ - layout: { - type: 'tabs', - defaultCollapsed: false, - }, - }} - /> - - - - ) -} diff --git a/src/features/dashboard/team-blocked/index.ts b/src/features/dashboard/team-blocked/index.ts new file mode 100644 index 000000000..1b1be421d --- /dev/null +++ b/src/features/dashboard/team-blocked/index.ts @@ -0,0 +1,2 @@ +export { MissingPaymentMethodDialog } from './missing-payment-method-dialog' +export { VerificationRequiredDialog } from './verification-required-dialog' diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx new file mode 100644 index 000000000..2882d3d84 --- /dev/null +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useEffect, useState } from 'react' +import type { PaymentMethodsSession } from '@/core/modules/billing/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CardIcon } from '@/ui/primitives/icons' +import { useDashboard } from '../context' +import { + LoadingState, + TeamBlockedRecoveryPaymentElement, + useStripeReturnHandler, +} from './team-blocked-recovery' + +const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' + +const stripeSetupIntentParams = { + setup_intent: parseAsString, + setup_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} + +interface MissingPaymentMethodDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const MissingPaymentMethodDialog = ({ + open, + onOpenChange, +}: MissingPaymentMethodDialogProps) => { + return ( + + + + + + ) +} + +const MissingPaymentMethodDialogContent = ({ + open, + onOpenChange, +}: MissingPaymentMethodDialogProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const trpc = useTRPC() + const router = useRouter() + const [paymentMethodsSession, setPaymentMethodsSession] = + useState(null) + const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = + useState(false) + const [setupIntentParams, setSetupIntentParams] = useQueryStates( + stripeSetupIntentParams, + { + history: 'replace', + shallow: true, + } + ) + + const paymentMethodsSessionMutation = useMutation( + trpc.billing.createPaymentMethodsSession.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load payment method form.' + ) + ) + onOpenChange(false) + }, + }) + ) + + const createPaymentMethodsSession = useCallback(async () => { + setIsLoadingPaymentMethodsSession(true) + + try { + const session = await paymentMethodsSessionMutation.mutateAsync({ + teamSlug: team.slug, + }) + setPaymentMethodsSession(session) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingPaymentMethodsSession(false) + } + }, [paymentMethodsSessionMutation.mutateAsync, team.slug]) + + useEffect(() => { + if (open) return + + setPaymentMethodsSession(null) + setIsLoadingPaymentMethodsSession(false) + paymentMethodsSessionMutation.reset() + }, [open, paymentMethodsSessionMutation.reset]) + + useStripeReturnHandler({ + open, + clientSecret: setupIntentParams.setup_intent_client_secret, + clearReturnParams: () => { + setSetupIntentParams({ + setup_intent: null, + setup_intent_client_secret: null, + redirect_status: null, + }) + }, + createPaymentSession: createPaymentMethodsSession, + retrieveStatus: async (stripe, clientSecret) => { + const { setupIntent, error } = + await stripe.retrieveSetupIntent(clientSecret) + + return { + status: setupIntent?.status, + errorMessage: error?.message, + } + }, + onSucceeded: () => { + toast({ + variant: 'success', + title: 'Payment method added', + description: 'Your payment method was added successfully.', + }) + router.refresh() + onOpenChange(false) + }, + onProcessing: () => { + toast({ + title: 'Payment method processing', + description: + 'Your bank is still processing this payment method. Please check again in a moment.', + }) + router.refresh() + onOpenChange(false) + }, + requiresPaymentMethodMessage: + 'Payment method setup was not completed. Please try again.', + retrieveErrorMessage: 'Failed to check payment method status.', + fallbackErrorMessage: 'Failed to check payment method status.', + }) + + return ( + <> + + Add payment method + + This team is blocked because there is no payment method on file. Add a + card to continue using E2B. + + + + {isLoadingPaymentMethodsSession ? ( + + ) : paymentMethodsSession ? ( + + stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: returnUrl, + }, + }) + } + alert={ + + + + Your payment method will be saved to your team billing account. + + + } + loadingMessage={PAYMENT_METHOD_LOADING_MESSAGE} + submitLabel="Save payment method" + processingLabel="Saving..." + submittedToast={{ + title: 'Payment method added', + description: + 'We are checking whether your team has been unblocked.', + }} + successToast={{ + variant: 'success', + title: 'Team unblocked', + description: + 'Your payment method was added and your team has been unblocked.', + }} + errorMessages={{ + ready: 'Payment form is still loading.', + confirm: 'Failed to save payment method. Please try again.', + stillBlocked: + 'Payment method added, but your team is still blocked. Please wait a moment and try again.', + statusCheck: + 'Payment method added, but we could not check your team status. Please refresh or try again in a moment.', + load: 'Failed to load payment details. Please refresh and try again.', + }} + /> + ) : null} + + ) +} diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx new file mode 100644 index 000000000..81039d908 --- /dev/null +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -0,0 +1,430 @@ +'use client' + +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js' +import type { Stripe, StripeElements, StripeError } from '@stripe/stripe-js' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import type { FormEvent, ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { TeamModel } from '@/core/modules/teams/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils/ui' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { ArrowRightIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' +import { useDashboard } from '../context' + +const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 +const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 + +type ToastInput = Parameters['toast']>[0] + +interface LoadingStateProps { + message: string + className?: string +} + +export const LoadingState = ({ message, className }: LoadingStateProps) => { + return ( +
+ + {message} +
+ ) +} + +// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. +const wait = (ms: number) => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) + +export const useTeamUnblockPolling = () => { + const trpc = useTRPC() + const queryClient = useQueryClient() + const { team } = useDashboard() + + const teamListQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + + const teamListQueryKey = teamListQueryOptions.queryKey + + const pollUntilTeamUnblocked = useCallback(async () => { + for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + + const teams = await queryClient.fetchQuery({ + ...teamListQueryOptions, + staleTime: 0, + }) + + const activeTeam = teams.find( + (candidate: TeamModel) => + candidate.id === team.id || candidate.slug === team.slug + ) + + if (activeTeam && !activeTeam.isBlocked) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + return true + } + + if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) + await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) + } + + return false + }, [queryClient, team.id, team.slug, teamListQueryKey, teamListQueryOptions]) + + return pollUntilTeamUnblocked +} + +interface StripeReturnStatusResult { + status?: string + errorMessage?: string | null +} + +interface StripeReturnHandlerOptions { + open: boolean + clientSecret: string | null + clearReturnParams: () => void + createPaymentSession: () => Promise + retrieveStatus: ( + stripe: Stripe, + clientSecret: string + ) => Promise + onSucceeded: () => Promise | void + onProcessing: () => void + requiresPaymentMethodMessage: string + retrieveErrorMessage: string + fallbackErrorMessage: string +} + +export const useStripeReturnHandler = ({ + open, + clientSecret, + clearReturnParams, + createPaymentSession, + retrieveStatus, + onSucceeded, + onProcessing, + requiresPaymentMethodMessage, + retrieveErrorMessage, + fallbackErrorMessage, +}: StripeReturnHandlerOptions) => { + const { toast } = useToast() + const hasHandledStripeReturn = useRef(false) + + useEffect(() => { + if (open) return + + hasHandledStripeReturn.current = false + }, [open]) + + useEffect(() => { + if (!open) return + if (hasHandledStripeReturn.current) return + + hasHandledStripeReturn.current = true + + if (!clientSecret) { + void createPaymentSession() + return + } + + clearReturnParams() + + const checkStripeReturn = async () => { + const stripe = await stripePromise + + if (!stripe) { + toast(defaultErrorToast('Failed to load Stripe.')) + await createPaymentSession() + return + } + + const result = await retrieveStatus(stripe, clientSecret) + + if (result.errorMessage) { + toast(defaultErrorToast(result.errorMessage ?? retrieveErrorMessage)) + await createPaymentSession() + return + } + + if (result.status === 'succeeded') { + await onSucceeded() + return + } + + if (result.status === 'processing') { + onProcessing() + return + } + + if (result.status === 'requires_payment_method') + toast(defaultErrorToast(requiresPaymentMethodMessage)) + + await createPaymentSession() + } + + checkStripeReturn().catch(() => { + toast(defaultErrorToast(fallbackErrorMessage)) + void createPaymentSession() + }) + }, [ + clearReturnParams, + clientSecret, + createPaymentSession, + fallbackErrorMessage, + onProcessing, + onSucceeded, + open, + requiresPaymentMethodMessage, + retrieveErrorMessage, + retrieveStatus, + toast, + ]) +} + +interface StripePaymentElementWrapperProps { + clientSecret: string + customerSessionClientSecret?: string + children: ReactNode +} + +export const StripePaymentElementWrapper = ({ + clientSecret, + customerSessionClientSecret, + children, +}: StripePaymentElementWrapperProps) => { + const appearance = usePaymentElementAppearance() + + return ( + + {children} + + ) +} + +interface StripePaymentElementFormProps { + onSubmit: (params: { + stripe: Stripe + elements: StripeElements + }) => Promise + submitLabel: ReactNode + processingLabel: string + loadingMessage: string + readyErrorMessage?: string + paymentElementDefaultCollapsed?: boolean + alert?: ReactNode + isProcessing?: boolean + className?: string + buttonClassName?: string + processingFallbackMessage?: string + onLoadError?: (error: StripeError) => void +} + +export const StripePaymentElementForm = ({ + onSubmit, + submitLabel, + processingLabel, + loadingMessage, + readyErrorMessage = 'Payment form is still loading.', + paymentElementDefaultCollapsed = false, + alert, + isProcessing = false, + className, + buttonClassName, + processingFallbackMessage, + onLoadError, +}: StripePaymentElementFormProps) => { + const stripe = useStripe() + const elements = useElements() + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + if (!stripe || !elements || !isPaymentElementReady) { + toast(defaultErrorToast(readyErrorMessage)) + return + } + + setIsSubmitting(true) + + try { + await onSubmit({ stripe, elements }) + } finally { + setIsSubmitting(false) + } + } + + const isFormProcessing = isProcessing || isSubmitting + + return ( +
+ {alert} + + {!isPaymentElementReady && } + + { + setIsPaymentElementReady(true) + }} + onLoadError={(event) => { + setIsPaymentElementReady(false) + onLoadError?.(event.error) + }} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: paymentElementDefaultCollapsed, + }, + }} + /> + + {processingFallbackMessage && isFormProcessing ? ( + + ) : ( + + )} + + ) +} + +interface TeamBlockedRecoveryPaymentElementProps { + clientSecret: string + customerSessionClientSecret?: string + onOpenChange: (open: boolean) => void + confirmPayment: (params: { + stripe: Stripe + elements: StripeElements + returnUrl: string + }) => Promise<{ error?: StripeError }> + alert: ReactNode + loadingMessage: string + submitLabel: ReactNode + processingLabel: string + submittedToast: ToastInput + successToast: ToastInput + errorMessages: { + ready: string + confirm: string + stillBlocked: string + statusCheck: string + load: string + } +} + +export const TeamBlockedRecoveryPaymentElement = ({ + clientSecret, + customerSessionClientSecret, + onOpenChange, + confirmPayment, + alert, + loadingMessage, + submitLabel, + processingLabel, + submittedToast, + successToast, + errorMessages, +}: TeamBlockedRecoveryPaymentElementProps) => { + const router = useRouter() + const { toast } = useToast() + const pollUntilTeamUnblocked = useTeamUnblockPolling() + const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) + + const handleSubmit = async ({ + stripe, + elements, + }: { + stripe: Stripe + elements: StripeElements + }) => { + const { error } = await confirmPayment({ + stripe, + elements, + returnUrl: window.location.href, + }) + + if (error) { + toast(defaultErrorToast(error.message ?? errorMessages.confirm)) + return + } + + toast(submittedToast) + setIsCheckingTeamStatus(true) + + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast(defaultErrorToast(errorMessages.stillBlocked)) + return + } + + toast(successToast) + router.refresh() + onOpenChange(false) + } catch { + toast(defaultErrorToast(errorMessages.statusCheck)) + } finally { + setIsCheckingTeamStatus(false) + } + } + + return ( + + { + toast(defaultErrorToast(error.message ?? errorMessages.load)) + onOpenChange(false) + }} + /> + + ) +} diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx new file mode 100644 index 000000000..b2476c055 --- /dev/null +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useEffect, useState } from 'react' +import type { VerificationPaymentResponse } from '@/core/modules/billing/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CardIcon } from '@/ui/primitives/icons' +import { useDashboard } from '../context' +import { + LoadingState, + TeamBlockedRecoveryPaymentElement, + useStripeReturnHandler, + useTeamUnblockPolling, +} from './team-blocked-recovery' + +const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' +const stripePaymentIntentParams = { + payment_intent: parseAsString, + payment_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} + +interface VerificationRequiredDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const VerificationRequiredDialog = ({ + open, + onOpenChange, +}: VerificationRequiredDialogProps) => { + return ( + + + + + + ) +} + +const VerificationRequiredDialogContent = ({ + open, + onOpenChange, +}: VerificationRequiredDialogProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const trpc = useTRPC() + const router = useRouter() + const pollUntilTeamUnblocked = useTeamUnblockPolling() + const [verificationPayment, setVerificationPayment] = + useState(null) + const [isLoadingVerificationPayment, setIsLoadingVerificationPayment] = + useState(false) + const [paymentIntentParams, setPaymentIntentParams] = useQueryStates( + stripePaymentIntentParams, + { + history: 'replace', + shallow: true, + } + ) + + const verificationPaymentMutation = useMutation( + trpc.billing.createVerificationPayment.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load verification payment form.' + ) + ) + onOpenChange(false) + }, + }) + ) + + const createVerificationPayment = useCallback(async () => { + setIsLoadingVerificationPayment(true) + + try { + const payment = await verificationPaymentMutation.mutateAsync({ + teamSlug: team.slug, + }) + setVerificationPayment(payment) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingVerificationPayment(false) + } + }, [verificationPaymentMutation.mutateAsync, team.slug]) + + useEffect(() => { + if (open) return + + setVerificationPayment(null) + setIsLoadingVerificationPayment(false) + verificationPaymentMutation.reset() + }, [open, verificationPaymentMutation.reset]) + + useStripeReturnHandler({ + open, + clientSecret: paymentIntentParams.payment_intent_client_secret, + clearReturnParams: () => { + setPaymentIntentParams({ + payment_intent: null, + payment_intent_client_secret: null, + redirect_status: null, + }) + }, + createPaymentSession: createVerificationPayment, + retrieveStatus: async (stripe, clientSecret) => { + const { paymentIntent, error } = await stripe.retrievePaymentIntent( + clientSecret + ) + + return { + status: paymentIntent?.status, + errorMessage: error?.message, + } + }, + onSucceeded: async () => { + toast({ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }) + + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + ) + ) + router.refresh() + onOpenChange(false) + return + } + + toast({ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }) + } catch { + toast( + defaultErrorToast( + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' + ) + ) + } + + router.refresh() + onOpenChange(false) + }, + onProcessing: () => { + toast({ + title: 'Verification payment processing', + description: + 'Your bank is still processing this payment. Please check again in a moment.', + }) + router.refresh() + onOpenChange(false) + }, + requiresPaymentMethodMessage: + 'Verification payment was not completed. Please try again.', + retrieveErrorMessage: 'Failed to check verification payment status.', + fallbackErrorMessage: 'Failed to check verification payment status.', + }) + + return ( + <> + + Verify team + + This team is blocked because verification is required. Make a $5 + payment to verify and continue using E2B. + + + + {isLoadingVerificationPayment ? ( + + ) : verificationPayment ? ( + + stripe.confirmPayment({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: returnUrl, + }, + }) + } + alert={ + + + + A $5 card payment will be charged and added back to your team as + credits. + + + } + loadingMessage={VERIFICATION_PAYMENT_LOADING_MESSAGE} + submitLabel="Pay $5 and verify" + processingLabel="Processing..." + submittedToast={{ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }} + successToast={{ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }} + errorMessages={{ + ready: 'Payment form is still loading.', + confirm: + 'Failed to process verification payment. Please try again.', + stillBlocked: + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.', + statusCheck: + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.', + load: 'Failed to load payment details. Please refresh and try again.', + }} + /> + ) : null} + + ) +} From 80fd811e75c7df7810606a5733108b970dc4ad2a Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 16:20:50 -0400 Subject: [PATCH 22/39] Enhance verification payment response and update dialog payment display - Added `amount_due_cents` to the `VerificationPaymentResponse` interface for clearer payment tracking. - Updated the `VerificationPaymentResponseSchema` to validate the new `amount_due_cents` field. - Modified the `VerificationRequiredDialogContent` to dynamically display the payment amount, improving user clarity on verification costs. --- src/core/modules/billing/models.ts | 1 + src/core/modules/billing/repository.server.ts | 1 + .../verification-required-dialog.tsx | 21 ++++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/core/modules/billing/models.ts b/src/core/modules/billing/models.ts index 180367416..506020c8c 100644 --- a/src/core/modules/billing/models.ts +++ b/src/core/modules/billing/models.ts @@ -57,6 +57,7 @@ export interface AddOnOrderConfirmResponse { export interface VerificationPaymentResponse { client_secret: string + amount_due_cents: number } export interface PaymentMethodsCustomerSession { diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 720afb9b1..2a44256a9 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -53,6 +53,7 @@ const PaymentMethodsSessionResponseSchema = z.object({ const VerificationPaymentResponseSchema = z.object({ client_secret: z.string().min(1), + amount_due_cents: z.number().int().positive(), }) export function createBillingRepository( diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index b2476c055..2595d18c9 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -6,6 +6,7 @@ import { parseAsString, useQueryStates } from 'nuqs' import { useCallback, useEffect, useState } from 'react' import type { VerificationPaymentResponse } from '@/core/modules/billing/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { formatCurrency } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription } from '@/ui/primitives/alert' import { @@ -25,6 +26,7 @@ import { } from './team-blocked-recovery' const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' + const stripePaymentIntentParams = { payment_intent: parseAsString, payment_intent_client_secret: parseAsString, @@ -121,9 +123,8 @@ const VerificationRequiredDialogContent = ({ }, createPaymentSession: createVerificationPayment, retrieveStatus: async (stripe, clientSecret) => { - const { paymentIntent, error } = await stripe.retrievePaymentIntent( - clientSecret - ) + const { paymentIntent, error } = + await stripe.retrievePaymentIntent(clientSecret) return { status: paymentIntent?.status, @@ -182,13 +183,17 @@ const VerificationRequiredDialogContent = ({ fallbackErrorMessage: 'Failed to check verification payment status.', }) + const paymentAmountLabel = verificationPayment + ? formatCurrency(verificationPayment.amount_due_cents / 100) + : null + return ( <> Verify team - This team is blocked because verification is required. Make a $5 - payment to verify and continue using E2B. + This team is blocked because verification is required. Make a + verification payment to continue using E2B. @@ -211,13 +216,13 @@ const VerificationRequiredDialogContent = ({ - A $5 card payment will be charged and added back to your team as - credits. + A {paymentAmountLabel} card payment will be charged and added + back to your team as credits. } loadingMessage={VERIFICATION_PAYMENT_LOADING_MESSAGE} - submitLabel="Pay $5 and verify" + submitLabel={`Pay ${paymentAmountLabel} and verify`} processingLabel="Processing..." submittedToast={{ title: 'Verification payment submitted', From bd31f5bf0081e7120a1a542989714c4f3d594301 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 16:39:52 -0400 Subject: [PATCH 23/39] Add session storage hook and enhance team blocked dialog handling - Introduced a new custom hook, `useSessionStorage`, for managing session storage interactions. - Updated `TeamBlockedIndicator` to utilize session storage for tracking dismissed dialog states, improving user experience by preventing re-prompting of already dismissed dialogs. - Refactored dialog open/close logic to streamline state management and enhance clarity in handling user interactions. --- .../layouts/team-blocked-indicator.tsx | 36 +++++++++++++++++-- src/lib/hooks/use-session-storage.ts | 33 +++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 src/lib/hooks/use-session-storage.ts diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 298746b31..d17aa5d6a 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { BLOCKED_REASONS } from '@/core/modules/teams/constants' import { useDashboard } from '@/features/dashboard/context' +import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { BlockIcon } from '@/ui/primitives/icons' import { MissingPaymentMethodDialog, @@ -61,15 +62,44 @@ export default function TeamBlockedIndicator() { Object.values(BLOCKED_REASONS).find((blockedReason) => reason.includes(blockedReason) ) ?? null + const dismissedStorageKey = blockedReasonDialog + ? `team-blocked-dialog-dismissed:${team.slug}:${blockedReasonDialog}` + : null + const dismissedStorage = useSessionStorage(dismissedStorageKey) useEffect(() => { + if (!blockedReasonDialog || !dismissedStorageKey) { + setOpenDialog(null) + return + } + + const hasDismissedDialog = dismissedStorage.getValue() === 'true' + + if (hasDismissedDialog) { + setOpenDialog(null) + return + } + setOpenDialog(blockedReasonDialog) - }, [blockedReasonDialog]) + }, [blockedReasonDialog, dismissedStorageKey, dismissedStorage]) const handleDialogAction = () => { setOpenDialog(blockedReasonDialog) } + const handleDialogOpenChange = ( + open: boolean, + dialog: Exclude + ) => { + if (!open) { + dismissedStorage.setValue('true') + setOpenDialog(null) + return + } + + setOpenDialog(dialog) + } + if (!team.isBlocked) return null return ( @@ -101,13 +131,13 @@ export default function TeamBlockedIndicator() { { - setOpenDialog(open ? BLOCKED_REASONS.missingPayment : null) + handleDialogOpenChange(open, BLOCKED_REASONS.missingPayment) }} /> { - setOpenDialog(open ? BLOCKED_REASONS.verification : null) + handleDialogOpenChange(open, BLOCKED_REASONS.verification) }} /> diff --git a/src/lib/hooks/use-session-storage.ts b/src/lib/hooks/use-session-storage.ts new file mode 100644 index 000000000..6faa22540 --- /dev/null +++ b/src/lib/hooks/use-session-storage.ts @@ -0,0 +1,33 @@ +'use client' + +import { useCallback, useMemo } from 'react' + +const useSessionStorage = (key: string | null) => { + const getValue = useCallback(() => { + if (!key || typeof window === 'undefined') return null + + return window.sessionStorage.getItem(key) + }, [key]) + + const setValue = useCallback( + (value: string) => { + if (!key || typeof window === 'undefined') return + + window.sessionStorage.setItem(key, value) + }, + [key] + ) + + const removeValue = useCallback(() => { + if (!key || typeof window === 'undefined') return + + window.sessionStorage.removeItem(key) + }, [key]) + + return useMemo( + () => ({ getValue, setValue, removeValue }), + [getValue, setValue, removeValue] + ) +} + +export { useSessionStorage } From 06166fa14630beb2f76d2a59b04480ff42ddc1e8 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 16:41:43 -0400 Subject: [PATCH 24/39] Update team unblock polling configuration for improved responsiveness - Increased the number of polling attempts from 15 to 30 to enhance the likelihood of successful team unblocking. - Reduced the polling interval from 2000ms to 1000ms for quicker feedback during the unblocking process. - Updated comments to reflect the new timing adjustments for clarity. --- .../dashboard/team-blocked/team-blocked-recovery.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index 81039d908..90b0c53be 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -22,8 +22,8 @@ import { Loader } from '@/ui/primitives/loader' import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' import { useDashboard } from '../context' -const TEAM_UNBLOCK_POLL_ATTEMPTS = 15 -const TEAM_UNBLOCK_POLL_INTERVAL_MS = 2000 +const TEAM_UNBLOCK_POLL_ATTEMPTS = 30 +const TEAM_UNBLOCK_POLL_INTERVAL_MS = 1000 type ToastInput = Parameters['toast']>[0] @@ -43,7 +43,7 @@ export const LoadingState = ({ message, className }: LoadingStateProps) => { ) } -// Waits before retrying team status polling; 2000 -> resolves after 2 seconds. +// Waits before retrying team status polling; 1000 -> resolves after 1 second. const wait = (ms: number) => new Promise((resolve) => { window.setTimeout(resolve, ms) From d2e0655fee83181731f9e0ecde66d007eb0be31e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 16:44:45 -0400 Subject: [PATCH 25/39] Refactor team-blocked dialogs to remove unnecessary useEffect hooks - Removed useEffect hooks from MissingPaymentMethodDialog and VerificationRequiredDialog to simplify state management and improve performance. - Streamlined the handling of payment methods and verification payment sessions by focusing on essential state updates, enhancing clarity in dialog behavior. --- .../team-blocked/missing-payment-method-dialog.tsx | 10 +--------- .../team-blocked/verification-required-dialog.tsx | 10 +--------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index 2882d3d84..33b4bb3ca 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -3,7 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import type { PaymentMethodsSession } from '@/core/modules/billing/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' @@ -100,14 +100,6 @@ const MissingPaymentMethodDialogContent = ({ } }, [paymentMethodsSessionMutation.mutateAsync, team.slug]) - useEffect(() => { - if (open) return - - setPaymentMethodsSession(null) - setIsLoadingPaymentMethodsSession(false) - paymentMethodsSessionMutation.reset() - }, [open, paymentMethodsSessionMutation.reset]) - useStripeReturnHandler({ open, clientSecret: setupIntentParams.setup_intent_client_secret, diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index 2595d18c9..a793aa28a 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -3,7 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' import type { VerificationPaymentResponse } from '@/core/modules/billing/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { formatCurrency } from '@/lib/utils/formatting' @@ -103,14 +103,6 @@ const VerificationRequiredDialogContent = ({ } }, [verificationPaymentMutation.mutateAsync, team.slug]) - useEffect(() => { - if (open) return - - setVerificationPayment(null) - setIsLoadingVerificationPayment(false) - verificationPaymentMutation.reset() - }, [open, verificationPaymentMutation.reset]) - useStripeReturnHandler({ open, clientSecret: paymentIntentParams.payment_intent_client_secret, From 731a798f760a3532502041abaee3fab4d9c0fb90 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 16:54:59 -0400 Subject: [PATCH 26/39] Refactor TeamBlockedIndicator to use Button component for improved UI consistency - Replaced the native button element with a custom Button component to enhance styling and maintain consistency across the UI. - Updated button properties to align with the new component's API, ensuring proper functionality and visual appearance. --- .../dashboard/layouts/team-blocked-indicator.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index d17aa5d6a..a13264396 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -6,6 +6,7 @@ import { PROTECTED_URLS } from '@/configs/urls' import { BLOCKED_REASONS } from '@/core/modules/teams/constants' import { useDashboard } from '@/features/dashboard/context' import { useSessionStorage } from '@/lib/hooks/use-session-storage' +import { Button } from '@/ui/primitives/button' import { BlockIcon } from '@/ui/primitives/icons' import { MissingPaymentMethodDialog, @@ -116,13 +117,15 @@ export default function TeamBlockedIndicator() { {message.cta} ) : ( - + )} )} From 540448af6266701d4b999f5643c234437ae5a6c2 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 17:10:18 -0400 Subject: [PATCH 27/39] Add PostHog event tracking for payment method and verification submissions - Integrated PostHog tracking to capture 'payment_method_added' and 'verification_payment_submitted' events in the MissingPaymentMethodDialog and VerificationRequiredDialog. - Updated the dialog components to call the tracking function upon successful payment method addition and team verification, enhancing analytics capabilities for user interactions. - Added optional onSuccess callback to the TeamBlockedRecoveryPaymentElement for improved flexibility in handling success events. --- .../team-blocked/missing-payment-method-dialog.tsx | 7 +++++++ .../dashboard/team-blocked/team-blocked-recovery.tsx | 3 +++ .../team-blocked/verification-required-dialog.tsx | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index 33b4bb3ca..0fac3d82b 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' +import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import type { PaymentMethodsSession } from '@/core/modules/billing/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' @@ -24,6 +25,7 @@ import { } from './team-blocked-recovery' const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' +const PAYMENT_METHOD_ADDED_EVENT = 'payment_method_added' const stripeSetupIntentParams = { setup_intent: parseAsString, @@ -58,6 +60,7 @@ const MissingPaymentMethodDialogContent = ({ }: MissingPaymentMethodDialogProps) => { const { team } = useDashboard() const { toast } = useToast() + const posthog = usePostHog() const trpc = useTRPC() const router = useRouter() const [paymentMethodsSession, setPaymentMethodsSession] = @@ -126,6 +129,7 @@ const MissingPaymentMethodDialogContent = ({ title: 'Payment method added', description: 'Your payment method was added successfully.', }) + posthog.capture(PAYMENT_METHOD_ADDED_EVENT) router.refresh() onOpenChange(false) }, @@ -192,6 +196,9 @@ const MissingPaymentMethodDialogContent = ({ description: 'Your payment method was added and your team has been unblocked.', }} + onSuccess={() => { + posthog.capture(PAYMENT_METHOD_ADDED_EVENT) + }} errorMessages={{ ready: 'Payment form is still loading.', confirm: 'Failed to save payment method. Please try again.', diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index 90b0c53be..651e6f6a7 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -339,6 +339,7 @@ interface TeamBlockedRecoveryPaymentElementProps { processingLabel: string submittedToast: ToastInput successToast: ToastInput + onSuccess?: () => void errorMessages: { ready: string confirm: string @@ -359,6 +360,7 @@ export const TeamBlockedRecoveryPaymentElement = ({ processingLabel, submittedToast, successToast, + onSuccess, errorMessages, }: TeamBlockedRecoveryPaymentElementProps) => { const router = useRouter() @@ -396,6 +398,7 @@ export const TeamBlockedRecoveryPaymentElement = ({ } toast(successToast) + onSuccess?.() router.refresh() onOpenChange(false) } catch { diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index a793aa28a..edf691bc5 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' +import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import type { VerificationPaymentResponse } from '@/core/modules/billing/models' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' @@ -26,6 +27,7 @@ import { } from './team-blocked-recovery' const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' +const VERIFICATION_PAYMENT_SUBMITTED_EVENT = 'verification_payment_submitted' const stripePaymentIntentParams = { payment_intent: parseAsString, @@ -60,6 +62,7 @@ const VerificationRequiredDialogContent = ({ }: VerificationRequiredDialogProps) => { const { team } = useDashboard() const { toast } = useToast() + const posthog = usePostHog() const trpc = useTRPC() const router = useRouter() const pollUntilTeamUnblocked = useTeamUnblockPolling() @@ -149,6 +152,7 @@ const VerificationRequiredDialogContent = ({ title: 'Team unblocked', description: 'Your team has been verified and unblocked.', }) + posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) } catch { toast( defaultErrorToast( @@ -226,6 +230,9 @@ const VerificationRequiredDialogContent = ({ title: 'Team unblocked', description: 'Your team has been verified and unblocked.', }} + onSuccess={() => { + posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) + }} errorMessages={{ ready: 'Payment form is still loading.', confirm: From 7f513d2a4c509cbc99118912beff3931fdbce278 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 17:18:51 -0400 Subject: [PATCH 28/39] Refactor Stripe return handling in useStripeReturnHandler for improved state management - Introduced a new type, StripeReturnHandlerState, to manage the state of the Stripe return handler more effectively. - Replaced the previous boolean flag with a state variable to track the handling process, enhancing clarity and control over the payment session flow. - Updated error handling to ensure proper state transitions during payment session creation and Stripe return checks, improving user feedback and reliability. --- .../team-blocked/team-blocked-recovery.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index 651e6f6a7..b83c4e955 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -111,6 +111,8 @@ interface StripeReturnHandlerOptions { fallbackErrorMessage: string } +type StripeReturnHandlerState = 'idle' | 'checking' | 'handled' + export const useStripeReturnHandler = ({ open, clientSecret, @@ -124,22 +126,28 @@ export const useStripeReturnHandler = ({ fallbackErrorMessage, }: StripeReturnHandlerOptions) => { const { toast } = useToast() - const hasHandledStripeReturn = useRef(false) + const stripeReturnHandlerState = useRef('idle') useEffect(() => { if (open) return - hasHandledStripeReturn.current = false + stripeReturnHandlerState.current = 'idle' }, [open]) useEffect(() => { if (!open) return - if (hasHandledStripeReturn.current) return + if (stripeReturnHandlerState.current !== 'idle') return - hasHandledStripeReturn.current = true + stripeReturnHandlerState.current = 'checking' if (!clientSecret) { - void createPaymentSession() + createPaymentSession() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + stripeReturnHandlerState.current = 'idle' + }) return } @@ -178,10 +186,20 @@ export const useStripeReturnHandler = ({ await createPaymentSession() } - checkStripeReturn().catch(() => { - toast(defaultErrorToast(fallbackErrorMessage)) - void createPaymentSession() - }) + checkStripeReturn() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + toast(defaultErrorToast(fallbackErrorMessage)) + createPaymentSession() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + stripeReturnHandlerState.current = 'idle' + }) + }) }, [ clearReturnParams, clientSecret, From 044abce45ed14a0f877317910a33074273eaeb52 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 17:41:47 -0400 Subject: [PATCH 29/39] Refactor team blocked reason handling and update dialog components - Renamed BLOCKED_REASONS to TEAM_BLOCKED_REASONS for clarity and consistency across the codebase. - Updated imports and type definitions in TeamBlockedIndicator and related dialog components to utilize the new TEAM_BLOCKED_REASONS constant. - Introduced a utility function, getBlockedDialogStorageKey, for managing dismissed dialog states in session storage, enhancing user experience by preventing re-prompting of already dismissed dialogs. - Streamlined state management in MissingPaymentMethodDialog and VerificationRequiredDialog to improve clarity and performance. --- src/core/modules/teams/constants.ts | 4 +-- src/core/modules/teams/models.ts | 3 ++ .../layouts/team-blocked-indicator.tsx | 35 +++++++++---------- .../missing-payment-method-dialog.tsx | 8 +++++ .../team-blocked-dialog-storage.ts | 13 +++++++ .../team-blocked/team-blocked-recovery.tsx | 2 +- .../verification-required-dialog.tsx | 11 ++++++ 7 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts diff --git a/src/core/modules/teams/constants.ts b/src/core/modules/teams/constants.ts index 00ae46bbd..f9ebdcc48 100644 --- a/src/core/modules/teams/constants.ts +++ b/src/core/modules/teams/constants.ts @@ -1,6 +1,6 @@ -const BLOCKED_REASONS = { +const TEAM_BLOCKED_REASONS = { missingPayment: 'missing payment method', verification: 'verification required', } as const -export { BLOCKED_REASONS } +export { TEAM_BLOCKED_REASONS } diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts index 9dd922c85..d9b651ca0 100644 --- a/src/core/modules/teams/models.ts +++ b/src/core/modules/teams/models.ts @@ -1,7 +1,10 @@ import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import type { TEAM_BLOCKED_REASONS } from './constants' export type TeamModel = DashboardComponents['schemas']['UserTeam'] export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits'] +export type TeamBlockedReason = + (typeof TEAM_BLOCKED_REASONS)[keyof typeof TEAM_BLOCKED_REASONS] export type TeamMemberInfo = { id: string diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index a13264396..932ff3a2f 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -3,7 +3,8 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' -import { BLOCKED_REASONS } from '@/core/modules/teams/constants' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import type { TeamBlockedReason } from '@/core/modules/teams/models' import { useDashboard } from '@/features/dashboard/context' import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { Button } from '@/ui/primitives/button' @@ -12,10 +13,7 @@ import { MissingPaymentMethodDialog, VerificationRequiredDialog, } from '../team-blocked' - -type BlockedReasonDialog = - | (typeof BLOCKED_REASONS)[keyof typeof BLOCKED_REASONS] - | null +import { getBlockedDialogStorageKey } from '../team-blocked/team-blocked-dialog-storage' function useBlockedMessage(slug: string, blockedReason: string | null) { return useMemo(() => { @@ -29,7 +27,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (reason.includes(BLOCKED_REASONS.missingPayment)) { + if (reason.includes(TEAM_BLOCKED_REASONS.missingPayment)) { return { text: 'Missing payment method.', cta: 'Add payment method.', @@ -37,7 +35,7 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { } } - if (reason.includes(BLOCKED_REASONS.verification)) { + if (reason.includes(TEAM_BLOCKED_REASONS.verification)) { return { text: 'Verification required.', cta: 'Complete verification.', @@ -55,17 +53,18 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { export default function TeamBlockedIndicator() { const { team } = useDashboard() - const [openDialog, setOpenDialog] = useState(null) + const [openDialog, setOpenDialog] = useState(null) const message = useBlockedMessage(team.slug, team.blockedReason) const reason = team.blockedReason?.toLowerCase() ?? '' - const blockedReasonDialog: BlockedReasonDialog = - Object.values(BLOCKED_REASONS).find((blockedReason) => + const blockedReasonDialog: TeamBlockedReason | null = + Object.values(TEAM_BLOCKED_REASONS).find((blockedReason) => reason.includes(blockedReason) ) ?? null - const dismissedStorageKey = blockedReasonDialog - ? `team-blocked-dialog-dismissed:${team.slug}:${blockedReasonDialog}` - : null + const dismissedStorageKey = getBlockedDialogStorageKey( + team.slug, + blockedReasonDialog + ) const dismissedStorage = useSessionStorage(dismissedStorageKey) useEffect(() => { @@ -90,7 +89,7 @@ export default function TeamBlockedIndicator() { const handleDialogOpenChange = ( open: boolean, - dialog: Exclude + dialog: TeamBlockedReason ) => { if (!open) { dismissedStorage.setValue('true') @@ -132,15 +131,15 @@ export default function TeamBlockedIndicator() {
{ - handleDialogOpenChange(open, BLOCKED_REASONS.missingPayment) + handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.missingPayment) }} /> { - handleDialogOpenChange(open, BLOCKED_REASONS.verification) + handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.verification) }} /> diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index 0fac3d82b..ab86a866f 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -6,6 +6,8 @@ import { parseAsString, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import type { PaymentMethodsSession } from '@/core/modules/billing/models' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription } from '@/ui/primitives/alert' @@ -18,6 +20,7 @@ import { } from '@/ui/primitives/dialog' import { CardIcon } from '@/ui/primitives/icons' import { useDashboard } from '../context' +import { getBlockedDialogStorageKey } from './team-blocked-dialog-storage' import { LoadingState, TeamBlockedRecoveryPaymentElement, @@ -63,6 +66,9 @@ const MissingPaymentMethodDialogContent = ({ const posthog = usePostHog() const trpc = useTRPC() const router = useRouter() + const dismissedStorage = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.missingPayment) + ) const [paymentMethodsSession, setPaymentMethodsSession] = useState(null) const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = @@ -132,6 +138,7 @@ const MissingPaymentMethodDialogContent = ({ posthog.capture(PAYMENT_METHOD_ADDED_EVENT) router.refresh() onOpenChange(false) + dismissedStorage.removeValue() }, onProcessing: () => { toast({ @@ -198,6 +205,7 @@ const MissingPaymentMethodDialogContent = ({ }} onSuccess={() => { posthog.capture(PAYMENT_METHOD_ADDED_EVENT) + dismissedStorage.removeValue() }} errorMessages={{ ready: 'Payment form is still loading.', diff --git a/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts new file mode 100644 index 000000000..19a213bd7 --- /dev/null +++ b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts @@ -0,0 +1,13 @@ +import type { TeamBlockedReason } from '@/core/modules/teams/models' + +// Builds the dismissed-dialog session key; ("acme", "verification required") -> "team-blocked-dialog-dismissed:acme:verification required". +const getBlockedDialogStorageKey = ( + teamSlug: string, + blockedReason: TeamBlockedReason | null +) => { + if (!blockedReason) return null + + return `team-blocked-dialog-dismissed:${teamSlug}:${blockedReason}` +} + +export { getBlockedDialogStorageKey } diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index b83c4e955..022b660ce 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -416,9 +416,9 @@ export const TeamBlockedRecoveryPaymentElement = ({ } toast(successToast) - onSuccess?.() router.refresh() onOpenChange(false) + onSuccess?.() } catch { toast(defaultErrorToast(errorMessages.statusCheck)) } finally { diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index edf691bc5..bb029a60b 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -6,6 +6,8 @@ import { parseAsString, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import type { VerificationPaymentResponse } from '@/core/modules/billing/models' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { formatCurrency } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' @@ -19,6 +21,7 @@ import { } from '@/ui/primitives/dialog' import { CardIcon } from '@/ui/primitives/icons' import { useDashboard } from '../context' +import { getBlockedDialogStorageKey } from './team-blocked-dialog-storage' import { LoadingState, TeamBlockedRecoveryPaymentElement, @@ -66,6 +69,9 @@ const VerificationRequiredDialogContent = ({ const trpc = useTRPC() const router = useRouter() const pollUntilTeamUnblocked = useTeamUnblockPolling() + const dismissedStorage = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.verification) + ) const [verificationPayment, setVerificationPayment] = useState(null) const [isLoadingVerificationPayment, setIsLoadingVerificationPayment] = @@ -153,6 +159,10 @@ const VerificationRequiredDialogContent = ({ description: 'Your team has been verified and unblocked.', }) posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) + router.refresh() + onOpenChange(false) + dismissedStorage.removeValue() + return } catch { toast( defaultErrorToast( @@ -232,6 +242,7 @@ const VerificationRequiredDialogContent = ({ }} onSuccess={() => { posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) + dismissedStorage.removeValue() }} errorMessages={{ ready: 'Payment form is still loading.', From c9bb2358d00610d6212426f8b5e0e6485e93e4ff Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 17:43:14 -0400 Subject: [PATCH 30/39] Run biome format --- src/features/dashboard/layouts/team-blocked-indicator.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 932ff3a2f..02dac9ab9 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -87,10 +87,7 @@ export default function TeamBlockedIndicator() { setOpenDialog(blockedReasonDialog) } - const handleDialogOpenChange = ( - open: boolean, - dialog: TeamBlockedReason - ) => { + const handleDialogOpenChange = (open: boolean, dialog: TeamBlockedReason) => { if (!open) { dismissedStorage.setValue('true') setOpenDialog(null) From 4303729a22d2b782b35399e991fd760b3254af9e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 18:27:00 -0400 Subject: [PATCH 31/39] Refactor TeamBlockedIndicator and related components for improved clarity and functionality - Introduced TeamBlockedIndicatorContent and TeamBlockedDialogController components to separate concerns and enhance readability. - Updated useBlockedMessage to return a structured BlockedMessage type for better type safety. - Replaced direct session storage interactions with a simplified useSessionStorage hook for managing dismissed dialog states. - Streamlined dialog handling logic in TeamBlockedIndicator to improve user experience and maintainability. - Removed the deprecated use-session-storage hook, consolidating session storage management. --- .../layouts/team-blocked-indicator.tsx | 139 ++++++++++++------ .../missing-payment-method-dialog.tsx | 13 +- .../team-blocked-dialog-storage.ts | 4 +- .../team-blocked/team-blocked-recovery.tsx | 2 +- .../verification-required-dialog.tsx | 13 +- src/lib/hooks/use-session-storage.ts | 33 ----- 6 files changed, 107 insertions(+), 97 deletions(-) delete mode 100644 src/lib/hooks/use-session-storage.ts diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 02dac9ab9..9eadd32c0 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -2,11 +2,11 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' import { PROTECTED_URLS } from '@/configs/urls' import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' import type { TeamBlockedReason } from '@/core/modules/teams/models' import { useDashboard } from '@/features/dashboard/context' -import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { Button } from '@/ui/primitives/button' import { BlockIcon } from '@/ui/primitives/icons' import { @@ -15,7 +15,55 @@ import { } from '../team-blocked' import { getBlockedDialogStorageKey } from '../team-blocked/team-blocked-dialog-storage' -function useBlockedMessage(slug: string, blockedReason: string | null) { +interface BlockedMessage { + text: string + cta: string | null + href: string | null +} + +interface TeamBlockedIndicatorContentProps { + message: BlockedMessage + onDialogAction?: () => void +} + +const TeamBlockedIndicatorContent = ({ + message, + onDialogAction, +}: TeamBlockedIndicatorContentProps) => { + return ( +
+ + + {message.text} + {message.cta && ( + <> + {' '} + {message.href ? ( + + {message.cta} + + ) : onDialogAction ? ( + + ) : null} + + )} + +
+ ) +} + +const useBlockedMessage = ( + slug: string, + blockedReason: string | null +): BlockedMessage => { return useMemo(() => { const reason = blockedReason?.toLowerCase() ?? '' @@ -51,37 +99,34 @@ function useBlockedMessage(slug: string, blockedReason: string | null) { }, [slug, blockedReason]) } -export default function TeamBlockedIndicator() { +interface TeamBlockedDialogControllerProps { + blockedReasonDialog: TeamBlockedReason + message: BlockedMessage +} + +const TeamBlockedDialogController = ({ + blockedReasonDialog, + message, +}: TeamBlockedDialogControllerProps) => { const { team } = useDashboard() const [openDialog, setOpenDialog] = useState(null) - - const message = useBlockedMessage(team.slug, team.blockedReason) - const reason = team.blockedReason?.toLowerCase() ?? '' - const blockedReasonDialog: TeamBlockedReason | null = - Object.values(TEAM_BLOCKED_REASONS).find((blockedReason) => - reason.includes(blockedReason) - ) ?? null const dismissedStorageKey = getBlockedDialogStorageKey( team.slug, blockedReasonDialog ) - const dismissedStorage = useSessionStorage(dismissedStorageKey) + const [hasDismissedDialog, setHasDismissedDialog] = useSessionStorage( + dismissedStorageKey, + false + ) useEffect(() => { - if (!blockedReasonDialog || !dismissedStorageKey) { - setOpenDialog(null) - return - } - - const hasDismissedDialog = dismissedStorage.getValue() === 'true' - if (hasDismissedDialog) { setOpenDialog(null) return } setOpenDialog(blockedReasonDialog) - }, [blockedReasonDialog, dismissedStorageKey, dismissedStorage]) + }, [blockedReasonDialog, hasDismissedDialog]) const handleDialogAction = () => { setOpenDialog(blockedReasonDialog) @@ -89,7 +134,7 @@ export default function TeamBlockedIndicator() { const handleDialogOpenChange = (open: boolean, dialog: TeamBlockedReason) => { if (!open) { - dismissedStorage.setValue('true') + setHasDismissedDialog(true) setOpenDialog(null) return } @@ -97,36 +142,12 @@ export default function TeamBlockedIndicator() { setOpenDialog(dialog) } - if (!team.isBlocked) return null - return ( <> -
- - - {message.text} - {message.cta && ( - <> - {' '} - {message.href ? ( - - {message.cta} - - ) : ( - - )} - - )} - -
+ { @@ -142,3 +163,25 @@ export default function TeamBlockedIndicator() { ) } + +export default function TeamBlockedIndicator() { + const { team } = useDashboard() + const message = useBlockedMessage(team.slug, team.blockedReason) + const reason = team.blockedReason?.toLowerCase() ?? '' + const blockedReasonDialog: TeamBlockedReason | null = + Object.values(TEAM_BLOCKED_REASONS).find((blockedReason) => + reason.includes(blockedReason) + ) ?? null + + if (!team.isBlocked) return null + + if (!blockedReasonDialog) + return + + return ( + + ) +} diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index ab86a866f..be4421b1a 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' import type { PaymentMethodsSession } from '@/core/modules/billing/models' import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' -import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' import { Alert, AlertDescription } from '@/ui/primitives/alert' @@ -66,8 +66,9 @@ const MissingPaymentMethodDialogContent = ({ const posthog = usePostHog() const trpc = useTRPC() const router = useRouter() - const dismissedStorage = useSessionStorage( - getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.missingPayment) + const [, , removeDismissedDialog] = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.missingPayment), + false ) const [paymentMethodsSession, setPaymentMethodsSession] = useState(null) @@ -136,9 +137,9 @@ const MissingPaymentMethodDialogContent = ({ description: 'Your payment method was added successfully.', }) posthog.capture(PAYMENT_METHOD_ADDED_EVENT) - router.refresh() onOpenChange(false) - dismissedStorage.removeValue() + removeDismissedDialog() + router.refresh() }, onProcessing: () => { toast({ @@ -205,7 +206,7 @@ const MissingPaymentMethodDialogContent = ({ }} onSuccess={() => { posthog.capture(PAYMENT_METHOD_ADDED_EVENT) - dismissedStorage.removeValue() + removeDismissedDialog() }} errorMessages={{ ready: 'Payment form is still loading.', diff --git a/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts index 19a213bd7..e9f033c3b 100644 --- a/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts +++ b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts @@ -3,10 +3,8 @@ import type { TeamBlockedReason } from '@/core/modules/teams/models' // Builds the dismissed-dialog session key; ("acme", "verification required") -> "team-blocked-dialog-dismissed:acme:verification required". const getBlockedDialogStorageKey = ( teamSlug: string, - blockedReason: TeamBlockedReason | null + blockedReason: TeamBlockedReason ) => { - if (!blockedReason) return null - return `team-blocked-dialog-dismissed:${teamSlug}:${blockedReason}` } diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index 022b660ce..009a6a886 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -416,9 +416,9 @@ export const TeamBlockedRecoveryPaymentElement = ({ } toast(successToast) - router.refresh() onOpenChange(false) onSuccess?.() + router.refresh() } catch { toast(defaultErrorToast(errorMessages.statusCheck)) } finally { diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index bb029a60b..012a61d51 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -5,9 +5,9 @@ import { useRouter } from 'next/navigation' import { parseAsString, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' import type { VerificationPaymentResponse } from '@/core/modules/billing/models' import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' -import { useSessionStorage } from '@/lib/hooks/use-session-storage' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { formatCurrency } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' @@ -69,8 +69,9 @@ const VerificationRequiredDialogContent = ({ const trpc = useTRPC() const router = useRouter() const pollUntilTeamUnblocked = useTeamUnblockPolling() - const dismissedStorage = useSessionStorage( - getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.verification) + const [, , removeDismissedDialog] = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.verification), + false ) const [verificationPayment, setVerificationPayment] = useState(null) @@ -159,9 +160,9 @@ const VerificationRequiredDialogContent = ({ description: 'Your team has been verified and unblocked.', }) posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) - router.refresh() onOpenChange(false) - dismissedStorage.removeValue() + removeDismissedDialog() + router.refresh() return } catch { toast( @@ -242,7 +243,7 @@ const VerificationRequiredDialogContent = ({ }} onSuccess={() => { posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) - dismissedStorage.removeValue() + removeDismissedDialog() }} errorMessages={{ ready: 'Payment form is still loading.', diff --git a/src/lib/hooks/use-session-storage.ts b/src/lib/hooks/use-session-storage.ts deleted file mode 100644 index 6faa22540..000000000 --- a/src/lib/hooks/use-session-storage.ts +++ /dev/null @@ -1,33 +0,0 @@ -'use client' - -import { useCallback, useMemo } from 'react' - -const useSessionStorage = (key: string | null) => { - const getValue = useCallback(() => { - if (!key || typeof window === 'undefined') return null - - return window.sessionStorage.getItem(key) - }, [key]) - - const setValue = useCallback( - (value: string) => { - if (!key || typeof window === 'undefined') return - - window.sessionStorage.setItem(key, value) - }, - [key] - ) - - const removeValue = useCallback(() => { - if (!key || typeof window === 'undefined') return - - window.sessionStorage.removeItem(key) - }, [key]) - - return useMemo( - () => ({ getValue, setValue, removeValue }), - [getValue, setValue, removeValue] - ) -} - -export { useSessionStorage } From 4553e53f4073436c7352996cb82dfeb3a20c3700 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 6 May 2026 18:36:54 -0400 Subject: [PATCH 32/39] Refactor dialog components to improve state management and clarity - Updated MissingPaymentMethodDialog and VerificationRequiredDialog to use onDismiss callback for handling dialog closure, enhancing clarity in state transitions. - Streamlined dialog open/close logic by consolidating the handling of open state changes, improving maintainability. - Adjusted props in TeamBlockedRecoveryPaymentElement to replace onOpenChange with onClose for consistency across dialog components. - Enhanced user experience by ensuring dialogs properly dismiss and manage state without unnecessary complexity. --- .../layouts/team-blocked-indicator.tsx | 13 ++++---- .../missing-payment-method-dialog.tsx | 29 +++++++++++----- .../team-blocked/team-blocked-recovery.tsx | 8 ++--- .../verification-required-dialog.tsx | 33 +++++++++++++------ 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 9eadd32c0..6c03ffa12 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -133,13 +133,12 @@ const TeamBlockedDialogController = ({ } const handleDialogOpenChange = (open: boolean, dialog: TeamBlockedReason) => { - if (!open) { - setHasDismissedDialog(true) - setOpenDialog(null) - return - } + setOpenDialog(open ? dialog : null) + } - setOpenDialog(dialog) + const handleDialogDismiss = () => { + setHasDismissedDialog(true) + setOpenDialog(null) } return ( @@ -153,12 +152,14 @@ const TeamBlockedDialogController = ({ onOpenChange={(open) => { handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.missingPayment) }} + onDismiss={handleDialogDismiss} /> { handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.verification) }} + onDismiss={handleDialogDismiss} /> ) diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index be4421b1a..6fd2747ec 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -39,28 +39,41 @@ const stripeSetupIntentParams = { interface MissingPaymentMethodDialogProps { open: boolean onOpenChange: (open: boolean) => void + onDismiss: () => void } export const MissingPaymentMethodDialog = ({ open, onOpenChange, + onDismiss, }: MissingPaymentMethodDialogProps) => { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) return onDismiss() + + onOpenChange(nextOpen) + } + return ( - + onOpenChange(false)} /> ) } +interface MissingPaymentMethodDialogContentProps { + open: boolean + onClose: () => void +} + const MissingPaymentMethodDialogContent = ({ open, - onOpenChange, -}: MissingPaymentMethodDialogProps) => { + onClose, +}: MissingPaymentMethodDialogContentProps) => { const { team } = useDashboard() const { toast } = useToast() const posthog = usePostHog() @@ -90,7 +103,7 @@ const MissingPaymentMethodDialogContent = ({ error.message || 'Failed to load payment method form.' ) ) - onOpenChange(false) + onClose() }, }) ) @@ -137,8 +150,8 @@ const MissingPaymentMethodDialogContent = ({ description: 'Your payment method was added successfully.', }) posthog.capture(PAYMENT_METHOD_ADDED_EVENT) - onOpenChange(false) removeDismissedDialog() + onClose() router.refresh() }, onProcessing: () => { @@ -148,7 +161,7 @@ const MissingPaymentMethodDialogContent = ({ 'Your bank is still processing this payment method. Please check again in a moment.', }) router.refresh() - onOpenChange(false) + onClose() }, requiresPaymentMethodMessage: 'Payment method setup was not completed. Please try again.', @@ -172,7 +185,7 @@ const MissingPaymentMethodDialogContent = ({ stripe.confirmSetup({ elements, diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx index 009a6a886..19b7b29c2 100644 --- a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -345,7 +345,7 @@ export const StripePaymentElementForm = ({ interface TeamBlockedRecoveryPaymentElementProps { clientSecret: string customerSessionClientSecret?: string - onOpenChange: (open: boolean) => void + onClose: () => void confirmPayment: (params: { stripe: Stripe elements: StripeElements @@ -370,7 +370,7 @@ interface TeamBlockedRecoveryPaymentElementProps { export const TeamBlockedRecoveryPaymentElement = ({ clientSecret, customerSessionClientSecret, - onOpenChange, + onClose, confirmPayment, alert, loadingMessage, @@ -416,8 +416,8 @@ export const TeamBlockedRecoveryPaymentElement = ({ } toast(successToast) - onOpenChange(false) onSuccess?.() + onClose() router.refresh() } catch { toast(defaultErrorToast(errorMessages.statusCheck)) @@ -443,7 +443,7 @@ export const TeamBlockedRecoveryPaymentElement = ({ onSubmit={handleSubmit} onLoadError={(error) => { toast(defaultErrorToast(error.message ?? errorMessages.load)) - onOpenChange(false) + onClose() }} /> diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx index 012a61d51..4baf10a6c 100644 --- a/src/features/dashboard/team-blocked/verification-required-dialog.tsx +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -41,28 +41,41 @@ const stripePaymentIntentParams = { interface VerificationRequiredDialogProps { open: boolean onOpenChange: (open: boolean) => void + onDismiss: () => void } export const VerificationRequiredDialog = ({ open, onOpenChange, + onDismiss, }: VerificationRequiredDialogProps) => { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) return onDismiss() + + onOpenChange(nextOpen) + } + return ( - + onOpenChange(false)} /> ) } +interface VerificationRequiredDialogContentProps { + open: boolean + onClose: () => void +} + const VerificationRequiredDialogContent = ({ open, - onOpenChange, -}: VerificationRequiredDialogProps) => { + onClose, +}: VerificationRequiredDialogContentProps) => { const { team } = useDashboard() const { toast } = useToast() const posthog = usePostHog() @@ -93,7 +106,7 @@ const VerificationRequiredDialogContent = ({ error.message || 'Failed to load verification payment form.' ) ) - onOpenChange(false) + onClose() }, }) ) @@ -150,7 +163,7 @@ const VerificationRequiredDialogContent = ({ ) ) router.refresh() - onOpenChange(false) + onClose() return } @@ -160,8 +173,8 @@ const VerificationRequiredDialogContent = ({ description: 'Your team has been verified and unblocked.', }) posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) - onOpenChange(false) removeDismissedDialog() + onClose() router.refresh() return } catch { @@ -173,7 +186,7 @@ const VerificationRequiredDialogContent = ({ } router.refresh() - onOpenChange(false) + onClose() }, onProcessing: () => { toast({ @@ -182,7 +195,7 @@ const VerificationRequiredDialogContent = ({ 'Your bank is still processing this payment. Please check again in a moment.', }) router.refresh() - onOpenChange(false) + onClose() }, requiresPaymentMethodMessage: 'Verification payment was not completed. Please try again.', @@ -209,7 +222,7 @@ const VerificationRequiredDialogContent = ({ ) : verificationPayment ? ( stripe.confirmPayment({ elements, From f7137b90dab5dae33ad2a7a525ba8a2e476fb3a9 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 7 May 2026 15:51:29 -0700 Subject: [PATCH 33/39] Request setup intent for payment method recovery --- src/core/modules/billing/repository.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 2a44256a9..85bbab183 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -282,7 +282,7 @@ export function createBillingRepository( }, async createPaymentMethodsSession() { const res = await fetch( - `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session?include_setup_intent=true`, { method: 'POST', headers: { From 79a7e0e19a748ce66c5455f533af22e068f57857 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 7 May 2026 16:21:47 -0700 Subject: [PATCH 34/39] Clarify payment setup session types --- src/core/modules/billing/models.ts | 4 ++++ src/core/modules/billing/repository.server.ts | 4 ++-- .../dashboard/team-blocked/missing-payment-method-dialog.tsx | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/core/modules/billing/models.ts b/src/core/modules/billing/models.ts index 506020c8c..51f4f6547 100644 --- a/src/core/modules/billing/models.ts +++ b/src/core/modules/billing/models.ts @@ -66,6 +66,10 @@ export interface PaymentMethodsCustomerSession { export interface PaymentMethodsSession { client_secret: string + setup_intent_client_secret?: string +} + +export interface PaymentMethodsSetupSession extends PaymentMethodsSession { setup_intent_client_secret: string } diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 85bbab183..96c657fd9 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -9,7 +9,7 @@ import type { CustomerPortalResponse, Invoice, PaymentMethodsCustomerSession, - PaymentMethodsSession, + PaymentMethodsSetupSession, TeamItems, UsageResponse, VerificationPaymentResponse, @@ -38,7 +38,7 @@ export interface BillingRepository { createOrder(itemId: string): Promise> confirmOrder(orderId: string): Promise> getCustomerSession(): Promise> - createPaymentMethodsSession(): Promise> + createPaymentMethodsSession(): Promise> createVerificationPayment(): Promise> } diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx index 6fd2747ec..19670bf94 100644 --- a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -6,7 +6,7 @@ import { parseAsString, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useCallback, useState } from 'react' import { useSessionStorage } from 'usehooks-ts' -import type { PaymentMethodsSession } from '@/core/modules/billing/models' +import type { PaymentMethodsSetupSession } from '@/core/modules/billing/models' import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' import { useTRPC } from '@/trpc/client' @@ -84,7 +84,7 @@ const MissingPaymentMethodDialogContent = ({ false ) const [paymentMethodsSession, setPaymentMethodsSession] = - useState(null) + useState(null) const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = useState(false) const [setupIntentParams, setSetupIntentParams] = useQueryStates( From b43f89fcfc98abb9acddb06faab1b08127ef0d15 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Thu, 7 May 2026 16:33:37 -0700 Subject: [PATCH 35/39] Fix customer session billing route --- src/core/modules/billing/repository.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 96c657fd9..4cc79c1e3 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -264,7 +264,7 @@ export function createBillingRepository( }, async getCustomerSession() { const res = await fetch( - `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`, + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, { method: 'POST', headers: { From 3853c98ae17fb49a184171772a84f954a456fa1f Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 7 May 2026 17:27:30 -0700 Subject: [PATCH 36/39] Match blocked indicator CTA to surrounding label style Add uppercase and md:prose-label! to the dialog-trigger button so the clickable CTA renders in the same uppercase label style as the rest of the indicator text. --- src/features/dashboard/layouts/team-blocked-indicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index 6c03ffa12..06d182b9a 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -44,7 +44,7 @@ const TeamBlockedIndicatorContent = ({ ) : onDialogAction ? (