From cb6c52026c0a3fcdbd2ddb78c0bf467508d0c73b Mon Sep 17 00:00:00 2001 From: sundayonah Date: Mon, 20 Jul 2026 18:18:49 +0100 Subject: [PATCH 1/4] feat: Implement order validation and payment confirmation modal - Added `validateOrder` function to confirm user receipt of funds for stuck orders, integrating with the aggregator's validate API. - Introduced `PaymentConfirmationModal` component to prompt users for payment confirmation after a delay if their transaction is stuck. - Updated `TransactionStatus` to manage payment confirmation state and handle user interactions for confirming payments. - Enhanced configuration to include `aggregatorSenderApiKeyId` for secure API interactions. --- app/api/aggregator.ts | 44 ++++ app/api/v1/orders/[id]/validate/route.ts | 240 ++++++++++++++++++++ app/components/PaymentConfirmationModal.tsx | 196 ++++++++++++++++ app/components/index.ts | 1 + app/lib/config.ts | 1 + app/pages/TransactionStatus.tsx | 177 +++++++++++++++ app/types.ts | 2 + 7 files changed, 661 insertions(+) create mode 100644 app/api/v1/orders/[id]/validate/route.ts create mode 100644 app/components/PaymentConfirmationModal.tsx diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts index bb31d99b..2ae1cdeb 100644 --- a/app/api/aggregator.ts +++ b/app/api/aggregator.ts @@ -914,6 +914,50 @@ export async function updateTransactionStatus({ return response.data; } +/** + * Confirms the user received funds for a stuck order (proxies to aggregator validate). + */ +export async function validateOrder({ + orderId, + accessToken, + walletAddress, +}: { + orderId: string; + accessToken: string; + walletAddress: string; +}): Promise<{ + success: boolean; + data?: { message?: string; validatedAt?: string }; + error?: string; +}> { + try { + const response = await axios.post( + `/api/v1/orders/${orderId}/validate`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "x-wallet-address": walletAddress.toLowerCase(), + }, + }, + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const message = + (error.response?.data as { error?: string })?.error || + error.message || + "Failed to validate order"; + return { success: false, error: message }; + } + console.error("Validate order error:", error); + return { + success: false, + error: "Failed to validate order. Please try again.", + }; + } +} + /** * Updates the details of a transaction including status, hash, and time spent * @param {Object} params - The parameters object diff --git a/app/api/v1/orders/[id]/validate/route.ts b/app/api/v1/orders/[id]/validate/route.ts new file mode 100644 index 00000000..4896dace --- /dev/null +++ b/app/api/v1/orders/[id]/validate/route.ts @@ -0,0 +1,240 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import config, { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { verifyJWT } from "@/app/lib/jwt"; +import { supabaseAdmin } from "@/app/lib/supabase"; + +// Route handler for POST - validate order (user confirmed payment received) +export const POST = withRateLimit( + async ( + request: NextRequest, + context: { params: Promise<{ id: string }> }, + ) => { + const startTime = Date.now(); + let orderId: string | null = null; + + try { + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace(/^Bearer\s+/i, ""); + + if (!token) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Missing auth token"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + let authenticatedUserId: string; + try { + const jwtResult = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + authenticatedUserId = jwtResult.payload.sub ?? ""; + + if (!authenticatedUserId) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Invalid token"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + } catch (jwtError) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + jwtError as Error, + 401, + ); + return NextResponse.json( + { success: false, error: "Invalid or expired token" }, + { status: 401 }, + ); + } + + const walletAddress = request.headers + .get("x-wallet-address") + ?.toLowerCase(); + + if (!walletAddress) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Missing wallet address"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + const params = await context.params; + orderId = params.id; + if (!orderId) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Order ID is required"), + 400, + ); + return NextResponse.json( + { success: false, error: "Order ID is required" }, + { status: 400 }, + ); + } + + const fullPath = `/api/v1/orders/${orderId}/validate`; + + // Verify the order belongs to the authenticated wallet before using sender API key + const { data: ownedTransaction, error: ownershipError } = + await supabaseAdmin + .from("transactions") + .select("id") + .eq("order_id", orderId) + .eq("wallet_address", walletAddress) + .maybeSingle(); + + if (ownershipError || !ownedTransaction) { + trackApiError( + request, + fullPath, + "POST", + new Error("Order not found or unauthorized"), + 403, + ); + return NextResponse.json( + { success: false, error: "Order not found or unauthorized" }, + { status: 403 }, + ); + } + + trackApiRequest(request, fullPath, "POST", { + wallet_address: walletAddress, + order_id: orderId, + }); + + const aggregatorUrl = config.aggregatorUrl?.trim(); + const apiKeyId = + config.aggregatorSenderApiKeyId?.trim() || + config.aggregatorSenderApiKey?.trim(); + if (!aggregatorUrl || !apiKeyId) { + trackApiError( + request, + fullPath, + "POST", + new Error("Order validate service is not configured"), + 503, + ); + return NextResponse.json( + { + success: false, + error: + "Order validate service is not configured. Please try again later.", + }, + { status: 503 }, + ); + } + + const url = `${aggregatorUrl}/sender/orders/${orderId}/validate`; + const FETCH_TIMEOUT_MS = 30_000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "API-Key": apiKeyId, + }, + body: JSON.stringify({}), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + const data = await res.json().catch(() => ({})); + const rawMessage = + typeof data?.message === "string" + ? data.message + : (data?.Message as string) ?? ""; + + if (!res.ok) { + const status = + res.status >= 400 && res.status < 600 ? res.status : 502; + const errorMessage = + rawMessage.trim() || "Failed to validate order"; + trackApiError( + request, + fullPath, + "POST", + new Error(errorMessage), + status, + { response_time_ms: Date.now() - startTime }, + ); + return NextResponse.json( + { success: false, error: errorMessage }, + { status }, + ); + } + + const responseTime = Date.now() - startTime; + trackApiResponse(fullPath, "POST", 200, responseTime, { + wallet_address: walletAddress, + order_id: orderId, + }); + + const successMessage = + rawMessage.trim() || "Order validated successfully"; + + return NextResponse.json({ + success: true, + data: { + message: successMessage, + validatedAt: new Date().toISOString(), + }, + }); + } catch (error) { + console.error("Validate order error:", error); + const responseTime = Date.now() - startTime; + const fullPath = orderId + ? `/api/v1/orders/${orderId}/validate` + : "/api/v1/orders/validate"; + const isTimeout = + error instanceof Error && error.name === "AbortError"; + const status = isTimeout ? 504 : 500; + trackApiError(request, fullPath, "POST", error as Error, status, { + response_time_ms: responseTime, + }); + return NextResponse.json( + { + success: false, + error: isTimeout + ? "Validation request timed out. Please try again." + : "Failed to validate order. Please try again.", + }, + { status }, + ); + } + }, +); diff --git a/app/components/PaymentConfirmationModal.tsx b/app/components/PaymentConfirmationModal.tsx new file mode 100644 index 00000000..e79a90c7 --- /dev/null +++ b/app/components/PaymentConfirmationModal.tsx @@ -0,0 +1,196 @@ +"use client"; +import { useState, useCallback, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Dialog, DialogPanel } from "@headlessui/react"; +import { Cancel01Icon, Loading03Icon } from "hugeicons-react"; +import Image from "next/image"; +import { classNames } from "../utils"; +import { primaryBtnClasses } from "./Styles"; + +interface PaymentConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + tokenAmount: string | number; + token: string; + /** Destination wallet where funds are going. */ + recipientAddress?: string; + explorerLink?: string; +} + +function truncateAddress(address: string) { + if (address.length <= 14) return address; + return `${address.slice(0, 6)}...${address.slice(-5)}`; +} + +export const PaymentConfirmationModal = ({ + isOpen, + onClose, + onConfirm, + tokenAmount, + token, + recipientAddress, + explorerLink, +}: PaymentConfirmationModalProps) => { + const [confirming, setConfirming] = useState(false); + + useEffect(() => { + if (!isOpen) { + setConfirming(false); + } + }, [isOpen]); + + const handleConfirm = useCallback(async () => { + if (confirming) return; + setConfirming(true); + try { + await Promise.resolve(onConfirm()); + } catch { + // Parent shows toast; keep modal open so user can retry. + } finally { + setConfirming(false); + } + }, [confirming, onConfirm]); + + const tokenLogo = token?.toLowerCase() || "usdc"; + + return ( + + {isOpen && ( + { + if (!confirming) onClose(); + }} + className="relative z-[70]" + > + + +
+ + +
+
+ + + Pending + + +
+ +

