The Wallet page was Cashu-only and said 'App only' on web, so anyone with an NWC wallet connected saw nothing. Adds: - useNWC.getBalance() (millisats -> sats) and makeInvoice() via NWCClient - NwcWalletCard: live balance + refresh, Receive (create invoice, QR, copy), Send (paste invoice, decoded amount shown, pay via the connected wallet) Renders only when an NWC connection is active.
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useNWC } from '@/hooks/useNWCContext';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
ArrowDownToLine,
|
|
ArrowUpFromLine,
|
|
Check,
|
|
Copy,
|
|
Loader2,
|
|
RefreshCw,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
|
|
/**
|
|
* Lightning wallet over Nostr Wallet Connect (NIP-47).
|
|
*
|
|
* The Wallet page was Cashu-only and showed "App only" on web, so anyone who
|
|
* connected an NWC wallet (Alby, Coinos, …) saw no balance, send or receive.
|
|
* This card surfaces the connected wallet: balance, make-invoice (receive) and
|
|
* pay-invoice (send). It renders nothing when no NWC connection is active.
|
|
*/
|
|
export function NwcWalletCard() {
|
|
const { getActiveConnection, getBalance, makeInvoice, sendPayment } = useNWC();
|
|
const { toast } = useToast();
|
|
const connection = getActiveConnection();
|
|
|
|
const [balance, setBalance] = useState<number | null>(null);
|
|
const [loadingBalance, setLoadingBalance] = useState(false);
|
|
|
|
const [receiveAmount, setReceiveAmount] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
const [invoice, setInvoice] = useState<string | null>(null);
|
|
const [qr, setQr] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const [payInput, setPayInput] = useState('');
|
|
const [paying, setPaying] = useState(false);
|
|
|
|
const refreshBalance = useCallback(async () => {
|
|
if (!connection) return;
|
|
setLoadingBalance(true);
|
|
try {
|
|
setBalance(await getBalance(connection));
|
|
} catch (e) {
|
|
toast({
|
|
title: 'Could not read balance',
|
|
description: e instanceof Error ? e.message : 'Your wallet did not respond.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoadingBalance(false);
|
|
}
|
|
}, [connection, getBalance, toast]);
|
|
|
|
useEffect(() => {
|
|
void refreshBalance();
|
|
}, [refreshBalance]);
|
|
|
|
const handleCreateInvoice = async () => {
|
|
if (!connection) return;
|
|
const sats = Number(receiveAmount);
|
|
if (!Number.isFinite(sats) || sats <= 0) {
|
|
toast({ title: 'Enter an amount in sats', variant: 'destructive' });
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
const inv = await makeInvoice(connection, sats, 'SkateSpots');
|
|
setInvoice(inv);
|
|
const { default: QRCode } = await import('qrcode');
|
|
setQr(await QRCode.toDataURL(inv.toUpperCase(), { width: 256, margin: 1 }));
|
|
} catch (e) {
|
|
toast({
|
|
title: 'Could not create invoice',
|
|
description: e instanceof Error ? e.message : 'Try again in a moment.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handlePay = async () => {
|
|
if (!connection) return;
|
|
const inv = payInput.trim();
|
|
if (!inv) return;
|
|
setPaying(true);
|
|
try {
|
|
await sendPayment(connection, inv);
|
|
const sats = (() => { try { return getSatoshisFromBolt11(inv); } catch { return 0; } })();
|
|
toast({ title: 'Payment sent', description: sats > 0 ? `Paid ${sats.toLocaleString()} sats.` : 'Paid.' });
|
|
setPayInput('');
|
|
void refreshBalance();
|
|
} catch (e) {
|
|
toast({
|
|
title: 'Payment failed',
|
|
description: e instanceof Error ? e.message : 'The wallet rejected the payment.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setPaying(false);
|
|
}
|
|
};
|
|
|
|
const copyInvoice = async () => {
|
|
if (!invoice) return;
|
|
try {
|
|
await navigator.clipboard.writeText(invoice);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
if (!connection) return null;
|
|
|
|
const paySats = (() => {
|
|
try { return payInput.trim() ? getSatoshisFromBolt11(payInput.trim()) : 0; } catch { return 0; }
|
|
})();
|
|
|
|
return (
|
|
<Card className="border-2 border-[#ff6600]/30 bg-black/90">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
|
<Zap className="h-5 w-5" />
|
|
Lightning Wallet
|
|
</CardTitle>
|
|
<p className="text-xs text-gray-500">
|
|
{connection.alias || 'NWC wallet'} · NIP-47
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5">
|
|
{/* Balance */}
|
|
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-gray-500">Balance</p>
|
|
<p className="text-2xl font-black text-white">
|
|
{loadingBalance && balance === null ? '…' : balance === null ? '—' : balance.toLocaleString()}
|
|
<span className="ml-1 text-sm font-bold text-gray-400">sats</span>
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Refresh balance"
|
|
onClick={() => refreshBalance()}
|
|
disabled={loadingBalance}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4', loadingBalance && 'animate-spin')} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Receive */}
|
|
<div className="space-y-2">
|
|
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
|
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> Receive
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={receiveAmount}
|
|
onChange={(e) => setReceiveAmount(e.target.value)}
|
|
inputMode="numeric"
|
|
placeholder="Amount in sats"
|
|
className="h-11 border-gray-700 bg-gray-900 text-white"
|
|
/>
|
|
<Button
|
|
onClick={handleCreateInvoice}
|
|
disabled={creating}
|
|
className="h-11 shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create invoice'}
|
|
</Button>
|
|
</div>
|
|
{invoice && (
|
|
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
|
{qr && <img src={qr} alt="Lightning invoice QR code" className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
|
|
<p className="break-all font-mono text-[11px] text-gray-400">{invoice}</p>
|
|
<Button variant="outline" onClick={copyInvoice} className="w-full gap-2 border-white/15">
|
|
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
{copied ? 'Copied' : 'Copy invoice'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Send */}
|
|
<div className="space-y-2">
|
|
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
|
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> Send
|
|
</p>
|
|
<Textarea
|
|
value={payInput}
|
|
onChange={(e) => setPayInput(e.target.value)}
|
|
placeholder="Paste a Lightning invoice (lnbc…)"
|
|
rows={2}
|
|
className="resize-none border-gray-700 bg-gray-900 font-mono text-xs text-white"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-500">
|
|
{paySats > 0 ? `Amount: ${paySats.toLocaleString()} sats` : 'Paste an invoice to see the amount'}
|
|
</span>
|
|
<Button
|
|
onClick={handlePay}
|
|
disabled={!payInput.trim() || paying}
|
|
className="ml-auto h-10 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
|
>
|
|
{paying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
|
Pay
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|