You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
419 lines
15 KiB
419 lines
15 KiB
import GroupedSatsInput from '@/components/GroupedSatsInput' |
|
import QrCode from '@/components/QrCode' |
|
import SatsAmountEquivalents from '@/components/SatsAmountEquivalents' |
|
import { Button } from '@/components/ui/button' |
|
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, |
|
LN_INVOICE_COFFEE_PRESET_SATS, |
|
LN_INVOICE_PRESET_SATS, |
|
MIN_ZAP_SATS, |
|
} from '@/lib/lightning' |
|
import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto' |
|
import { superchatLightningAccentClass } from '@/lib/superchat-ui' |
|
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 { closeModal } from '@getalby/bitcoin-connect-react' |
|
import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup' |
|
import { useEffect, useMemo, useRef, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { toast } from 'sonner' |
|
|
|
function normalizeInvoiceZapSats(amount: number): number { |
|
const clamped = clampZapSats(amount) |
|
return clamped > 0 && clamped < MIN_ZAP_SATS ? MIN_ZAP_SATS : clamped |
|
} |
|
|
|
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, |
|
onBolt11InvoiceChange, |
|
onPaymentFlowComplete |
|
}: { |
|
lightningAddress: string |
|
paytoUri: string |
|
/** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */ |
|
onBolt11InvoiceChange?: (invoice: string | null) => void |
|
/** After a wallet payment succeeds (dialog stays open for the user to choose next steps). */ |
|
onPaymentFlowComplete?: (details?: { |
|
amountMsat: number |
|
payto: string |
|
messageDraft?: string |
|
}) => void |
|
}) { |
|
const { t } = useTranslation() |
|
const { defaultZapSats, isWalletConnected } = useZap() |
|
const [sats, setSats] = useState(() => normalizeInvoiceZapSats(defaultZapSats)) |
|
const [description, setDescription] = useState('') |
|
const [commentMax, setCommentMax] = useState<number | null>(null) |
|
const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>( |
|
'loading' |
|
) |
|
const [invoice, setInvoice] = useState<string | null>(null) |
|
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null) |
|
const [creating, setCreating] = useState(false) |
|
const [paying, setPaying] = useState(false) |
|
const mountedRef = useRef(true) |
|
|
|
useEffect(() => { |
|
mountedRef.current = true |
|
return () => { |
|
mountedRef.current = false |
|
closeModal() |
|
releaseBodyScrollLocks() |
|
} |
|
}, []) |
|
|
|
useEffect(() => { |
|
setSats(normalizeInvoiceZapSats(defaultZapSats)) |
|
setDescription('') |
|
setInvoice(null) |
|
setInvoiceDescription(null) |
|
setCommentMax(null) |
|
setLnurlMetadataState('loading') |
|
let cancelled = false |
|
void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => { |
|
if (!cancelled && mountedRef.current) { |
|
if (opts) { |
|
setCommentMax(opts.commentAllowed) |
|
setLnurlMetadataState('ready') |
|
} else { |
|
setCommentMax(0) |
|
setLnurlMetadataState('error') |
|
} |
|
} |
|
}) |
|
return () => { |
|
cancelled = true |
|
} |
|
}, [lightningAddress, defaultZapSats]) |
|
|
|
useEffect(() => { |
|
setInvoice(null) |
|
setInvoiceDescription(null) |
|
}, [sats, description]) |
|
|
|
useEffect(() => { |
|
onBolt11InvoiceChange?.(invoice) |
|
}, [invoice, onBolt11InvoiceChange]) |
|
|
|
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 |
|
}) |
|
if (!mountedRef.current) return |
|
setInvoice(pr) |
|
setInvoiceDescription(trimmedDesc || null) |
|
} catch (error) { |
|
if (mountedRef.current) { |
|
toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`) |
|
} |
|
} finally { |
|
if (mountedRef.current) setCreating(false) |
|
} |
|
} |
|
|
|
const buildPaymentDetails = (pr: string) => { |
|
let amountMsat = clampZapSats(sats) * 1000 |
|
try { |
|
amountMsat = getAmountFromInvoice(pr) * 1000 |
|
} catch { |
|
/* use form amount */ |
|
} |
|
return { |
|
amountMsat, |
|
payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress)), |
|
messageDraft: invoiceDescription?.trim() || description.trim() || undefined |
|
} |
|
} |
|
|
|
const handlePay = async () => { |
|
if (!invoice) return |
|
try { |
|
setPaying(true) |
|
const result = await lightning.payInvoice(invoice, undefined) |
|
if (!mountedRef.current) return |
|
if (result) { |
|
toast.success(t('Payment sent')) |
|
onPaymentFlowComplete?.(buildPaymentDetails(invoice)) |
|
setInvoice(null) |
|
setInvoiceDescription(null) |
|
} |
|
} catch (error) { |
|
if (mountedRef.current) { |
|
toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) |
|
} |
|
} finally { |
|
if (mountedRef.current) setPaying(false) |
|
} |
|
} |
|
|
|
const copyText = (text: string, message?: string) => { |
|
navigator.clipboard.writeText(text) |
|
toast.success(message ?? t('Copied to clipboard')) |
|
} |
|
|
|
return ( |
|
<section |
|
className="min-w-0 space-y-5 rounded-xl border border-border/80 bg-muted/25 p-4 sm:p-5 text-base" |
|
aria-label={t('Lightning payment')} |
|
> |
|
<div className="min-w-0 rounded-lg bg-background/90 px-3 py-2.5 ring-1 ring-border/60"> |
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground"> |
|
{t('Pay to')} |
|
</p> |
|
<p className="break-all text-base font-medium leading-snug sm:text-lg">{lightningAddress}</p> |
|
</div> |
|
<p className="text-sm leading-relaxed text-muted-foreground"> |
|
{t( |
|
'Plain Lightning payments do not create NIP-57 zap receipts on Nostr. Use the zap button when available.' |
|
)} |
|
</p> |
|
|
|
<div className="min-w-0 space-y-3"> |
|
<Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base"> |
|
{t('Amount (sats)')} |
|
</Label> |
|
<SatsAmountEquivalents sats={sats} id="ln-invoice-sats-equiv" /> |
|
<div className="flex min-w-0 items-center gap-3"> |
|
<GroupedSatsInput |
|
id="ln-invoice-sats" |
|
sats={sats} |
|
onSatsChange={setSats} |
|
className="min-w-0 flex-1" |
|
inputClassName="h-12 sm:h-14" |
|
aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint" |
|
/> |
|
<span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span> |
|
</div> |
|
<div |
|
id="ln-invoice-preset-hint" |
|
className="grid min-w-0 grid-cols-6 gap-1" |
|
role="group" |
|
aria-label={t('Amount (sats)')} |
|
> |
|
{LN_INVOICE_PRESET_SATS.map((preset) => { |
|
const active = sats === preset |
|
const isCoffeePreset = preset === LN_INVOICE_COFFEE_PRESET_SATS |
|
return ( |
|
<Button |
|
key={preset} |
|
type="button" |
|
variant={active ? 'default' : 'outline'} |
|
size="sm" |
|
title={isCoffeePreset ? t('Buy them a coffee.') : undefined} |
|
className={cn( |
|
'h-8 min-w-0 px-0.5 text-xs tabular-nums sm:h-9 sm:px-1 sm:text-sm', |
|
isCoffeePreset && 'col-span-2 gap-1', |
|
active && 'ring-1 ring-amber-600/45 dark:ring-yellow-400/50' |
|
)} |
|
onClick={() => setSats(preset)} |
|
> |
|
{formatAmount(preset)} |
|
{isCoffeePreset ? <span aria-hidden>☕</span> : null} |
|
</Button> |
|
) |
|
})} |
|
</div> |
|
</div> |
|
|
|
{lnurlMetadataState === 'loading' ? ( |
|
<Skeleton className="h-[4.5rem] w-full rounded-lg" aria-hidden /> |
|
) : lnurlMetadataState === 'error' ? ( |
|
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground sm:text-base"> |
|
{t( |
|
'Could not read this Lightning address (network or browser block). Descriptions need LNURL-pay support on the recipient side.' |
|
)} |
|
</p> |
|
) : (commentMax ?? 0) > 0 ? ( |
|
<div className="min-w-0 space-y-2"> |
|
<div className="flex items-baseline justify-between gap-2"> |
|
<Label htmlFor="ln-invoice-description" className="text-sm font-medium text-muted-foreground sm:text-base"> |
|
{t('Description (optional)')} |
|
</Label> |
|
<span className="shrink-0 text-sm tabular-nums text-muted-foreground"> |
|
{description.length}/{commentMax ?? 0} |
|
</span> |
|
</div> |
|
<Textarea |
|
id="ln-invoice-description" |
|
value={description} |
|
onChange={(e) => |
|
setDescription(e.target.value.slice(0, commentMax ?? 0)) |
|
} |
|
maxLength={commentMax ?? 0} |
|
rows={3} |
|
placeholder={t('Payment description')} |
|
className="min-h-[5rem] resize-none text-base leading-relaxed sm:text-lg" |
|
/> |
|
</div> |
|
) : ( |
|
<p className="rounded-lg bg-muted/50 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground sm:text-base"> |
|
{t('This address does not support payment descriptions.')} |
|
</p> |
|
)} |
|
|
|
{!invoice ? ( |
|
<Button |
|
type="button" |
|
className="h-12 w-full gap-2 text-base sm:h-14 sm:text-lg" |
|
disabled={creating || sats < MIN_ZAP_SATS} |
|
onClick={() => void handleCreateInvoice()} |
|
> |
|
{creating ? ( |
|
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> |
|
) : ( |
|
<Zap className={cn('size-5 shrink-0', superchatLightningAccentClass)} /> |
|
)} |
|
{t('Create invoice')} |
|
</Button> |
|
) : null} |
|
|
|
{invoice ? ( |
|
<div |
|
className="min-w-0 space-y-3 rounded-lg border border-green-500/35 bg-green-500/[0.07] p-3 sm:p-4" |
|
role="status" |
|
aria-live="polite" |
|
> |
|
<div className="flex min-w-0 items-start gap-2"> |
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-green-500/20"> |
|
<Check className="size-5 text-green-600 dark:text-green-400" aria-hidden /> |
|
</div> |
|
<div className="min-w-0 flex-1"> |
|
<p className="text-sm font-medium uppercase tracking-wide text-green-700 dark:text-green-400"> |
|
{t('Invoice ready')} |
|
</p> |
|
{invoiceSats != null ? ( |
|
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight sm:text-3xl"> |
|
{formatSatsGrouped(invoiceSats)}{' '} |
|
<span className="text-base font-semibold text-muted-foreground sm:text-lg">{t('sats')}</span> |
|
</p> |
|
) : null} |
|
{invoiceDescription ? ( |
|
<p className="mt-2 text-base leading-snug text-foreground/90 break-words sm:text-lg"> |
|
{invoiceDescription} |
|
</p> |
|
) : null} |
|
</div> |
|
</div> |
|
|
|
<div |
|
className="flex min-w-0 flex-col items-center gap-2" |
|
role="img" |
|
aria-label={t('Scan to pay with a Lightning wallet')} |
|
> |
|
<div className="w-full max-w-[min(100%,280px)]"> |
|
<QrCode value={invoiceQrValue} fill /> |
|
</div> |
|
<p className="text-center text-sm text-muted-foreground sm:text-base"> |
|
{t('Scan to pay with a Lightning wallet')} |
|
</p> |
|
</div> |
|
|
|
<div className="min-w-0"> |
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground"> |
|
{t('BOLT11 invoice')} |
|
</p> |
|
<div className="max-h-32 min-w-0 overflow-y-auto overflow-x-hidden rounded-md bg-background/80 p-3 ring-1 ring-border/50"> |
|
<p className="select-text break-all font-mono text-sm leading-relaxed text-muted-foreground sm:text-base"> |
|
{invoice} |
|
</p> |
|
</div> |
|
</div> |
|
|
|
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2"> |
|
<Button |
|
type="button" |
|
variant="secondary" |
|
className="h-11 w-full min-w-0 gap-2 text-base" |
|
onClick={() => copyText(invoice)} |
|
> |
|
<Copy className="size-5 shrink-0" /> |
|
<span className="truncate">{t('Copy invoice')}</span> |
|
</Button> |
|
{LIGHTNING_WALLET_PAY_ENABLED ? ( |
|
<Button |
|
type="button" |
|
variant="default" |
|
className="h-11 w-full min-w-0 gap-2 text-base" |
|
disabled={paying} |
|
onClick={() => void handlePay()} |
|
> |
|
{paying ? ( |
|
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> |
|
) : ( |
|
<Wallet className="size-5 shrink-0" /> |
|
)} |
|
<span className="truncate"> |
|
{isWalletConnected ? t('Pay with connected wallet') : t('Open in wallet')} |
|
</span> |
|
</Button> |
|
) : null} |
|
</div> |
|
|
|
<Button |
|
type="button" |
|
variant="ghost" |
|
className="h-10 w-full text-base text-muted-foreground" |
|
onClick={() => void handleCreateInvoice()} |
|
disabled={creating} |
|
> |
|
{t('Regenerate invoice')} |
|
</Button> |
|
</div> |
|
) : null} |
|
|
|
<div className="grid min-w-0 grid-cols-1 gap-2 border-t border-border/60 pt-3 sm:grid-cols-2"> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
className="h-11 min-w-0 flex-1 gap-2 text-base" |
|
onClick={() => copyText(lightningAddress, t('Copied {{label}} address', { label: 'Lightning' }))} |
|
> |
|
<Copy className="size-5 shrink-0" /> |
|
<span className="truncate">{t('Copy address')}</span> |
|
</Button> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
className="h-11 min-w-0 flex-1 gap-2 text-base" |
|
onClick={() => copyText(paytoUri)} |
|
> |
|
<Copy className="size-5 shrink-0" /> |
|
<span className="truncate">{t('Copy payto URI')}</span> |
|
</Button> |
|
</div> |
|
</section> |
|
) |
|
}
|
|
|