8 changed files with 531 additions and 56 deletions
@ -0,0 +1,332 @@
@@ -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<number | null>(null) |
||||
const [invoice, setInvoice] = useState<string | null>(null) |
||||
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(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 ( |
||||
<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> |
||||
|
||||
<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> |
||||
<div className="flex min-w-0 items-center gap-3"> |
||||
<Input |
||||
id="ln-invoice-sats" |
||||
inputMode="numeric" |
||||
value={sats === 0 ? '' : formatSatsGrouped(sats)} |
||||
onChange={(e) => 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" |
||||
/> |
||||
<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-3 gap-1.5 sm:grid-cols-6" |
||||
role="group" |
||||
aria-label={t('Amount (sats)')} |
||||
> |
||||
{PRESET_SATS.map((preset) => { |
||||
const active = sats === preset |
||||
return ( |
||||
<Button |
||||
key={preset} |
||||
type="button" |
||||
variant={active ? 'default' : 'outline'} |
||||
size="default" |
||||
className={cn( |
||||
'h-10 min-w-0 px-1.5 text-sm tabular-nums sm:text-base', |
||||
active && 'ring-1 ring-yellow-400/50' |
||||
)} |
||||
onClick={() => setSats(preset)} |
||||
> |
||||
{formatAmount(preset)} |
||||
</Button> |
||||
) |
||||
})} |
||||
</div> |
||||
</div> |
||||
|
||||
{commentMax === null ? ( |
||||
<Skeleton className="h-[4.5rem] w-full rounded-lg" aria-hidden /> |
||||
) : commentMax > 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} |
||||
</span> |
||||
</div> |
||||
<Textarea |
||||
id="ln-invoice-description" |
||||
value={description} |
||||
onChange={(e) => setDescription(e.target.value.slice(0, commentMax))} |
||||
maxLength={commentMax} |
||||
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 < 1} |
||||
onClick={() => void handleCreateInvoice()} |
||||
> |
||||
{creating ? ( |
||||
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> |
||||
) : ( |
||||
<Zap className="size-5 shrink-0 text-yellow-400" /> |
||||
)} |
||||
{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')} |
||||
> |
||||
<QrCode value={invoiceQrValue} size={240} /> |
||||
<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> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue