From eb4fa7054f5899beeba7296ec76d930e8ce821ce Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Thu, 26 Feb 2026 13:55:17 +0500 Subject: [PATCH] refactor(buy-nft-modal): improve UX, safety, and accessibility - Added local isSubmitting state to prevent double purchases - Wrapped onConfirm in try/catch to handle async errors safely - Memoized derived values (price, balance, seller) - Added image fallback and error handler - Prevented modal close while transaction is processing - Improved accessibility (aria-busy, aria-disabled, role="alert") - Added defensive handling for missing seller/description --- frontend/src/components/BuyNFTModal.tsx | 107 +++++++++++++++++++----- 1 file changed, 84 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/BuyNFTModal.tsx b/frontend/src/components/BuyNFTModal.tsx index 31bfa94..a4f57e1 100644 --- a/frontend/src/components/BuyNFTModal.tsx +++ b/frontend/src/components/BuyNFTModal.tsx @@ -1,6 +1,6 @@ -// Buy NFT Confirmation Modal +// Buy NFT Confirmation Modal (Refactored) -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { AlertTriangle, User, ShoppingCart } from 'lucide-react'; import clsx from 'clsx'; import { Modal } from './Modal'; @@ -29,42 +29,73 @@ export const BuyNFTModal = memo(function BuyNFTModal({ isProcessing = false, className, }) { - const display = formatNFTDisplay(token); - const price = token.listing?.price ?? 0; - const hasEnoughBalance = userBalance >= price; - const remainingBalance = userBalance - price; + const [isSubmitting, setIsSubmitting] = useState(false); + + const display = useMemo(() => formatNFTDisplay(token), [token]); + + const price = useMemo(() => token.listing?.price ?? 0, [token]); + const hasEnoughBalance = useMemo(() => userBalance >= price, [userBalance, price]); + const remainingBalance = useMemo(() => userBalance - price, [userBalance, price]); + + const sellerAddress = useMemo(() => { + return token.listing?.seller || token.owner || 'Unknown'; + }, [token]); const handleConfirm = useCallback(async () => { - await onConfirm(); - }, [onConfirm]); + if (isSubmitting || isProcessing || !hasEnoughBalance) return; + + try { + setIsSubmitting(true); + await onConfirm(); + } catch (error) { + console.error('NFT purchase failed:', error); + } finally { + setIsSubmitting(false); + } + }, [onConfirm, isSubmitting, isProcessing, hasEnoughBalance]); const handleClose = useCallback(() => { - if (!isProcessing) { + if (!isProcessing && !isSubmitting) { onClose(); } - }, [onClose, isProcessing]); + }, [onClose, isProcessing, isSubmitting]); + + const handleImageError = useCallback((e: React.SyntheticEvent) => { + e.currentTarget.src = '/images/nft-placeholder.png'; + }, []); + + const isBusy = isProcessing || isSubmitting; return ( -
+
{display.title}

{display.title}

-

{display.description}

+

+ {display.description || 'No description available.'} +

Seller: - {truncateAddress(token.listing?.seller || token.owner || '')} + {sellerAddress !== 'Unknown' + ? truncateAddress(sellerAddress) + : 'Unknown'}
@@ -73,39 +104,69 @@ export const BuyNFTModal = memo(function BuyNFTModal({
NFT Price - {formatSTX(price, 2)} + + {formatSTX(price, 2)} +
+
Your Balance - + {formatSTX(userBalance, 2)}
+
+
Remaining After Purchase - + {formatSTX(Math.max(0, remainingBalance), 2)}
{!hasEnoughBalance && ( -
- - Insufficient balance. You need {formatSTX(price - userBalance, 2)} more. + )}
- +