diff --git a/ui/components/providers/ProviderBalance.tsx b/ui/components/providers/ProviderBalance.tsx
new file mode 100644
index 00000000..2ebb3966
--- /dev/null
+++ b/ui/components/providers/ProviderBalance.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { AdminService } from '@/lib/api/services/admin';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Separator } from '@/components/ui/separator';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { SimpleLightningTopup } from './SimpleLightningTopup';
+import { SimpleCashuTopup } from './SimpleCashuTopup';
+
+interface ProviderBalanceProps {
+ providerId: number;
+ platformUrl?: string | null;
+ isRoutstr?: boolean;
+ nodeUrl?: string;
+}
+
+export function ProviderBalance({
+ providerId,
+ platformUrl,
+ isRoutstr = false,
+ nodeUrl,
+}: ProviderBalanceProps) {
+ const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const queryClient = useQueryClient();
+
+ const {
+ data: balanceData,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['provider-balance', providerId],
+ queryFn: () => AdminService.getProviderBalance(providerId),
+ refetchInterval: 30000,
+ refetchOnWindowFocus: true,
+ retry: 1,
+ });
+
+ const handleTopUpClick = () => {
+ if (
+ platformUrl &&
+ (platformUrl.includes('openrouter.ai') ||
+ platformUrl.includes('openai.com'))
+ ) {
+ window.open(platformUrl, '_blank');
+ return;
+ }
+
+ setIsTopupDialogOpen(true);
+ };
+
+ const handleCloseDialog = () => {
+ setIsTopupDialogOpen(false);
+ queryClient.invalidateQueries({
+ queryKey: ['provider-balance', providerId],
+ });
+ };
+
+ if (isLoading) {
+ return
;
+ }
+
+ if (
+ error ||
+ !balanceData?.ok ||
+ balanceData.balance_data === undefined ||
+ balanceData.balance_data === null
+ ) {
+ return null;
+ }
+
+ const balance = balanceData.balance_data;
+ let displayValue = 'N/A';
+
+ if (typeof balance === 'number') {
+ displayValue = isRoutstr
+ ? `${balance.toLocaleString()} sats`
+ : `$${balance.toFixed(2)}`;
+ } else if (balance && typeof balance === 'object') {
+ const b = balance as Record
;
+ if (typeof b.balance === 'number') {
+ displayValue = `$${b.balance.toFixed(2)}`;
+ } else if (typeof b.balance === 'string') {
+ displayValue = b.balance;
+ } else if (b.amount !== undefined) {
+ displayValue = `$${Number(b.amount).toFixed(2)}`;
+ }
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/ui/components/providers/RoutstrNodeSettings.tsx b/ui/components/providers/RoutstrNodeSettings.tsx
new file mode 100644
index 00000000..18ace0dc
--- /dev/null
+++ b/ui/components/providers/RoutstrNodeSettings.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+
+interface ProviderSettings {
+ topup_mint_url?: string;
+ auto_topup?: boolean;
+ topup_threshold?: number;
+ topup_amount_limit?: number;
+ refund_on_expiry?: boolean;
+ [key: string]: unknown;
+}
+
+interface RoutstrNodeSettingsProps {
+ settings: ProviderSettings;
+ onSettingsChange: (settings: ProviderSettings) => void;
+ availableMints: string[];
+ idPrefix?: string;
+}
+
+export function RoutstrNodeSettings({
+ settings,
+ onSettingsChange,
+ availableMints,
+ idPrefix = '',
+}: RoutstrNodeSettingsProps) {
+ const prefix = idPrefix ? `${idPrefix}_` : '';
+
+ const update = (patch: Partial) => {
+ onSettingsChange({ ...settings, ...patch });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ The token for top-up will be created from this mint.
+
+
+
+
+
+ update({ auto_topup: checked })}
+ />
+
+
+ {settings.auto_topup && (
+
+ )}
+
+
+ );
+}