mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-30 15:48:37 +00:00
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
|
|
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { WithdrawModal } from '@/components/withdraw-modal';
|
|
import { cn } from '@/lib/utils';
|
|
import type { DisplayUnit } from '@/lib/types/units';
|
|
import { convertToMsat, formatFromMsat } from '@/lib/currency';
|
|
|
|
export function DetailedWalletBalance({
|
|
refreshInterval = 10000,
|
|
displayUnit,
|
|
usdPerSat,
|
|
}: {
|
|
refreshInterval?: number;
|
|
displayUnit: DisplayUnit;
|
|
usdPerSat: number | null;
|
|
}) {
|
|
const [withdrawModalOpen, setWithdrawModalOpen] = useState(false);
|
|
|
|
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
|
|
queryKey: ['detailed-wallet-balance'],
|
|
queryFn: async () => {
|
|
return WalletService.getDetailedBalances();
|
|
},
|
|
refetchInterval: refreshInterval,
|
|
});
|
|
|
|
const formatAmount = (msatAmount: number): string =>
|
|
formatFromMsat(msatAmount, displayUnit, usdPerSat);
|
|
|
|
const calculateTotals = (balances: BalanceDetail[]) => {
|
|
let totalWallet = 0;
|
|
let totalUser = 0;
|
|
let totalOwner = 0;
|
|
|
|
balances.forEach((detail) => {
|
|
if (!detail.error) {
|
|
const walletMsat = convertToMsat(
|
|
detail.wallet_balance || 0,
|
|
detail.unit
|
|
);
|
|
const userMsat = convertToMsat(detail.user_balance || 0, detail.unit);
|
|
const ownerMsat = convertToMsat(detail.owner_balance || 0, detail.unit);
|
|
|
|
totalWallet += walletMsat;
|
|
totalUser += userMsat;
|
|
totalOwner += ownerMsat;
|
|
}
|
|
});
|
|
|
|
return { totalWallet, totalUser, totalOwner };
|
|
};
|
|
|
|
const totals = data
|
|
? calculateTotals(data)
|
|
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
|
|
|
|
return (
|
|
<>
|
|
<Card className='h-full w-full shadow-sm'>
|
|
<CardHeader className='pb-4'>
|
|
<div className='flex items-center justify-between'>
|
|
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant='default'
|
|
size='sm'
|
|
onClick={() => setWithdrawModalOpen(true)}
|
|
disabled={isLoading || !data || data.length === 0}
|
|
>
|
|
<Wallet className='mr-2 h-4 w-4' />
|
|
Withdraw
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
onClick={() => refetch()}
|
|
disabled={isLoading || isFetching}
|
|
className='h-8 w-8'
|
|
>
|
|
<RefreshCw
|
|
className={cn(
|
|
'h-4 w-4',
|
|
(isFetching || isLoading) && 'animate-spin'
|
|
)}
|
|
/>
|
|
<span className='sr-only'>Refresh balance</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<CardDescription>
|
|
Detailed balance breakdown by mint and currency
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className='flex items-center justify-center py-8'>
|
|
<Loader2 className='text-primary h-8 w-8 animate-spin' />
|
|
</div>
|
|
) : isError ? (
|
|
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
|
|
<AlertCircle className='h-5 w-5' />
|
|
<span>Error loading balance: {(error as Error).message}</span>
|
|
</div>
|
|
) : (
|
|
<div className='space-y-6'>
|
|
<div className='space-y-3'>
|
|
<div className='flex items-center justify-between'>
|
|
<span className='text-muted-foreground text-sm'>
|
|
Your Balance (Total)
|
|
</span>
|
|
<span className='text-2xl font-bold text-green-600'>
|
|
{formatAmount(totals.totalOwner)}
|
|
</span>
|
|
</div>
|
|
<div className='flex items-center justify-between'>
|
|
<span className='text-muted-foreground text-sm'>
|
|
Total Wallet
|
|
</span>
|
|
<span className='text-lg font-semibold'>
|
|
{formatAmount(totals.totalWallet)}
|
|
</span>
|
|
</div>
|
|
<div className='flex items-center justify-between'>
|
|
<span className='text-muted-foreground text-sm'>
|
|
User Balance
|
|
</span>
|
|
<span className='text-lg font-semibold'>
|
|
{formatAmount(totals.totalUser)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p className='text-muted-foreground text-xs'>
|
|
Your balance = Total wallet - User balance
|
|
</p>
|
|
|
|
<div className='overflow-hidden rounded-lg border'>
|
|
{/* Desktop Table Header */}
|
|
<div className='bg-muted hidden grid-cols-4 gap-2 p-3 text-sm font-semibold md:grid'>
|
|
<div>Mint / Unit</div>
|
|
<div className='text-right'>Wallet</div>
|
|
<div className='text-right'>Users</div>
|
|
<div className='text-right'>Owner</div>
|
|
</div>
|
|
|
|
{data && data.length > 0 ? (
|
|
data
|
|
.filter(
|
|
(detail) =>
|
|
(detail.wallet_balance && detail.wallet_balance > 0) ||
|
|
detail.error
|
|
)
|
|
.map((detail, index) => {
|
|
const walletMsat = convertToMsat(
|
|
detail.wallet_balance || 0,
|
|
detail.unit
|
|
);
|
|
const userMsat = convertToMsat(
|
|
detail.user_balance || 0,
|
|
detail.unit
|
|
);
|
|
const ownerMsat = convertToMsat(
|
|
detail.owner_balance || 0,
|
|
detail.unit
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
'border-t p-3 text-sm',
|
|
detail.error && 'bg-destructive/10 text-destructive'
|
|
)}
|
|
>
|
|
{/* Desktop Layout */}
|
|
<div className='hidden grid-cols-4 gap-2 md:grid'>
|
|
<div className='text-xs break-all'>
|
|
{detail.mint_url
|
|
.replace('https://', '')
|
|
.replace('http://', '')}{' '}
|
|
• {detail.unit.toUpperCase()}
|
|
</div>
|
|
<div className='text-right font-mono'>
|
|
{detail.error
|
|
? 'error'
|
|
: formatAmount(walletMsat)}
|
|
</div>
|
|
<div className='text-right font-mono'>
|
|
{detail.error ? '-' : formatAmount(userMsat)}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'text-right font-mono',
|
|
!detail.error &&
|
|
ownerMsat > 0 &&
|
|
'font-semibold text-green-600'
|
|
)}
|
|
>
|
|
{detail.error ? '-' : formatAmount(ownerMsat)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Layout */}
|
|
<div className='space-y-3 md:hidden'>
|
|
<div className='space-y-1'>
|
|
<span className='text-muted-foreground text-xs font-medium'>
|
|
Mint / Unit
|
|
</span>
|
|
<div className='font-mono text-xs break-all'>
|
|
{detail.mint_url
|
|
.replace('https://', '')
|
|
.replace('http://', '')}{' '}
|
|
• {detail.unit.toUpperCase()}
|
|
</div>
|
|
</div>
|
|
<div className='grid grid-cols-3 gap-2'>
|
|
<div className='space-y-1'>
|
|
<div className='text-muted-foreground text-xs font-medium'>
|
|
Wallet
|
|
</div>
|
|
<div className='truncate font-mono text-sm'>
|
|
{detail.error
|
|
? 'error'
|
|
: formatAmount(walletMsat)}
|
|
</div>
|
|
</div>
|
|
<div className='space-y-1'>
|
|
<div className='text-muted-foreground text-xs font-medium'>
|
|
Users
|
|
</div>
|
|
<div className='truncate font-mono text-sm'>
|
|
{detail.error ? '-' : formatAmount(userMsat)}
|
|
</div>
|
|
</div>
|
|
<div className='space-y-1'>
|
|
<div className='text-muted-foreground text-xs font-medium'>
|
|
Owner
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'truncate font-mono text-sm',
|
|
!detail.error &&
|
|
ownerMsat > 0 &&
|
|
'font-semibold text-green-600'
|
|
)}
|
|
>
|
|
{detail.error ? '-' : formatAmount(ownerMsat)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className='text-muted-foreground p-4 text-center text-sm'>
|
|
No balances to display
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<WithdrawModal
|
|
open={withdrawModalOpen}
|
|
onOpenChange={setWithdrawModalOpen}
|
|
balances={data || []}
|
|
onSuccess={() => {
|
|
refetch();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|