SkateSpots/src/components/ZapDialog.tsx
shakespeare.diy 39ac517610 Fix Cashu zap: pay in one click + honor custom-amount balance guard
Audit of the wallet/zap flow surfaced a real money-path bug in ZapDialog's
Cashu branch:

• One-click Cashu zap never worked. The Cashu payment branch only ran
  `if (... && invoice)`, but no invoice exists on the first click — so
  selecting "Cashu" and tapping Zap silently fell through to the Lightning
  flow (generating an invoice / paying via NWC-WebLN) and the user's Cashu
  balance was never used. Now the Cashu path generates the recipient's
  amount-verified zap invoice and melts it from the Cashu wallet in a single
  click.

  To support this cleanly, useZaps now exposes `generateZapInvoice()` —
  builds + signs the NIP-57 request, fetches the LNURL invoice, and runs the
  same `assertInvoiceAmount` security check the Lightning rail uses — WITHOUT
  auto-paying. The Lightning `zap()` path was refactored to use it too, so
  both rails share one verified-invoice codepath.

• Custom-amount balance guard bypassed. The insufficient-balance warning and
  the disabled Send state only checked `typeof amount === 'number'`, but the
  custom-amount input stores a string — so a typed amount over the Cashu
  balance slipped past both. Both now coerce to a number first.

• Smarter defaults: when the dialog opens and the user has Cashu funds but no
  connected Lightning wallet, Cashu is pre-selected and the "connect a wallet"
  setup nag is skipped (they can already pay).

• Removed a dead `useLightningOperations` import.

Also audited mobile layout (Layout.tsx — mature, safe-area-aware, no changes
needed) and ZapCrewDialog (uses the separate useCrewZap splitter, no Cashu
option, no bug).

Bumps to 1.2.21 (Android versionCode 10221 auto-derived).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 20:15:10 +07:00

