mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-17 01:58:33 +02:00
refactor: redesign wallet UI to single-view layout with virtualized transactions
Converts the tabbed wallet interface to a conventional single-view layout with improved UX and performance optimizations. **Layout Changes:** - Removed tabs in favor of single-page layout - Balance header at top with wallet name and refresh button - Side-by-side Send/Receive cards for quick access - Transaction history below with virtualized scrolling - Disconnect button at bottom of page **New Features:** - Connect Wallet button when no wallet is connected (opens dialog in-app) - Wallet capabilities shown in tooltip on info icon - Virtualized transaction list using react-virtuoso - Batched transaction loading (20 per batch) - Automatic "load more" when scrolling to bottom - Loading states for initial load and pagination - "No more transactions" message when exhausted **Performance Improvements:** - Virtualized list rendering for smooth scrolling with many transactions - Only renders visible transactions in viewport - Lazy loads additional batches on demand - Reduced initial load to 20 transactions instead of 50 **UX Improvements:** - More conventional wallet UI pattern - Send/Receive always visible (no tab switching) - QR code and invoice appear inline when generated - Info icon with tooltip for capabilities (cleaner than full card) - Disconnect option always accessible at bottom **Technical Details:** - Fixed transaction loading race condition with separate useEffect - Proper dependency tracking for loadMoreTransactions callback - Footer component in Virtuoso for loading/end states - Responsive grid layout for Send/Receive cards All tests passing. Build verified.
This commit is contained in:
@@ -2,17 +2,10 @@
|
||||
* WalletViewer Component
|
||||
*
|
||||
* Displays NWC wallet information and provides UI for wallet operations.
|
||||
* Dynamically shows features based on wallet capabilities (methods).
|
||||
*
|
||||
* Features:
|
||||
* - Balance display with real-time updates
|
||||
* - Transaction history (if list_transactions supported)
|
||||
* - Send/Receive Lightning payments
|
||||
* - Budget information (if get_budget supported)
|
||||
* - Wallet info and capabilities
|
||||
* Single-view layout with balance, send/receive, and transaction history.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Wallet,
|
||||
@@ -20,20 +13,25 @@ import {
|
||||
Send,
|
||||
Download,
|
||||
Info,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Check,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import ConnectWalletDialog from "./ConnectWalletDialog";
|
||||
|
||||
interface Transaction {
|
||||
type: "incoming" | "outgoing";
|
||||
@@ -61,6 +59,8 @@ interface WalletInfo {
|
||||
notifications?: string[];
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
export default function WalletViewer() {
|
||||
const {
|
||||
wallet,
|
||||
@@ -71,19 +71,22 @@ export default function WalletViewer() {
|
||||
listTransactions,
|
||||
makeInvoice,
|
||||
payInvoice,
|
||||
disconnect,
|
||||
} = useWallet();
|
||||
|
||||
const [walletInfo, setWalletInfo] = useState<WalletInfo | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
||||
|
||||
// Send tab state
|
||||
// Send state
|
||||
const [sendInvoice, setSendInvoice] = useState("");
|
||||
const [sendAmount, setSendAmount] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// Receive tab state
|
||||
// Receive state
|
||||
const [receiveAmount, setReceiveAmount] = useState("");
|
||||
const [receiveDescription, setReceiveDescription] = useState("");
|
||||
const [generatedInvoice, setGeneratedInvoice] = useState("");
|
||||
@@ -98,15 +101,12 @@ export default function WalletViewer() {
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
// Load transactions when switching to that tab
|
||||
// Load transactions when wallet info is available
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab === "transactions" &&
|
||||
walletInfo?.methods.includes("list_transactions")
|
||||
) {
|
||||
loadTransactions();
|
||||
if (walletInfo?.methods.includes("list_transactions")) {
|
||||
loadInitialTransactions();
|
||||
}
|
||||
}, [activeTab, walletInfo]);
|
||||
}, [walletInfo]);
|
||||
|
||||
async function loadWalletInfo() {
|
||||
try {
|
||||
@@ -118,13 +118,16 @@ export default function WalletViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
if (!walletInfo?.methods.includes("list_transactions")) return;
|
||||
|
||||
async function loadInitialTransactions() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listTransactions({ limit: 50 });
|
||||
setTransactions(result.transactions || []);
|
||||
const result = await listTransactions({
|
||||
limit: BATCH_SIZE,
|
||||
offset: 0,
|
||||
});
|
||||
const txs = result.transactions || [];
|
||||
setTransactions(txs);
|
||||
setHasMore(txs.length === BATCH_SIZE);
|
||||
} catch (error) {
|
||||
console.error("Failed to load transactions:", error);
|
||||
toast.error("Failed to load transactions");
|
||||
@@ -133,6 +136,32 @@ export default function WalletViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreTransactions = useCallback(async () => {
|
||||
if (
|
||||
!walletInfo?.methods.includes("list_transactions") ||
|
||||
!hasMore ||
|
||||
loadingMore
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await listTransactions({
|
||||
limit: BATCH_SIZE,
|
||||
offset: transactions.length,
|
||||
});
|
||||
const newTxs = result.transactions || [];
|
||||
setTransactions((prev) => [...prev, ...newTxs]);
|
||||
setHasMore(newTxs.length === BATCH_SIZE);
|
||||
} catch (error) {
|
||||
console.error("Failed to load more transactions:", error);
|
||||
toast.error("Failed to load more transactions");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]);
|
||||
|
||||
async function handleRefreshBalance() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -159,6 +188,8 @@ export default function WalletViewer() {
|
||||
toast.success("Payment sent successfully");
|
||||
setSendInvoice("");
|
||||
setSendAmount("");
|
||||
// Reload transactions
|
||||
loadInitialTransactions();
|
||||
} catch (error) {
|
||||
console.error("Payment failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Payment failed");
|
||||
@@ -188,7 +219,7 @@ export default function WalletViewer() {
|
||||
|
||||
// Generate QR code
|
||||
const qrDataUrl = await QRCode.toDataURL(result.invoice.toUpperCase(), {
|
||||
width: 300,
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: "#000000",
|
||||
@@ -215,6 +246,11 @@ export default function WalletViewer() {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
disconnect();
|
||||
toast.success("Wallet disconnected");
|
||||
}
|
||||
|
||||
function formatSats(millisats: number | undefined): string {
|
||||
if (millisats === undefined) return "—";
|
||||
return Math.floor(millisats / 1000).toLocaleString();
|
||||
@@ -230,31 +266,70 @@ export default function WalletViewer() {
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="size-5" />
|
||||
<Wallet className="size-5" />
|
||||
No Wallet Connected
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to
|
||||
use this feature.
|
||||
send and receive payments.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Wallet className="mr-2 size-4" />
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConnectWalletDialog
|
||||
open={connectDialogOpen}
|
||||
onOpenChange={setConnectDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with Balance */}
|
||||
<div className="border-b border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="size-6 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{walletInfo?.alias || "Lightning Wallet"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{walletInfo?.alias || "Lightning Wallet"}
|
||||
</h2>
|
||||
{walletInfo && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">Capabilities:</div>
|
||||
<div className="space-y-1">
|
||||
{walletInfo.methods.map((method) => (
|
||||
<div
|
||||
key={method}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<Check className="size-3 text-green-500" />
|
||||
<span className="font-mono">{method}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance: {formatSats(balance)} sats
|
||||
</p>
|
||||
@@ -274,316 +349,252 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1">
|
||||
<div className="border-b border-border bg-card px-4">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="overview">
|
||||
<Info className="mr-2 size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="send">
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="receive">
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</TabsTrigger>
|
||||
{walletInfo?.methods.includes("list_transactions") && (
|
||||
<TabsTrigger value="transactions">
|
||||
<Zap className="mr-2 size-4" />
|
||||
Transactions
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
{/* Send and Receive Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Send Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Send className="size-4" />
|
||||
Send Payment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Invoice</label>
|
||||
<Input
|
||||
placeholder="lnbc..."
|
||||
value={sendInvoice}
|
||||
onChange={(e) => setSendInvoice(e.target.value)}
|
||||
disabled={sending}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<TabsContent value="overview" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallet Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{walletInfo?.alias && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Alias
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{walletInfo.alias}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Amount (optional, millisats)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Leave empty for invoice amount"
|
||||
value={sendAmount}
|
||||
onChange={(e) => setSendAmount(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSendPayment}
|
||||
disabled={sending || !sendInvoice.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
{walletInfo?.network && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Network
|
||||
</span>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{walletInfo.network}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Receive Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Download className="size-4" />
|
||||
Receive Payment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Amount (sats)</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
value={receiveAmount}
|
||||
onChange={(e) => setReceiveAmount(e.target.value)}
|
||||
disabled={generating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="What's this for?"
|
||||
value={receiveDescription}
|
||||
onChange={(e) => setReceiveDescription(e.target.value)}
|
||||
disabled={generating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateInvoice}
|
||||
disabled={generating || !receiveAmount}
|
||||
className="w-full"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 size-4" />
|
||||
Generate Invoice
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Balance
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatSats(balance)} sats
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{walletInfo?.methods.map((method) => (
|
||||
<div
|
||||
key={method}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Check className="size-4 text-green-500" />
|
||||
<span className="font-mono">{method}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{walletInfo?.notifications &&
|
||||
walletInfo.notifications.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{walletInfo.notifications.map((notification) => (
|
||||
<div
|
||||
key={notification}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Check className="size-4 text-green-500" />
|
||||
<span className="font-mono">{notification}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="send" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send Payment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Invoice</label>
|
||||
<Input
|
||||
placeholder="lnbc..."
|
||||
value={sendInvoice}
|
||||
onChange={(e) => setSendInvoice(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Amount (optional, in millisats)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Leave empty for invoice amount"
|
||||
value={sendAmount}
|
||||
onChange={(e) => setSendAmount(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSendPayment}
|
||||
disabled={sending || !sendInvoice.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 size-4" />
|
||||
Send Payment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="receive" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Receive Payment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Amount (sats)</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
value={receiveAmount}
|
||||
onChange={(e) => setReceiveAmount(e.target.value)}
|
||||
disabled={generating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="What's this for?"
|
||||
value={receiveDescription}
|
||||
onChange={(e) => setReceiveDescription(e.target.value)}
|
||||
disabled={generating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateInvoice}
|
||||
disabled={generating || !receiveAmount}
|
||||
className="w-full"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 size-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 size-4" />
|
||||
Generate Invoice
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{generatedInvoice && (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/50 p-4">
|
||||
<div className="flex justify-center">
|
||||
{invoiceQR && (
|
||||
<img
|
||||
src={invoiceQR}
|
||||
alt="Invoice QR Code"
|
||||
className="size-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Invoice</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyInvoice}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 size-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="break-all rounded bg-background p-3 font-mono text-xs">
|
||||
{generatedInvoice}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{walletInfo?.methods.includes("list_transactions") && (
|
||||
<TabsContent value="transactions" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No transactions found
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{transactions.map((tx, index) => (
|
||||
<div
|
||||
key={tx.payment_hash || index}
|
||||
className="flex items-start justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{tx.type === "incoming" ? (
|
||||
<ArrowDownLeft className="mt-0.5 size-5 text-green-500" />
|
||||
) : (
|
||||
<ArrowUpRight className="mt-0.5 size-5 text-red-500" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{tx.type === "incoming" ? "Received" : "Sent"}
|
||||
</p>
|
||||
{tx.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(tx.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{tx.type === "incoming" ? "+" : "-"}
|
||||
{formatSats(tx.amount)} sats
|
||||
</p>
|
||||
{tx.fees_paid !== undefined &&
|
||||
tx.fees_paid > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fee: {formatSats(tx.fees_paid)} sats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
{/* Generated Invoice Display */}
|
||||
{generatedInvoice && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
{invoiceQR && (
|
||||
<img
|
||||
src={invoiceQR}
|
||||
alt="Invoice QR Code"
|
||||
className="size-64 rounded-lg border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Invoice</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopyInvoice}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 size-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="break-all rounded bg-muted p-3 font-mono text-xs">
|
||||
{generatedInvoice}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transactions List */}
|
||||
{walletInfo?.methods.includes("list_transactions") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Transaction History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No transactions found
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-96">
|
||||
<Virtuoso
|
||||
data={transactions}
|
||||
endReached={loadMoreTransactions}
|
||||
itemContent={(index, tx) => (
|
||||
<div
|
||||
key={tx.payment_hash || index}
|
||||
className="flex items-start justify-between border-b border-border py-3 last:border-0"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{tx.type === "incoming" ? (
|
||||
<ArrowDownLeft className="mt-0.5 size-5 text-green-500" />
|
||||
) : (
|
||||
<ArrowUpRight className="mt-0.5 size-5 text-red-500" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{tx.type === "incoming" ? "Received" : "Sent"}
|
||||
</p>
|
||||
{tx.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(tx.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{tx.type === "incoming" ? "+" : "-"}
|
||||
{formatSats(tx.amount)} sats
|
||||
</p>
|
||||
{tx.fees_paid !== undefined && tx.fees_paid > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fee: {formatSats(tx.fees_paid)} sats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
components={{
|
||||
Footer: () =>
|
||||
loadingMore ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasMore && transactions.length > 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
No more transactions
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Disconnect Button */}
|
||||
<div className="flex justify-center pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Disconnect Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user