From 6bee49a332c30cfd65539f76af306b371b660c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 21:31:40 +0000 Subject: [PATCH] feat: full-width custom amount and wallet timeout with QR fallback Custom Amount Input: - Change custom amount input from w-24 to flex-1 - Now takes full remaining width in flex row - Better UX on smaller screens and more obvious input field Wallet Payment Timeout Handling: - Add 30 second timeout to wallet payments using Promise.race - On timeout, automatically show QR code as fallback - Add paymentTimedOut state to track timeout condition - Toast warning when payment times out Retry with Wallet Feature: - Add handleRetryWallet function to retry timed out payment - Show "Retry with Wallet" button in QR dialog when timed out - Button only appears if wallet is connected and payment capable - Retry uses same 30s timeout, shows error if fails again - Provides loading state with spinner during retry User Flow: 1. User attempts wallet payment 2. If timeout after 30s, shows QR code automatically 3. User can scan QR to pay manually OR 4. User can click "Retry with Wallet" to try again 5. If retry times out, stays on QR for manual payment Implementation Details: - Promise.race between payInvoice and 30s timeout - Timeout throws Error("TIMEOUT") for easy detection - QR dialog conditionally shows retry button - Retry resets state and attempts payment again - Console logging for debugging timeout issues All tests passing (939 passed) Build successful --- src/components/ZapWindow.tsx | 125 ++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/src/components/ZapWindow.tsx b/src/components/ZapWindow.tsx index e45d8a6..e7fddf1 100644 --- a/src/components/ZapWindow.tsx +++ b/src/components/ZapWindow.tsx @@ -125,6 +125,7 @@ export function ZapWindow({ const [invoice, setInvoice] = useState(""); const [showQrDialog, setShowQrDialog] = useState(false); const [showLogin, setShowLogin] = useState(false); + const [paymentTimedOut, setPaymentTimedOut] = useState(false); // Editor ref and search functions const editorRef = useRef(null); @@ -334,25 +335,48 @@ export function ZapWindow({ // Step 5: Pay or show QR code if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) { - // Pay with NWC wallet + // Pay with NWC wallet with timeout toast.info("Paying invoice with wallet..."); - await payInvoice(invoiceText); - await refreshBalance(); - setIsPaid(true); - toast.success( - `⚡ Zapped ${amount} sats to ${recipientProfile?.name || recipientName}!`, - ); + try { + // Race between payment and 30 second timeout + const paymentPromise = payInvoice(invoiceText); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("TIMEOUT")), 30000), + ); - // Show success message from LNURL service if available - if (invoiceResponse.successAction?.message) { - toast.info(invoiceResponse.successAction.message); - } + await Promise.race([paymentPromise, timeoutPromise]); + await refreshBalance(); - // Close the window after successful zap - if (onClose) { - // Small delay to let the user see the success toast - setTimeout(() => onClose(), 1500); + setIsPaid(true); + toast.success( + `⚡ Zapped ${amount} sats to ${recipientProfile?.name || recipientName}!`, + ); + + // Show success message from LNURL service if available + if (invoiceResponse.successAction?.message) { + toast.info(invoiceResponse.successAction.message); + } + + // Close the window after successful zap + if (onClose) { + // Small delay to let the user see the success toast + setTimeout(() => onClose(), 1500); + } + } catch (error) { + if (error instanceof Error && error.message === "TIMEOUT") { + // Payment timed out - show QR code with retry option + console.log("[Zap] Wallet payment timed out, showing QR code"); + toast.warning("Wallet payment timed out. Showing QR code instead."); + setPaymentTimedOut(true); + const qrUrl = await generateQrCode(invoiceText); + setQrCodeUrl(qrUrl); + setInvoice(invoiceText); + setShowQrDialog(true); + } else { + // Other payment error - re-throw + throw error; + } } } else { // Show QR code and invoice @@ -392,6 +416,51 @@ export function ZapWindow({ setShowLogin(true); }; + // Retry wallet payment + const handleRetryWallet = async () => { + if (!invoice || !wallet) return; + + setIsProcessing(true); + setShowQrDialog(false); + setPaymentTimedOut(false); + + try { + toast.info("Retrying payment with wallet..."); + + // Try again with timeout + const paymentPromise = payInvoice(invoice); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("TIMEOUT")), 30000), + ); + + await Promise.race([paymentPromise, timeoutPromise]); + await refreshBalance(); + + setIsPaid(true); + toast.success("⚡ Payment successful!"); + + // Close the window after successful zap + if (onClose) { + setTimeout(() => onClose(), 1500); + } + } catch (error) { + if (error instanceof Error && error.message === "TIMEOUT") { + console.log("[Zap] Wallet payment timed out again"); + toast.error("Payment timed out again. Please try manually."); + setPaymentTimedOut(true); + setShowQrDialog(true); + } else { + console.error("[Zap] Retry payment error:", error); + toast.error( + error instanceof Error ? error.message : "Failed to retry payment", + ); + setShowQrDialog(true); + } + } finally { + setIsProcessing(false); + } + }; + return (
@@ -430,7 +499,7 @@ export function ZapWindow({ setSelectedAmount(null); }} min="1" - className="w-24 h-9" + className="flex-1 h-9" />
@@ -547,6 +616,30 @@ export function ZapWindow({ Copy Invoice
+ + {/* Retry with wallet button if payment timed out */} + {paymentTimedOut && + wallet && + walletInfo?.methods.includes("pay_invoice") && ( + + )}