use react query

This commit is contained in:
9qeklajc
2026-03-25 10:21:18 +01:00
parent a1c2a785a1
commit bb97e8dedb
4 changed files with 165 additions and 154 deletions
+55 -25
View File
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletService } from '@/lib/api/services/wallet';
import { ApiKeyInput } from './api-key-input';
import { Button } from '@/components/ui/button';
@@ -15,17 +16,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
@@ -43,6 +35,14 @@ interface ChildKeyCreatorProps {
costPerKeyMsats?: number;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
@@ -62,14 +62,18 @@ export function ChildKeyCreator({
},
]);
const [childKeyToCheck, setChildKeyToCheck] = useState('');
const [checking, setChecking] = useState(false);
const [keyStatus, setKeyStatus] = useState<{
total_spent: number;
balance_limit: number | null;
validity_date: number | null;
is_expired: boolean;
is_drained: boolean;
} | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const { data: walletInfo, refetch: refetchWalletInfo } = useWalletInfo(
baseUrl ?? '',
activeApiKey
);
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
@@ -77,13 +81,6 @@ export function ChildKeyCreator({
} | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const addConfig = () => {
setConfigs([
...configs,
@@ -269,6 +266,39 @@ export function ChildKeyCreator({
<Copy className='h-4 w-4' />
</Button>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
</div>
)}
@@ -10,9 +10,9 @@ import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import { WalletBalanceStats } from './wallet-balance-stats';
import { ApiKeyInput } from '../api-key-input';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
import { useWalletInfo } from '@/hooks/use-wallet-info';
export type RefundReceipt = {
token?: string;
@@ -31,68 +31,18 @@ interface CashuPaymentWorkflowProps {
onRefundComplete?: (receipt: RefundReceipt) => void;
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = errorText || 'Unable to load wallet info';
try {
const parsed = JSON.parse(errorText);
// The error structure seems to be { detail: { error: { message: ... } } }
errorMessage =
parsed.detail?.error?.message ||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
} catch {}
throw new Error(errorMessage);
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function CashuPaymentWorkflow({
baseUrl,
apiKey = '',
walletInfo = null,
walletInfo: propWalletInfo = null,
onApiKeyCreated,
onWalletInfoUpdated,
}: CashuPaymentWorkflowProps): JSX.Element {
@@ -101,7 +51,6 @@ export function CashuPaymentWorkflow({
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
@@ -111,6 +60,13 @@ export function CashuPaymentWorkflow({
const activeApiKey = apiKeyInput.trim();
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, activeApiKey);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
@@ -211,11 +167,9 @@ export function CashuPaymentWorkflow({
return;
}
setIsSyncingBalance(true);
setError(null);
try {
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
await refetch();
toast.success('Balance synced');
} catch (error) {
console.error(error);
@@ -223,10 +177,8 @@ export function CashuPaymentWorkflow({
error instanceof Error ? error.message : 'Failed to sync balance';
setError(message);
toast.error(message);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
}, [activeApiKey, refetch]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
@@ -255,15 +207,14 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onApiKeyCreated?.(snapshot.apiKey, snapshot);
await refetch();
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
}, [activeApiKey, baseUrl, topupToken, refetch]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
@@ -363,13 +314,48 @@ export function CashuPaymentWorkflow({
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
disabled={isFetching || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
<RefreshCcw
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
Sync
</Button>
</div>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
{error && (
<Alert variant='destructive' className='mt-2'>
<AlertTitle>Error</AlertTitle>
+18 -61
View File
@@ -1,7 +1,6 @@
'use client';
import { type JSX, useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import type { RefundReceipt } from './cashu-payment-workflow';
import { toast } from 'sonner';
import { ApiKeyInput } from '../api-key-input';
@@ -16,6 +15,8 @@ import {
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { WalletService } from '@/lib/api/services/wallet';
import { useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
export type ChildKeyInfo = {
api_key: string;
@@ -52,76 +53,33 @@ interface KeyInfoDetailsProps {
export function KeyInfoDetails({
baseUrl,
apiKey = '',
walletInfo = null,
walletInfo: propWalletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: KeyInfoDetailsProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isResetting, setIsResetting] = useState<string | null>(null);
const [isRefunding, setIsRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, apiKeyInput);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
// Sync internal state with props if they change
useEffect(() => {
setApiKeyInput(apiKey);
}, [apiKey]);
const fetchDetails = useCallback(
async (keyToFetch: string) => {
setIsRefreshing(true);
setError(null);
try {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: { Authorization: `Bearer ${keyToFetch}` },
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = errorText || 'Failed to fetch key info';
try {
const parsed = JSON.parse(errorText);
// The error structure seems to be { detail: { error: { message: ... } } }
errorMessage =
parsed.detail?.error?.message ||
(typeof parsed.detail === 'string'
? parsed.detail
: errorMessage);
} catch {}
throw new Error(errorMessage);
}
const payload = await response.json();
const snapshot: WalletSnapshot = {
apiKey: payload.api_key || keyToFetch,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
onWalletInfoUpdated?.(snapshot);
toast.success('Key details synced');
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to fetch details';
setError(message);
} finally {
setIsRefreshing(false);
}
},
[baseUrl, onWalletInfoUpdated]
);
const handleRefresh = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!apiKeyInput) return;
await fetchDetails(apiKeyInput);
await refetch();
};
const handleKeyChange = (newKey: string) => {
@@ -146,7 +104,7 @@ export function KeyInfoDetails({
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await fetchDetails(apiKeyInput);
await refetch();
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
@@ -176,16 +134,15 @@ export function KeyInfoDetails({
}
const receipt = (await response.json()) as RefundReceipt;
onRefundComplete?.(receipt);
// Removed onWalletInfoUpdated?.(null); // Prevents card disappearance
toast.success('Refund completed');
await fetchDetails(apiKeyInput); // Refresh to show burned status
await refetch();
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]);
}, [apiKeyInput, baseUrl, onRefundComplete, refetch]);
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
@@ -220,13 +177,13 @@ export function KeyInfoDetails({
size='sm'
className='min-w-[80px] gap-1'
onClick={handleRefresh}
disabled={isRefreshing || !apiKeyInput}
disabled={isFetching || !apiKeyInput}
type='button'
>
<RefreshCcw
className={`h-8 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
className={`h-8 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
{isRefreshing ? 'Syncing...' : 'Sync'}
{isFetching ? 'Syncing...' : 'Sync'}
</Button>
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import type { WalletSnapshot } from '@/components/landing/key-info-details';
export function useWalletInfo(baseUrl: string, apiKey: string) {
return useQuery({
queryKey: ['walletInfo', baseUrl, apiKey],
queryFn: async (): Promise<WalletSnapshot> => {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = await response.json();
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
},
enabled: !!baseUrl && !!apiKey,
staleTime: 5000, // Consider data stale after 5 seconds
});
}