add: msat/sat/usd unit toggle

This commit is contained in:
Shroominic
2025-11-02 13:34:57 +08:00
parent beedfdc1d2
commit 293f8471b3
6 changed files with 291 additions and 114 deletions
+62 -9
View File
@@ -1,31 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
import { TemporaryBalances } from '@/components/temporary-balances';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { DisplayUnit } from '@/lib/types/units';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
export default function Page() {
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
queryFn: fetchBtcUsdPrice,
refetchInterval: 120_000,
staleTime: 60_000,
});
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
useEffect(() => {
if (displayUnit === 'usd' && usdPerSat === null) {
setDisplayUnit('sat');
}
}, [displayUnit, usdPerSat]);
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='text-3xl font-bold tracking-tight'>
Admin Dashboard
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>
Admin Dashboard
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
</div>
<div className='flex items-center'>
<ToggleGroup
type='single'
value={displayUnit}
onValueChange={(value) => {
if (value) {
setDisplayUnit(value as DisplayUnit);
}
}}
variant='outline'
size='sm'
>
<ToggleGroupItem value='msat'>mSAT</ToggleGroupItem>
<ToggleGroupItem value='sat'>sat</ToggleGroupItem>
<ToggleGroupItem value='usd' disabled={!usdPerSat}>
USD
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance refreshInterval={30000} />
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances refreshInterval={60000} />
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</div>
</div>
+96 -97
View File
@@ -14,11 +14,17 @@ import {
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);
@@ -30,20 +36,8 @@ export function DetailedWalletBalance({
refetchInterval: refreshInterval,
});
const convertToMsat = (amount: number, unit: string): number => {
if (unit === 'sat') {
return amount * 1000; // Convert sat to msat
}
return amount; // Already in msat
};
const formatMsatBalance = (msatAmount: number): string => {
if (msatAmount >= 1000) {
// Display as sats if >= 1000 msat
return `${(msatAmount / 1000).toLocaleString()} sats`;
}
return `${msatAmount.toLocaleString()} msat`;
};
const formatAmount = (msatAmount: number): string =>
formatFromMsat(msatAmount, displayUnit, usdPerSat);
const calculateTotals = (balances: BalanceDetail[]) => {
let totalWallet = 0;
@@ -127,7 +121,7 @@ export function DetailedWalletBalance({
Your Balance (Total)
</span>
<span className='text-2xl font-bold text-green-600'>
{formatMsatBalance(totals.totalOwner)}
{formatAmount(totals.totalOwner)}
</span>
</div>
<div className='flex items-center justify-between'>
@@ -135,7 +129,7 @@ export function DetailedWalletBalance({
Total Wallet
</span>
<span className='text-lg font-semibold'>
{formatMsatBalance(totals.totalWallet)}
{formatAmount(totals.totalWallet)}
</span>
</div>
<div className='flex items-center justify-between'>
@@ -143,7 +137,7 @@ export function DetailedWalletBalance({
User Balance
</span>
<span className='text-lg font-semibold'>
{formatMsatBalance(totals.totalUser)}
{formatAmount(totals.totalUser)}
</span>
</div>
</div>
@@ -168,101 +162,106 @@ export function DetailedWalletBalance({
(detail.wallet_balance && detail.wallet_balance > 0) ||
detail.error
)
.map((detail, index) => (
<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'
: `${detail.wallet_balance.toLocaleString()} ${detail.unit}`}
</div>
<div className='text-right font-mono'>
{detail.error
? '-'
: `${detail.user_balance.toLocaleString()} ${detail.unit}`}
</div>
<div
className={cn(
'text-right font-mono',
!detail.error &&
detail.owner_balance > 0 &&
'font-semibold text-green-600'
)}
>
{detail.error
? '-'
: `${detail.owner_balance.toLocaleString()} ${detail.unit}`}
</div>
</div>
.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
);
{/* 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'>
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>
<div className='grid grid-cols-3 gap-2'>
{/* Mobile Layout */}
<div className='space-y-3 md:hidden'>
<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'
: `${detail.wallet_balance.toLocaleString()} ${detail.unit}`}
<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='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Users
<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='truncate font-mono text-sm'>
{detail.error
? '-'
: `${detail.user_balance.toLocaleString()} ${detail.unit}`}
<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>
<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 &&
detail.owner_balance > 0 &&
'font-semibold text-green-600'
)}
>
{detail.error
? '-'
: `${detail.owner_balance.toLocaleString()} ${detail.unit}`}
<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>
))
);
})
) : (
<div className='text-muted-foreground p-4 text-center text-sm'>
No balances to display
@@ -284,4 +283,4 @@ export function DetailedWalletBalance({
/>
</>
);
}
}
+16 -8
View File
@@ -11,7 +11,10 @@ import {
DollarSign,
Activity,
} from 'lucide-react';
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
import {
AdminService,
TemporaryBalance,
} from '@/lib/api/services/admin';
import {
Card,
CardContent,
@@ -21,11 +24,17 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
import { formatFromMsat } from '@/lib/currency';
export function TemporaryBalances({
refreshInterval = 10000,
displayUnit,
usdPerSat,
}: {
refreshInterval?: number;
displayUnit: DisplayUnit;
usdPerSat: number | null;
}) {
const [searchTerm, setSearchTerm] = useState('');
@@ -37,9 +46,8 @@ export function TemporaryBalances({
refetchInterval: refreshInterval,
});
const formatBalance = (balance: number) => {
return `${balance.toLocaleString()} msat`;
};
const formatBalance = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const filteredData = data
? data.filter(
@@ -235,16 +243,16 @@ export function TemporaryBalances({
<div className='text-muted-foreground text-xs font-medium'>
Balance
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.balance)}
<div className='truncate font-mono text-sm'>
{formatBalance(balance.balance)}
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
Spent
</div>
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
<div className='truncate font-mono text-sm'>
{formatBalance(balance.total_spent)}
</div>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
import type { DisplayUnit } from './types/units';
export type CashuUnit = 'sat' | 'msat';
export function convertToMsat(amount: number, unit: string): number {
if (Number.isNaN(amount)) {
return 0;
}
if (unit === 'sat') {
return amount * 1000;
}
return amount;
}
export function formatFromMsat(
amountMsat: number,
displayUnit: DisplayUnit,
usdPerSat: number | null
): string {
if (displayUnit === 'msat') {
return `${amountMsat.toLocaleString()} msat`;
}
if (displayUnit === 'sat') {
const sats = amountMsat / 1000;
return `${sats.toLocaleString()} sats`;
}
if (usdPerSat === null) {
return '—';
}
const sats = amountMsat / 1000;
const usd = sats * usdPerSat;
const precision = Math.abs(usd) >= 1 ? 2 : 4;
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: precision,
maximumFractionDigits: Math.max(precision, 6),
});
return formatter.format(usd);
}
+54
View File
@@ -0,0 +1,54 @@
export async function fetchBtcUsdPrice(): Promise<number | null> {
const sources = [
async () => {
const response = await fetch(
'https://api.kraken.com/0/public/Ticker?pair=XBTUSD'
);
const data = await response.json();
return parseFloat(data.result.XXBTZUSD.c[0]);
},
async () => {
const response = await fetch(
'https://api.coinbase.com/v2/prices/BTC-USD/spot'
);
const data = await response.json();
return parseFloat(data.data.amount);
},
async () => {
const response = await fetch(
'https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT'
);
const data = await response.json();
return parseFloat(data.price);
},
];
const results = await Promise.allSettled(
sources.map((fn) =>
Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000)
),
])
)
);
const prices: number[] = [];
for (const result of results) {
if (result.status === 'fulfilled' && typeof result.value === 'number') {
prices.push(result.value);
}
}
if (prices.length === 0) {
return null;
}
return Math.min(...prices);
}
export function btcToSatsRate(btcUsdPrice: number): number {
return btcUsdPrice / 100_000_000;
}
+17
View File
@@ -0,0 +1,17 @@
export type DisplayUnit = 'msat' | 'sat' | 'usd';
export const DISPLAY_UNITS: DisplayUnit[] = ['msat', 'sat', 'usd'];
export function getDisplayUnitLabel(unit: DisplayUnit): string {
switch (unit) {
case 'msat':
return 'mSAT';
case 'sat':
return 'sat';
case 'usd':
return 'USD';
default:
return unit;
}
}