SkateSpots/src/components/ZapDialog.tsx
shakespeare.diy ed27f719fa Sasquatch: teach him all the new features + fix zap comment
- Expand AI system prompt with full feature map: Lab (coaches/filmers/
  photographers/editors/builders + individual vs collective), Crews (map-anchored,
  how they differ from the Lab), Stash, Tours, Shops, Gear Swap, Rider Card
- Document money layer interconnections: wallet rails (Cashu/NWC/WebLN), zap
  fallback order, nutzaps (NIP-61, receiving), CLINK/noffer, Geyser supporters
- Instruct Sasquatch that Crews/Stash/Wallet/Tours/Shops have no live search tool
  and must be answered from knowledge (prevents bogus tool calls)
- Add ~15 new STATIC_FAQ offline answers (crews, stash, builder, nutzap, clink,
  noffer, tours, shops, sell gear, supporters)
- Add Crews page guide context + crew jokes; refresh Lab suggestions
- Fix leftover "Zapped with Engorile!" default comment → SkateSpots branding

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 14:12:30 +07:00

701 lines
23 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 { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
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 */}
{paymentMethod === 'cashu' && cashuBalance !== undefined && (
typeof amount === 'number' && amount > cashuBalance ? (
<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 {(amount - cashuBalance).toLocaleString()} more sats.
Switch to Lightning or top up your Cashu wallet.
</p>
</CardContent>
</Card>
) : null
)}
<Button
onClick={handleZap}
className="w-full"
disabled={
isZapping ||
(paymentMethod === 'cashu' && typeof amount === 'number' && cashuBalance !== undefined && amount > 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, isZapping, 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]);
// 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 if no wallet connected and haven't shown it yet
if (!hasWallet && !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, hasShownSetup]);
const handleZap = async () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
// If Cashu payment method is selected and user has invoice to pay
if (paymentMethod === 'cashu' && hasCashu && coco && invoice) {
try {
// Get first active mint
const activeMint = mints.find(m => m.url);
if (!activeMint) {
throw new Error('No active mint found');
}
// Pay the Lightning invoice using Cashu (create and pay melt quote)
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, invoice);
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
toast({
title: 'Zap sent!',
description: `Successfully zapped ${finalAmount} sats from your Cashu wallet`,
});
setOpen(false);
setInvoice(null);
} catch (error) {
console.error('Cashu payment error:', error);
toast({
variant: 'destructive',
title: 'Payment failed',
description: error instanceof Error ? error.message : 'Failed to pay with Cashu wallet',
});
}
} else {
// Use regular Lightning/NWC zap flow
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>
);
}