Files
routstr-core/ui/components/detailed-wallet-balance.tsx

405 lines
15 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import { getApiErrorMessage } from '@/lib/api/errors';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
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 queryClient = useQueryClient();
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 };
const rows = (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 {
key: `${detail.mint_url}-${detail.unit}-${index}`,
detail,
walletMsat,
userMsat,
ownerMsat,
};
});
const formatMintLabel = (detail: BalanceDetail) =>
`${detail.mint_url.replace('https://', '').replace('http://', '')} • ${detail.unit.toUpperCase()}`;
const formatBalanceError = (detail: BalanceDetail) => {
const labels: Record<string, string> = {
rate_limited: 'rate limited',
unreachable: 'unreachable',
cooldown: 'cooling down',
mint_error: 'mint error',
};
const label =
(detail.error_code ? labels[detail.error_code] : undefined) ??
detail.error ??
'error';
const retryAfter = detail.retry_after_seconds;
return retryAfter && retryAfter > 0
? `${label} (retry in ${Math.ceil(retryAfter)}s)`
: label;
};
return (
<>
<Card>
<CardHeader className='pb-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>Cashu Wallet Balance</CardTitle>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</div>
<div className='flex w-full gap-2 sm:w-auto'>
<Button
variant='default'
size='sm'
onClick={() => setWithdrawModalOpen(true)}
disabled={isLoading || !data || data.length === 0}
className='flex-1 sm:flex-none'
>
<Wallet className='mr-2 h-4 w-4' />
Withdraw
</Button>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh balance</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={`wallet-stat-skeleton-${index}`}>
<CardHeader className='space-y-2 pb-1'>
<Skeleton className='h-3.5 w-28' />
<Skeleton className='h-3 w-8' />
</CardHeader>
<CardContent className='pt-0'>
<Skeleton className='h-7 w-24' />
</CardContent>
</Card>
))}
</div>
<Skeleton className='h-3 w-52' />
<div className='space-y-2'>
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`wallet-row-skeleton-${index}`}
className='h-11 w-full'
/>
))}
</div>
</div>
) : isError ? (
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
Error loading balance:{' '}
{getApiErrorMessage(error, 'Request failed')}
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<div className='grid gap-3 md:grid-cols-3'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Your Balance (Total)
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Coins className='size-4 text-green-600 dark:text-green-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-primary text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalOwner)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
Total Wallet
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<Wallet className='size-4 text-blue-600 dark:text-blue-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalWallet)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-muted-foreground text-sm font-medium'>
User Balance
</CardTitle>
<span className='inline-flex size-8 items-center justify-center'>
<User className='size-4 text-purple-600 dark:text-purple-300' />
</span>
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatAmount(totals.totalUser)}
</p>
</CardContent>
</Card>
</div>
<p className='text-muted-foreground text-xs'>
Your balance = Total wallet - User balance
</p>
{rows.length > 0 ? (
<>
<div className='hidden md:block'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Mint / Unit</TableHead>
<TableHead className='text-right'>Wallet</TableHead>
<TableHead className='text-right'>Users</TableHead>
<TableHead className='text-right'>Owner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(
({
key,
detail,
walletMsat,
userMsat,
ownerMsat,
}) => (
<TableRow
key={key}
className={cn(
detail.error &&
'bg-destructive/10 text-destructive'
)}
>
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
{formatMintLabel(detail)}
</TableCell>
<TableCell
className='text-right font-mono'
title={detail.error}
>
{detail.error
? formatBalanceError(detail)
: formatAmount(walletMsat)}
</TableCell>
<TableCell className='text-right font-mono'>
{detail.error ? '-' : formatAmount(userMsat)}
</TableCell>
<TableCell
className={cn(
'text-right font-mono',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
<div className='space-y-2 md:hidden'>
{rows.map(
({ key, detail, walletMsat, userMsat, ownerMsat }) => (
<Card
key={`${key}-mobile`}
className={cn(
detail.error &&
'border-destructive/40 bg-destructive/5'
)}
>
<CardHeader className='p-4 pb-2'>
<CardDescription className='font-mono text-xs break-all'>
{formatMintLabel(detail)}
</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-2 p-4 pt-0 sm:grid-cols-3 sm:gap-3'>
<div>
<p className='text-muted-foreground text-xs'>
Wallet
</p>
<p
className='font-mono text-sm'
title={detail.error}
>
{detail.error
? formatBalanceError(detail)
: formatAmount(walletMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Users
</p>
<p className='font-mono text-sm'>
{detail.error ? '-' : formatAmount(userMsat)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Owner
</p>
<p
className={cn(
'font-mono text-sm',
!detail.error &&
ownerMsat > 0 &&
'text-primary font-semibold'
)}
>
{detail.error ? '-' : formatAmount(ownerMsat)}
</p>
</div>
</CardContent>
</Card>
)
)}
</div>
</>
) : (
<Empty className='py-6'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Wallet className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No balances to display</EmptyTitle>
<EmptyDescription>
Wallet balances will appear here after funds are
available.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
)}
</CardContent>
</Card>
<WithdrawModal
open={withdrawModalOpen}
onOpenChange={setWithdrawModalOpen}
balances={data || []}
onSuccess={() => {
refetch();
// The success dialog tells the operator the token is in the
// Withdrawals tab; without this the cached tab stays stale for 5 min.
queryClient.invalidateQueries({
queryKey: ['transactions', 'admin'],
});
}}
/>
</>
);
}