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
This commit is contained in:
Claude
2026-01-18 21:31:40 +00:00
parent 35966bb8b8
commit 6bee49a332

View File

@@ -125,6 +125,7 @@ export function ZapWindow({
const [invoice, setInvoice] = useState<string>("");
const [showQrDialog, setShowQrDialog] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const [paymentTimedOut, setPaymentTimedOut] = useState(false);
// Editor ref and search functions
const editorRef = useRef<MentionEditorHandle>(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 (
<div className="h-full flex flex-col bg-background overflow-hidden">
<div className="flex-1 overflow-y-auto">
@@ -430,7 +499,7 @@ export function ZapWindow({
setSelectedAmount(null);
}}
min="1"
className="w-24 h-9"
className="flex-1 h-9"
/>
</div>
@@ -547,6 +616,30 @@ export function ZapWindow({
Copy Invoice
</Button>
</div>
{/* Retry with wallet button if payment timed out */}
{paymentTimedOut &&
wallet &&
walletInfo?.methods.includes("pay_invoice") && (
<Button
onClick={handleRetryWallet}
disabled={isProcessing}
className="w-full"
variant="default"
>
{isProcessing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Retrying...
</>
) : (
<>
<Wallet className="size-4 mr-2" />
Retry with Wallet
</>
)}
</Button>
)}
</div>
</DialogContent>
</Dialog>