760 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useCashu } from '@/hooks/wallet/useCashu';
import { WalletModal } from '@/components/WalletModal';
import type { Event } from 'nostr-tools';
// qrcode imported lazily in async function to avoid canvas crash at module load
import type { WebLNProvider } from "@webbtc/webln-types";
interface ZapDialogProps {
target: Event;
children?: React.ReactNode;
className?: string;
}
const presetAmounts = [
{ amount: 1, icon: Sparkle },
{ amount: 50, icon: Sparkles },
{ amount: 100, icon: Zap },
{ amount: 250, icon: Star },
{ amount: 1000, icon: Rocket },
];
interface ZapContentProps {
invoice: string | null;
amount: number | string;
comment: string;
isZapping: boolean;
qrCodeUrl: string;
copied: boolean;
webln: WebLNProvider | null;
handleZap: () => void;
handleCopy: () => void;
openInWallet: () => void;
setAmount: (amount: number | string) => void;
setComment: (comment: string) => void;
inputRef: React.RefObject<HTMLInputElement>;
zap: (amount: number, comment: string) => void;
paymentMethod?: 'lightning' | 'cashu';
setPaymentMethod?: (method: 'lightning' | 'cashu') => void;
cashuBalance?: number;
hasCashu?: boolean;
}
// Wallet Setup Screen - shown when user has no wallet connected
const WalletSetupContent = forwardRef<HTMLDivElement, {
onContinue: () => void;
}>(({ onContinue }, ref) => (
<div className="px-4 py-6 space-y-6" ref={ref}>
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<Wallet className="h-8 w-8 text-orange-400" />
</div>
<div>
<h3 className="font-bold text-lg mb-2">Connect Your Wallet First</h3>
<p className="text-sm text-muted-foreground">
To send zaps (Bitcoin tips), you need to connect a Lightning wallet.
</p>
</div>
</div>
<Card className="bg-blue-950/30 border-blue-500/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="text-2xl">💡</div>
<div className="flex-1">
<p className="text-sm font-medium mb-1">Quick Setup (30 seconds)</p>
<p className="text-xs text-muted-foreground">
Connect your existing Lightning wallet with Nostr Wallet Connect (NWC). No extra accounts needed.
</p>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-3">
<WalletModal>
<Button className="w-full gap-2 bg-orange-500 hover:bg-orange-600" size="lg">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
</Button>
</WalletModal>
<a
href="https://planb.academy/en/tutorials/wallet"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Button variant="outline" className="w-full gap-2" size="lg">
<ExternalLink className="h-4 w-4" />
Don't have a wallet? Learn how
</Button>
</a>
<Button
variant="ghost"
onClick={onContinue}
className="w-full text-muted-foreground hover:text-foreground"
size="sm"
>
Skip for now (manual invoice)
</Button>
</div>
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-gray-300">
<strong className="text-yellow-400">ℹ️ Note:</strong> You can still create an invoice without connecting a wallet, but you'll need to copy it manually to pay.
</p>
</CardContent>
</Card>
</div>
));
WalletSetupContent.displayName = 'WalletSetupContent';
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
webln,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
paymentMethod,
setPaymentMethod,
cashuBalance,
hasCashu,
}, ref) => (
<div ref={ref}>
{invoice ? (
<div className="flex flex-col h-full min-h-0">
{/* Payment amount display */}
<div className="text-center pt-4">
<div className="text-2xl font-bold">{amount} sats</div>
</div>
<Separator className="my-4" />
<div className="flex flex-col justify-center min-h-0 flex-1 px-2">
{/* QR Code */}
<div className="flex justify-center">
<Card className="p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto">
<CardContent className="p-0 flex justify-center">
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
className="w-full h-auto aspect-square max-w-full object-contain"
/>
) : (
<div className="w-full aspect-square bg-muted animate-pulse rounded" />
)}
</CardContent>
</Card>
</div>
{/* Invoice input */}
<div className="space-y-2 mt-4">
<Label htmlFor="invoice">Lightning Invoice</Label>
<div className="flex gap-2 min-w-0">
<Input
id="invoice"
value={invoice}
readOnly
className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Payment buttons */}
<div className="space-y-3 mt-4">
{webln && (
<Button
onClick={() => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
}}
disabled={isZapping}
className="w-full"
size="lg"
>
<Zap className="h-4 w-4 mr-2" />
{isZapping ? "Processing..." : "Pay with WebLN"}
</Button>
)}
<Button
variant="outline"
onClick={openInWallet}
className="w-full"
size="lg"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
</Button>
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
Scan the QR code or copy the invoice to pay with any Lightning wallet.
</div>
</div>
</div>
</div>
) : (
<>
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
{/* Cashu Balance Banner - Show prominently if user has Cashu funds */}
{hasCashu && cashuBalance !== undefined && cashuBalance > 0 && setPaymentMethod && (
<Card className={cn(
"border-2 transition-all cursor-pointer",
paymentMethod === 'cashu'
? "bg-orange-500/20 border-orange-500/50"
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/15"
)}
onClick={() => setPaymentMethod('cashu')}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<span className="text-xl">🥜</span>
</div>
<div>
<p className="text-sm font-bold text-orange-400">Cashu Wallet</p>
<p className="text-xs text-muted-foreground">Pay instantly with your built-in wallet</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-orange-400">{cashuBalance.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground uppercase">sats available</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Payment Method Selector - show if user has Cashu but might want Lightning too */}
{hasCashu && cashuBalance !== undefined && setPaymentMethod && (
<>
<div className="flex items-center gap-2">
<Button
type="button"
variant={paymentMethod === 'cashu' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPaymentMethod('cashu')}
className={cn(
"flex-1 gap-2 h-9",
paymentMethod === 'cashu' && "bg-orange-500 hover:bg-orange-600"
)}
>
<span>🥜</span>
<span className="text-xs">Cashu</span>
</Button>
<Button
type="button"
variant={paymentMethod === 'lightning' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPaymentMethod('lightning')}
className="flex-1 gap-2 h-9"
>
<Zap className="h-4 w-4" />
<span className="text-xs">Lightning</span>
</Button>
</div>
<Separator />
</>
)}
<ToggleGroup
type="single"
value={String(amount)}
onValueChange={(value) => {
if (value) {
setAmount(parseInt(value, 10));
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (
<ToggleGroupItem
key={presetAmount}
value={String(presetAmount)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<Icon className="h-4 w-4 mb-1" />
<span className="truncate">{presetAmount}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<Input
ref={inputRef}
id="custom-amount"
type="number"
placeholder="Custom amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-sm"
/>
<Textarea
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none text-sm"
rows={2}
/>
</div>
<div className="px-4 pb-4 space-y-2">
{/* Show insufficient balance warning for Cashu. Coerce amount to a
number first — the custom-amount input stores it as a string, so a
plain `typeof === 'number'` check would let an over-balance custom
amount slip past both this warning and the disabled state. */}
{(() => {
const numericAmount =
typeof amount === 'string' ? parseInt(amount, 10) : amount;
const overBalance =
paymentMethod === 'cashu' &&
cashuBalance !== undefined &&
Number.isFinite(numericAmount) &&
numericAmount > cashuBalance;
return overBalance ? (
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-yellow-200">
⚠️ Insufficient Cashu balance. You need {(numericAmount - cashuBalance).toLocaleString()} more sats.
Switch to Lightning or top up your Cashu wallet.
</p>
</CardContent>
</Card>
) : null;
})()}
<Button
onClick={handleZap}
className="w-full"
disabled={(() => {
if (isZapping) return true;
if (paymentMethod !== 'cashu' || cashuBalance === undefined) return false;
const numericAmount =
typeof amount === 'string' ? parseInt(amount, 10) : amount;
return Number.isFinite(numericAmount) && numericAmount > cashuBalance;
})()}
size="default"
>
{isZapping ? (
'Creating invoice...'
) : (
<>
{paymentMethod === 'cashu' ? '🥜' : <Zap className="h-4 w-4 mr-2" />}
Zap {amount} sats {paymentMethod === 'cashu' ? 'with Cashu' : ''}
</>
)}
</Button>
</div>
</>
)}
</div>
));
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { zap, generateZapInvoice, isZapping, setIsZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const [showWalletSetup, setShowWalletSetup] = useState(false);
const [hasShownSetup, setHasShownSetup] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<'lightning' | 'cashu'>('lightning');
const inputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile();
// Cashu wallet integration
const { totalBalance: cashuBalance, isInitialized: hasCashu, coco, mints } = useCashu();
// Check if user has any wallet connected
const hasWallet = !!webln || !!activeNWC;
const hasAnyCashuBalance = hasCashu && cashuBalance > 0;
useEffect(() => {
if (target) {
setComment('Zapped on SkateSpots! 🛹');
}
}, [target]);
// Pick the most useful default payment method when the dialog opens: if the
// user has Cashu funds but no connected Lightning wallet, default to Cashu so
// the one-click built-in payment is pre-selected instead of the manual-invoice
// Lightning fallback.
useEffect(() => {
if (!open) return;
if (hasAnyCashuBalance && !hasWallet) {
setPaymentMethod('cashu');
} else {
setPaymentMethod('lightning');
}
}, [open, hasAnyCashuBalance, hasWallet]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
const generateQR = async () => {
if (!invoice) {
setQrCodeUrl('');
return;
}
try {
const { default: QRCode } = await import('qrcode');
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
if (!isCancelled) {
setQrCodeUrl(url);
}
} catch (err) {
if (!isCancelled) {
console.error('Failed to generate QR code:', err);
}
}
};
generateQR();
return () => {
isCancelled = true;
};
}, [invoice]);
const handleCopy = async () => {
if (invoice) {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({
title: 'Invoice copied',
description: 'Lightning invoice copied to clipboard',
});
setTimeout(() => setCopied(false), 2000);
}
};
const openInWallet = () => {
if (invoice) {
const lightningUrl = `lightning:${invoice}`;
window.open(lightningUrl, '_blank');
}
};
useEffect(() => {
if (open) {
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
// Show wallet setup screen only if the user has NO way to pay yet — no
// connected Lightning wallet AND no Cashu balance. If they have Cashu
// funds, they can zap immediately, so skip the "connect a wallet" nag.
if (!hasWallet && !hasAnyCashuBalance && !hasShownSetup) {
setShowWalletSetup(true);
} else {
setShowWalletSetup(false);
}
} else {
// Clean up state when dialog closes
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
setShowWalletSetup(false);
}
}, [open, setInvoice, hasWallet, hasAnyCashuBalance, hasShownSetup]);
const handleZap = async () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
if (!Number.isFinite(finalAmount) || finalAmount <= 0) {
toast({
variant: 'destructive',
title: 'Enter an amount',
description: 'Choose a preset or type a valid number of sats.',
});
return;
}
// ── Cashu (built-in wallet) path ──
// Pay in ONE click: fetch the recipient's zap invoice for exactly this
// amount, then melt it from the Cashu wallet. Previously this branch only
// ran when an `invoice` already existed, so the first click silently fell
// through to the Lightning flow and Cashu funds were never used.
if (paymentMethod === 'cashu') {
if (!hasCashu || !coco) {
toast({
variant: 'destructive',
title: 'Cashu wallet unavailable',
description: 'Your Cashu wallet is not ready. Switch to Lightning or open your wallet first.',
});
return;
}
if (finalAmount > cashuBalance) {
toast({
variant: 'destructive',
title: 'Insufficient Cashu balance',
description: `You need ${(finalAmount - cashuBalance).toLocaleString()} more sats. Switch to Lightning or top up.`,
});
return;
}
setIsZapping(true);
try {
const activeMint = mints.find((m) => m.url);
if (!activeMint) throw new Error('No active Cashu mint found.');
// Same verified invoice the Lightning rail would use (amount-checked).
const zapInvoice = await generateZapInvoice(finalAmount, comment);
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, zapInvoice);
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
toast({
title: 'Zap sent! 🥜',
description: `Zapped ${finalAmount.toLocaleString()} sats from your Cashu wallet.`,
});
setInvoice(null);
setOpen(false);
} catch (error) {
console.error('Cashu payment error:', error);
toast({
variant: 'destructive',
title: 'Cashu payment failed',
description: error instanceof Error ? error.message : 'Failed to pay with your Cashu wallet.',
});
} finally {
setIsZapping(false);
}
return;
}
// ── Lightning / NWC / WebLN path ──
zap(finalAmount, comment);
};
const handleContinueWithoutWallet = () => {
setShowWalletSetup(false);
setHasShownSetup(true);
};
const contentProps = {
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
webln,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
paymentMethod,
setPaymentMethod,
cashuBalance,
hasCashu,
};
if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) {
return null;
}
if (isMobile) {
// Use drawer for entire mobile flow, make it full-screen when showing invoice
return (
<Drawer
open={open}
onOpenChange={(newOpen) => {
// Reset invoice when closing
if (!newOpen) {
setInvoice(null);
setQrCodeUrl('');
}
setOpen(newOpen);
}}
dismissible={true} // Always allow dismissal via drag
snapPoints={invoice ? [0.5, 0.75, 0.98] : [0.98]}
activeSnapPoint={invoice ? 0.98 : 0.98}
modal={true}
shouldScaleBackground={false}
fadeFromIndex={0}
>
<DrawerTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DrawerTrigger>
<DrawerContent
key={invoice ? 'payment' : 'form'}
className={cn(
"transition-all duration-300",
invoice ? "h-full max-h-screen" : "max-h-[98vh]"
)}
data-testid="zap-modal"
>
<DrawerHeader className="text-center relative">
{/* Back button when showing invoice */}
{invoice && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setInvoice(null);
setQrCodeUrl('');
}}
className="absolute left-4 top-4 flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{/* Close button */}
<DrawerClose asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DrawerClose>
<DrawerTitle className="text-lg break-words pt-2">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
</DrawerTitle>
<DrawerDescription className="text-sm break-words text-center">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
) : invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
)}
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
{showWalletSetup ? (
<WalletSetupContent onContinue={handleContinueWithoutWallet} />
) : (
<ZapContent {...contentProps} />
)}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<div className={`cursor-pointer ${className || ''}`}>
{children}
</div>
</DialogTrigger>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg break-words">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-center break-words">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
) : invoice ? (
'Pay with Bitcoin Lightning Network'
) : (
<>
Zaps are small Bitcoin payments that support creators. Consider sending a zap!
</>
)}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto -mx-4 sm:-mx-6 px-4 sm:px-6">
{showWalletSetup ? (
<WalletSetupContent onContinue={handleContinueWithoutWallet} />
) : (
<ZapContent {...contentProps} />
)}
</div>
</DialogContent>
</Dialog>
);
}