feat: improve zap wallet payment flow UX (#144)

* feat: improve zap wallet payment flow UX

Improvements to the zap window to better communicate wallet payment status:

- Add clear "Paying with wallet..." message during NWC payment attempts
- Show QR code immediately on payment timeout or failure
- Improve error messages with actionable guidance
- Always display "Open in External Wallet" option in QR view
- Rename "Retry with Wallet" to "Retry with NWC Wallet" for clarity
- Generate QR code upfront to enable instant display on errors

This provides better feedback when wallet payments fail or timeout,
giving users clear fallback options without confusion.

* feat: add LNURL address caching for instant zap UI

Implements LNURL address caching similar to NIP-05 caching pattern:

**Database Changes** (v16):
- Add `lnurlCache` table with 24-hour TTL
- Stores LNURL-pay response data for Lightning addresses
- Indexed by address and fetchedAt for efficient queries

**New Hook** (`useLnurlCache`):
- Reactive hook using Dexie + useLiveQuery pattern
- Auto-fetches and caches LNURL data on first use
- Returns cached data instantly on subsequent calls
- Re-fetches when cache is stale (>24 hours)

**ZapWindow Optimization**:
- Uses cached LNURL data instead of network calls
- Eliminates 10-second delay on repeat zaps
- Shows zap amounts/limits instantly from cache
- Graceful error handling when cache is warming up

**Testing**:
- 11 comprehensive tests for LNURL validation
- Validates zap support, pubkey format, field requirements
- Tests edge cases (uppercase hex, missing fields, etc.)

**Benefits**:
- Instant zap UI for frequently zapped users
- Reduced load on LNURL servers
- Offline capability (show cached limits/amounts)
- Better UX with sub-100ms response time

Verification: All 950 tests pass, build succeeds

* fix: match comment input styling to amount input in zap window

Update MentionEditor styling to match Input component:
- Change padding from py-2 to py-1
- Add responsive text sizing (text-base md:text-sm)
- Add min-h-9 to match Input height

This creates visual consistency between the amount and comment fields.

* feat: add amount preview above invoice in zap QR view

Display the zap amount prominently above the invoice when showing
the QR code. This provides clear visual confirmation of what the
user is paying before they scan or copy the invoice.

Format:
- Large bold amount with k/m notation (e.g., "420", "2.1k", "100m")
- Smaller "sats" label underneath
- Positioned between QR code and invoice field

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-19 00:07:05 +01:00
committed by GitHub
parent 9f6e524ea9
commit dbcbcf6181
4 changed files with 330 additions and 58 deletions

View File

@@ -42,12 +42,13 @@ import {
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import LoginDialog from "./nostr/LoginDialog";
import { resolveLightningAddress, validateZapSupport } from "@/lib/lnurl";
import { validateZapSupport } from "@/lib/lnurl";
import {
createZapRequest,
serializeZapRequest,
} from "@/lib/create-zap-request";
import { fetchInvoiceFromCallback } from "@/lib/lnurl";
import { useLnurlCache } from "@/hooks/useLnurlCache";
export interface ZapWindowProps {
/** Recipient pubkey (who receives the zap) */
@@ -117,9 +118,13 @@ export function ZapWindow({
}
}, [wallet, getInfo]);
// Cache LNURL data for recipient's Lightning address
const { data: lnurlData } = useLnurlCache(recipientProfile?.lud16);
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isPayingWithWallet, setIsPayingWithWallet] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [invoice, setInvoice] = useState<string>("");
@@ -287,7 +292,7 @@ export function ZapWindow({
// Track usage
trackAmountUsage(amount);
// Step 1: Get Lightning address from recipient profile
// Step 1: Verify recipient has Lightning address configured
const lud16 = recipientProfile?.lud16;
const lud06 = recipientProfile?.lud06;
@@ -297,21 +302,21 @@ export function ZapWindow({
);
}
// Step 2: Resolve LNURL to get callback URL and nostrPubkey
let lnurlData;
if (lud16) {
lnurlData = await resolveLightningAddress(lud16);
validateZapSupport(lnurlData);
} else if (lud06) {
if (lud06) {
throw new Error(
"LNURL (lud06) not supported. Recipient should use a Lightning address (lud16) instead.",
);
}
// Step 2: Use cached LNURL data (fetched by useLnurlCache hook)
if (!lnurlData) {
throw new Error("Failed to resolve Lightning address");
throw new Error(
"Failed to resolve Lightning address. Please wait and try again.",
);
}
validateZapSupport(lnurlData);
// Validate amount is within acceptable range
const amountMillisats = amount * 1000;
if (amountMillisats < lnurlData.minSendable) {
@@ -365,9 +370,15 @@ export function ZapWindow({
const invoiceText = invoiceResponse.pr;
// Generate QR code upfront so we can show it immediately on error
const qrUrl = await generateQrCode(invoiceText);
setQrCodeUrl(qrUrl);
setInvoice(invoiceText);
// Step 5: Pay or show QR code
if (useWallet && wallet && walletInfo?.methods.includes("pay_invoice")) {
// Pay with NWC wallet with timeout
setIsPayingWithWallet(true);
try {
// Race between payment and 30 second timeout
const paymentPromise = payInvoice(invoiceText);
@@ -379,25 +390,27 @@ export function ZapWindow({
await refreshBalance();
setIsPaid(true);
setIsPayingWithWallet(false);
toast.success(`⚡ Zapped ${amount} sats to ${recipientName}!`);
} catch (error) {
// Payment failed or timed out - show QR code immediately
setIsPayingWithWallet(false);
setPaymentTimedOut(true);
setShowQrDialog(true);
// Show specific error message
if (error instanceof Error && error.message === "TIMEOUT") {
// Payment timed out - show QR code with retry option
setPaymentTimedOut(true);
const qrUrl = await generateQrCode(invoiceText);
setQrCodeUrl(qrUrl);
setInvoice(invoiceText);
setShowQrDialog(true);
toast.error("Payment timed out. Use QR code or retry with wallet.");
} else {
// Other payment error - re-throw
throw error;
toast.error(
error instanceof Error
? `Payment failed: ${error.message}`
: "Payment failed. Use QR code or retry.",
);
}
}
} else {
// Show QR code and invoice
const qrUrl = await generateQrCode(invoiceText);
setQrCodeUrl(qrUrl);
setInvoice(invoiceText);
// Show QR code and invoice directly
setShowQrDialog(true);
}
} catch (error) {
@@ -435,6 +448,7 @@ export function ZapWindow({
if (!invoice || !wallet) return;
setIsProcessing(true);
setIsPayingWithWallet(true);
setShowQrDialog(false);
setPaymentTimedOut(false);
@@ -452,18 +466,19 @@ export function ZapWindow({
setShowQrDialog(false);
toast.success("⚡ Payment successful!");
} catch (error) {
setShowQrDialog(true);
setPaymentTimedOut(true);
if (error instanceof Error && error.message === "TIMEOUT") {
toast.error("Payment timed out. Please try manually.");
setPaymentTimedOut(true);
setShowQrDialog(true);
} else {
toast.error(
error instanceof Error ? error.message : "Failed to retry payment",
);
setShowQrDialog(true);
}
} finally {
setIsProcessing(false);
setIsPayingWithWallet(false);
}
};
@@ -495,6 +510,16 @@ export function ZapWindow({
</div>
)}
{/* Amount Preview */}
{(selectedAmount || customAmount) && (
<div className="text-center">
<div className="text-3xl font-bold text-foreground">
{formatAmount(selectedAmount || parseInt(customAmount))}
</div>
<div className="text-sm text-muted-foreground">sats</div>
</div>
)}
{/* Invoice */}
<div className="space-y-2">
<Label>Invoice</Label>
@@ -515,38 +540,43 @@ export function ZapWindow({
</div>
{/* Actions */}
<Button
variant="outline"
className="w-full"
onClick={() => openInWallet(invoice)}
>
<ExternalLink className="size-4 mr-2" />
Open in Wallet
</Button>
<div className="space-y-2">
{/* Retry with wallet button if payment failed/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" />
{isPayingWithWallet
? "Paying with wallet..."
: "Retrying..."}
</>
) : (
<>
<Wallet className="size-4 mr-2" />
Retry with NWC Wallet
</>
)}
</Button>
)}
{/* 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>
)}
{/* Always show option to open in external wallet */}
<Button
variant={paymentTimedOut ? "outline" : "default"}
className="w-full"
onClick={() => openInWallet(invoice)}
>
<ExternalLink className="size-4 mr-2" />
Open in External Wallet
</Button>
</div>
</div>
) : (
<>
@@ -614,7 +644,7 @@ export function ZapWindow({
placeholder="Say something nice..."
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
className="rounded-md border border-input bg-background px-3 py-2"
className="rounded-md border border-input bg-background px-3 py-1 text-base md:text-sm min-h-9"
/>
)}
</div>
@@ -658,7 +688,9 @@ export function ZapWindow({
{isProcessing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Processing...
{isPayingWithWallet
? "Paying with wallet..."
: "Processing..."}
</>
) : isPaid ? (
<>