From 51f18153fc9789e137cd61dac27b79556ab450ae Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 18:05:49 +0200 Subject: [PATCH] Add LN invoices --- src/components/Embedded/EmbeddedLNInvoice.tsx | 7 +- .../PaytoDialog/LightningInvoiceSection.tsx | 332 ++++++++++++++++++ src/components/PaytoDialog/index.tsx | 101 ++++-- src/constants.ts | 5 +- src/i18n/locales/en.ts | 17 + src/pages/secondary/WalletPage/index.tsx | 4 +- src/providers/ZapProvider.tsx | 4 +- src/services/lightning.service.ts | 117 +++++- 8 files changed, 531 insertions(+), 56 deletions(-) create mode 100644 src/components/PaytoDialog/LightningInvoiceSection.tsx diff --git a/src/components/Embedded/EmbeddedLNInvoice.tsx b/src/components/Embedded/EmbeddedLNInvoice.tsx index a1cc767d..6f60ad90 100644 --- a/src/components/Embedded/EmbeddedLNInvoice.tsx +++ b/src/components/Embedded/EmbeddedLNInvoice.tsx @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { formatAmount, getAmountFromInvoice } from '@/lib/lightning' import { cn } from '@/lib/utils' -import { useNostr } from '@/providers/NostrProvider' import lightning from '@/services/lightning.service' import { Zap } from 'lucide-react' import { useMemo, useState } from 'react' @@ -11,7 +10,6 @@ import { toast } from 'sonner' export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; className?: string }) { const { t } = useTranslation() - const { checkLogin, pubkey } = useNostr() const [paying, setPaying] = useState(false) const amount = useMemo(() => { @@ -20,9 +18,6 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla const handlePay = async () => { try { - if (!pubkey) { - throw new Error('You need to be logged in to zap') - } setPaying(true) const invoiceResult = await lightning.payInvoice(invoice) // user canceled @@ -38,7 +33,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla const handlePayClick = (e: React.MouseEvent) => { e.stopPropagation() - checkLogin(() => handlePay()) + void handlePay() } return ( diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx new file mode 100644 index 00000000..67637fdc --- /dev/null +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -0,0 +1,332 @@ +import QrCode from '@/components/QrCode' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { LIGHTNING_WALLET_PAY_ENABLED } from '@/constants' +import { + clampZapSats, + formatAmount, + formatSatsGrouped, + getAmountFromInvoice, + parseGroupedIntegerInput +} from '@/lib/lightning' +import { cn } from '@/lib/utils' +import { useZap } from '@/providers/ZapProvider' +import lightning from '@/services/lightning.service' +import { Check, Copy, Wallet, Zap } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +const PRESET_SATS = [21, 210, 420, 1000, 2100, 10_000, 21_000] + +function invoiceQrPayload(pr: string): string { + const trimmed = pr.trim() + if (trimmed.toLowerCase().startsWith('lightning:')) return trimmed + return `lightning:${trimmed}` +} + +export default function LightningInvoiceSection({ + lightningAddress, + paytoUri +}: { + lightningAddress: string + paytoUri: string +}) { + const { t } = useTranslation() + const { defaultZapSats, defaultZapComment, isWalletConnected } = useZap() + const [sats, setSats] = useState(() => clampZapSats(defaultZapSats)) + const [description, setDescription] = useState(defaultZapComment) + const [commentMax, setCommentMax] = useState(null) + const [invoice, setInvoice] = useState(null) + const [invoiceDescription, setInvoiceDescription] = useState(null) + const [creating, setCreating] = useState(false) + const [paying, setPaying] = useState(false) + + useEffect(() => { + setSats(clampZapSats(defaultZapSats)) + setDescription(defaultZapComment) + setInvoice(null) + setInvoiceDescription(null) + setCommentMax(null) + let cancelled = false + void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => { + if (!cancelled) setCommentMax(opts?.commentAllowed ?? 0) + }) + return () => { + cancelled = true + } + }, [lightningAddress, defaultZapSats, defaultZapComment]) + + useEffect(() => { + setInvoice(null) + setInvoiceDescription(null) + }, [sats, description]) + + const invoiceSats = useMemo(() => { + if (!invoice) return null + try { + return getAmountFromInvoice(invoice) + } catch { + return null + } + }, [invoice]) + + const invoiceQrValue = useMemo( + () => (invoice ? invoiceQrPayload(invoice) : ''), + [invoice] + ) + + const handleCreateInvoice = async () => { + try { + setCreating(true) + const trimmedDesc = description.trim() + const pr = await lightning.createLnurlInvoice(lightningAddress, sats, { + description: trimmedDesc || undefined + }) + setInvoice(pr) + setInvoiceDescription(trimmedDesc || null) + } catch (error) { + toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`) + } finally { + setCreating(false) + } + } + + const handlePay = async () => { + if (!invoice) return + try { + setPaying(true) + const result = await lightning.payInvoice(invoice) + if (result) { + toast.success(t('Payment sent')) + setInvoice(null) + setInvoiceDescription(null) + } + } catch (error) { + toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) + } finally { + setPaying(false) + } + } + + const copyText = (text: string, message?: string) => { + navigator.clipboard.writeText(text) + toast.success(message ?? t('Copied to clipboard')) + } + + return ( +
+
+

+ {t('Pay to')} +

+

{lightningAddress}

+
+ +
+ +
+ setSats(parseGroupedIntegerInput(e.target.value))} + className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl" + aria-describedby="ln-invoice-preset-hint" + /> + {t('sats')} +
+
+ {PRESET_SATS.map((preset) => { + const active = sats === preset + return ( + + ) + })} +
+
+ + {commentMax === null ? ( + + ) : commentMax > 0 ? ( +
+
+ + + {description.length}/{commentMax} + +
+