mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
add: msat/sat/usd unit toggle
This commit is contained in:
+62
-9
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user