Files
lumina/components/ZapButton.tsx

782 lines
32 KiB
TypeScript
Raw Permalink 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.
import {
finalizeEvent,
nip19,
} from "nostr-tools";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { ReloadIcon, CheckCircledIcon } from "@radix-ui/react-icons";
import ZapButtonList from "./ZapButtonList";
import { Input } from "./ui/input";
import { useNostr, useNostrEvents, useProfile } from "nostr-react";
import { useEffect, useState, useRef } from "react";
import QRCode from "react-qr-code";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { signEvent } from "@/utils/utils";
import { nwc } from "@getalby/sdk";
import { Sparkles, Zap } from "lucide-react";
// NWC connection storage key (same as used in NostrWalletConnect.tsx)
const NWC_STORAGE_KEY = "lumina-nwc-connection";
export default function ZapButton({ event }: { event: any }) {
let loginType = '';
if (typeof window !== 'undefined') {
loginType = window.localStorage.getItem("loginType") ?? '';
}
const { connectedRelays } = useNostr();
const { events, isLoading } = useNostrEvents({
filter: {
'#e': [event.id],
kinds: [9735],
},
});
const [lnurlPayInfo, setLnurlPayInfo] = useState<any>(null);
const [invoice, setInvoice] = useState<string>("");
const [customAmount, setCustomAmount] = useState<string>("21");
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
const [paymentComplete, setPaymentComplete] = useState<boolean>(false);
const { publish } = useNostr();
// NWC state
const [useNwc, setUseNwc] = useState<boolean>(false);
const [nwcClient, setNwcClient] = useState<nwc.NWCClient | null>(null);
const [nwcPaymentStatus, setNwcPaymentStatus] = useState<string>("");
const [paymentPreimage, setPaymentPreimage] = useState<string>("");
// Store the initial count of zap receipts when an invoice is generated
const invoiceEventsCountRef = useRef<number>(0);
// Effect to check for new zap receipts when showing an invoice
useEffect(() => {
if (invoice) {
// Store the current count of zap receipts when invoice is generated
invoiceEventsCountRef.current = events.length;
setPaymentComplete(false);
}
}, [invoice]);
// Effect to detect new zap receipts after invoice is shown
useEffect(() => {
if (invoice && events.length > invoiceEventsCountRef.current) {
// Filter events to find new zap receipts related to current invoice
const newEvents = events.slice(invoiceEventsCountRef.current);
// Check if any new events contain the current invoice
const relevantEvents = newEvents.filter(zapEvent => {
// Look for bolt11 tag containing the invoice
return zapEvent.tags.some(tag =>
tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50))
);
});
if (relevantEvents.length > 0) {
setPaymentComplete(true);
}
}
}, [events, invoice]);
// Initialize NWC client on first render
useEffect(() => {
initializeNwc();
}, []);
// Initialize NWC client from localStorage
const initializeNwc = async () => {
if (typeof window === 'undefined') return;
const connectionUrl = localStorage.getItem(NWC_STORAGE_KEY);
if (!connectionUrl) return;
try {
const client = new nwc.NWCClient({
nostrWalletConnectUrl: connectionUrl,
});
// Test the connection by getting wallet info
await client.getInfo();
setNwcClient(client);
setUseNwc(true);
} catch (error) {
console.error("Failed to initialize NWC client:", error);
// Don't show error to user here as this is just an initialization check
}
};
const { data: userData } = useProfile({
pubkey: event.pubkey,
});
let sats = 0;
var lightningPayReq = require('bolt11');
events.forEach((event) => {
event.tags.forEach((tag) => {
if (tag[0] === 'bolt11') {
try {
lightningPayReq.decode(tag[1]);
let decoded = lightningPayReq.decode(tag[1]);
sats = sats + decoded.satoshis;
} catch (e) {
console.error("Error decoding bolt11 tag:", e);
console.log(tag[1]);
return null;
}
}
});
});
const fetchLnurlInfo = async () => {
setErrorMessage("");
if (!userData?.lud06 && !userData?.lud16) {
setErrorMessage("This user doesn't have a Lightning address configured");
return;
}
try {
setIsProcessing(true);
let lnurl;
if (userData.lud06) {
lnurl = userData.lud06;
} else if (userData.lud16) {
const [name, domain] = userData.lud16.split('@');
if (!name || !domain) {
throw new Error("Invalid lightning address format");
}
const url = `https://${domain}/.well-known/lnurlp/${name}`;
lnurl = url;
} else {
throw new Error("No lightning address found in profile");
}
if (lnurl.toLowerCase().startsWith('lnurl')) {
try {
lnurl = bech32ToUrl(lnurl);
} catch (e) {
console.error("Error decoding LNURL:", e);
throw new Error("Invalid LNURL format");
}
}
try {
// Add a timeout to the fetch request
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch(lnurl, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
}
});
clearTimeout(timeoutId);
// Check content type to ensure it's JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
console.error("Invalid content type:", contentType);
throw new Error("Lightning service returned invalid content type (expected JSON)");
}
// Parse response as JSON
const data = await response.json();
if (!response.ok) {
throw new Error(`Error fetching LNURL info: ${data.reason || response.statusText || 'Unknown error'}`);
}
if (!data.callback) {
throw new Error("Invalid LNURL response: missing callback URL");
}
if (!data.allowsNostr) {
setErrorMessage("This Lightning address doesn't support Nostr zaps");
setIsProcessing(false);
return;
}
setLnurlPayInfo(data);
} catch (error) {
if (typeof error === "object" && error !== null && "name" in error && typeof (error as any).name === "string") {
if ((error as any).name === 'AbortError') {
throw new Error("Request timed out - Lightning service not responding");
} else if ((error as any).name === 'SyntaxError') {
throw new Error("Lightning service returned invalid data format");
}
}
if (typeof error === "object" && error !== null && "message" in error && typeof (error as any).message === "string" && (error as any).message.includes('NetworkError')) {
throw new Error("Network error: CORS issue or service unavailable");
} else {
throw error;
}
}
} catch (error) {
console.error("Error fetching LNURL info:", error);
setErrorMessage(error instanceof Error ? error.message : "Failed to fetch Lightning payment information");
} finally {
setIsProcessing(false);
}
};
const createZapRequest = async (amount: number) => {
if (!lnurlPayInfo || !lnurlPayInfo.callback || !lnurlPayInfo.allowsNostr || !lnurlPayInfo.nostrPubkey) {
setErrorMessage("Invalid Lightning payment information");
return;
}
if (amount < lnurlPayInfo.minSendable || amount > lnurlPayInfo.maxSendable) {
setErrorMessage(`Amount must be between ${lnurlPayInfo.minSendable / 1000} and ${lnurlPayInfo.maxSendable / 1000} sats`);
return;
}
try {
setIsProcessing(true);
setErrorMessage("");
let senderPubkey = '';
if (typeof window !== 'undefined') {
senderPubkey = window.localStorage.getItem('pubkey') ?? '';
}
if (!senderPubkey) {
setErrorMessage("You need to be logged in to send zaps");
setIsProcessing(false);
return;
}
let zapRequestEvent = {
kind: 9734,
content: "",
tags: [
["relays", ...connectedRelays.map((relay) => relay.url)],
["amount", amount.toString()],
["p", event.pubkey],
["e", event.id],
],
created_at: Math.floor(Date.now() / 1000),
pubkey: senderPubkey,
id: "", // Add placeholder for id
sig: "", // Add placeholder for sig
};
let params = new URLSearchParams();
params.append('amount', amount.toString());
if (userData?.lud06) {
zapRequestEvent.tags.push(["lnurl", userData.lud06]);
}
const signedZapRequest = await signEvent(loginType, zapRequestEvent);
params.append('nostr', JSON.stringify(signedZapRequest));
if (userData?.lud06) {
params.append('lnurl', userData.lud06);
}
let callbackUrl = `${lnurlPayInfo.callback}?${params.toString()}`;
const invoiceResponse = await fetch(callbackUrl);
const invoiceData = await invoiceResponse.json();
if (!invoiceResponse.ok || !invoiceData.pr) {
throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`);
}
setInvoice(invoiceData.pr);
} catch (error) {
console.error("Error creating zap request:", error);
setErrorMessage(error instanceof Error ? error.message : "Failed to create zap request");
} finally {
setIsProcessing(false);
}
};
// Handle sending a zap using NWC
const handleNwcPayment = async (amountSats: number) => {
if (!nwcClient) {
setErrorMessage("NWC client not initialized");
return;
}
if (!userData?.lud16 && !userData?.lud06) {
setErrorMessage("This user doesn't have a Lightning address configured");
return;
}
setIsProcessing(true);
setNwcPaymentStatus("Preparing payment...");
setPaymentComplete(false);
setErrorMessage("");
try {
let senderPubkey = '';
if (typeof window !== 'undefined') {
senderPubkey = window.localStorage.getItem('pubkey') ?? '';
}
if (!senderPubkey) {
setErrorMessage("You need to be logged in to send zaps");
return;
}
// Create zap request event
let zapRequestEvent = {
kind: 9734,
content: "",
tags: [
["relays", ...connectedRelays.map((relay) => relay.url)],
["amount", (amountSats * 1000).toString()],
["p", event.pubkey],
["e", event.id],
],
created_at: Math.floor(Date.now() / 1000),
pubkey: senderPubkey,
id: "",
sig: "",
};
// Sign the zap request
setNwcPaymentStatus("Signing zap request...");
const signedZapRequest = await signEvent(loginType, zapRequestEvent);
// Get LNURL payment info for the Lightning address
let recipient = '';
if (userData?.lud16) {
recipient = userData.lud16;
} else if (userData?.lud06) {
recipient = userData.lud06;
}
if (!recipient) {
throw new Error("Could not determine recipient lightning address");
}
setNwcPaymentStatus("Getting invoice from recipient...");
// Get LNURL payment info and invoice
let lnurl;
let invoice;
if (recipient.includes('@')) {
// Handle Lightning Address (lud16)
const [name, domain] = recipient.split('@');
const url = `https://${domain}/.well-known/lnurlp/${name}`;
// Get LNURL callback info
const lnurlResponse = await fetch(url);
const lnurlData = await lnurlResponse.json();
if (!lnurlData.callback || !lnurlData.allowsNostr) {
throw new Error("Lightning address doesn't support Nostr zaps");
}
// Get invoice with zap request
const params = new URLSearchParams();
params.append('amount', (amountSats * 1000).toString());
params.append('nostr', JSON.stringify(signedZapRequest));
const callbackUrl = `${lnurlData.callback}?${params.toString()}`;
const invoiceResponse = await fetch(callbackUrl);
const invoiceData = await invoiceResponse.json();
if (!invoiceResponse.ok || !invoiceData.pr) {
throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`);
}
invoice = invoiceData.pr;
} else {
// Handle LNURL directly (lud06)
// Decode if needed
if (recipient.toLowerCase().startsWith('lnurl')) {
try {
recipient = bech32ToUrl(recipient);
} catch (e) {
throw new Error("Invalid LNURL format");
}
}
// Get LNURL callback info
const lnurlResponse = await fetch(recipient);
const lnurlData = await lnurlResponse.json();
if (!lnurlData.callback || !lnurlData.allowsNostr) {
throw new Error("LNURL doesn't support Nostr zaps");
}
// Get invoice with zap request
const params = new URLSearchParams();
params.append('amount', (amountSats * 1000).toString());
params.append('nostr', JSON.stringify(signedZapRequest));
const callbackUrl = `${lnurlData.callback}?${params.toString()}`;
const invoiceResponse = await fetch(callbackUrl);
const invoiceData = await invoiceResponse.json();
if (!invoiceResponse.ok || !invoiceData.pr) {
throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`);
}
invoice = invoiceData.pr;
}
// Now pay the invoice using NWC
setNwcPaymentStatus("Sending payment via NWC...");
const paymentResponse = await nwcClient.payInvoice({
invoice: invoice
});
if (paymentResponse.preimage) {
setPaymentPreimage(paymentResponse.preimage);
setPaymentComplete(true);
setNwcPaymentStatus("Payment sent successfully!");
} else {
throw new Error("Payment failed - no preimage returned");
}
} catch (error) {
console.error("NWC payment error:", error);
setNwcPaymentStatus("");
setErrorMessage(error instanceof Error ? error.message : "Payment failed");
} finally {
setIsProcessing(false);
}
};
const handleZap = async (amountSats: number) => {
if (useNwc && nwcClient) {
await handleNwcPayment(amountSats);
} else {
if (!lnurlPayInfo) {
await fetchLnurlInfo();
}
createZapRequest(amountSats * 1000);
}
};
const handleCustomZap = async () => {
const amount = parseInt(customAmount, 10);
if (isNaN(amount) || amount <= 0) {
setErrorMessage("Please enter a valid amount");
return;
}
if (useNwc && nwcClient) {
await handleNwcPayment(amount);
} else {
if (!lnurlPayInfo) {
await fetchLnurlInfo();
}
createZapRequest(amount * 1000);
}
};
const bech32ToUrl = (lnurl: string): string => {
try {
const decoded = nip19.decode(lnurl);
return decoded.data as string;
} catch (e) {
return lnurl;
}
};
const handleOpenDrawer = () => {
if (!useNwc) {
fetchLnurlInfo();
}
};
const handleCloseDrawer = () => {
setInvoice("");
setErrorMessage("");
setIsProcessing(false);
setPaymentComplete(false);
setNwcPaymentStatus("");
};
const checkPaymentStatus = async () => {
setIsProcessing(true);
try {
// Force a re-fetch of zap receipt events
const eventFilter = {
'#e': [event.id],
kinds: [9735],
};
// Manually check relays for new zap events
const zapPromises = connectedRelays.map(async (relay) => {
return new Promise(async (resolve) => {
const timeout = setTimeout(() => resolve([]), 3000); // 3 second timeout
try {
const sub = relay.sub([eventFilter]);
const events: any[] = [];
sub.on('event', (event) => {
// Check if this event contains the current invoice
const hasBolt11 = event.tags.some(tag =>
tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50))
);
if (hasBolt11) {
events.push(event);
}
});
sub.on('eose', () => {
clearTimeout(timeout);
resolve(events);
sub.unsub();
});
} catch (error) {
clearTimeout(timeout);
resolve([]);
}
});
});
const zapEventsArrays = await Promise.all(zapPromises);
const newZapEvents = zapEventsArrays.flat();
if (newZapEvents.length > 0) {
setPaymentComplete(true);
}
} catch (error) {
console.error("Error checking payment status:", error);
} finally {
setIsProcessing(false);
}
};
return (
<Drawer onOpenChange={(open) => open ? handleOpenDrawer() : handleCloseDrawer()}>
<DrawerTrigger asChild>
{isLoading ? (
<Button variant="outline"><ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> </Button>
) : (
<Button variant="outline">{sats} sats </Button>
)}
</DrawerTrigger>
<DrawerContent>
{errorMessage && (
<Alert variant={"destructive"} className="mx-4 mt-4">
{/* <AlertCircleIcon className="h-4 w-4" /> */}
<AlertTitle>Error</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{/* NWC Payment Mode */}
{useNwc && !invoice && (
<div className="px-4 pt-4">
{!paymentComplete && (
<Alert variant="default" className="mb-4 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
<Sparkles className="h-4 w-4" />
<AlertTitle>NWC Enabled</AlertTitle>
<AlertDescription>Payments will be sent directly through your connected NWC wallet</AlertDescription>
</Alert>
)}
{nwcPaymentStatus && !paymentComplete && (
<Alert variant="default" className="mb-4 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
{isProcessing ? (
<ReloadIcon className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
<AlertDescription>{nwcPaymentStatus}</AlertDescription>
</Alert>
)}
{paymentComplete && (
<div className="flex flex-col items-center mb-6">
<div className="flex flex-col items-center justify-center mb-4 p-4 rounded-lg bg-green-50 dark:bg-green-900/20 w-[200px] h-[200px]">
<CheckCircledIcon className="h-24 w-24 text-green-500" />
<p className="text-lg font-semibold text-green-500 mt-4 text-center">
Payment Complete!
</p>
</div>
{/* {paymentPreimage && (
<div className="w-full overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs mb-4">
<p className="text-xs text-center mb-1">Payment Preimage:</p>
<p className="break-all text-center">{paymentPreimage}</p>
</div>
)} */}
<div className="flex items-center text-green-500 mb-4">
<CheckCircledIcon className="mr-2 h-4 w-4" />
Zap sent successfully!
</div>
<Button variant="outline" onClick={() => {
setPaymentComplete(false);
setNwcPaymentStatus("");
setPaymentPreimage("");
}}>
Send Another Zap
</Button>
</div>
)}
{(!paymentComplete || !isProcessing) && !paymentComplete && (
<div className="grid grid-cols-3 gap-2 mb-6">
<Button
variant={"outline"}
className="mx-1"
onClick={() => handleZap(1)}
disabled={isProcessing}
>
1 sat
</Button>
<Button
variant={"outline"}
className="mx-1"
onClick={() => handleZap(21)}
disabled={isProcessing}
>
21 sats
</Button>
<div className="flex">
<Input
className="mx-1"
placeholder="21"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
disabled={isProcessing}
/>
<Button
variant={"outline"}
className="mx-1"
onClick={handleCustomZap}
disabled={isProcessing}
>
{isProcessing ? <ReloadIcon className="h-4 w-4 animate-spin" /> : "send"}
</Button>
</div>
</div>
)}
{!paymentComplete && <hr className="my-4" />}
{!paymentComplete && <ZapButtonList events={events} />}
</div>
)}
{/* Original LNURL Payment Flow */}
{(!useNwc || invoice) && (
<>
{invoice ? (
<div className="flex flex-col items-center px-4 py-4">
{paymentComplete ? (
<div className="flex flex-col items-center justify-center mb-4 p-2 rounded-lg bg-green-50 dark:bg-green-900/20 w-[200px] h-[200px]">
<CheckCircledIcon className="h-24 w-24 text-green-500" />
<p className="text-lg font-semibold text-green-500 mt-4">
Payment Complete!
</p>
</div>
) : (
<div className="mb-4 p-2 bg-white rounded-lg">
<Link href={`lightning:${invoice}`} target="_blank" rel="noopener noreferrer">
<QRCode value={invoice} size={200} />
</Link>
</div>
)}
{!paymentComplete && (
<>
<p className="text-sm text-center mb-4">
Scan this QR code with a Lightning wallet to pay the invoice
</p>
<div className="w-full overflow-auto p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs mb-4">
<code className="break-all">{invoice}</code>
</div>
</>
)}
{paymentComplete ? (
<div className="flex items-center text-green-500 mb-4">
<CheckCircledIcon className="mr-2 h-4 w-4" />
Zap sent successfully!
</div>
) : (
<Button
variant="outline"
className="mb-4"
onClick={() => checkPaymentStatus()}
disabled={isProcessing}
>
{isProcessing ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> Checking...
</>
) : (
"Check if paid"
)}
</Button>
)}
<Button variant="outline" onClick={() => setInvoice("")}>
{paymentComplete ? "Send Another Zap" : "Back to Zap Options"}
</Button>
</div>
) : (
<>
<div className="px-4 pt-4 grid grid-cols-3 gap-2">
<Button
variant={"outline"}
className="mx-1"
onClick={() => handleZap(1)}
disabled={isProcessing || !lnurlPayInfo}
>
1 sat
</Button>
<Button
variant={"outline"}
className="mx-1"
onClick={() => handleZap(21)}
disabled={isProcessing || !lnurlPayInfo}
>
21 sats
</Button>
<div className="flex">
<Input
className="mx-1"
placeholder="1000 sats"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
disabled={isProcessing}
/>
<Button
variant={"outline"}
className="mx-1"
onClick={handleCustomZap}
disabled={isProcessing || !lnurlPayInfo}
>
{isProcessing ? <ReloadIcon className="h-4 w-4 animate-spin" /> : "send"}
</Button>
</div>
</div>
<hr className="my-4" />
<ZapButtonList events={events} />
</>
)}
</>
)}
</DrawerContent>
</Drawer>
);
}