diff --git a/ui/app/page.tsx b/ui/app/page.tsx index da8ffba3..b9917325 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -1,88 +1,5 @@ -'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'; +import { CheatSheet } from '@/components/landing/cheat-sheet'; 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 -

-
-
- { - if (value) { - setDisplayUnit(value as DisplayUnit); - } - }} - variant='outline' - size='sm' - > - mSAT - sat - - USD - - -
-
- -
-
- -
-
- -
-
-
-
-
- ); + return ; } diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx new file mode 100644 index 00000000..838b2afc --- /dev/null +++ b/ui/components/landing/cheat-sheet.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Bolt, + Copy, + KeyRound, + RefreshCcw, + ShieldCheck, + Terminal, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { ConfigurationService } from '@/lib/api/services/configuration'; + +type NodeInfo = { + name: string; + description: string; + version: string; + npub?: string | null; + mints: string[]; + http_url?: string | null; + onion_url?: string | null; +}; + +type WalletSnapshot = { + apiKey: string; + balanceMsats: number; + reservedMsats: number; +}; + +type RefundReceipt = { + token?: string; + recipient?: string; + sats?: string; + msats?: string; +}; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:8000'; + +async function fetchNodeInfo(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/v1/info`, { + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load node info'); + } + + const payload = (await response.json()) as NodeInfo; + return { + ...payload, + mints: Array.isArray(payload.mints) ? payload.mints : [], + }; +} + +async function fetchWalletInfo( + baseUrl: string, + apiKey: string +): Promise { + 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(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = (await response.json()) as { + api_key: string; + balance: number; + reserved?: number; + }; + + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + }; +} + +function normalizeBaseUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + return trimmed.replace(/\/+$/, ''); +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +export function CheatSheet(): JSX.Element { + const [baseUrl, setBaseUrl] = useState(() => + typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl() + ); + const [initialToken, setInitialToken] = useState(''); + const [topupToken, setTopupToken] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [walletInfo, setWalletInfo] = useState(null); + const [refundReceipt, setRefundReceipt] = useState(null); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [isTopupLoading, setIsTopupLoading] = useState(false); + const [isRefunding, setIsRefunding] = useState(false); + const [isSyncingBalance, setIsSyncingBalance] = useState(false); + const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [hasInteractedManage, setHasInteractedManage] = useState(false); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); + + useEffect(() => { + if (!baseUrl && typeof window !== 'undefined') { + setBaseUrl(ConfigurationService.getLocalBaseUrl()); + } + }, [baseUrl]); + + const normalizedBaseUrl = useMemo( + () => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL, + [baseUrl] + ); + + const activeApiKey = apiKeyInput.trim(); + + const { + data: nodeInfo, + isLoading: isInfoLoading, + isError: isInfoError, + refetch: refetchNodeInfo, + } = useQuery({ + queryKey: ['node-info', normalizedBaseUrl], + queryFn: () => fetchNodeInfo(normalizedBaseUrl), + enabled: Boolean(normalizedBaseUrl), + refetchInterval: 300_000, + staleTime: 120_000, + }); + + const handleCopy = useCallback(async (value: string): Promise => { + if (!value) { + return; + } + if (typeof navigator === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard API unavailable'); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + } catch (error) { + console.error(error); + toast.error('Unable to copy'); + } + }, []); + + const handleCreateKey = useCallback(async (): Promise => { + if (!initialToken.trim()) { + toast.error('Cashu token required'); + return; + } + + setIsCreatingKey(true); + setRefundReceipt(null); + + try { + const params = new URLSearchParams({ + initial_balance_token: initialToken.trim(), + }); + const response = await fetch( + `${normalizedBaseUrl}/v1/balance/create?${params.toString()}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to create API key'); + } + const payload = (await response.json()) as { + api_key: string; + balance: number; + }; + const snapshot: WalletSnapshot = { + apiKey: payload.api_key, + balanceMsats: payload.balance ?? 0, + reservedMsats: 0, + }; + setApiKeyInput(snapshot.apiKey); + setWalletInfo(snapshot); + setInitialToken(''); + toast.success('API key ready'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to create API key' + ); + } finally { + setIsCreatingKey(false); + } + }, [initialToken, normalizedBaseUrl]); + + const handleSyncBalance = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsSyncingBalance(true); + try { + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + toast.success('Balance synced'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Failed to sync balance' + ); + } finally { + setIsSyncingBalance(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleTopup = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + if (!topupToken.trim()) { + toast.error('Cashu token required for top-up'); + return; + } + + setIsTopupLoading(true); + setRefundReceipt(null); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${activeApiKey}`, + }, + body: JSON.stringify({ cashu_token: topupToken.trim() }), + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to top up'); + } + const payload = (await response.json()) as { msats: number }; + toast.success(`Added ${formatSats(payload.msats)} sats`); + setTopupToken(''); + const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey); + setWalletInfo(snapshot); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Top-up failed'); + } finally { + setIsTopupLoading(false); + } + }, [activeApiKey, normalizedBaseUrl, topupToken]); + + const handleRefund = useCallback(async (): Promise => { + if (!activeApiKey) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${normalizedBaseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${activeApiKey}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const payload = (await response.json()) as RefundReceipt; + setRefundReceipt(payload); + setWalletInfo(null); + toast.success('Refund requested'); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [activeApiKey, normalizedBaseUrl]); + + const handleRefreshInfo = useCallback(async (): Promise => { + const result = await refetchNodeInfo(); + if (result.error) { + toast.error('Unable to refresh node info'); + } else { + toast.success('Node info refreshed'); + } + }, [refetchNodeInfo]); + + const curlSnippet = useMemo(() => { + const keyPreview = activeApiKey || 'YOUR_API_KEY'; + return [ + `curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`, + ` -H "Authorization: Bearer ${keyPreview}"`, + ' -H "Content-Type: application/json"', + " -d '{", + ' "model": "openai/gpt-4o-mini",', + ' "messages": [', + ' {"role":"system","content":"You are Routstr."},', + ' {"role":"user","content":"Ping the node"}', + ' ]', + " }'", + ].join('\n'); + }, [activeApiKey, normalizedBaseUrl]); + + const showCreateDetails = + hasInteractedCreate || initialToken.trim().length > 0; + const showManageDetails = hasInteractedManage || Boolean(walletInfo); + const showTopupDetails = + hasInteractedTopup || topupToken.trim().length > 0; + const refundToken = refundReceipt?.token ?? null; + const canTopup = Boolean(activeApiKey); + + return ( +
+
+
+
+ +
+
+ + Routstr cheat sheet +
+

+ Node Identity and Cheat Sheet +

+
+ +
+
+ +
+ + +
+ + + Node identity + +

+ /v1/info snapshot +

+
+ +
+ + {isInfoLoading && ( +

+ Loading node profile… +

+ )} + {isInfoError && !isInfoLoading && ( +

+ Unable to reach /v1/info at {normalizedBaseUrl} +

+ )} + {nodeInfo && ( + <> +
+

{nodeInfo.name}

+

+ {nodeInfo.description} +

+
+
+
+
+ Version +
+
+ {nodeInfo.version} +
+
+
+
+ HTTP +
+
+ {nodeInfo.http_url || normalizedBaseUrl} +
+
+ {nodeInfo.onion_url && ( +
+
+ Onion +
+
+ {nodeInfo.onion_url} +
+
+ )} + {nodeInfo.npub && ( +
+
+ npub +
+
+ {nodeInfo.npub} + +
+
+ )} +
+
+

+ Cashu mints +

+
+ {nodeInfo.mints.length ? ( + nodeInfo.mints.map((mint) => ( + + {mint} + + )) + ) : ( +

+ No mint list published +

+ )} +
+
+ + )} +
+
+ + + + + + Quick docs + + + curl-ready + + + +
+ setBaseUrl(event.target.value)} + className='text-sm' + /> + +
+
+
{curlSnippet}
+
+
+ + +
+
+
+
+ + + + + + API key workflow + +

+ Sections expand as soon as you interact +

+
+ +
+
+ 1 · Create key + {showCreateDetails && ( + Cashu token detected + )} +
+