diff --git a/ui/app/page.tsx b/ui/app/page.tsx index 3fd44ad3..da8ffba3 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -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('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 (
-
-

- Admin Dashboard -

-

- Monitor and manage wallet balances -

+
+
+

+ Admin Dashboard +

+

+ Monitor and manage wallet balances +

+
+
+ { + if (value) { + setDisplayUnit(value as DisplayUnit); + } + }} + variant='outline' + size='sm' + > + mSAT + sat + + USD + + +
- +
- +
diff --git a/ui/components/detailed-wallet-balance.tsx b/ui/components/detailed-wallet-balance.tsx index c1a57674..e5dd69b4 100644 --- a/ui/components/detailed-wallet-balance.tsx +++ b/ui/components/detailed-wallet-balance.tsx @@ -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) - {formatMsatBalance(totals.totalOwner)} + {formatAmount(totals.totalOwner)}
@@ -135,7 +129,7 @@ export function DetailedWalletBalance({ Total Wallet - {formatMsatBalance(totals.totalWallet)} + {formatAmount(totals.totalWallet)}
@@ -143,7 +137,7 @@ export function DetailedWalletBalance({ User Balance - {formatMsatBalance(totals.totalUser)} + {formatAmount(totals.totalUser)}
@@ -168,101 +162,106 @@ export function DetailedWalletBalance({ (detail.wallet_balance && detail.wallet_balance > 0) || detail.error ) - .map((detail, index) => ( -
- {/* Desktop Layout */} -
-
- {detail.mint_url - .replace('https://', '') - .replace('http://', '')}{' '} - • {detail.unit.toUpperCase()} -
-
- {detail.error - ? 'error' - : `${detail.wallet_balance.toLocaleString()} ${detail.unit}`} -
-
- {detail.error - ? '-' - : `${detail.user_balance.toLocaleString()} ${detail.unit}`} -
-
0 && - 'font-semibold text-green-600' - )} - > - {detail.error - ? '-' - : `${detail.owner_balance.toLocaleString()} ${detail.unit}`} -
-
+ .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 */} -
-
- - Mint / Unit - -
+ return ( +
+ {/* Desktop Layout */} +
+
{detail.mint_url .replace('https://', '') .replace('http://', '')}{' '} • {detail.unit.toUpperCase()}
+
+ {detail.error + ? 'error' + : formatAmount(walletMsat)} +
+
+ {detail.error ? '-' : formatAmount(userMsat)} +
+
0 && + 'font-semibold text-green-600' + )} + > + {detail.error ? '-' : formatAmount(ownerMsat)} +
-
+ + {/* Mobile Layout */} +
-
- Wallet -
-
- {detail.error - ? 'error' - : `${detail.wallet_balance.toLocaleString()} ${detail.unit}`} + + Mint / Unit + +
+ {detail.mint_url + .replace('https://', '') + .replace('http://', '')}{' '} + • {detail.unit.toUpperCase()}
-
-
- Users +
+
+
+ Wallet +
+
+ {detail.error + ? 'error' + : formatAmount(walletMsat)} +
-
- {detail.error - ? '-' - : `${detail.user_balance.toLocaleString()} ${detail.unit}`} +
+
+ Users +
+
+ {detail.error ? '-' : formatAmount(userMsat)} +
-
-
-
- Owner -
-
0 && - 'font-semibold text-green-600' - )} - > - {detail.error - ? '-' - : `${detail.owner_balance.toLocaleString()} ${detail.unit}`} +
+
+ Owner +
+
0 && + 'font-semibold text-green-600' + )} + > + {detail.error ? '-' : formatAmount(ownerMsat)} +
-
- )) + ); + }) ) : (
No balances to display @@ -284,4 +283,4 @@ export function DetailedWalletBalance({ /> ); -} +} \ No newline at end of file diff --git a/ui/components/temporary-balances.tsx b/ui/components/temporary-balances.tsx index 8b5778d3..656f3aff 100644 --- a/ui/components/temporary-balances.tsx +++ b/ui/components/temporary-balances.tsx @@ -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({
Balance
-
- {formatBalance(balance.balance)} +
+ {formatBalance(balance.balance)}
Spent
-
- {formatBalance(balance.total_spent)} +
+ {formatBalance(balance.total_spent)}
diff --git a/ui/lib/currency.ts b/ui/lib/currency.ts new file mode 100644 index 00000000..bd5fb3d6 --- /dev/null +++ b/ui/lib/currency.ts @@ -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); +} + diff --git a/ui/lib/exchange-rate.ts b/ui/lib/exchange-rate.ts new file mode 100644 index 00000000..a4131611 --- /dev/null +++ b/ui/lib/exchange-rate.ts @@ -0,0 +1,54 @@ +export async function fetchBtcUsdPrice(): Promise { + 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((_, 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; +} + diff --git a/ui/lib/types/units.ts b/ui/lib/types/units.ts new file mode 100644 index 00000000..162e8613 --- /dev/null +++ b/ui/lib/types/units.ts @@ -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; + } +} +