ZapDialog.tsx
1 import { useState, useEffect, useRef, forwardRef } from 'react'; 2 import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X } from 'lucide-react'; 3 import { Button } from '@/components/ui/button'; 4 import { cn } from '@/lib/utils'; 5 import { 6 Dialog, 7 DialogContent, 8 DialogDescription, 9 DialogHeader, 10 DialogTitle, 11 DialogTrigger, 12 } from '@/components/ui/dialog'; 13 import { 14 Drawer, 15 DrawerContent, 16 DrawerDescription, 17 DrawerHeader, 18 DrawerTitle, 19 DrawerTrigger, 20 DrawerClose, 21 } from '@/components/ui/drawer'; 22 import { Input } from '@/components/ui/input'; 23 import { Label } from '@/components/ui/label'; 24 import { Textarea } from '@/components/ui/textarea'; 25 import { Card, CardContent } from '@/components/ui/card'; 26 import { Separator } from '@/components/ui/separator'; 27 import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; 28 import { useCurrentUser } from '@/hooks/useCurrentUser'; 29 import { useAuthor } from '@/hooks/useAuthor'; 30 import { useToast } from '@/hooks/useToast'; 31 import { useZaps } from '@/hooks/useZaps'; 32 import { useWallet } from '@/hooks/useWallet'; 33 import { useIsMobile } from '@/hooks/useIsMobile'; 34 import type { Event } from 'nostr-tools'; 35 import QRCode from 'qrcode'; 36 import type { WebLNProvider } from "@webbtc/webln-types"; 37 38 interface ZapDialogProps { 39 target: Event; 40 children?: React.ReactNode; 41 className?: string; 42 } 43 44 const presetAmounts = [ 45 { amount: 1, icon: Sparkle }, 46 { amount: 50, icon: Sparkles }, 47 { amount: 100, icon: Zap }, 48 { amount: 250, icon: Star }, 49 { amount: 1000, icon: Rocket }, 50 ]; 51 52 interface ZapContentProps { 53 invoice: string | null; 54 amount: number | string; 55 comment: string; 56 isZapping: boolean; 57 qrCodeUrl: string; 58 copied: boolean; 59 webln: WebLNProvider | null; 60 handleZap: () => void; 61 handleCopy: () => void; 62 openInWallet: () => void; 63 setAmount: (amount: number | string) => void; 64 setComment: (comment: string) => void; 65 inputRef: React.RefObject<HTMLInputElement>; 66 zap: (amount: number, comment: string) => void; 67 } 68 69 // Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss 70 const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({ 71 invoice, 72 amount, 73 comment, 74 isZapping, 75 qrCodeUrl, 76 copied, 77 webln, 78 handleZap, 79 handleCopy, 80 openInWallet, 81 setAmount, 82 setComment, 83 inputRef, 84 zap, 85 }, ref) => ( 86 <div ref={ref}> 87 {invoice ? ( 88 <div className="flex flex-col h-full min-h-0"> 89 {/* Payment amount display */} 90 <div className="text-center pt-4"> 91 <div className="text-2xl font-bold">{amount} sats</div> 92 </div> 93 94 <Separator className="my-4" /> 95 96 <div className="flex flex-col justify-center min-h-0 flex-1 px-2"> 97 {/* QR Code */} 98 <div className="flex justify-center"> 99 <Card className="p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto"> 100 <CardContent className="p-0 flex justify-center"> 101 {qrCodeUrl ? ( 102 <img 103 src={qrCodeUrl} 104 alt="Lightning Invoice QR Code" 105 className="w-full h-auto aspect-square max-w-full object-contain" 106 /> 107 ) : ( 108 <div className="w-full aspect-square bg-muted animate-pulse rounded" /> 109 )} 110 </CardContent> 111 </Card> 112 </div> 113 114 {/* Invoice input */} 115 <div className="space-y-2 mt-4"> 116 <Label htmlFor="invoice">Lightning Invoice</Label> 117 <div className="flex gap-2 min-w-0"> 118 <Input 119 id="invoice" 120 value={invoice} 121 readOnly 122 className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis" 123 onClick={(e) => e.currentTarget.select()} 124 /> 125 <Button 126 variant="outline" 127 size="icon" 128 onClick={handleCopy} 129 className="shrink-0" 130 > 131 {copied ? ( 132 <Check className="h-4 w-4 text-green-600" /> 133 ) : ( 134 <Copy className="h-4 w-4" /> 135 )} 136 </Button> 137 </div> 138 </div> 139 140 {/* Payment buttons */} 141 <div className="space-y-3 mt-4"> 142 {webln && ( 143 <Button 144 onClick={() => { 145 const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount; 146 zap(finalAmount, comment); 147 }} 148 disabled={isZapping} 149 className="w-full" 150 size="lg" 151 > 152 <Zap className="h-4 w-4 mr-2" /> 153 {isZapping ? "Processing..." : "Pay with WebLN"} 154 </Button> 155 )} 156 157 <Button 158 variant="outline" 159 onClick={openInWallet} 160 className="w-full" 161 size="lg" 162 > 163 <ExternalLink className="h-4 w-4 mr-2" /> 164 Open in Lightning Wallet 165 </Button> 166 167 <div className="text-xs sm:text-[.65rem] text-muted-foreground text-center"> 168 Scan the QR code or copy the invoice to pay with any Lightning wallet. 169 </div> 170 </div> 171 </div> 172 </div> 173 ) : ( 174 <> 175 <div className="grid gap-3 px-4 py-4 w-full overflow-hidden"> 176 <ToggleGroup 177 type="single" 178 value={String(amount)} 179 onValueChange={(value) => { 180 if (value) { 181 setAmount(parseInt(value, 10)); 182 } 183 }} 184 className="grid grid-cols-5 gap-1 w-full" 185 > 186 {presetAmounts.map(({ amount: presetAmount, icon: Icon }) => ( 187 <ToggleGroupItem 188 key={presetAmount} 189 value={String(presetAmount)} 190 className="flex flex-col h-auto min-w-0 text-xs px-1 py-2" 191 > 192 <Icon className="h-4 w-4 mb-1" /> 193 <span className="truncate">{presetAmount}</span> 194 </ToggleGroupItem> 195 ))} 196 </ToggleGroup> 197 <div className="flex items-center gap-2"> 198 <div className="h-px flex-1 bg-muted" /> 199 <span className="text-xs text-muted-foreground">OR</span> 200 <div className="h-px flex-1 bg-muted" /> 201 </div> 202 <Input 203 ref={inputRef} 204 id="custom-amount" 205 type="number" 206 placeholder="Custom amount" 207 value={amount} 208 onChange={(e) => setAmount(e.target.value)} 209 className="w-full text-sm" 210 /> 211 <Textarea 212 id="custom-comment" 213 placeholder="Add a comment (optional)" 214 value={comment} 215 onChange={(e) => setComment(e.target.value)} 216 className="w-full resize-none text-sm" 217 rows={2} 218 /> 219 </div> 220 <div className="px-4 pb-4"> 221 <Button onClick={handleZap} className="w-full" disabled={isZapping} size="default"> 222 {isZapping ? ( 223 'Creating invoice...' 224 ) : ( 225 <> 226 <Zap className="h-4 w-4 mr-2" /> 227 Zap {amount} sats 228 </> 229 )} 230 </Button> 231 </div> 232 </> 233 )} 234 </div> 235 )); 236 ZapContent.displayName = 'ZapContent'; 237 238 export function ZapDialog({ target, children, className }: ZapDialogProps) { 239 const [open, setOpen] = useState(false); 240 const { user } = useCurrentUser(); 241 const { data: author } = useAuthor(target.pubkey); 242 const { toast } = useToast(); 243 const { webln, activeNWC } = useWallet(); 244 const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false)); 245 const [amount, setAmount] = useState<number | string>(100); 246 const [comment, setComment] = useState<string>(''); 247 const [copied, setCopied] = useState(false); 248 const [qrCodeUrl, setQrCodeUrl] = useState<string>(''); 249 const inputRef = useRef<HTMLInputElement>(null); 250 const isMobile = useIsMobile(); 251 252 useEffect(() => { 253 if (target) { 254 setComment('Zapped with MKStack!'); 255 } 256 }, [target]); 257 258 // Generate QR code 259 useEffect(() => { 260 let isCancelled = false; 261 262 const generateQR = async () => { 263 if (!invoice) { 264 setQrCodeUrl(''); 265 return; 266 } 267 268 try { 269 const url = await QRCode.toDataURL(invoice.toUpperCase(), { 270 width: 512, 271 margin: 2, 272 color: { 273 dark: '#000000', 274 light: '#FFFFFF', 275 }, 276 }); 277 278 if (!isCancelled) { 279 setQrCodeUrl(url); 280 } 281 } catch (err) { 282 if (!isCancelled) { 283 console.error('Failed to generate QR code:', err); 284 } 285 } 286 }; 287 288 generateQR(); 289 290 return () => { 291 isCancelled = true; 292 }; 293 }, [invoice]); 294 295 const handleCopy = async () => { 296 if (invoice) { 297 await navigator.clipboard.writeText(invoice); 298 setCopied(true); 299 toast({ 300 title: 'Invoice copied', 301 description: 'Lightning invoice copied to clipboard', 302 }); 303 setTimeout(() => setCopied(false), 2000); 304 } 305 }; 306 307 const openInWallet = () => { 308 if (invoice) { 309 const lightningUrl = `lightning:${invoice}`; 310 window.open(lightningUrl, '_blank'); 311 } 312 }; 313 314 useEffect(() => { 315 if (open) { 316 setAmount(100); 317 setInvoice(null); 318 setCopied(false); 319 setQrCodeUrl(''); 320 } else { 321 // Clean up state when dialog closes 322 setAmount(100); 323 setInvoice(null); 324 setCopied(false); 325 setQrCodeUrl(''); 326 } 327 }, [open, setInvoice]); 328 329 const handleZap = () => { 330 const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount; 331 zap(finalAmount, comment); 332 }; 333 334 const contentProps = { 335 invoice, 336 amount, 337 comment, 338 isZapping, 339 qrCodeUrl, 340 copied, 341 webln, 342 handleZap, 343 handleCopy, 344 openInWallet, 345 setAmount, 346 setComment, 347 inputRef, 348 zap, 349 }; 350 351 if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) { 352 return null; 353 } 354 355 if (isMobile) { 356 // Use drawer for entire mobile flow, make it full-screen when showing invoice 357 return ( 358 <Drawer 359 open={open} 360 onOpenChange={(newOpen) => { 361 // Reset invoice when closing 362 if (!newOpen) { 363 setInvoice(null); 364 setQrCodeUrl(''); 365 } 366 setOpen(newOpen); 367 }} 368 dismissible={true} // Always allow dismissal via drag 369 snapPoints={invoice ? [0.5, 0.75, 0.98] : [0.98]} 370 activeSnapPoint={invoice ? 0.98 : 0.98} 371 modal={true} 372 shouldScaleBackground={false} 373 fadeFromIndex={0} 374 > 375 <DrawerTrigger asChild> 376 <div className={`cursor-pointer ${className || ''}`}> 377 {children} 378 </div> 379 </DrawerTrigger> 380 <DrawerContent 381 key={invoice ? 'payment' : 'form'} 382 className={cn( 383 "transition-all duration-300", 384 invoice ? "h-full max-h-screen" : "max-h-[98vh]" 385 )} 386 data-testid="zap-modal" 387 > 388 <DrawerHeader className="text-center relative"> 389 {/* Back button when showing invoice */} 390 {invoice && ( 391 <Button 392 variant="ghost" 393 size="sm" 394 onClick={() => { 395 setInvoice(null); 396 setQrCodeUrl(''); 397 }} 398 className="absolute left-4 top-4 flex items-center gap-2" 399 > 400 <ArrowLeft className="h-4 w-4" /> 401 </Button> 402 )} 403 404 {/* Close button */} 405 <DrawerClose asChild> 406 <Button 407 variant="ghost" 408 size="sm" 409 className="absolute right-4 top-4" 410 > 411 <X className="h-4 w-4" /> 412 <span className="sr-only">Close</span> 413 </Button> 414 </DrawerClose> 415 416 <DrawerTitle className="text-lg break-words pt-2"> 417 {invoice ? 'Lightning Payment' : 'Send a Zap'} 418 </DrawerTitle> 419 <DrawerDescription className="text-sm break-words text-center"> 420 {invoice ? ( 421 'Pay with Bitcoin Lightning Network' 422 ) : ( 423 'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!' 424 )} 425 </DrawerDescription> 426 </DrawerHeader> 427 <div className="flex-1 overflow-y-auto px-4 pb-4"> 428 <ZapContent {...contentProps} /> 429 </div> 430 </DrawerContent> 431 </Drawer> 432 ); 433 } 434 435 return ( 436 <Dialog open={open} onOpenChange={setOpen}> 437 <DialogTrigger asChild> 438 <div className={`cursor-pointer ${className || ''}`}> 439 {children} 440 </div> 441 </DialogTrigger> 442 <DialogContent className="sm:max-w-[425px] max-h-[95vh] overflow-hidden" data-testid="zap-modal"> 443 <DialogHeader> 444 <DialogTitle className="text-lg break-words"> 445 {invoice ? 'Lightning Payment' : 'Send a Zap'} 446 </DialogTitle> 447 <DialogDescription className="text-sm text-center break-words"> 448 {invoice ? ( 449 'Pay with Bitcoin Lightning Network' 450 ) : ( 451 <> 452 Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap! 453 </> 454 )} 455 </DialogDescription> 456 </DialogHeader> 457 <div className="overflow-y-auto"> 458 <ZapContent {...contentProps} /> 459 </div> 460 </DialogContent> 461 </Dialog> 462 ); 463 }