/ src / components / ZapDialog.tsx
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  }