+ Have you received +
+ this payment? +

+ +
+
+ {`${token} + + {tokenAmount} {token} + +
+ + + -······· + + + {recipientAddress ? ( +
+ + {truncateAddress(recipientAddress)} + + {explorerLink && ( + + View + + )} +
+ ) : ( + explorerLink && ( + + View + + ) + )} +
+ +

+ We noticed this transaction is taking longer than usual to + update. Please let us know if you have received your funds + so we can finalize your status. +

+ +
+ + +
+
+
+
+
+
+ )} +
+ ); +}; diff --git a/app/components/index.ts b/app/components/index.ts index f3f5306e..95942f15 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -44,6 +44,7 @@ export { SearchInput } from "./recipient/SearchInput"; export { RecipientListItem } from "./recipient/RecipientListItem"; export { SavedBeneficiariesModal } from "./recipient/SavedBeneficiariesModal"; export { TransactionHelperText } from "./TransactionHelperText"; +export { PaymentConfirmationModal } from "./PaymentConfirmationModal"; export { inputClasses, primaryBtnClasses, diff --git a/app/lib/config.ts b/app/lib/config.ts index 9c99d9a7..7f63a80b 100644 --- a/app/lib/config.ts +++ b/app/lib/config.ts @@ -24,6 +24,7 @@ export const STARKNET_PAYMASTER_MODE = "sponsored"; const config: Config = { aggregatorUrl: process.env.NEXT_PUBLIC_AGGREGATOR_URL || "", + aggregatorSenderApiKeyId: process.env.AGGREGATOR_SENDER_API_KEY_ID || "", privyAppId: process.env.NEXT_PUBLIC_PRIVY_APP_ID || "", rpcUrlKey: process.env.NEXT_PUBLIC_RPC_URL_KEY || "", mixpanelToken: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN || "", diff --git a/app/pages/TransactionStatus.tsx b/app/pages/TransactionStatus.tsx index a6fdc9ae..1ce75fb8 100644 --- a/app/pages/TransactionStatus.tsx +++ b/app/pages/TransactionStatus.tsx @@ -40,6 +40,7 @@ import { resolveOnrampOrderStatusFromV2Response, updateTransactionDetails, unwrapV2SenderOrderEnvelope, + validateOrder, fetchSavedRecipients, saveRecipient, deleteSavedRecipient, @@ -75,6 +76,7 @@ import { import { readForwardBalanceWei } from "../lib/onrampForwarding/readForwardBalanceWei"; import type { Token } from "../types"; import { usePrivy } from "@privy-io/react-auth"; +import { PaymentConfirmationModal } from "../components/PaymentConfirmationModal"; import { TransactionHelperText } from "../components/TransactionHelperText"; import { useConfetti } from "../hooks/useConfetti"; import { BlockFestCashbackComponent } from "../components/blockfest"; @@ -169,6 +171,10 @@ export function TransactionStatus({ const [hasShownConfetti, setHasShownConfetti] = useState(false); const [isSavingRecipient, setIsSavingRecipient] = useState(false); const [showSaveSuccess, setShowSaveSuccess] = useState(false); + const [showPaymentConfirmation, setShowPaymentConfirmation] = useState(false); + const paymentConfirmationTimerRef = useRef(null); + const stuckInFulfillingSinceRef = useRef(null); + const lastStuckOrderIdRef = useRef(null); const [hasReindexed, setHasReindexed] = useState(false); // Noblocks Play banner — dismissed state persists via localStorage. const [isFantasyBannerDismissed, setIsFantasyBannerDismissed] = @@ -840,6 +846,158 @@ export function TransactionStatus({ [orderId, transactionStatus], ); + useEffect( + function showPaymentConfirmationAfterDelay() { + const STUCK_STORAGE_KEY_PREFIX = "stuck_fulfilling_since_"; + const getStuckStorageKey = () => + orderId ? `${STUCK_STORAGE_KEY_PREFIX}${orderId}` : null; + + const isStuckState = ["fulfilling", "fulfilled"].includes( + transactionStatus, + ); + + if (!isStuckState) { + setShowPaymentConfirmation(false); + stuckInFulfillingSinceRef.current = null; + lastStuckOrderIdRef.current = null; + const key = getStuckStorageKey(); + if (key && typeof window !== "undefined") { + try { + localStorage.removeItem(key); + } catch { + // ignore + } + } + if (paymentConfirmationTimerRef.current) { + clearTimeout(paymentConfirmationTimerRef.current); + paymentConfirmationTimerRef.current = null; + } + return; + } + + if (orderId !== lastStuckOrderIdRef.current) { + stuckInFulfillingSinceRef.current = null; + lastStuckOrderIdRef.current = orderId ?? null; + } + + const now = Date.now(); + if (stuckInFulfillingSinceRef.current === null) { + const key = getStuckStorageKey(); + if (key && typeof window !== "undefined") { + try { + const stored = localStorage.getItem(key); + const parsed = stored ? parseInt(stored, 10) : NaN; + if (Number.isFinite(parsed) && parsed <= now) { + stuckInFulfillingSinceRef.current = parsed; + } + } catch { + // ignore + } + } + if (stuckInFulfillingSinceRef.current === null) { + stuckInFulfillingSinceRef.current = now; + const keyToWrite = getStuckStorageKey(); + if (keyToWrite && typeof window !== "undefined") { + try { + localStorage.setItem(keyToWrite, String(now)); + } catch { + // ignore + } + } + } + } + const stuckSince = stuckInFulfillingSinceRef.current; + const elapsed = now - stuckSince; + const delayMs = 120_000; + + if (elapsed >= delayMs) { + setShowPaymentConfirmation(true); + } else { + paymentConfirmationTimerRef.current = setTimeout(() => { + setShowPaymentConfirmation(true); + }, delayMs - elapsed); + } + + return () => { + if (paymentConfirmationTimerRef.current) { + clearTimeout(paymentConfirmationTimerRef.current); + paymentConfirmationTimerRef.current = null; + } + }; + }, + [transactionStatus, orderId], + ); + + const handlePaymentConfirmed = async () => { + const accessToken = await getAccessToken(); + if (!accessToken || !orderId) { + toast.error("Unable to confirm payment. Please try again."); + throw new Error("Missing access token or order ID"); + } + + const walletAddress = embeddedWallet?.address || ""; + if (!walletAddress) { + toast.error("Wallet not connected. Please reconnect and try again."); + throw new Error("Missing wallet address"); + } + + try { + const validateResult = await validateOrder({ + orderId, + accessToken, + walletAddress, + }); + + if (!validateResult.success) { + toast.error( + validateResult.error || + "Could not validate order. Please try again or contact support.", + ); + throw new Error(validateResult.error || "Validation failed"); + } + + const transactionId = localStorage.getItem("currentTransactionId"); + if (!transactionId) throw new Error("Transaction not found"); + + const updateResult = await updateTransactionDetails({ + transactionId, + status: "settled", + txHash: createdHash || orderDetails?.txHash, + timeSpent: calculateDuration(createdAt, new Date().toISOString()), + accessToken, + walletAddress, + }); + + if (!updateResult?.success) { + toast.error("Could not update transaction status. Please try again."); + throw new Error("Transaction update failed"); + } + + setTransactionStatus("settled"); + setShowPaymentConfirmation(false); + if (orderId && typeof window !== "undefined") { + try { + localStorage.removeItem(`stuck_fulfilling_since_${orderId}`); + } catch { + // ignore + } + } + } catch (error) { + console.error("Error confirming payment:", error); + const msg = error instanceof Error ? error.message : ""; + const alreadyToasted = + msg === "Missing access token or order ID" || + msg === "Missing wallet address" || + msg === "Transaction not found" || + msg === "Validation failed" || + msg === "Transaction update failed"; + if (!alreadyToasted) { + toast.error("Failed to confirm payment. Please try again."); + } + throw error; + } + }; + /** * Tracks transaction events for analytics * Only tracks once per transaction when status is final @@ -1487,6 +1645,25 @@ export function TransactionStatus({ className="w-full space-y-4" /> + setShowPaymentConfirmation(false)} + onConfirm={handlePaymentConfirmed} + tokenAmount={String(amount)} + token={String(token)} + recipientAddress={ + recipientWalletAddress || String(accountIdentifier || "") + } + explorerLink={ + (createdHash || orderDetails?.txHash) && orderDetails?.network + ? getExplorerLink( + orderDetails.network, + (createdHash || orderDetails?.txHash) ?? "", + ) + : undefined + } + /> + {[ "validated", diff --git a/app/types.ts b/app/types.ts index cd1f9d12..83112682 100644 --- a/app/types.ts +++ b/app/types.ts @@ -414,6 +414,8 @@ export type KYCStatusResponse = { export type Config = { aggregatorUrl: string; + /** Server-only sender API key UUID for order validate proxy. */ + aggregatorSenderApiKeyId: string; privyAppId: string; rpcUrlKey: string; mixpanelToken: string; From 775ff59ac8b47b98571ba4055fabdb42a96967cf Mon Sep 17 00:00:00 2001 From: sundayonah Date: Mon, 27 Jul 2026 10:13:22 +0100 Subject: [PATCH 2/4] feat: Enhance payment session management and confirmation handling. --- app/components/MainPageContent.tsx | 76 ++++++++++++++- app/components/PaymentConfirmationModal.tsx | 36 +++++-- app/lib/session-cleanup.ts | 17 ++++ app/lib/stuckPaymentSession.ts | 102 ++++++++++++++++++++ app/pages/TransactionStatus.tsx | 96 ++++++++++++++++-- 5 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 app/lib/stuckPaymentSession.ts diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx index 87951f3c..35db3ed4 100644 --- a/app/components/MainPageContent.tsx +++ b/app/components/MainPageContent.tsx @@ -72,6 +72,11 @@ import { } from "../lib/pendingReferralCode"; import { isReferralEnabled } from "../utils"; import { useWalletAddress } from "../hooks/useWalletAddress"; +import { networks } from "../mocks"; +import { + readStuckPaymentSession, + clearStuckPaymentSession, +} from "../lib/stuckPaymentSession"; /** * PageLayout component renders the main page structure including modals, @@ -261,6 +266,7 @@ export function MainPageContent() { const failedProviders = useRef>(new Set()); const autoSelectedNetworkSessionRef = useRef(null); const noProviderEventGuard = useRef>(new Set()); + const restoredStuckSessionRef = useRef(false); const [isUserVerified, setIsUserVerified] = useState(false); const [rateError, setRateError] = useState(null); @@ -476,12 +482,74 @@ export function MainPageContent() { }, [walletAddress]); useEffect(function setPageLoadingState() { - setOrderId(""); - setOnrampPaymentAccount(null); - setActiveOrderIsOnramp(false); + // Keep stuck-order fields intact when a session will be restored on auth ready. + if (!readStuckPaymentSession()) { + setOrderId(""); + setOnrampPaymentAccount(null); + setActiveOrderIsOnramp(false); + } setIsPageLoading(false); }, []); + useEffect( + function restoreStuckPaymentSessionOnLoad() { + if (restoredStuckSessionRef.current) return; + if (!ready) return; + if (!authenticated && !isInjectedWallet) return; + + const session = readStuckPaymentSession(); + if (!session) return; + + restoredStuckSessionRef.current = true; + + const restoredForm: FormData = { + network: session.network || selectedNetwork.chain.name, + token: session.form.token, + currency: session.form.currency, + institution: session.form.institution, + accountIdentifier: session.form.accountIdentifier, + recipientName: session.form.recipientName, + accountType: session.form.accountType || "bank", + walletAddress: session.form.walletAddress || "", + memo: session.form.memo || "", + amountSent: session.form.amountSent, + amountReceived: session.form.amountReceived, + swapMode: session.form.swapMode || (session.isOnramp ? "onramp" : "offramp"), + isSwapped: session.isOnramp, + receiveDestinationExplicitlySelected: true, + }; + + formMethods.reset({ + ...formMethods.getValues(), + ...restoredForm, + }); + setFormValues(restoredForm); + setOrderId(session.orderId); + setCreatedAt(session.createdAt); + setTransactionStatus(session.transactionStatus); + setActiveOrderIsOnramp(session.isOnramp); + + if (session.transactionId) { + try { + localStorage.setItem("currentTransactionId", session.transactionId); + } catch { + // ignore + } + } + + if (session.network) { + const match = networks.find((n) => n.chain.name === session.network); + if (match) { + setSelectedNetwork(match); + } + } + + setCurrentStep(STEPS.STATUS); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ready, authenticated, isInjectedWallet], + ); + useEffect( function resetOnLogout() { // Reset form when user logs out (but not for injected wallets) @@ -490,6 +558,8 @@ export function MainPageContent() { setFormValues({} as FormData); setOnrampPaymentAccount(null); setActiveOrderIsOnramp(false); + clearStuckPaymentSession(); + restoredStuckSessionRef.current = false; } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/components/PaymentConfirmationModal.tsx b/app/components/PaymentConfirmationModal.tsx index e79a90c7..cb1e70aa 100644 --- a/app/components/PaymentConfirmationModal.tsx +++ b/app/components/PaymentConfirmationModal.tsx @@ -9,8 +9,12 @@ import { primaryBtnClasses } from "./Styles"; interface PaymentConfirmationModalProps { isOpen: boolean; + /** Close without action (X / backdrop). */ onClose: () => void; + /** User confirmed funds received. */ onConfirm: () => void | Promise; + /** User has not received funds — triggers reindex / status retry. */ + onDecline: () => void | Promise; tokenAmount: string | number; token: string; /** Destination wallet where funds are going. */ @@ -27,21 +31,25 @@ export const PaymentConfirmationModal = ({ isOpen, onClose, onConfirm, + onDecline, tokenAmount, token, recipientAddress, explorerLink, }: PaymentConfirmationModalProps) => { const [confirming, setConfirming] = useState(false); + const [declining, setDeclining] = useState(false); + const busy = confirming || declining; useEffect(() => { if (!isOpen) { setConfirming(false); + setDeclining(false); } }, [isOpen]); const handleConfirm = useCallback(async () => { - if (confirming) return; + if (busy) return; setConfirming(true); try { await Promise.resolve(onConfirm()); @@ -50,7 +58,19 @@ export const PaymentConfirmationModal = ({ } finally { setConfirming(false); } - }, [confirming, onConfirm]); + }, [busy, onConfirm]); + + const handleDecline = useCallback(async () => { + if (busy) return; + setDeclining(true); + try { + await Promise.resolve(onDecline()); + } catch { + // Parent shows toast; keep modal open so user can retry. + } finally { + setDeclining(false); + } + }, [busy, onDecline]); const tokenLogo = token?.toLowerCase() || "usdc"; @@ -60,7 +80,7 @@ export const PaymentConfirmationModal = ({ { - if (!confirming) onClose(); + if (!busy) onClose(); }} className="relative z-[70]" > @@ -94,7 +114,7 @@ export const PaymentConfirmationModal = ({

@@ -194,7 +178,7 @@ export const PaymentConfirmationModal = ({ "!min-h-12 !min-w-0 !flex-none !rounded-2xl border-none px-8 text-sm leading-6 shadow-none", )} > - {declining ? "Checking…" : "No, I haven't"} + No, I haven't