From 6cf3a6e0724bd897805cb0024c1306a1c60eedc8 Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Tue, 2 Jun 2026 16:06:10 +0000 Subject: [PATCH] Migrate dashboard components from JavaScript to TypeScript --- .../dashboard/{Account.jsx => Account.tsx} | 44 +- .../dashboard/AccountComparison.jsx | 513 ----------- .../dashboard/AccountComparison.tsx | 443 ++++++++++ ...{AdvancedSearch.jsx => AdvancedSearch.tsx} | 0 ....jsx => AdvancedTransactionSimulation.tsx} | 43 +- .../{Analytics.jsx => Analytics.tsx} | 5 +- src/components/dashboard/AuditLog.jsx | 579 ------------ src/components/dashboard/AuditLog.tsx | 468 ++++++++++ .../dashboard/{Builder.jsx => Builder.tsx} | 0 .../{CacheStats.jsx => CacheStats.tsx} | 92 +- ...ableBalances.jsx => ClaimableBalances.tsx} | 77 +- ...borativeView.jsx => CollaborativeView.tsx} | 0 ...omparisonChart.jsx => ComparisonChart.tsx} | 104 ++- .../{ContractABI.jsx => ContractABI.tsx} | 0 ...tractDebugger.jsx => ContractDebugger.tsx} | 0 ...ontractHistory.jsx => ContractHistory.tsx} | 0 ...nteraction.jsx => ContractInteraction.tsx} | 0 .../{Contracts.jsx => Contracts.tsx} | 0 .../{DEXExplorer.jsx => DEXExplorer.tsx} | 173 ++-- .../{DataExport.jsx => DataExport.tsx} | 35 +- .../{ExplorerEmbed.jsx => ExplorerEmbed.tsx} | 14 +- .../dashboard/{Faucet.jsx => Faucet.tsx} | 20 +- ...{LiquidityPools.jsx => LiquidityPools.tsx} | 235 ++--- ...eActivityFeed.jsx => LiveActivityFeed.tsx} | 40 +- .../{NetworkStats.jsx => NetworkStats.tsx} | 0 ...{OrderBookChart.jsx => OrderBookChart.tsx} | 20 +- .../dashboard/{Overview.jsx => Overview.tsx} | 158 ++-- src/components/dashboard/PathExplorer.jsx | 412 --------- src/components/dashboard/PathExplorer.tsx | 417 +++++++++ ...anceMonitor.jsx => PerformanceMonitor.tsx} | 0 ...egistryView.jsx => PluginRegistryView.tsx} | 53 +- ...{PortfolioValue.jsx => PortfolioValue.tsx} | 0 .../{PriceTicker.jsx => PriceTicker.tsx} | 49 +- ...{RealTimeLedger.jsx => RealTimeLedger.tsx} | 44 +- .../dashboard/{Settings.jsx => Settings.tsx} | 163 +++- .../{SystemHealth.jsx => SystemHealth.tsx} | 41 +- ...tionBuilder.jsx => TransactionBuilder.tsx} | 0 ...actionDetail.jsx => TransactionDetail.tsx} | 53 +- ...actionSigner.jsx => TransactionSigner.tsx} | 35 +- ...Simulator.jsx => TransactionSimulator.tsx} | 55 +- .../{Transactions.jsx => Transactions.tsx} | 14 - ...tualizedLists.jsx => VirtualizedLists.tsx} | 61 +- .../{WalletConnect.jsx => WalletConnect.tsx} | 821 ++++++++---------- src/components/dashboard/types.ts | 374 ++++++++ 44 files changed, 2979 insertions(+), 2676 deletions(-) rename src/components/dashboard/{Account.jsx => Account.tsx} (91%) delete mode 100644 src/components/dashboard/AccountComparison.jsx create mode 100644 src/components/dashboard/AccountComparison.tsx rename src/components/dashboard/{AdvancedSearch.jsx => AdvancedSearch.tsx} (100%) rename src/components/dashboard/{AdvancedTransactionSimulation.jsx => AdvancedTransactionSimulation.tsx} (85%) rename src/components/dashboard/{Analytics.jsx => Analytics.tsx} (94%) delete mode 100644 src/components/dashboard/AuditLog.jsx create mode 100644 src/components/dashboard/AuditLog.tsx rename src/components/dashboard/{Builder.jsx => Builder.tsx} (100%) rename src/components/dashboard/{CacheStats.jsx => CacheStats.tsx} (87%) rename src/components/dashboard/{ClaimableBalances.jsx => ClaimableBalances.tsx} (87%) rename src/components/dashboard/{CollaborativeView.jsx => CollaborativeView.tsx} (100%) rename src/components/dashboard/{ComparisonChart.jsx => ComparisonChart.tsx} (64%) rename src/components/dashboard/{ContractABI.jsx => ContractABI.tsx} (100%) rename src/components/dashboard/{ContractDebugger.jsx => ContractDebugger.tsx} (100%) rename src/components/dashboard/{ContractHistory.jsx => ContractHistory.tsx} (100%) rename src/components/dashboard/{ContractInteraction.jsx => ContractInteraction.tsx} (100%) rename src/components/dashboard/{Contracts.jsx => Contracts.tsx} (100%) rename src/components/dashboard/{DEXExplorer.jsx => DEXExplorer.tsx} (87%) rename src/components/dashboard/{DataExport.jsx => DataExport.tsx} (90%) rename src/components/dashboard/{ExplorerEmbed.jsx => ExplorerEmbed.tsx} (92%) rename src/components/dashboard/{Faucet.jsx => Faucet.tsx} (93%) rename src/components/dashboard/{LiquidityPools.jsx => LiquidityPools.tsx} (82%) rename src/components/dashboard/{LiveActivityFeed.jsx => LiveActivityFeed.tsx} (84%) rename src/components/dashboard/{NetworkStats.jsx => NetworkStats.tsx} (100%) rename src/components/dashboard/{OrderBookChart.jsx => OrderBookChart.tsx} (78%) rename src/components/dashboard/{Overview.jsx => Overview.tsx} (73%) delete mode 100644 src/components/dashboard/PathExplorer.jsx create mode 100644 src/components/dashboard/PathExplorer.tsx rename src/components/dashboard/{PerformanceMonitor.jsx => PerformanceMonitor.tsx} (100%) rename src/components/dashboard/{PluginRegistryView.jsx => PluginRegistryView.tsx} (80%) rename src/components/dashboard/{PortfolioValue.jsx => PortfolioValue.tsx} (100%) rename src/components/dashboard/{PriceTicker.jsx => PriceTicker.tsx} (71%) rename src/components/dashboard/{RealTimeLedger.jsx => RealTimeLedger.tsx} (89%) rename src/components/dashboard/{Settings.jsx => Settings.tsx} (72%) rename src/components/dashboard/{SystemHealth.jsx => SystemHealth.tsx} (86%) rename src/components/dashboard/{TransactionBuilder.jsx => TransactionBuilder.tsx} (100%) rename src/components/dashboard/{TransactionDetail.jsx => TransactionDetail.tsx} (91%) rename src/components/dashboard/{TransactionSigner.jsx => TransactionSigner.tsx} (91%) rename src/components/dashboard/{TransactionSimulator.jsx => TransactionSimulator.tsx} (86%) rename src/components/dashboard/{Transactions.jsx => Transactions.tsx} (96%) rename src/components/dashboard/{VirtualizedLists.jsx => VirtualizedLists.tsx} (77%) rename src/components/dashboard/{WalletConnect.jsx => WalletConnect.tsx} (68%) create mode 100644 src/components/dashboard/types.ts diff --git a/src/components/dashboard/Account.jsx b/src/components/dashboard/Account.tsx similarity index 91% rename from src/components/dashboard/Account.jsx rename to src/components/dashboard/Account.tsx index ca3bb516..ae0d0994 100644 --- a/src/components/dashboard/Account.jsx +++ b/src/components/dashboard/Account.tsx @@ -1,17 +1,19 @@ import React, { useEffect, useState, useMemo } from 'react' import { format } from 'date-fns' +import type { Horizon } from '@stellar/stellar-sdk' import { useStore } from '../../lib/store' import { shortAddress, formatXLM, fetchAccountCreationDate, fetchAccountOffers, calculateAccountReserves } from '../../lib/stellar' import CopyableValue from './CopyableValue' import useAssetUsdEstimates, { formatEstimatedUsd } from '../../hooks/useAssetUsdEstimates' +import type { AccountOffer, ReservesInfo, InfoRowProps } from './types' -function formatAsset(assetType, assetCode) { +function formatAsset(assetType: string, assetCode?: string): string { if (assetType === 'native') return 'XLM' return assetCode || 'Unknown' } -function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue }) { - const textStyle = { +function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue }: InfoRowProps) { + const textStyle: React.CSSProperties = { fontSize: '12px', color: accent || 'var(--text-primary)', fontFamily: mono ? 'var(--font-mono)' : 'var(--font-display)', @@ -49,14 +51,13 @@ function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue export default function Account() { const { accountData, connectedAddress, network, networkStats } = useStore() - const [offers, setOffers] = useState([]) + const [offers, setOffers] = useState([]) const [offersLoading, setOffersLoading] = useState(false) - const [offersError, setOffersError] = useState(null) - const [createdAt, setCreatedAt] = useState(null) + const [offersError, setOffersError] = useState(null) + const [createdAt, setCreatedAt] = useState(null) const [createdAtLoading, setCreatedAtLoading] = useState(false) - // Calculate reserves when accountData, networkStats, or offers change - const reserves = useMemo(() => { + const reserves = useMemo(() => { if (!accountData) return null return calculateAccountReserves(accountData, networkStats, offers.length) }, [accountData, networkStats, offers.length]) @@ -79,7 +80,7 @@ export default function Account() { setCreatedAt(null) fetchAccountCreationDate(connectedAddress, network) - .then((date) => { + .then((date: Date) => { if (!isActive) return setCreatedAt(date) }) @@ -89,11 +90,11 @@ export default function Account() { }) fetchAccountOffers(connectedAddress, network) - .then((res) => { + .then((res: AccountOffer[]) => { if (!isActive) return setOffers(res) }) - .catch((err) => { + .catch((err: Error) => { if (!isActive) return setOffersError(err.message) }) @@ -111,8 +112,8 @@ export default function Account() {
No account loaded
) - const xlm = accountData.balances?.find(b => b.asset_type === 'native') - const otherAssets = accountData.balances?.filter(b => b.asset_type !== 'native') || [] + const xlm = accountData.balances?.find((b: { asset_type: string }) => b.asset_type === 'native') + const otherAssets = accountData.balances?.filter((b: { asset_type: string }) => b.asset_type !== 'native') || [] const signers = accountData.signers || [] const flags = accountData.flags || {} const thresholds = accountData.thresholds || {} @@ -131,7 +132,7 @@ export default function Account() {
Identity
- + @@ -224,12 +225,12 @@ export default function Account() { {otherAssets.length === 0 ? (
No non-native assets
) : ( - otherAssets.map((asset, index) => { + otherAssets.map((asset: Horizon.BalanceLine, index: number) => { const estimate = getEstimate(asset) return (
- {formatAsset(asset.asset_type, asset.asset_code)} + {formatAsset(asset.asset_type, (asset as Horizon.BalanceLineAsset).asset_code)}
- {asset.asset_issuer && ( + {(asset as Horizon.BalanceLineAsset).asset_issuer && ( - {shortAddress(asset.asset_issuer)} + {shortAddress((asset as Horizon.BalanceLineAsset).asset_issuer)} )}
@@ -305,7 +306,7 @@ export default function Account() {
Signers ({signers.length})
- {signers.map((s, i) => ( + {signers.map((s: Horizon.AccountSigner, i: number) => (
- {/* Claimable Balances shortcut */}
Claimable Balances
diff --git a/src/components/dashboard/AccountComparison.jsx b/src/components/dashboard/AccountComparison.jsx deleted file mode 100644 index c7a38501..00000000 --- a/src/components/dashboard/AccountComparison.jsx +++ /dev/null @@ -1,513 +0,0 @@ -import React from 'react' -import { useStore } from '../../lib/store' -import { useResponsive } from '../../hooks/useResponsive' -import { fetchAccount, fetchAccountOffers, isValidPublicKey, resolveAddress, shortAddress, formatXLM } from '../../lib/stellar' -import { Copy, Search, Trash2, Plus, Download, ArrowUpDown } from 'lucide-react' -import ComparisonChart from './ComparisonChart' - -const COMPARISON_METRICS = [ - { label: 'Status', key: 'status' }, - { label: 'XLM Balance', key: 'balance' }, - { label: 'Assets', key: 'assets' }, - { label: 'Active Orders', key: 'orders' }, - { label: 'Sequence', key: 'sequence' }, - { label: 'Subentries', key: 'subentries' }, - { label: 'Signers', key: 'signers' }, -] - -export default function AccountComparison() { - const { - network, - comparisonSlots, - addComparisonSlot, - removeComparisonSlot, - reorderComparisonSlots, - setComparisonKey, - setComparisonData, - setComparisonLoading, - setComparisonError - } = useStore() - const { isMobile } = useResponsive() - - const handleFetch = async () => { - const promises = comparisonSlots.map(async (slot, index) => { - // Trim whitespace - const input = slot.key?.trim() - if (!input) { - setComparisonData(index, null) - setComparisonError(index, null) - return - } - - if (!isValidPublicKey(input)) { - setComparisonError(index, 'Invalid address format') - setComparisonData(index, null) - return - } - - setComparisonLoading(index, true) - setComparisonError(index, null) - - try { - // Resolve the address (handles G, M, and federated formats) - const resolved = await resolveAddress(input, network) - - if (!resolved) { - setComparisonError(index, 'Failed to resolve address') - setComparisonData(index, null) - setComparisonLoading(index, false) - return - } - - // Use the master account for fetching data - const [data, offers] = await Promise.all([ - fetchAccount(resolved.accountId, network), - fetchAccountOffers(resolved.accountId, network).catch(() => []) // Fallback to empty if error - ]) - - // Store the resolved info with the account data - setComparisonData(index, { - ...data, - offers, - _muxedId: resolved.muxedId, - _federatedAddress: resolved.federatedAddress, - _originalInput: input, - }) - } catch (err) { - setComparisonError(index, err.message || 'Account not found') - setComparisonData(index, null) - } finally { - setComparisonLoading(index, false) - } - }) - - await Promise.allSettled(promises) - } - - const copyToClipboard = (text) => { - navigator.clipboard.writeText(text) - } - - const exportToCSV = () => { - const rows = [] - const headers = ['Metric', ...comparisonSlots.map((s, i) => s.key || `Slot_${i+1}`)] - rows.push(headers.join(',')) - - const metrics = [ - { label: 'Status', key: 'status' }, - { label: 'XLM Balance', key: 'balance' }, - { label: 'Assets', key: 'assets' }, - { label: 'Active Orders', key: 'orders' }, - { label: 'Sequence', key: 'sequence' }, - { label: 'Subentries', key: 'subentries' }, - { label: 'Signers', key: 'signers' } - ] - - metrics.forEach(row => { - const rowData = [row.label] - comparisonSlots.forEach(slot => { - let cellVal = '—' - if (slot.data) { - if (row.key === 'status') cellVal = 'Active' - if (row.key === 'balance') cellVal = slot.data.balances.find(b => b.asset_type === 'native')?.balance || '0' - if (row.key === 'assets') cellVal = String(slot.data.balances.filter(b => b.asset_type !== 'native').length) - if (row.key === 'orders') cellVal = String(slot.data.offers?.length || 0) - if (row.key === 'sequence') cellVal = slot.data.sequence - if (row.key === 'subentries') cellVal = String(slot.data.subentry_count) - if (row.key === 'signers') cellVal = String(slot.data.signers?.length || 1) - } else if (slot.error) { - cellVal = 'Error' - } - rowData.push(cellVal) - }) - rows.push(rowData.join(',')) - }) - - const csvContent = "data:text/csv;charset=utf-8," + rows.join("\n") - const encodedUri = encodeURI(csvContent) - const link = document.createElement("a") - link.setAttribute("href", encodedUri) - link.setAttribute("download", `stellar_comparison_${network}.csv`) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } - - const sortSlots = (metric) => { - const sorted = [...comparisonSlots].sort((a, b) => { - if (!a.data && !b.data) return 0 - if (!a.data) return 1 - if (!b.data) return -1 - - let valA = 0, valB = 0 - if (metric === 'balance') { - valA = parseFloat(a.data.balances.find(b => b.asset_type === 'native')?.balance || '0') - valB = parseFloat(b.data.balances.find(b => b.asset_type === 'native')?.balance || '0') - } else if (metric === 'orders') { - valA = a.data.offers?.length || 0 - valB = b.data.offers?.length || 0 - } else if (metric === 'assets') { - valA = a.data.balances.filter(b => b.asset_type !== 'native').length - valB = b.data.balances.filter(b => b.asset_type !== 'native').length - } - return valB - valA // descending - }) - reorderComparisonSlots(sorted) // atomic swap — preserves all data - } - - return ( -
- {/* Header */} -
-
-
Account Comparison
-
- Multi-View -
-
-
- {comparisonSlots.some(s => s.data) && ( -
- - -
- )} - - - -
-
- - {/* Input grid */} -
- {comparisonSlots.map((slot, i) => ( -
-
- ACCOUNT {i + 1} - {comparisonSlots.length > 2 && ( - - )} -
- setComparisonKey(i, e.target.value)} - style={{ - width: '100%', - background: 'var(--bg-elevated)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-md)', - padding: '12px', - color: 'var(--text-primary)', - fontSize: '12px', - fontFamily: 'var(--font-mono)', - outline: 'none', - transition: 'var(--transition)' - }} - onFocus={e => e.target.style.borderColor = 'var(--cyan-dim)'} - onBlur={e => e.target.style.borderColor = 'var(--border)'} - /> - {/* Error Indicator inside card */} - {slot.error && ( -
{slot.error}
- )} - {slot.loading && ( -
Loading...
- )} -
- ))} - - {comparisonSlots.length < 5 && ( - - )} -
- - {/* Visual Charts */} - - - {/* Comparison Table */} -
- {isMobile ? ( -
- {comparisonSlots.map((slot, i) => { - const data = slot.data - const loading = slot.loading - const error = slot.error - - return ( -
-
-
-
- Slot {i + 1} -
-
- {slot.key ? shortAddress(slot.key, 6) : 'Enter an account to compare'} -
-
- {slot.key && !slot.error && ( - - )} -
- - {error &&
{error}
} - {loading &&
Loading…
} - - {data ? ( -
- {COMPARISON_METRICS.map((row) => { - const content = renderComparisonCell(slot, row) - return ( -
- {row.label} - {content} -
- ) - })} -
- ) : !loading && !error ? ( -
Enter an account address to compare metrics here.
- ) : null} -
- ) - })} -
- ) : ( -
- - - - - {comparisonSlots.map((slot, i) => ( - - ))} - - - - {COMPARISON_METRICS.slice(0, 6).map((row, rowIndex) => ( - e.currentTarget.style.background = 'rgba(255,255,255,0.01)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'} - > - - {comparisonSlots.map((slot, i) => ( - - ))} - - ))} - -
METRIC -
- - {slot.key ? shortAddress(slot.key, 6) : `Slot ${i + 1}`} - - {slot.key && !slot.error && ( - - )} -
-
- {row.label} - - {renderComparisonCell(slot, row)} -
-
- )} -
-
- ) -} - -function renderComparisonCell(slot, row) { - const data = slot.data - const loading = slot.loading - const error = slot.error - - if (loading) return
- if (error) return Error - if (!data) return - - if (row.key === 'status') { - return - - Active - - } - - if (row.key === 'balance') { - const balStr = data.balances.find(b => b.asset_type === 'native')?.balance || '0' - return - {formatXLM(balStr)} XLM - - } - - if (row.key === 'assets') { - const otherAssets = data.balances.filter(b => b.asset_type !== 'native') - return 0 ? 'var(--amber)' : 'var(--text-primary)' }}>{otherAssets.length} assets - } - - if (row.key === 'orders') { - const ordersCount = data.offers?.length || 0 - return 0 ? 'var(--purple, #b388ff)' : 'var(--text-secondary)', fontWeight: ordersCount > 0 ? 600 : 400 }}>{ordersCount} orders - } - - if (row.key === 'sequence') { - return {data.sequence} - } - - if (row.key === 'subentries') { - return {data.subentry_count} - } - - if (row.key === 'signers') { - return {String(data.signers?.length || 1)} - } - - return -} diff --git a/src/components/dashboard/AccountComparison.tsx b/src/components/dashboard/AccountComparison.tsx new file mode 100644 index 00000000..21cf5ddf --- /dev/null +++ b/src/components/dashboard/AccountComparison.tsx @@ -0,0 +1,443 @@ +import React, { useState, useEffect, useMemo, type ChangeEvent, type ReactNode, type ReactElement } from 'react'; +import { useStore } from '../../lib/store'; +import { shortAddress } from '../../lib/stellar'; +import CopyableValue from './CopyableValue'; +import { fetchAccountDetails, type AccountDetails } from '../../lib/stellar'; +import { isPublicKey } from '../../lib/stellar'; +import Card from './Card'; +import type { ComparisonSlot } from '../../lib/store'; + +interface ComparisonRowProps { + label: string + values: ReactNode[] + highlight?: number | null +} + +interface DiffBadgeProps { + text: string + variant: 'positive' | 'negative' | 'neutral' +} + +interface MetricCardProps { + title: string + accounts: (AccountDetails | null)[] + format: (val: AccountDetails | null) => ReactNode + highlight?: (val: AccountDetails | null) => boolean +} + +interface AnalysisRow { + label: string + values: ReactNode[] + highlight?: number | null +} + +interface ComparisonData { + rows: AnalysisRow[] +} + +const DiffBadge = ({ text, variant }: DiffBadgeProps) => { + const colors: Record = { + positive: { bg: 'var(--green-glow-sm)', border: 'var(--green)', color: 'var(--green)' }, + negative: { bg: 'var(--red-glow-sm)', border: 'var(--red)', color: 'var(--red)' }, + neutral: { bg: 'var(--bg-elevated)', border: 'var(--border)', color: 'var(--text-muted)' }, + }; + + return ( + + {text} + + ); +}; + +const ComparisonRow = ({ label, values, highlight }: ComparisonRowProps) => ( +
+
{label}
+ {values.map((val: ReactNode, i: number) => ( +
+ {val ?? } +
+ ))} +
+); + +const MetricCard = ({ title, accounts, format, highlight }: MetricCardProps) => { + const values = accounts.map((a, i) => ({ + value: format(a), + isHighlight: highlight ? highlight(a) : false + })); + + return ( +
+
+ {title} +
+
+ {values.map((v, i) => ( +
+ {v.value} +
+ ))} +
+
+ ); +}; + +function buildComparison(accounts: (AccountDetails | null)[]): ComparisonData { + if (!accounts.length || accounts.every(a => a === null)) { + return { rows: [] }; + } + + const rows: AnalysisRow[] = []; + + const balances = accounts.map(a => a?.balances?.reduce((sum: number, b: { balance?: string }) => + sum + parseFloat(b.balance || '0'), 0) ?? null); + rows.push({ + label: 'Total Balance (XLM)', + values: balances.map(b => b !== null ? b.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'), + highlight: balances.indexOf(Math.max(...balances.filter((b): b is number => b !== null))) + }); + + const homeDomains = accounts.map(a => a?.home_domain || null); + rows.push({ + label: 'Home Domain', + values: homeDomains.map(d => d || '—'), + }); + + const hasThresholds = accounts.map(a => (a?.thresholds?.low_weight && a?.thresholds?.med_weight && a?.thresholds?.high_weight) ? true : false); + rows.push({ + label: 'Thresholds Set', + values: hasThresholds.map(t => t ? '✓' : '✗'), + }); + + const signerCounts = accounts.map(a => (a?.signers?.length ?? 0)); + rows.push({ + label: 'Signers', + values: signerCounts.map(c => c.toString()), + highlight: signerCounts.indexOf(Math.max(...signerCounts)) + }); + + const seqNums = accounts.map(a => a?.sequence ? BigInt(a.sequence) : null); + rows.push({ + label: 'Sequence', + values: seqNums.map(s => s !== null ? `${(Number(s) & 0xFFFFFFFFFFFFFFFF).toString(16).padStart(16, '0').substring(0, 8)}…` : '—'), + }); + + return { rows }; +} + +export default function AccountComparison() { + const { comparisonSlots, addComparisonSlot, removeComparisonSlot, updateComparisonSlot, network } = useStore(); + const [accounts, setAccounts] = useState<(AccountDetails | null)[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [inputValue, setInputValue] = useState(''); + + const currentSlots = comparisonSlots || []; + + useEffect(() => { + if (currentSlots.length === 0) { + setAccounts([]); + return; + } + + let cancelled = false; + + async function loadAccounts() { + setIsLoading(true); + const results: (AccountDetails | null)[] = []; + + for (const slot of currentSlots) { + if (cancelled) break; + try { + const details = await fetchAccountDetails(slot.address); + results.push(details); + } catch { + results.push(null); + } + } + + if (!cancelled) { + setAccounts(results); + setIsLoading(false); + } + } + + loadAccounts(); + + return () => { cancelled = true; }; + }, [currentSlots.map((s: ComparisonSlot) => s.address).join(',')]); + + const comparison = useMemo(() => buildComparison(accounts), [accounts]); + + const handleAddAccount = () => { + const address = inputValue.trim(); + if (!address || !isPublicKey(address)) return; + addComparisonSlot({ id: `slot-${Date.now()}`, address, label: shortAddress(address, 6) }); + setInputValue(''); + }; + + const handleAccountChange = (index: number, e: ChangeEvent) => { + const slot = currentSlots[index]; + if (slot) { + updateComparisonSlot(slot.id, { ...slot, address: e.target.value }); + } + }; + + const handleRemoveAccount = (index: number) => { + const slot = currentSlots[index]; + if (slot) removeComparisonSlot(slot.id); + }; + + const allLoaded = accounts.length > 0 && accounts.every(a => a !== null); + + const netWorthCard = ( + { + if (!a) return '—'; + const total = a.balances?.reduce((s, b) => s + parseFloat(b.balance || '0'), 0) || 0; + return `${total.toLocaleString(undefined, { maximumFractionDigits: 2 })} XLM`; + }} + highlight={(a) => { + if (!a) return false; + const total = a.balances?.reduce((s, b) => s + parseFloat(b.balance || '0'), 0) || 0; + const max = Math.max(...accounts.filter(Boolean).map(acc => + acc!.balances?.reduce((s, b) => s + parseFloat(b.balance || '0'), 0) || 0 + )); + return total === max && max > 0; + }} + /> + ); + + const signerCard = ( + a?.signers?.length?.toString() ?? '—'} + highlight={(a) => { + if (!a?.signers) return false; + const max = Math.max(...accounts.filter(Boolean).map(acc => acc!.signers?.length || 0)); + return a.signers.length === max && max > 0; + }} + /> + ); + + const subEntryCard = ( + a?.num_subentries?.toString() ?? '—'} + highlight={(a) => { + if (!a?.num_subentries) return false; + const max = Math.max(...accounts.filter(Boolean).map(acc => acc!.num_subentries || 0)); + return a.num_subentries === max && max > 0; + }} + /> + ); + + return ( +
+
+
+ Account Comparison +
+

+ Compare key metrics across multiple Stellar accounts side by side. +

+
+ +
+
+
+ + ) => setInputValue(e.target.value)} + placeholder="GABCDEF1234..." + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') handleAddAccount(); }} + style={{ + padding: '10px 12px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'var(--font-mono)', + outline: 'none', + transition: 'var(--transition)', + }} + /> +
+ +
+ + {currentSlots.length > 0 && ( +
+ {currentSlots.map((slot: ComparisonSlot, index: number) => ( +
+ + {index + 1} + + ) => handleAccountChange(index, e)} + style={{ + flex: 1, + padding: '8px 10px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '12px', + fontFamily: 'var(--font-mono)', + outline: 'none', + minWidth: 0, + }} + /> + + {accounts[index] ? shortAddress(accounts[index]?.id || '', 6) : '—'} + + +
+ ))} +
+ )} +
+ + {isLoading && ( +
+ Loading account data... +
+ )} + + {!isLoading && currentSlots.length === 0 && ( +
+
⚖️
+
Add two or more accounts to compare
+
+ Compare balances, thresholds, signers, and more. +
+
+ )} + + {!isLoading && currentSlots.length > 0 && ( +
+
+ {netWorthCard} + {signerCard} + {subEntryCard} +
+ +
+
+ Detailed Comparison +
+
+ {comparison.rows.length > 0 ? ( + comparison.rows.map((row: AnalysisRow, i: number) => ( + + )) + ) : ( +
+ No comparison data available. +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/dashboard/AdvancedSearch.jsx b/src/components/dashboard/AdvancedSearch.tsx similarity index 100% rename from src/components/dashboard/AdvancedSearch.jsx rename to src/components/dashboard/AdvancedSearch.tsx diff --git a/src/components/dashboard/AdvancedTransactionSimulation.jsx b/src/components/dashboard/AdvancedTransactionSimulation.tsx similarity index 85% rename from src/components/dashboard/AdvancedTransactionSimulation.jsx rename to src/components/dashboard/AdvancedTransactionSimulation.tsx index d8f5a508..6ab76830 100644 --- a/src/components/dashboard/AdvancedTransactionSimulation.jsx +++ b/src/components/dashboard/AdvancedTransactionSimulation.tsx @@ -1,10 +1,33 @@ +import React, { useMemo, useState, type ReactNode } from 'react' import { runAdvancedTransactionSimulation, } from '../../lib/stellar' +import type { AdvancedSimulationReport } from '../../lib/stellar' import { useStore } from '../../lib/store' import { getErrorMessage } from '../../lib/errorHandling/ErrorMessages' -function Panel({ title, subtitle, children }) { +interface PanelProps { + title: string + subtitle?: string + children: ReactNode +} + +interface TransactionParams { + sourceAccount?: string + operations?: Array> + baseFee?: number + timeBounds?: Record + network?: string +} + +interface ScenarioConfig { + label: string + networkCongestion: number + operationMultiplier: number + baseFee: number +} + +function Panel({ title, subtitle, children }: PanelProps) { return (
(null) const [error, setError] = useState('') const [congestion, setCongestion] = useState('0.55') - const transactionParams = useMemo(() => propParams || { + const transactionParams = useMemo(() => propParams || { sourceAccount: connectedAddress || '', operations: [], baseFee: 100, @@ -36,7 +59,7 @@ export default function AdvancedTransactionSimulation({ transactionParams: propP network }, [propParams, connectedAddress, network]) - const scenarios = useMemo(() => ([ + const scenarios = useMemo(() => ([ { label: 'Low Congestion', networkCongestion: 0.2, operationMultiplier: 1, baseFee: (transactionParams?.baseFee || 100) }, { label: 'Peak Congestion', networkCongestion: 1.1, operationMultiplier: 1.1, baseFee: (transactionParams?.baseFee || 100) }, { label: 'Complex Payload', networkCongestion: 0.7, operationMultiplier: 1.4, baseFee: (transactionParams?.baseFee || 100) + 50 }, @@ -50,10 +73,10 @@ export default function AdvancedTransactionSimulation({ transactionParams: propP ...transactionParams, currentLedgerLoad: parseFloat(congestion) || 0.55, scenarios, - }) + } as Parameters[0]) setResult(output) - } catch (err) { - setError(err.message || 'Simulation failed') + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Simulation failed') setResult(null) } finally { setIsLoading(false) @@ -70,7 +93,7 @@ export default function AdvancedTransactionSimulation({ transactionParams: propP setCongestion(event.target.value)} + onChange={(event: React.ChangeEvent) => setCongestion(event.target.value)} style={{ width: '120px', padding: '8px 10px', @@ -84,7 +107,7 @@ export default function AdvancedTransactionSimulation({ transactionParams: propP /> -
-
-
- - {/* Table */} -
- {events.length === 0 ? ( -
- No audit events match the current filters. -
- ) : isMobile ? ( -
- {events.map((event) => ( - - ))} -
- ) : ( -
- - - - - - - - - - - - - {events.map((event) => ( - - ))} - -
TimeTypeSeverityMessageUser IDDetails
-
- )} -
-
- ); -} - -function AuditRow({ event }) { - const [open, setOpen] = useState(false); - return ( - <> - setOpen((o) => !o)} - style={{ cursor: 'pointer', borderTop: '1px solid var(--border)' }} - > - {new Date(event.timestamp).toLocaleTimeString()} - - - {event.type} - - - - - {event.severity} - - - {event.message} - {truncate(event.userId) ?? '—'} - {open ? '▾' : '▸'} - - {open && ( - - -
-              {JSON.stringify(
-                {
-                  id: event.id,
-                  sessionId: event.sessionId,
-                  metadata: event.metadata,
-                  userAgent: event.userAgent,
-                  location: event.location,
-                },
-                null,
-                2,
-              )}
-            
- - - )} - - ); -} - -function AuditMobileCard({ event }) { - const [open, setOpen] = useState(false); - - return ( -
setOpen((previous) => !previous)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setOpen((previous) => !previous); - } - }} - style={{ - background: 'var(--bg-card)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-lg)', - padding: '16px', - cursor: 'pointer', - boxShadow: '0 10px 30px rgba(0,0,0,0.08)', - transition: 'var(--transition)', - }} - > -
-
-
- {new Date(event.timestamp).toLocaleTimeString()} -
-
- {event.type} - - {event.severity} - -
-
-
{event.userId || '—'}
-
-
{event.message}
- {open && ( -
-
- Details -
-
-            {JSON.stringify(
-              {
-                id: event.id,
-                sessionId: event.sessionId,
-                metadata: event.metadata,
-                userAgent: event.userAgent,
-                location: event.location,
-              },
-              null,
-              2,
-            )}
-          
-
- )} -
- ); -} - -function StatCard({ label, value, color, icon }) { - return ( -
-
- {icon &&
{icon}
} -
- {label} -
-
-
{value}
-
- ); -} - -function ToolbarButton({ onClick, icon, label, danger }) { - return ( - - ); -} - -function truncate(s, n = 16) { - if (!s || typeof s !== 'string') return s; - return s.length > n ? `${s.slice(0, 6)}…${s.slice(-6)}` : s; -} - -const cellStyle = { padding: '10px 12px', verticalAlign: 'top' }; - -const inputStyle = { - width: '100%', - padding: '8px 12px', - background: 'var(--bg-elevated)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-md)', - color: 'var(--text-primary)', - fontSize: '12px', - fontFamily: 'var(--font-mono)', - outline: 'none', -}; - -const smallButtonStyle = { - background: 'transparent', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-sm)', - color: 'var(--text-secondary)', - fontSize: '11px', - padding: '4px 10px', - cursor: 'pointer', -}; diff --git a/src/components/dashboard/AuditLog.tsx b/src/components/dashboard/AuditLog.tsx new file mode 100644 index 00000000..afced2bb --- /dev/null +++ b/src/components/dashboard/AuditLog.tsx @@ -0,0 +1,468 @@ +import React, { useState, useEffect, useCallback, useRef, type MouseEvent, type ReactNode } from 'react'; +import { useStore } from '../../lib/store'; +import { formatDistanceToNow } from 'date-fns'; +import Card from './Card'; +import type { AuditEntry } from '../../types/audit'; + +interface LogRowProps { + entry: AuditEntry + isSelected: boolean + onSelect: (id: string) => void +} + +interface FilterBarProps { + onFilterChange: (filters: FilterState) => void +} + +interface FilterState { + severity: string[] + category: string[] + search: string +} + +interface SeverityBadgeProps { + severity: string +} + +const SEVERITY_COLORS: Record = { + critical: { bg: 'var(--red-glow-sm)', color: 'var(--red)', border: 'var(--red)' }, + error: { bg: 'var(--red-glow-sm)', color: 'var(--red)', border: 'var(--red)' }, + warning: { bg: 'var(--amber-glow-sm)', color: 'var(--amber)', border: 'var(--amber)' }, + info: { bg: 'var(--cyan-glow-sm)', color: 'var(--cyan)', border: 'var(--cyan)' }, + debug: { bg: 'var(--bg-elevated)', color: 'var(--text-muted)', border: 'var(--border)' }, +}; + +const CATEGORIES = ['security', 'transaction', 'auth', 'system', 'network', 'user']; +const SEVERITIES = ['critical', 'error', 'warning', 'info', 'debug']; + +const SeverityBadge = ({ severity }: SeverityBadgeProps) => { + const style = SEVERITY_COLORS[severity] || SEVERITY_COLORS.info; + return ( + + {severity} + + ); +}; + +const LogRow = ({ entry, isSelected, onSelect }: LogRowProps) => ( +
onSelect(entry.id)} + style={{ + display: 'grid', + gridTemplateColumns: 'auto 80px 100px 1fr 140px', + gap: '12px', + padding: '10px 14px', + borderBottom: '1px solid var(--border)', + cursor: 'pointer', + background: isSelected ? 'var(--cyan-glow-sm)' : 'transparent', + transition: 'var(--transition)', + alignItems: 'center', + fontSize: '12px', + }} + > +
+ +
+ {entry.category} +
+
+ {entry.message} +
+
+ {formatDistanceToNow(new Date(entry.timestamp), { addSuffix: true })} +
+
+); + +const FilterBar = ({ onFilterChange }: FilterBarProps) => { + const [search, setSearch] = useState(''); + const [selectedSeverities, setSelectedSeverities] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); + const [isFilterOpen, setIsFilterOpen] = useState(false); + + const toggleSeverity = (sev: string) => { + const next = selectedSeverities.includes(sev) + ? selectedSeverities.filter(s => s !== sev) + : [...selectedSeverities, sev]; + setSelectedSeverities(next); + onFilterChange({ severity: next, category: selectedCategories, search }); + }; + + const toggleCategory = (cat: string) => { + const next = selectedCategories.includes(cat) + ? selectedCategories.filter(c => c !== cat) + : [...selectedCategories, cat]; + setSelectedCategories(next); + onFilterChange({ severity: selectedSeverities, category: next, search }); + }; + + const handleSearchChange = (val: string) => { + setSearch(val); + onFilterChange({ severity: selectedSeverities, category: selectedCategories, search: val }); + }; + + return ( +
+
+
+ handleSearchChange(e.target.value)} + placeholder="Search audit log..." + style={{ + width: '100%', + padding: '9px 12px 9px 36px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '13px', + outline: 'none', + transition: 'var(--transition)', + boxSizing: 'border-box', + }} + /> + + 🔍 + +
+ + +
+ + {isFilterOpen && ( +
+
+
+ Severity +
+
+ {SEVERITIES.map(sev => ( + + ))} +
+
+ +
+
+ Category +
+
+ {CATEGORIES.map(cat => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default function AuditLog() { + const { network } = useStore(); + const [logs, setLogs] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [filters, setFilters] = useState({ severity: [], category: [], search: '' }); + const listRef = useRef(null); + + useEffect(() => { + async function fetchLogs() { + setIsLoading(true); + try { + const response = await fetch('/api/audit/logs'); + const data: AuditEntry[] = await response.json(); + setLogs(data); + } catch { + setLogs([]); + } finally { + setIsLoading(false); + } + } + fetchLogs(); + }, [network]); + + const filteredLogs = logs.filter((entry: AuditEntry) => { + if (filters.severity.length > 0 && !filters.severity.includes(entry.severity)) return false; + if (filters.category.length > 0 && !filters.category.includes(entry.category)) return false; + if (filters.search) { + const q = filters.search.toLowerCase(); + return ( + entry.message.toLowerCase().includes(q) || + entry.category.toLowerCase().includes(q) || + entry.severity.toLowerCase().includes(q) + ); + } + return true; + }); + + const selectedEntry = logs.find((e: AuditEntry) => e.id === selectedId) || null; + + return ( +
+
+
+ Audit Log +
+

+ Track and review actions across the dashboard with full search and filtering. +

+
+ + setFilters(f)} /> + +
+
+
+ {filteredLogs.length} entries + {logs.length} total +
+
+ {isLoading ? ( +
+ Loading audit log... +
+ ) : filteredLogs.length === 0 ? ( +
+ {logs.length === 0 ? 'No audit entries yet.' : 'No entries match the current filters.'} +
+ ) : ( + filteredLogs.map((entry: AuditEntry) => ( + + )) + )} +
+
+ +
+ {selectedEntry ? ( +
+
+
+ Entry Details +
+ +
+ +
+
+
Severity
+ +
+
+
Category
+
{selectedEntry.category}
+
+
+
Timestamp
+
+ {new Date(selectedEntry.timestamp).toLocaleString()} +
+
+
+
Message
+
+ {selectedEntry.message} +
+
+ {selectedEntry.metadata && Object.keys(selectedEntry.metadata).length > 0 && ( +
+
Metadata
+
+                      {JSON.stringify(selectedEntry.metadata, null, 2)}
+                    
+
+ )} +
+
+ ) : ( +
+
📋
+
Select an entry to view details
+
+ )} +
+
+
+ ); +} diff --git a/src/components/dashboard/Builder.jsx b/src/components/dashboard/Builder.tsx similarity index 100% rename from src/components/dashboard/Builder.jsx rename to src/components/dashboard/Builder.tsx diff --git a/src/components/dashboard/CacheStats.jsx b/src/components/dashboard/CacheStats.tsx similarity index 87% rename from src/components/dashboard/CacheStats.jsx rename to src/components/dashboard/CacheStats.tsx index 6eeef163..b18a385d 100644 --- a/src/components/dashboard/CacheStats.jsx +++ b/src/components/dashboard/CacheStats.tsx @@ -11,7 +11,7 @@ import { const REFRESH_MS = 5_000 -const ROW_STYLE = { +const ROW_STYLE: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1.5fr repeat(7, 1fr)', gap: '12px', @@ -20,7 +20,7 @@ const ROW_STYLE = { alignItems: 'center', } -const HEADER_STYLE = { +const HEADER_STYLE: React.CSSProperties = { ...ROW_STYLE, borderBottom: '1px solid var(--border)', color: 'var(--text-muted)', @@ -29,13 +29,13 @@ const HEADER_STYLE = { fontSize: '10px', } -function formatNumber(n) { +function formatNumber(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` return String(n) } -function StatusPill({ online }) { +function StatusPill({ online }: { online: boolean }) { return ( +
+ {label} +
+
+ {value ?? '—'} +
+
+ ) +} + export default function CacheStats() { - const [snapshot, setSnapshot] = useState({ managers: [], storage: { appState: 0, apiCache: 0, offlineQueue: 0 } }) + const [snapshot, setSnapshot] = useState({ managers: [], storage: { appState: 0, apiCache: 0, offlineQueue: 0 } }) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const { stats: rateLimiterStats } = useRateLimiter() @@ -84,7 +130,7 @@ export default function CacheStats() { return () => clearInterval(interval) }, []) - const handleClear = async (which) => { + const handleClear = async (which: string) => { setBusy(true) try { if (which === 'stellar') stellarCacheManager.clear() @@ -111,9 +157,9 @@ export default function CacheStats() { } } - const totalEntries = snapshot.managers.reduce((sum, m) => sum + m.size, 0) - const totalHits = snapshot.managers.reduce((sum, m) => sum + m.hits, 0) - const totalMisses = snapshot.managers.reduce((sum, m) => sum + m.misses, 0) + const totalEntries = snapshot.managers.reduce((sum: number, m) => sum + m.size, 0) + const totalHits = snapshot.managers.reduce((sum: number, m) => sum + m.hits, 0) + const totalMisses = snapshot.managers.reduce((sum: number, m) => sum + m.misses, 0) const overallRate = totalHits + totalMisses === 0 ? '0.0%' @@ -227,31 +273,3 @@ export default function CacheStats() {
) } - -function Stat({ label, value, accent }) { - return ( -
-
- {label} -
-
- {value ?? '—'} -
-
- ) -} diff --git a/src/components/dashboard/ClaimableBalances.jsx b/src/components/dashboard/ClaimableBalances.tsx similarity index 87% rename from src/components/dashboard/ClaimableBalances.jsx rename to src/components/dashboard/ClaimableBalances.tsx index 1fe1b6bb..8ce75519 100644 --- a/src/components/dashboard/ClaimableBalances.jsx +++ b/src/components/dashboard/ClaimableBalances.tsx @@ -5,19 +5,56 @@ import { formatClaimPredicate, isValidPublicKey, } from '../../lib/stellar' -import { buildTransaction, simulateTransaction } from '../../lib/transactionBuilder' +import type { ClaimableBalanceRecord } from '../../lib/stellar' +import { simulateTransaction } from '../../lib/transactionBuilder' +import type { SimulateResult } from '../../lib/stellar' + +interface ClaimStateResult extends SimulateResult { + minFee?: string +} import CopyableValue from './CopyableValue' -function shortId(id) { +interface ClaimState { + loading?: boolean + result?: ClaimStateResult | null + error?: string | null +} + +interface RowProps { + label: string + value?: React.ReactNode + mono?: boolean + accent?: string + copyValue?: string +} + +function shortId(id: string): string { return id ? `${id.slice(0, 10)}…${id.slice(-6)}` : '—' } +function Row({ label, value, mono = true, accent, copyValue }: RowProps) { + return ( +
+ {label} + {copyValue ? ( + + {value ?? '—'} + + ) : ( + + {value ?? '—'} + + )} +
+ ) +} + export default function ClaimableBalances() { const { connectedAddress, network } = useStore() - const [balances, setBalances] = useState([]) + const [balances, setBalances] = useState([]) const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [claimState, setClaimState] = useState({}) // { [balanceId]: { loading, result, error } } + const [error, setError] = useState(null) + const [claimState, setClaimState] = useState>({}) useEffect(() => { if (!connectedAddress || !isValidPublicKey(connectedAddress)) { @@ -29,12 +66,12 @@ export default function ClaimableBalances() { setError(null) fetchClaimableBalances(connectedAddress, network) .then((records) => { if (active) setBalances(records) }) - .catch((err) => { if (active) setError(err.message) }) + .catch((err: Error) => { if (active) setError(err.message) }) .finally(() => { if (active) setLoading(false) }) return () => { active = false } }, [connectedAddress, network]) - async function handleSimulateClaim(balanceId) { + async function handleSimulateClaim(balanceId: string) { setClaimState((prev) => ({ ...prev, [balanceId]: { loading: true, result: null, error: null } })) try { const result = await simulateTransaction({ @@ -43,8 +80,9 @@ export default function ClaimableBalances() { network, }) setClaimState((prev) => ({ ...prev, [balanceId]: { loading: false, result, error: null } })) - } catch (err) { - setClaimState((prev) => ({ ...prev, [balanceId]: { loading: false, result: null, error: err.message } })) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + setClaimState((prev) => ({ ...prev, [balanceId]: { loading: false, result: null, error: message } })) } } @@ -91,7 +129,6 @@ export default function ClaimableBalances() { key={bal.id} style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }} > - {/* Header */}
{shortId(bal.id)} @@ -101,7 +138,6 @@ export default function ClaimableBalances() {
- {/* Details */}
@@ -115,7 +151,6 @@ export default function ClaimableBalances() { )}
- {/* All claimants */} {bal.claimants.length > 1 && (
@@ -132,7 +167,6 @@ export default function ClaimableBalances() {
)} - {/* Claim action */}
) } - -function Row({ label, value, mono = true, accent, copyValue }) { - return ( -
- {label} - {copyValue ? ( - - {value ?? '—'} - - ) : ( - - {value ?? '—'} - - )} -
- ) -} diff --git a/src/components/dashboard/CollaborativeView.jsx b/src/components/dashboard/CollaborativeView.tsx similarity index 100% rename from src/components/dashboard/CollaborativeView.jsx rename to src/components/dashboard/CollaborativeView.tsx diff --git a/src/components/dashboard/ComparisonChart.jsx b/src/components/dashboard/ComparisonChart.tsx similarity index 64% rename from src/components/dashboard/ComparisonChart.jsx rename to src/components/dashboard/ComparisonChart.tsx index 49ac6ecb..02c6a762 100644 --- a/src/components/dashboard/ComparisonChart.jsx +++ b/src/components/dashboard/ComparisonChart.tsx @@ -1,68 +1,84 @@ -import React, { useMemo } from 'react' +import React, { useMemo, type ReactNode } from 'react' import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts' +import type { TooltipProps } from 'recharts' +import type { ComparisonSlot } from '../../lib/store' +import type { Horizon } from '@stellar/stellar-sdk' import { CHART_COLORS, TOOLTIP_STYLE, AXIS_TICK_STYLE, formatCompactNumber } from '../../lib/chartUtils' -import { formatXLM } from '../../lib/stellar' -import { shortAddress } from '../../lib/stellar' +import { formatXLM, shortAddress } from '../../lib/stellar' + +interface ComparisonChartProps { + comparisonSlots: ComparisonSlot[] +} + +interface BalanceDatum { + name: string + [key: string]: number | string +} + +interface ActivityDatum { + name: string + [key: string]: number | string +} + +interface RadarDatum { + metric: string + [key: string]: number | string +} const SLOT_COLORS = [ CHART_COLORS.cyan, CHART_COLORS.amber, CHART_COLORS.green, CHART_COLORS.red, - '#b388ff' // Extra color for slot 5 + '#b388ff' ] -export default function ComparisonChart({ comparisonSlots }) { - // Filter out slots that don't have data yet +export default function ComparisonChart({ comparisonSlots }: ComparisonChartProps) { const activeSlots = comparisonSlots.filter(s => s.data && !s.error && s.key) - // 1. Bar Chart Data (XLM Balance) - const balanceData = useMemo(() => { + const balanceData: BalanceDatum[] = useMemo(() => { if (activeSlots.length === 0) return [] - const entry = { name: 'XLM Balance' } + const entry: BalanceDatum = { name: 'XLM Balance' } activeSlots.forEach((slot, i) => { - const balStr = slot.data.balances.find(b => b.asset_type === 'native')?.balance || '0' + const balStr = (slot.data as Horizon.AccountResponse).balances.find(b => b.asset_type === 'native')?.balance || '0' entry[`slot_${i}`] = parseFloat(balStr) }) return [entry] }, [activeSlots]) - // 2. Bar Chart Data (Assets, Subentries) - const activityData = useMemo(() => { + const activityData: ActivityDatum[] = useMemo(() => { if (activeSlots.length === 0) return [] - const assetsEntry = { name: 'Assets' } - const subentriesEntry = { name: 'Subentries' } + const assetsEntry: ActivityDatum = { name: 'Assets' } + const subentriesEntry: ActivityDatum = { name: 'Subentries' } activeSlots.forEach((slot, i) => { - const otherAssets = slot.data.balances.filter(b => b.asset_type !== 'native') + const otherAssets = (slot.data as Horizon.AccountResponse).balances.filter(b => b.asset_type !== 'native') assetsEntry[`slot_${i}`] = otherAssets.length - subentriesEntry[`slot_${i}`] = slot.data.subentry_count + subentriesEntry[`slot_${i}`] = (slot.data as Horizon.AccountResponse).subentry_count }) return [assetsEntry, subentriesEntry] }, [activeSlots]) - // 3. Radar Chart Data (Normalized Score 0-100) - const radarData = useMemo(() => { + const radarData: RadarDatum[] = useMemo(() => { if (activeSlots.length < 2) return [] - // Helper to get max value of a metric across slots - const getMax = (extractFn) => Math.max(...activeSlots.map(s => extractFn(s)), 1) + const getMax = (extractFn: (s: typeof activeSlots[0]) => number) => Math.max(...activeSlots.map(s => extractFn(s)), 1) - const maxBal = getMax(s => parseFloat(s.data.balances.find(b => b.asset_type === 'native')?.balance || '0')) - const maxAssets = getMax(s => s.data.balances.filter(b => b.asset_type !== 'native').length) - const maxSubentries = getMax(s => s.data.subentry_count) - const maxSeq = getMax(s => parseFloat(s.data.sequence || '0')) - const maxSigners = getMax(s => s.data.signers?.length || 1) + const maxBal = getMax(s => parseFloat((s.data as Horizon.AccountResponse).balances.find(b => b.asset_type === 'native')?.balance || '0')) + const maxAssets = getMax(s => (s.data as Horizon.AccountResponse).balances.filter(b => b.asset_type !== 'native').length) + const maxSubentries = getMax(s => (s.data as Horizon.AccountResponse).subentry_count) + const maxSeq = getMax(s => parseFloat((s.data as Horizon.AccountResponse).sequence || '0')) + const maxSigners = getMax(s => (s.data as Horizon.AccountResponse).signers?.length || 1) return [ - { metric: 'Balance', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, (parseFloat(s.data.balances.find(b => b.asset_type === 'native')?.balance || '0') / maxBal) * 100])) }, - { metric: 'Assets', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, (s.data.balances.filter(b => b.asset_type !== 'native').length / maxAssets) * 100])) }, - { metric: 'Subentries', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, (s.data.subentry_count / maxSubentries) * 100])) }, - { metric: 'Signers', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, ((s.data.signers?.length || 1) / maxSigners) * 100])) }, + { metric: 'Balance', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, (parseFloat((s.data as Horizon.AccountResponse).balances.find(b => b.asset_type === 'native')?.balance || '0') / maxBal) * 100])) }, + { metric: 'Assets', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, ((s.data as Horizon.AccountResponse).balances.filter(b => b.asset_type !== 'native').length / maxAssets) * 100])) }, + { metric: 'Subentries', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, ((s.data as Horizon.AccountResponse).subentry_count / maxSubentries) * 100])) }, + { metric: 'Signers', ...Object.fromEntries(activeSlots.map((s, i) => [`slot_${i}`, (((s.data as Horizon.AccountResponse).signers?.length || 1) / maxSigners) * 100])) }, ] }, [activeSlots]) @@ -74,8 +90,7 @@ export default function ComparisonChart({ comparisonSlots }) { ) } - // Custom tooltips - const CustomBalanceTooltip = ({ active, payload }) => { + const CustomBalanceTooltip = ({ active, payload }: TooltipProps) => { if (active && payload && payload.length) { return (
@@ -85,7 +100,7 @@ export default function ComparisonChart({ comparisonSlots }) { {shortAddress(activeSlots[idx].key, 4)}: - {formatXLM(entry.value.toString())} XLM + {formatXLM(entry.value?.toString() || '0')} XLM
))}
@@ -94,7 +109,7 @@ export default function ComparisonChart({ comparisonSlots }) { return null } - const CustomRadarTooltip = ({ active, payload, label }) => { + const CustomRadarTooltip = ({ active, payload, label }: TooltipProps) => { if (active && payload && payload.length) { return (
@@ -104,7 +119,7 @@ export default function ComparisonChart({ comparisonSlots }) { {shortAddress(activeSlots[idx].key, 4)}: - {Math.round(entry.value)}% + {Math.round(entry.value || 0)}%
))}
@@ -115,7 +130,6 @@ export default function ComparisonChart({ comparisonSlots }) { return (
- {/* 1. Bar Chart: Balances */}
Balance Comparison @@ -128,16 +142,15 @@ export default function ComparisonChart({ comparisonSlots }) { } cursor={{ fill: 'rgba(255,255,255,0.02)' }} /> {shortAddress(activeSlots[index]?.key || '', 4)}} /> + formatter={(_value: string, _entry: unknown, index: number) => {shortAddress(activeSlots[index]?.key || '', 4)}} /> {activeSlots.map((slot, i) => ( - + ))}
- {/* 2. Radar Chart: Relative Metrics (only if 2+ slots) */}
Relative Strength (Normalized) @@ -147,11 +160,11 @@ export default function ComparisonChart({ comparisonSlots }) { - + } /> - {shortAddress(activeSlots[index]?.key || '', 4)}} /> + {shortAddress(activeSlots[index]?.key || '', 4)}} /> {activeSlots.map((slot, i) => ( ))} @@ -165,7 +178,6 @@ export default function ComparisonChart({ comparisonSlots }) {
- {/* 3. Bar Chart: Other Metrics */}
Activity & Assets @@ -176,11 +188,11 @@ export default function ComparisonChart({ comparisonSlots }) { - - {shortAddress(activeSlots[index]?.key || '', 4)}} /> + + {shortAddress(activeSlots[index]?.key || '', 4)}} /> {activeSlots.map((slot, i) => ( - + ))} diff --git a/src/components/dashboard/ContractABI.jsx b/src/components/dashboard/ContractABI.tsx similarity index 100% rename from src/components/dashboard/ContractABI.jsx rename to src/components/dashboard/ContractABI.tsx diff --git a/src/components/dashboard/ContractDebugger.jsx b/src/components/dashboard/ContractDebugger.tsx similarity index 100% rename from src/components/dashboard/ContractDebugger.jsx rename to src/components/dashboard/ContractDebugger.tsx diff --git a/src/components/dashboard/ContractHistory.jsx b/src/components/dashboard/ContractHistory.tsx similarity index 100% rename from src/components/dashboard/ContractHistory.jsx rename to src/components/dashboard/ContractHistory.tsx diff --git a/src/components/dashboard/ContractInteraction.jsx b/src/components/dashboard/ContractInteraction.tsx similarity index 100% rename from src/components/dashboard/ContractInteraction.jsx rename to src/components/dashboard/ContractInteraction.tsx diff --git a/src/components/dashboard/Contracts.jsx b/src/components/dashboard/Contracts.tsx similarity index 100% rename from src/components/dashboard/Contracts.jsx rename to src/components/dashboard/Contracts.tsx diff --git a/src/components/dashboard/DEXExplorer.jsx b/src/components/dashboard/DEXExplorer.tsx similarity index 87% rename from src/components/dashboard/DEXExplorer.jsx rename to src/components/dashboard/DEXExplorer.tsx index 79f0acc8..e4484218 100644 --- a/src/components/dashboard/DEXExplorer.jsx +++ b/src/components/dashboard/DEXExplorer.tsx @@ -1,10 +1,23 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState, type ReactNode } from "react"; import * as StellarSdk from "@stellar/stellar-sdk"; import { useStore } from "../../lib/store"; import { fetchOrderBook, fetchTrades, parseAssetString } from "../../lib/dex"; +import type { OrderBookEntry, SpreadInfo } from "./types"; import LiquidityPools from "./LiquidityPools"; -function toAsset(assetInput) { +interface OrderBookData { + bids: OrderBookEntry[] + asks: OrderBookEntry[] +} + +interface TradeRecord { + id: string + price?: { n: number; d: number } + base_amount?: string + ledger_close_time: string +} + +function toAsset(assetInput: string): StellarSdk.Asset { if (!assetInput || assetInput === "native" || assetInput === "XLM") { return StellarSdk.Asset.native(); } @@ -13,6 +26,76 @@ function toAsset(assetInput) { return new StellarSdk.Asset(parsed.code, parsed.issuer); } +function ViewButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function BookSide({ title, rows, accent }: { title: string; rows: OrderBookEntry[]; accent: string }) { + return ( +
+
+ {title} +
+ {rows.length === 0 && ( +
+ No levels loaded. +
+ )} + {rows.map((row, index) => ( +
+ {row.price} + {row.amount} +
+ ))} +
+ ); +} + export default function DEXExplorer() { const { network } = useStore(); const [activeView, setActiveView] = useState("orderbook"); @@ -20,8 +103,8 @@ export default function DEXExplorer() { const [buying, setBuying] = useState( "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" ); - const [book, setBook] = useState(null); - const [trades, setTrades] = useState([]); + const [book, setBook] = useState(null); + const [trades, setTrades] = useState([]); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -31,7 +114,7 @@ export default function DEXExplorer() { } }, []); - const spread = useMemo(() => { + const spread: SpreadInfo | null = useMemo(() => { const bestBid = Number(book?.bids?.[0]?.price || 0); const bestAsk = Number(book?.asks?.[0]?.price || 0); if (!bestBid || !bestAsk) return null; @@ -57,8 +140,8 @@ export default function DEXExplorer() { setBook(orderBook); setTrades(tradeList); - } catch (err) { - setError(err.message || "Failed to load DEX data."); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load DEX data."); } finally { setLoading(false); } @@ -102,7 +185,7 @@ export default function DEXExplorer() { > setSelling(event.target.value)} + onChange={(event: React.ChangeEvent) => setSelling(event.target.value)} placeholder='Selling asset (e.g. "native" or "USDC:G...")' style={{ border: "1px solid var(--border)", @@ -116,7 +199,7 @@ export default function DEXExplorer() { /> setBuying(event.target.value)} + onChange={(event: React.ChangeEvent) => setBuying(event.target.value)} placeholder='Buying asset (e.g. "native" or "USDC:G...")' style={{ border: "1px solid var(--border)", @@ -181,7 +264,7 @@ export default function DEXExplorer() { No trades loaded yet.
)} - {trades.map((trade) => ( + {trades.map((trade: TradeRecord) => (
); } - -function ViewButton({ active, onClick, children }) { - return ( - - ); -} - -function Stat({ label, value }) { - return ( -
-
{label}
-
- {value} -
-
- ); -} - -function BookSide({ title, rows, accent }) { - return ( -
-
- {title} -
- {rows.length === 0 && ( -
- No levels loaded. -
- )} - {rows.map((row, index) => ( -
- {row.price} - {row.amount} -
- ))} -
- ); -} diff --git a/src/components/dashboard/DataExport.jsx b/src/components/dashboard/DataExport.tsx similarity index 90% rename from src/components/dashboard/DataExport.jsx rename to src/components/dashboard/DataExport.tsx index ffaa1c6a..faa9fb93 100644 --- a/src/components/dashboard/DataExport.jsx +++ b/src/components/dashboard/DataExport.tsx @@ -1,16 +1,21 @@ -/** - * DataExport component (#114). - * - * Provides a UI panel for exporting dashboard data as JSON/CSV - * and importing a previously saved backup file. - */ - -import React, { useRef } from "react"; +import React, { useRef, type ReactNode } from "react"; import { useDataExport } from "../../hooks/useDataExport"; import { useStore } from "../../lib/store"; -function ActionButton({ onClick, disabled, children, variant = "primary" }) { - const base = { +interface ActionButtonProps { + onClick: () => void + disabled?: boolean + children: ReactNode + variant?: "primary" | "secondary" +} + +interface StatusMessageProps { + error?: string | null + success?: boolean +} + +function ActionButton({ onClick, disabled, children, variant = "primary" }: ActionButtonProps) { + const base: React.CSSProperties = { padding: "9px 18px", borderRadius: "var(--radius-md)", fontSize: "12px", @@ -24,7 +29,7 @@ function ActionButton({ onClick, disabled, children, variant = "primary" }) { alignItems: "center", gap: "6px", }; - const styles = + const styles: React.CSSProperties = variant === "primary" ? { ...base, background: "var(--cyan)", color: "#000" } : { ...base, background: "var(--bg-elevated)", color: "var(--text-primary)", border: "1px solid var(--border-bright)" }; @@ -35,7 +40,7 @@ function ActionButton({ onClick, disabled, children, variant = "primary" }) { ); } -function StatusMessage({ error, success }) { +function StatusMessage({ error, success }: StatusMessageProps) { if (!error && !success) return null; return (
(null); const { transactions, account } = useStore(); const { isExporting, @@ -70,7 +75,7 @@ export default function DataExport() { importBackup, } = useDataExport(); - const handleFileChange = (e) => { + const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { importBackup(file); @@ -90,7 +95,6 @@ export default function DataExport() { padding: "4px", }} > - {/* ── Export section ── */}
- {/* ── Import section ── */}
setSelectedExplorer(e.target.value)} + onChange={(e: React.ChangeEvent) => setSelectedExplorer(e.target.value)} style={textInputStyle()} > {Object.entries(EXPLORERS).map(([key, explorer]) => ( @@ -166,7 +166,7 @@ export default function ExplorerEmbed() { > setResourceId(e.target.value)} + onChange={(e: React.ChangeEvent) => setResourceId(e.target.value)} placeholder={`Enter ${resourceType} ID`} style={textInputStyle()} /> diff --git a/src/components/dashboard/Faucet.jsx b/src/components/dashboard/Faucet.tsx similarity index 93% rename from src/components/dashboard/Faucet.jsx rename to src/components/dashboard/Faucet.tsx index 0aa85fc6..cea4978c 100644 --- a/src/components/dashboard/Faucet.jsx +++ b/src/components/dashboard/Faucet.tsx @@ -3,6 +3,13 @@ import { useStore } from '../../lib/store' import { fundTestnetAccount, isValidPublicKey } from '../../lib/stellar' import CopyableValue from './CopyableValue' +interface FaucetResult { + success: boolean + address?: string + data?: unknown + error?: string +} + export default function Faucet() { const { connectedAddress, faucetLoading, setFaucetLoading, faucetResult, setFaucetResult } = useStore() const [input, setInput] = useState(connectedAddress || '') @@ -17,8 +24,8 @@ export default function Faucet() { try { const result = await fundTestnetAccount(addr) setFaucetResult({ success: true, address: addr, data: result }) - } catch (e) { - setFaucetResult({ success: false, error: e.message }) + } catch (e: unknown) { + setFaucetResult({ success: false, error: (e as Error).message }) } finally { setFaucetLoading(false) } @@ -31,7 +38,6 @@ export default function Faucet() {
Fund any testnet account with 10,000 XLM via Friendbot
- {/* Fund card */}
{ setInput(e.target.value); setError('') }} - onKeyDown={e => e.key === 'Enter' && handleFund()} + onChange={(e: React.ChangeEvent) => { setInput(e.target.value); setError('') }} + onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && handleFund()} placeholder="G... public key to fund" style={{ flex: 1, @@ -111,7 +117,6 @@ export default function Faucet() {
- {/* Result */} {faucetResult && (
Address funded:
)} - {/* Info */}
About Friendbot
diff --git a/src/components/dashboard/LiquidityPools.jsx b/src/components/dashboard/LiquidityPools.tsx similarity index 82% rename from src/components/dashboard/LiquidityPools.jsx rename to src/components/dashboard/LiquidityPools.tsx index 00ddb2dc..be38103a 100644 --- a/src/components/dashboard/LiquidityPools.jsx +++ b/src/components/dashboard/LiquidityPools.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState, type ReactNode } from "react"; import { Droplets, RefreshCw, Search } from "lucide-react"; import { useStore } from "../../lib/store"; import { @@ -6,51 +6,145 @@ import { fetchAccountLiquidityPoolPositions, fetchLiquidityPoolsByAssetPair, } from "../../lib/dex"; +import type { LiquidityPool, LiquidityPosition } from "./types"; const DEFAULT_ASSET_A = "native"; const DEFAULT_ASSET_B = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; -function formatNumber(value, maximumFractionDigits = 7) { +function formatNumber(value: string | number, maximumFractionDigits = 7): string { const number = Number(value); if (!Number.isFinite(number)) return "0"; return number.toLocaleString("en-US", { maximumFractionDigits }); } -function assetCode(asset) { +function assetCode(asset: string): string { if (!asset || asset === "native") return "XLM"; return asset.split(":")[0] || asset; } -function shortId(value) { +function shortId(value: string): string { if (!value) return "—"; return `${value.slice(0, 10)}…${value.slice(-8)}`; } -function operationAmount(op) { +function operationAmount(op: Record): string { if (op.type === "liquidity_pool_deposit") { const max = [op.max_amount_a, op.max_amount_b].filter(Boolean).join(" / "); return max || "Deposit"; } if (op.type === "liquidity_pool_withdraw") { const min = [op.min_amount_a, op.min_amount_b].filter(Boolean).join(" / "); - return min || op.shares || "Withdraw"; + return (min as string) || (op.shares as string) || "Withdraw"; } return "—"; } +function AssetField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { + return ( + + ); +} + +function PanelHeader({ icon, title, detail, compact = false }: { icon?: ReactNode; title: string; detail?: string; compact?: boolean }) { + return ( +
+
+ {icon} + {title} +
+
{detail}
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function MiniMetric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function EmptyState({ text }: { text: string }) { + return
{text}
; +} + +const panelStyle: React.CSSProperties = { + background: "var(--bg-card)", + border: "1px solid var(--border)", + borderRadius: "var(--radius-lg)", + padding: "14px", + minWidth: 0, +}; + +function buttonStyle(disabled: boolean): React.CSSProperties { + return { + alignSelf: "end", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: "7px", + border: "1px solid var(--cyan-dim)", + background: disabled ? "transparent" : "var(--cyan-glow)", + color: disabled ? "var(--text-muted)" : "var(--cyan)", + borderRadius: "var(--radius-sm)", + fontSize: "12px", + fontFamily: "var(--font-mono)", + padding: "9px 12px", + cursor: disabled ? "not-allowed" : "pointer", + minWidth: "94px", + }; +} + export default function LiquidityPools() { const { network, connectedAddress } = useStore(); const [assetA, setAssetA] = useState(DEFAULT_ASSET_A); const [assetB, setAssetB] = useState(DEFAULT_ASSET_B); - const [pools, setPools] = useState([]); - const [positions, setPositions] = useState([]); - const [history, setHistory] = useState([]); - const [selectedPoolId, setSelectedPoolId] = useState(null); + const [pools, setPools] = useState([]); + const [positions, setPositions] = useState([]); + const [history, setHistory] = useState>>([]); + const [selectedPoolId, setSelectedPoolId] = useState(null); const [loading, setLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false); const [error, setError] = useState(""); - const selectedPool = useMemo( + const selectedPool = useMemo( () => pools.find((pool) => pool.id === selectedPoolId) || pools[0] || null, [pools, selectedPoolId] ); @@ -59,11 +153,11 @@ export default function LiquidityPools() { setLoading(true); setError(""); try { - const records = await fetchLiquidityPoolsByAssetPair(nextA.trim(), nextB.trim(), network, 20); + const records: LiquidityPool[] = await fetchLiquidityPoolsByAssetPair(nextA.trim(), nextB.trim(), network, 20); setPools(records); setSelectedPoolId(records[0]?.id || null); - } catch (err) { - setError(err.message || "Failed to load liquidity pools."); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load liquidity pools."); setPools([]); setSelectedPoolId(null); } finally { @@ -71,7 +165,7 @@ export default function LiquidityPools() { } } - async function loadAccountPools(poolId = selectedPoolId) { + async function loadAccountPools(poolId?: string | null) { if (!connectedAddress) { setPositions([]); setHistory([]); @@ -102,7 +196,7 @@ export default function LiquidityPools() { } try { - const pair = JSON.parse(rawPair); + const pair = JSON.parse(rawPair) as { assetA: string; assetB: string }; sessionStorage.removeItem("dex:poolPair"); if (pair.assetA && pair.assetB) { setAssetA(pair.assetA); @@ -155,7 +249,7 @@ export default function LiquidityPools() { {pools.length === 0 && ( )} - {pools.map((pool) => ( + {pools.map((pool: LiquidityPool) => (
); } - -function AssetField({ label, value, onChange }) { - return ( - - ); -} - -function PanelHeader({ icon, title, detail, compact = false }) { - return ( -
-
- {icon} - {title} -
-
{detail}
-
- ); -} - -function Stat({ label, value }) { - return ( -
-
{label}
-
- {value} -
-
- ); -} - -function MiniMetric({ label, value }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function EmptyState({ text }) { - return
{text}
; -} - -const panelStyle = { - background: "var(--bg-card)", - border: "1px solid var(--border)", - borderRadius: "var(--radius-lg)", - padding: "14px", - minWidth: 0, -}; - -function buttonStyle(disabled) { - return { - alignSelf: "end", - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - gap: "7px", - border: "1px solid var(--cyan-dim)", - background: disabled ? "transparent" : "var(--cyan-glow)", - color: disabled ? "var(--text-muted)" : "var(--cyan)", - borderRadius: "var(--radius-sm)", - fontSize: "12px", - fontFamily: "var(--font-mono)", - padding: "9px 12px", - cursor: disabled ? "not-allowed" : "pointer", - minWidth: "94px", - }; -} diff --git a/src/components/dashboard/LiveActivityFeed.jsx b/src/components/dashboard/LiveActivityFeed.tsx similarity index 84% rename from src/components/dashboard/LiveActivityFeed.jsx rename to src/components/dashboard/LiveActivityFeed.tsx index 3f89a16c..c069a454 100644 --- a/src/components/dashboard/LiveActivityFeed.jsx +++ b/src/components/dashboard/LiveActivityFeed.tsx @@ -10,7 +10,7 @@ const CHANNELS = [ { id: 'transactions', label: 'Transactions' }, ] -const STATUS_COLORS = { +const STATUS_COLORS: Record = { idle: 'var(--text-muted)', connecting: 'var(--cyan, #06b6d4)', connected: 'var(--success, #22c55e)', @@ -19,25 +19,24 @@ const STATUS_COLORS = { disconnected: 'var(--text-muted)', } -// Milliseconds before marking stream as stale (no messages received) const STALE_STREAM_THRESHOLD_MS = 10_000 -function formatTime(ts) { +function formatTime(ts: number): string { return new Date(ts).toLocaleTimeString() } -function describeEvent(event) { +function describeEvent(event: { channel: string; record?: Record }): string { const r = event.record ?? {} if (event.channel === 'payments') { - const amount = r.amount ?? '?' - const asset = r.asset_code ?? 'XLM' - return `${amount} ${asset}: ${truncate(r.from)} → ${truncate(r.to)}` + const amount = (r.amount as string) ?? '?' + const asset = (r.asset_code as string) ?? 'XLM' + return `${amount} ${asset}: ${truncate(r.from as string)} → ${truncate(r.to as string)}` } if (event.channel === 'effects') { return `${r.type ?? 'effect'} ${r.amount ? `(${r.amount} ${r.asset_code ?? 'XLM'})` : ''}` } if (event.channel === 'operations') { - return `${r.type ?? 'operation'} #${(r.id ?? '').toString().slice(0, 12)}` + return `${r.type ?? 'operation'} #${((r.id ?? '').toString()).slice(0, 12)}` } if (event.channel === 'transactions') { const ops = r.operation_count ?? '?' @@ -46,26 +45,15 @@ function describeEvent(event) { return JSON.stringify(r).slice(0, 80) } -function truncate(s) { +function truncate(s: unknown): string { if (!s || typeof s !== 'string') return '—' if (s.length <= 12) return s return `${s.slice(0, 5)}…${s.slice(-4)}` } -/** - * Real-time, account-scoped activity feed. Shows a unified timeline of incoming - * events (effects, payments, operations, transactions) for the connected account. - * - * Features: - * - Multi-channel event subscription (effects, payments, operations, transactions) - * - Automatic unsubscription on account/network change or component unmount - * - Stale-stream warning when no messages received for threshold duration - * - Sorted unified feed across all channels - * - Status indicator with reconnection support - */ export default function LiveActivityFeed() { const { connectedAddress, network } = useStore() - const [selectedChannels, setSelectedChannels] = useState(['effects', 'payments']) + const [selectedChannels, setSelectedChannels] = useState(['effects', 'payments']) const [isStale, setIsStale] = useState(false) const { events, status, lastEventAt, errored } = useAccountStream( @@ -78,15 +66,12 @@ export default function LiveActivityFeed() { }, ) - // Track stream staleness: after status becomes 'connected', check if we receive - // messages within the threshold. If not, mark as stale. useEffect(() => { if (status !== 'connected' || !lastEventAt) { setIsStale(false) return } - // Set timer to check for staleness const checkStale = setTimeout(() => { const timeSinceLastEvent = Date.now() - lastEventAt if (timeSinceLastEvent > STALE_STREAM_THRESHOLD_MS) { @@ -97,15 +82,14 @@ export default function LiveActivityFeed() { return () => clearTimeout(checkStale) }, [status, lastEventAt]) - // Clear events and reset state when account/network changes useEffect(() => { setIsStale(false) }, [connectedAddress, network]) - const toggleChannel = (id) => { + const toggleChannel = (id: string) => { setSelectedChannels((prev) => { if (prev.includes(id)) { - if (prev.length === 1) return prev // never empty + if (prev.length === 1) return prev return prev.filter((c) => c !== id) } return [...prev, id] @@ -239,7 +223,7 @@ export default function LiveActivityFeed() { Waiting for new {selectedChannels.join(', ')} events…
) : ( - events.map((event, idx) => ( + events.map((event: { pagingToken: string; receivedAt: number; channel: string; record?: Record }, idx: number) => (
({ +interface OrderBookChartProps { + bids?: OrderBookEntry[] + asks?: OrderBookEntry[] +} + +export default function OrderBookChart({ bids = [], asks = [] }: OrderBookChartProps) { + const bidData: OrderBookChartPoint[] = bids.slice(0, 20).map((bid, i) => ({ price: parseFloat(bid.price), amount: parseFloat(bid.amount), cumulative: bids @@ -20,7 +26,7 @@ export default function OrderBookChart({ bids = [], asks = [] }) { type: "bid", })); - const askData = asks.slice(0, 20).map((ask, i) => ({ + const askData: OrderBookChartPoint[] = asks.slice(0, 20).map((ask, i) => ({ price: parseFloat(ask.price), amount: parseFloat(ask.amount), cumulative: asks @@ -75,12 +81,12 @@ export default function OrderBookChart({ bids = [], asks = [] }) { dataKey="price" stroke="var(--text-muted)" style={{ fontSize: "11px" }} - tickFormatter={(value) => value.toFixed(4)} + tickFormatter={(value: number) => value.toFixed(4)} /> value.toFixed(2)} + tickFormatter={(value: number) => value.toFixed(2)} /> [value.toFixed(4), name]} + formatter={(value: number, name: string) => [value.toFixed(4), name]} /> { - const components = { +const getWidgetComponent = (type: string): React.ComponentType> => { + const components: Record>> = { balance: BalanceWidget, assets: AssetsWidget, transactions: TransactionsWidget, @@ -33,64 +33,40 @@ const getWidgetComponent = (type) => { return components[type] || BalanceWidget; }; -// Default widget configuration layout fallbacks -const DEFAULT_WIDGETS = [ - { - id: 'balance-default', - type: 'balance', - height: 260, - span: 1 - }, - { - id: 'assets-default', - type: 'assets', - height: 320, - span: 1 - }, - { - id: 'transactions-default', - type: 'transactions', - height: 360, - span: 2 - }, - { - id: 'networkStats-default', - type: 'networkStats', - height: 300, - span: 1 - } +const DEFAULT_WIDGETS: WidgetConfig[] = [ + { id: 'balance-default', type: 'balance', height: 260, span: 1 }, + { id: 'assets-default', type: 'assets', height: 320, span: 1 }, + { id: 'transactions-default', type: 'transactions', height: 360, span: 2 }, + { id: 'networkStats-default', type: 'networkStats', height: 300, span: 1 } ]; export default function Overview() { const { connectedAddress, network } = useStore(); const { isMobile, isTablet, windowWidth } = useResponsive(); - - const [widgets, setWidgets] = useState([]); + + const [widgets, setWidgets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isEditing, setIsEditing] = useState(false); const [showWidgetSelector, setShowWidgetSelector] = useState(false); - // 1. Load layout preferences asynchronously on component mount useEffect(() => { async function hydrateDashboardLayout() { try { const savedLayout = await getDashboardLayout(); const activeLayoutRules = (savedLayout && savedLayout.length > 0) ? savedLayout : DEFAULT_WIDGETS; - - // Dynamically append non-serializable React elements using type descriptors - const hydratedWidgets = activeLayoutRules.map(widget => ({ + + const hydratedWidgets: WidgetItem[] = activeLayoutRules.map((widget: WidgetConfig) => ({ ...widget, component: React.createElement(getWidgetComponent(widget.type), { key: `${widget.id}-${Date.now()}`, onRefresh: () => refreshWidgets() }) })); - + setWidgets(hydratedWidgets); } catch (error) { console.error("Failed to restore overview widget layout:", error); - // Fallback to default layout state if an error is thrown - const fallbackWidgets = DEFAULT_WIDGETS.map(widget => ({ + const fallbackWidgets: WidgetItem[] = DEFAULT_WIDGETS.map((widget: WidgetConfig) => ({ ...widget, component: React.createElement(getWidgetComponent(widget.type), { key: `${widget.id}-fallback`, @@ -105,10 +81,9 @@ export default function Overview() { hydrateDashboardLayout(); }, []); - // Helper utility to clean non-serializable component properties before writing to store - const persistAndSyncLayout = async (updatedWidgets) => { + const persistAndSyncLayout = async (updatedWidgets: WidgetItem[]) => { setWidgets(updatedWidgets); - + const serializedLayout = updatedWidgets.map((w, index) => ({ id: w.id, type: w.type, @@ -116,16 +91,15 @@ export default function Overview() { span: Math.max(1, Number(w.span) || 1), order: index })); - + await saveDashboardLayout(serializedLayout); }; - // Refresh active widget components in-place when layout or data states update const refreshWidgets = () => { - setWidgets(prevWidgets => - prevWidgets.map(widget => ({ + setWidgets(prevWidgets => + prevWidgets.map((widget: WidgetItem) => ({ ...widget, - component: React.createElement(getWidgetComponent(widget.type), { + component: React.createElement(getWidgetComponent(widget.type), { key: `${widget.id}-${Date.now()}`, onRefresh: () => refreshWidgets() }) @@ -134,39 +108,29 @@ export default function Overview() { addBreadcrumb('Dashboard widgets refreshed', 'user_action'); }; - // Handle arrangement layout sequence shifts - const handleLayoutChange = (newLayout) => { + const handleLayoutChange = (newLayout: WidgetItem[]) => { persistAndSyncLayout(newLayout); - addBreadcrumb('Dashboard layout changed', 'user_action', { - widgetCount: newLayout.length + addBreadcrumb('Dashboard layout changed', 'user_action', { + widgetCount: newLayout.length }); }; - // Handle widget resizing dimensions modification - const handleWidgetResize = (widget, newSize) => { - const updatedWidgets = widgets.map(w => + const handleWidgetResize = (widget: WidgetItem, newSize: Partial) => { + const updatedWidgets = widgets.map(w => w.id === widget.id ? { ...w, ...newSize } : w ); persistAndSyncLayout(updatedWidgets); - addBreadcrumb('Widget resized', 'user_action', { - widgetId: widget.id, - newSize - }); + addBreadcrumb('Widget resized', 'user_action', { widgetId: widget.id, newSize }); }; - // Handle structural widget node deletions - const handleWidgetRemove = (widget) => { + const handleWidgetRemove = (widget: WidgetItem) => { const updatedWidgets = widgets.filter(w => w.id !== widget.id); persistAndSyncLayout(updatedWidgets); - addBreadcrumb('Widget removed', 'user_action', { - widgetId: widget.id, - widgetType: widget.type - }); + addBreadcrumb('Widget removed', 'user_action', { widgetId: widget.id, widgetType: widget.type }); }; - // Handle adding a new element container node - const handleAddWidget = (newWidget) => { - const freshWidgetWithElement = { + const handleAddWidget = (newWidget: WidgetConfig) => { + const freshWidgetWithElement: WidgetItem = { ...newWidget, component: React.createElement(getWidgetComponent(newWidget.type), { key: `${newWidget.id}-${Date.now()}`, @@ -175,15 +139,11 @@ export default function Overview() { }; const updatedWidgets = [...widgets, freshWidgetWithElement]; persistAndSyncLayout(updatedWidgets); - addBreadcrumb('Widget added', 'user_action', { - widgetId: newWidget.id, - widgetType: newWidget.type - }); + addBreadcrumb('Widget added', 'user_action', { widgetId: newWidget.id, widgetType: newWidget.type }); }; - // Reset to static fallback architecture layout const handleResetLayout = () => { - const factoryResetWidgets = DEFAULT_WIDGETS.map(widget => ({ + const factoryResetWidgets: WidgetItem[] = DEFAULT_WIDGETS.map((widget: WidgetConfig) => ({ ...widget, component: React.createElement(getWidgetComponent(widget.type), { key: `${widget.id}-${Date.now()}`, @@ -195,7 +155,6 @@ export default function Overview() { addBreadcrumb('Dashboard layout reset to default', 'user_action'); }; - // Toggle layout modification context views const toggleEditMode = () => { setIsEditing(!isEditing); addBreadcrumb(`Dashboard edit mode ${!isEditing ? 'enabled' : 'disabled'}`, 'user_action'); @@ -217,18 +176,17 @@ export default function Overview() { return (
- {/* Header */} -
-
@@ -237,29 +195,23 @@ export default function Overview() { {shortAddress(connectedAddress, 8)}
-
- {/* Network Badge */} +
- {/* Dashboard Controls */}
{isEditing && (
- {/* Edit Mode Notice */} {isEditing && (
)} - {/* Dashboard Grid */} - {/* Widget Selector Modal */} setShowWidgetSelector(false)} diff --git a/src/components/dashboard/PathExplorer.jsx b/src/components/dashboard/PathExplorer.jsx deleted file mode 100644 index 71062334..00000000 --- a/src/components/dashboard/PathExplorer.jsx +++ /dev/null @@ -1,412 +0,0 @@ -import React, { useState } from 'react' -import { useStore } from '../../lib/store' -import { fetchPaymentPaths } from '../../lib/stellar' - -const PRESET_ASSETS = [ - { label: 'XLM (native)', value: { type: 'native', code: 'XLM' } }, - { label: 'USDC (testnet)', value: { type: 'credit', code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' } }, - { label: 'USDC (mainnet)', value: { type: 'credit', code: 'USDC', issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' } }, -] - -function AssetInput({ label, value, onChange }) { - const [mode, setMode] = useState('preset') // 'preset' | 'custom' - const [customCode, setCustomCode] = useState('') - const [customIssuer, setCustomIssuer] = useState('') - - const inputStyle = { - background: 'var(--bg-surface)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-sm)', - color: 'var(--text-primary)', - fontFamily: 'var(--font-mono)', - fontSize: '12px', - padding: '7px 10px', - width: '100%', - boxSizing: 'border-box', - outline: 'none', - } - - const toggleStyle = (active) => ({ - padding: '4px 10px', - fontSize: '11px', - fontFamily: 'var(--font-mono)', - background: active ? 'var(--cyan-glow)' : 'transparent', - border: `1px solid ${active ? 'var(--cyan)' : 'var(--border)'}`, - color: active ? 'var(--cyan)' : 'var(--text-muted)', - borderRadius: 'var(--radius-sm)', - cursor: 'pointer', - transition: 'var(--transition)', - }) - - function handlePresetChange(e) { - const preset = PRESET_ASSETS.find(a => a.label === e.target.value) - if (preset) onChange(preset.value) - } - - function handleCustomChange(code, issuer) { - if (code === 'XLM' && !issuer) { - onChange({ type: 'native', code: 'XLM' }) - } else if (code && issuer) { - onChange({ type: 'credit', code, issuer }) - } - } - - return ( -
-
- {label} -
- - -
-
- {mode === 'preset' ? ( - - ) : ( -
- { setCustomCode(e.target.value.toUpperCase()); handleCustomChange(e.target.value.toUpperCase(), customIssuer) }} - /> - {customCode !== 'XLM' && ( - { setCustomIssuer(e.target.value); handleCustomChange(customCode, e.target.value) }} - /> - )} -
- )} - {value && ( -
- {value.type === 'native' ? '✦ XLM (native)' : `✦ ${value.code}`} -
- )} -
- ) -} - -function PathCard({ path, mode, index }) { - const sourceAmount = parseFloat(path.source_amount) - const destAmount = parseFloat(path.destination_amount) - const rate = mode === 'strict-send' - ? (destAmount / sourceAmount).toFixed(6) - : (sourceAmount / destAmount).toFixed(6) - - // Estimate slippage vs best path (index 0 = best) - const slippage = index === 0 ? null : null // computed by parent - - function assetLabel(a) { - if (a.asset_type === 'native') return 'XLM' - return a.asset_code - } - - const pathAssets = path.path || [] - - return ( -
- {index === 0 && ( -
BEST RATE
- )} - - {/* Path route */} -
- - {pathAssets.map((a, i) => ( - - - - - ))} - - -
- - {/* Amounts & rate */} -
- - - -
- - {path.slippagePct !== undefined && ( -
- ~{path.slippagePct}% vs best path -
- )} -
- ) -} - -function AssetBadge({ label, isSource, isDest }) { - return ( - {label} - ) -} - -function Metric({ label, value, accent }) { - return ( -
-
{label}
-
{value}
-
- ) -} - -export default function PathExplorer() { - const { network, setActiveTab } = useStore() - const [sourceAsset, setSourceAsset] = useState(null) - const [destAsset, setDestAsset] = useState(null) - const [amount, setAmount] = useState('') - const [mode, setMode] = useState('strict-send') - const [paths, setPaths] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - async function handleFind() { - if (!sourceAsset || !destAsset || !amount) return - setLoading(true) - setError(null) - setPaths(null) - try { - const results = await fetchPaymentPaths({ sourceAsset, destAsset, amount, mode, network }) - // Sort by best rate and annotate slippage - const sorted = [...results].sort((a, b) => { - const rateA = mode === 'strict-send' - ? parseFloat(a.destination_amount) / parseFloat(a.source_amount) - : parseFloat(a.source_amount) / parseFloat(a.destination_amount) - const rateB = mode === 'strict-send' - ? parseFloat(b.destination_amount) / parseFloat(b.source_amount) - : parseFloat(b.source_amount) / parseFloat(b.destination_amount) - return mode === 'strict-send' ? rateB - rateA : rateA - rateB - }) - const bestRate = sorted[0] - ? (mode === 'strict-send' - ? parseFloat(sorted[0].destination_amount) / parseFloat(sorted[0].source_amount) - : parseFloat(sorted[0].source_amount) / parseFloat(sorted[0].destination_amount)) - : 1 - const annotated = sorted.map((p, i) => { - if (i === 0) return p - const rate = mode === 'strict-send' - ? parseFloat(p.destination_amount) / parseFloat(p.source_amount) - : parseFloat(p.source_amount) / parseFloat(p.destination_amount) - const slippagePct = (((bestRate - rate) / bestRate) * 100).toFixed(2) - return { ...p, slippagePct } - }) - setPaths(annotated) - } catch (e) { - setError(e.message) - } finally { - setLoading(false) - } - } - - const canSearch = sourceAsset && destAsset && amount && parseFloat(amount) > 0 - - function assetToPoolString(asset) { - if (!asset || asset.type === 'native') return 'native' - return `${asset.code}:${asset.issuer}` - } - - function openLiquidityPools() { - if (!sourceAsset || !destAsset) return - sessionStorage.setItem('dex:poolPair', JSON.stringify({ - assetA: assetToPoolString(sourceAsset), - assetB: assetToPoolString(destAsset), - })) - setActiveTab('dex') - } - - const modeToggle = (m, label) => ( - - ) - - return ( -
- {/* Header */} -
-
-
Path Explorer
-
- Find DEX conversion paths via Horizon -
-
-
{network}
-
- - {/* Form */} -
- {/* Mode */} -
- Mode -
- {modeToggle('strict-send', 'Strict Send')} - {modeToggle('strict-receive', 'Strict Receive')} -
-
- {mode === 'strict-send' - ? 'Fix the amount you send — see how much you receive' - : 'Fix the amount you receive — see how much you need to send'} -
-
- - {/* Assets + amount */} -
- - -
- -
- - {mode === 'strict-send' ? 'Amount to Send' : 'Amount to Receive'} - - setAmount(e.target.value)} - style={{ - background: 'var(--bg-surface)', - border: '1px solid var(--border)', - borderRadius: 'var(--radius-sm)', - color: 'var(--text-primary)', - fontFamily: 'var(--font-mono)', - fontSize: '14px', - padding: '9px 12px', - width: '200px', - outline: 'none', - }} - /> -
- - -
- - {/* Results */} - {loading && ( -
-
-
- )} - - {error && ( -
- {error} -
- )} - - {paths !== null && !loading && ( - paths.length === 0 ? ( -
- No payment paths found for this pair on {network}. Try different assets or a different network. -
- ) : ( -
-
-
- {paths.length} path{paths.length !== 1 ? 's' : ''} found — sorted by best rate -
- -
- {paths.map((p, i) => )} -
- ) - )} -
- ) -} diff --git a/src/components/dashboard/PathExplorer.tsx b/src/components/dashboard/PathExplorer.tsx new file mode 100644 index 00000000..e8fe5e55 --- /dev/null +++ b/src/components/dashboard/PathExplorer.tsx @@ -0,0 +1,417 @@ +import React, { useState, useCallback, type FormEvent, type ReactNode } from 'react'; +import { shortAddress } from '../../lib/stellar'; +import { fetchPathPayments, type PathPaymentPath } from '../../lib/payments'; + +interface ExplorePathsCardProps { + destination: string + amount: string + sourceAsset: string + paths: PathPaymentPath[] + isLoading: boolean + lastSearched: boolean +} + +interface MultiHopPathProps { + path: PathPaymentPath + sourceAsset: string + index: number + total: number +} + +interface PathStepProps { + label: string + value: string + sub?: string +} + +const currentYear = new Date().getFullYear(); +const DEFAULT_ASSET = 'native'; + +const PathStep = ({ label, value, sub }: PathStepProps) => ( +
+
+ {label} +
+
+ {value} +
+ {sub && ( +
+ {sub} +
+ )} +
+); + +const MultiHopPath = ({ path, sourceAsset, index, total }: MultiHopPathProps) => { + const pathHops = path.path || []; + const isBest = index === 0; + const effectiveSourceAmount = sourceAsset === DEFAULT_ASSET + ? path.source_amount + : path.destination_amount; + + return ( +
+
+
+ {isBest ? '⭐ Best Path' : `Path #${index + 1}`} + + ({total} total) + +
+
+ {pathHops.length} hop{pathHops.length !== 1 ? 's' : ''} +
+
+ + {path.source_amount && ( +
+ +
+ )} + + {pathHops.length > 0 && ( +
+
+ Intermediate Hops +
+
+ {pathHops.map((hop: string, hopIndex: number) => ( +
+ + Hop {hopIndex + 1} + {shortAddress(hop, 6)} +
+ ))} +
+
+ )} + + {path.destination_amount && ( +
+ +
+ )} + + {effectiveSourceAmount && !path.destination_amount && ( +
+ ⚠ Incomplete path data – source amount available but destination amount is not quoted. +
+ )} +
+ ); +}; + +const ExplorePathsCard = ({ destination, amount, sourceAsset, paths, isLoading, lastSearched }: ExplorePathsCardProps) => { + if (isLoading) { + return ( +
+
+
Searching for payment paths...
+
+ ); + } + + if (!lastSearched) { + return ( +
+
{''}
+
Enter a destination and amount, then click "Find Paths"
+
+ Pathfinding finds the cheapest route across the Stellar network. +
+
+ ); + } + + if (!paths || paths.length === 0) { + return ( +
+
🔍
+
No paths found
+
+ Could not find any payment paths for the specified parameters. The destination may not accept the asset, or no liquid path exists. +
+
+ ); + } + + const bestPath = paths[0]; + const savingsMessage = paths.length > 1 && bestPath.source_amount && paths[paths.length - 1].source_amount + ? `${((parseFloat(paths[paths.length - 1].source_amount!) - parseFloat(bestPath.source_amount)) / parseFloat(paths[paths.length - 1].source_amount!) * 100).toFixed(1)}% cheaper than worst` + : null; + + return ( +
+
+
+ {paths.length} path{paths.length !== 1 ? 's' : ''} found +
+ {savingsMessage && ( +
+ {savingsMessage} +
+ )} +
+ {paths.map((path: PathPaymentPath, index: number) => ( + + ))} +
+ ); +}; + +export default function PathExplorer() { + const [destination, setDestination] = useState(''); + const [amount, setAmount] = useState(''); + const [sourceAsset, setSourceAsset] = useState(DEFAULT_ASSET); + const [paths, setPaths] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [lastSearched, setLastSearched] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = useCallback(async (e?: FormEvent) => { + if (e) e.preventDefault(); + setError(null); + + if (!destination || !amount) { + setError('Please enter both a destination address and amount.'); + return; + } + + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum <= 0) { + setError('Amount must be a positive number.'); + return; + } + + setIsLoading(true); + setPaths([]); + + try { + const result = await fetchPathPayments({ + destination, + amount: amountNum.toString(), + sourceAsset, + sourceAssetIssuer: undefined, + }); + setPaths(result); + setLastSearched(true); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error fetching paths'; + setError(message); + setPaths([]); + setLastSearched(true); + } finally { + setIsLoading(false); + } + }, [destination, amount, sourceAsset]); + + return ( +
+
+
+ Path Explorer +
+

+ Discover optimal payment paths across the Stellar network to minimize costs and maximize delivery. +

+
+ +
+
+ Search Parameters +
+
+
+ + setDestination(e.target.value)} + placeholder="GABCD... or *.stellar" + style={{ + padding: '10px 12px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'var(--font-mono)', + outline: 'none', + transition: 'var(--transition)', + }} + /> +
+ +
+
+ + setAmount(e.target.value)} + placeholder="100.00" + step="any" + min="0" + style={{ + padding: '10px 12px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'var(--font-mono)', + outline: 'none', + transition: 'var(--transition)', + }} + /> +
+ +
+ + +
+
+ + +
+ + {error && ( +
+ ⚠️ + {error} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/dashboard/PerformanceMonitor.jsx b/src/components/dashboard/PerformanceMonitor.tsx similarity index 100% rename from src/components/dashboard/PerformanceMonitor.jsx rename to src/components/dashboard/PerformanceMonitor.tsx diff --git a/src/components/dashboard/PluginRegistryView.jsx b/src/components/dashboard/PluginRegistryView.tsx similarity index 80% rename from src/components/dashboard/PluginRegistryView.jsx rename to src/components/dashboard/PluginRegistryView.tsx index 84ca3a06..d11d675b 100644 --- a/src/components/dashboard/PluginRegistryView.jsx +++ b/src/components/dashboard/PluginRegistryView.tsx @@ -1,7 +1,38 @@ import React, { useEffect, useMemo, useState } from "react"; import { pluginManager, registerActivePlugins } from "../../plugins"; -import { PLUGIN_STATUSES } from "../../plugins/PluginManager"; // Import PLUGIN_STATUSES -function PluginWidgetFrame({ widget }) { +import { PLUGIN_STATUSES } from "../../plugins/PluginManager"; + +interface PluginWidget { + id: string + component: React.ComponentType> + pluginName: string + title: string + pluginId: string + props?: Record +} + +interface PluginRecord { + id: string + name: string + status: string + error?: string +} + +interface PluginSnapshot { + plugins: PluginRecord[] + widgets: PluginWidget[] + dataSources: unknown[] +} + +interface PluginWidgetFrameProps { + widget: PluginWidget +} + +interface PluginStatusPillProps { + status: string +} + +function PluginWidgetFrame({ widget }: PluginWidgetFrameProps) { const Component = widget.component; return ( @@ -34,8 +65,8 @@ function PluginWidgetFrame({ widget }) { ); } -function PluginStatusPill({ status }) { - const colorByStatus = { +function PluginStatusPill({ status }: PluginStatusPillProps) { + const colorByStatus: Record = { [PLUGIN_STATUSES.INITIALIZED]: "var(--green)", [PLUGIN_STATUSES.REGISTERED]: "var(--cyan)", [PLUGIN_STATUSES.FAILED]: "var(--red)", @@ -57,8 +88,8 @@ function PluginStatusPill({ status }) { ); } -export default function PluginRegistryView({ placement = "settings" }) { - const [snapshot, setSnapshot] = useState(() => ({ +export default function PluginRegistryView({ placement = "settings" }: { placement?: string }) { + const [snapshot, setSnapshot] = useState(() => ({ plugins: pluginManager.getPluginRecords(), widgets: pluginManager.getWidgets({ placement }), dataSources: pluginManager.getDataSources(), @@ -67,9 +98,9 @@ export default function PluginRegistryView({ placement = "settings" }) { useEffect(() => { const refresh = () => { setSnapshot({ - plugins: pluginManager.getPluginRecords(), - widgets: pluginManager.getWidgets({ placement }), - dataSources: pluginManager.getDataSources(), + plugins: pluginManager.getPluginRecords(), + widgets: pluginManager.getWidgets({ placement }), + dataSources: pluginManager.getDataSources(), }); }; @@ -78,7 +109,7 @@ export default function PluginRegistryView({ placement = "settings" }) { }, [placement]); useEffect(() => { - registerActivePlugins().catch((error) => { + registerActivePlugins().catch((error: Error) => { console.error("Plugin registration failed", error); }); }, []); @@ -151,7 +182,7 @@ export default function PluginRegistryView({ placement = "settings" }) { )}
- {widgets.map((widget) => ( + {widgets.map((widget: PluginWidget) => ( ))} diff --git a/src/components/dashboard/PortfolioValue.jsx b/src/components/dashboard/PortfolioValue.tsx similarity index 100% rename from src/components/dashboard/PortfolioValue.jsx rename to src/components/dashboard/PortfolioValue.tsx diff --git a/src/components/dashboard/PriceTicker.jsx b/src/components/dashboard/PriceTicker.tsx similarity index 71% rename from src/components/dashboard/PriceTicker.jsx rename to src/components/dashboard/PriceTicker.tsx index 134a4dff..f66f404e 100644 --- a/src/components/dashboard/PriceTicker.jsx +++ b/src/components/dashboard/PriceTicker.tsx @@ -1,43 +1,36 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useStore } from '../../lib/store'; import { fetchXLMPrice } from '../../lib/priceFeed'; import { RefreshCw } from 'lucide-react'; export default function PriceTicker() { const { prices, setPrices, setPricesLoading, setPricesError } = useStore(); - const [lastUpdated, setLastUpdated] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); - useEffect(() => { - let cancelled = false; - - const loadPrice = async (forceRefresh = false) => { - setPricesLoading(true); - try { - const xlmPrice = await fetchXLMPrice({ forceRefresh }); - if (!cancelled) { - setPrices({ ...prices, XLM: xlmPrice }); - setLastUpdated(new Date()); - } - } catch (err) { - if (!cancelled) setPricesError(err.message); - } finally { - if (!cancelled) setPricesLoading(false); - } - }; + const loadPrice = useCallback(async (forceRefresh = false) => { + setPricesLoading(true); + try { + const xlmPrice = await fetchXLMPrice({ forceRefresh }); + setPrices({ ...prices, XLM: xlmPrice }); + setLastUpdated(new Date()); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setPricesError(message); + } finally { + setPricesLoading(false); + } + }, [prices, setPrices, setPricesLoading, setPricesError]); + useEffect(() => { loadPrice(); - // Refresh every 60 seconds - const interval = setInterval(loadPrice, 60_000); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, []); + const interval = setInterval(() => loadPrice(), 60_000); + return () => clearInterval(interval); + }, [loadPrice]); const xlm = prices?.XLM; - const changeColor = xlm?.usd_24h_change >= 0 ? 'var(--green)' : 'var(--red)'; - const changeSign = xlm?.usd_24h_change >= 0 ? '+' : ''; + const changeColor = xlm?.usd_24h_change != null && xlm.usd_24h_change >= 0 ? 'var(--green)' : 'var(--red)'; + const changeSign = xlm?.usd_24h_change != null && xlm.usd_24h_change >= 0 ? '+' : ''; return (
= { connected: { color: 'var(--green)', label: 'Live' }, connecting: { color: 'var(--amber)', label: 'Connecting' }, reconnecting: { color: 'var(--amber)', label: 'Reconnecting' }, @@ -24,11 +40,11 @@ export default function RealTimeLedger() { const cleanup = connectLedgerStream( network, - (ledger) => { + (ledger: Record) => { addStreamLedger(ledger) setStreamError(null) }, - (status) => { + (status: string) => { setStreamStatus(status) if (status === 'error') { setStreamError('Connection lost – reconnecting…') @@ -46,14 +62,10 @@ export default function RealTimeLedger() { return (
- - {/* Header */}
Real-Time Ledgers
- - {/* Status badge */}
- {/* Summary cards */}
- {/* Latest sequence */}
- {/* Tx count */}
in last ledger
- {/* Op count */}
- {/* Live ledger feed */}
) : (
- {/* Table header */}
Closed At
- {streamLedgers.map((l, i) => ( + {streamLedgers.map((raw, i) => { + const l = raw as LedgerDisplayEntry + return (
{ if (i !== 0) e.currentTarget.style.background = 'var(--bg-hover)' }} - onMouseLeave={e => { if (i !== 0) e.currentTarget.style.background = 'transparent' }} + onMouseEnter={(e: React.MouseEvent) => { if (i !== 0) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={(e: React.MouseEvent) => { if (i !== 0) e.currentTarget.style.background = 'transparent' }} > {l.sequence?.toLocaleString()} @@ -209,11 +217,11 @@ export default function RealTimeLedger() { {l.closed_at ? format(new Date(l.closed_at), 'HH:mm:ss') : '—'}
- ))} + ) + })}
)}
-
) } diff --git a/src/components/dashboard/Settings.jsx b/src/components/dashboard/Settings.tsx similarity index 72% rename from src/components/dashboard/Settings.jsx rename to src/components/dashboard/Settings.tsx index 7dec3e79..16191b24 100644 --- a/src/components/dashboard/Settings.jsx +++ b/src/components/dashboard/Settings.tsx @@ -1,14 +1,17 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useSettings } from "../../hooks/useSettings"; import { useRateLimiter } from "../../hooks/useRateLimiter"; import { useStore } from "../../lib/store"; import { getEnvironmentConfig } from "../../lib/config"; +import { getCustomNetworkAuthHeaders } from "../../lib/stellar"; import { saveAlertRule, getAlertRules, deleteAlertRule } from "../../lib/alertRulesDb"; import { ALERT_RULE_TYPE, ALERT_CHANNEL } from "../../lib/alerts"; import PluginRegistryView from "./PluginRegistryView"; import DataExport from "./DataExport"; -function FieldLabel({ children }) { +const SESSION_API_KEY = 'stellar_custom_api_key'; + +function FieldLabel({ children }: { children: ReactNode }) { return (
{children} @@ -16,12 +19,12 @@ function FieldLabel({ children }) { ); } -function ErrorMessage({ message }) { +function ErrorMessage({ message }: { message?: string | null }) { if (!message) return null; return ( -
= { + banner: { + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '12px 16px', + borderRadius: 'var(--radius-md)', + fontSize: '13px', + marginBottom: '1rem', + }, + updateBanner: { + background: 'rgba(99, 179, 237, 0.1)', + border: '1px solid rgba(99, 179, 237, 0.3)', + color: '#63b3ed', + }, + offlineBanner: { + background: 'rgba(252, 129, 74, 0.1)', + border: '1px solid rgba(252, 129, 74, 0.3)', + color: '#fc814a', + }, + dot: { + width: '8px', + height: '8px', + borderRadius: '50%', + display: 'inline-block', + flexShrink: 0, + }, + section: { + marginBottom: '1.5rem', + }, + sectionTitle: { + fontFamily: 'Syne, sans-serif', + fontSize: '0.9rem', + fontWeight: 700, + color: 'var(--color-text)', + margin: '0 0 0.75rem 0', + }, + card: { + background: 'var(--bg-card)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-lg)', + padding: '14px', + }, + row: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '12px', + }, + label: { + fontSize: '13px', + color: 'var(--color-text)', + fontWeight: 600, + margin: '0 0 4px 0', + }, + description: { + fontSize: '12px', + color: 'var(--color-text-muted)', + margin: 0, + }, + button: { + border: 'none', + borderRadius: 'var(--radius-sm)', + padding: '8px 16px', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + whiteSpace: 'nowrap', + flexShrink: 0, + }, + primaryButton: { + background: 'var(--cyan)', + color: '#000', + }, + badge: { + padding: '4px 10px', + borderRadius: '20px', + fontSize: '11px', + fontWeight: 600, + whiteSpace: 'nowrap', + flexShrink: 0, + }, +}; + +function canInstall(): boolean { return false; } +function onUpdateReady(cb: () => void): () => void { return () => {}; } +function onNetworkChange(cb: (online: boolean) => void): () => void { return () => {}; } +function promptInstall(): Promise { return Promise.resolve(null); } +function applyUpdate(): void {} + export default function Settings() { const initialCustomHeaders = getCustomNetworkAuthHeaders(); const initialHeaderName = Object.keys(initialCustomHeaders)[0] || "Authorization"; @@ -48,28 +141,29 @@ export default function Settings() { setPreference, } = useSettings(); - // Custom network profile state (Issue #188) - const [customProfiles, setCustomProfiles] = useState([]); - const [selectedProfileId, setSelectedProfileId] = useState(null); + const [customProfiles, setCustomProfiles] = useState>>([]); + const [selectedProfileId, setSelectedProfileId] = useState(null); const [profileName, setProfileName] = useState(""); const [horizonUrl, setHorizonUrl] = useState(""); const [sorobanUrl, setSorobanUrl] = useState(""); const [passphrase, setPassphrase] = useState(""); - const [validationErrors, setValidationErrors] = useState({}); + const [validationErrors, setValidationErrors] = useState>({}); const [draftConfig, setDraftConfig] = useState(() => activeProfile.config); const [apiKey, setApiKey] = useState(() => sessionStorage.getItem(SESSION_API_KEY) || ""); const baseline = useMemo(() => getEnvironmentConfig(), []); - // State for Alert Rules - const [alertRules, setAlertRules] = useState([]); + const [alertRules, setAlertRules] = useState>>([]); const [newRuleType, setNewRuleType] = useState(ALERT_RULE_TYPE.BALANCE_LOW); const [newRuleThreshold, setNewRuleThreshold] = useState(0); const [newRuleAssetCode, setNewRuleAssetCode] = useState("XLM"); const [newRuleChannel, setNewRuleChannel] = useState(ALERT_CHANNEL.EFFECTS); - const [newRuleAccount, setNewRuleAccount] = useState(""); // Optional: specific account for the rule + const [newRuleAccount, setNewRuleAccount] = useState(""); + + const [installable, setInstallable] = useState(false); + const [installOutcome, setInstallOutcome] = useState(null); + const [updateReady, setUpdateReady] = useState(false); + const [offline, setOffline] = useState(false); - // Poll canInstall every second — the beforeinstallprompt event can fire after - // the component mounts, so we need to re-check. useEffect(() => { const interval = setInterval(() => { setInstallable(canInstall()); @@ -77,15 +171,13 @@ export default function Settings() { return () => clearInterval(interval); }, []); - // Subscribe to SW update events useEffect(() => { const unsub = onUpdateReady(() => setUpdateReady(true)); return unsub; }, []); - // Subscribe to network changes useEffect(() => { - const unsub = onNetworkChange((online) => setOffline(!online)); + const unsub = onNetworkChange((online: boolean) => setOffline(!online)); return unsub; }, []); @@ -113,15 +205,14 @@ export default function Settings() { Settings - {/* ── Update banner ── */} {updateReady && ( -
- +
+ A new version of Stellar Dev Dashboard is available.
)} - {/* ── Offline banner ── */} {offline && ( -
- +
+ You're offline. The app shell loads from cache, but live account data requires a network connection. @@ -140,7 +230,6 @@ export default function Settings() {
)} - {/* ── Install app section ── */}

App Installation

@@ -153,12 +242,12 @@ export default function Settings() { with offline app shell support.

{installOutcome === 'accepted' && ( -

+

✓ Installing…

)} {installOutcome === 'dismissed' && ( -

+

Dismissed. You can try again later.

)} @@ -166,7 +255,7 @@ export default function Settings() { {installable && installOutcome !== 'accepted' ? (
- {/* Offline capability info */}
@@ -211,7 +299,7 @@ export default function Settings() { background: 'rgba(104, 211, 145, 0.15)', color: '#68d391', border: '1px solid rgba(104, 211, 145, 0.3)', - }} + } as React.CSSProperties} > Active @@ -219,7 +307,6 @@ export default function Settings() {
- {/* ── Network status section ── */}

Network

@@ -244,7 +331,7 @@ export default function Settings() { ? 'rgba(252, 129, 74, 0.4)' : 'rgba(104, 211, 145, 0.3)' }`, - }} + } as React.CSSProperties} > {offline ? 'Offline' : 'Online'} @@ -252,7 +339,6 @@ export default function Settings() {
- {/* ── App update section ── */}

Updates

@@ -267,7 +353,7 @@ export default function Settings() {
{updateReady ? (
- {/* Export & Import */}
Export & Import
@@ -322,4 +407,4 @@ export default function Settings() {
); -} \ No newline at end of file +} diff --git a/src/components/dashboard/SystemHealth.jsx b/src/components/dashboard/SystemHealth.tsx similarity index 86% rename from src/components/dashboard/SystemHealth.jsx rename to src/components/dashboard/SystemHealth.tsx index 746e9c30..87fc5d07 100644 --- a/src/components/dashboard/SystemHealth.jsx +++ b/src/components/dashboard/SystemHealth.tsx @@ -3,8 +3,9 @@ import { useStore } from '../../lib/store' import { useMonitoring } from "../../hooks/useMonitoring"; import { StatCard } from "./Card"; import { LatencyTrendChart } from "../charts/AnalyticsChart"; +import type { AlertEntry, MonitoringSnapshot, HealthProbe, NetworkHealthProbe } from "./types"; -function AlertRow({ alert, onClear }) { +function AlertRow({ alert, onClear }: { alert: AlertEntry; onClear: (id: string) => void }) { const color = alert.severity === "critical" ? "var(--red)" @@ -47,7 +48,7 @@ function AlertRow({ alert, onClear }) { ); } -function ServiceStatus({ label, probe }) { +function ServiceStatus({ label, probe }: { label: string; probe: HealthProbe }) { const color = probe.status === "up" ? "var(--green)" @@ -85,16 +86,23 @@ function ServiceStatus({ label, probe }) { export default function SystemHealth() { const { setActiveTab } = useStore() - const { snapshot, score, alerts, errors, clearAlert, resetAlerts } = useMonitoring(); + const { snapshot, score, alerts, errors, clearAlert, resetAlerts } = useMonitoring() as { + snapshot: MonitoringSnapshot + score: number + alerts: AlertEntry[] + errors: Array> + clearAlert: (id: string) => void + resetAlerts: () => void + } const memory = snapshot?.memory; - const networkHealth = snapshot?.networkHealth || []; - const latencyHistory = snapshot?.latencyHistory || []; + const networkHealth: NetworkHealthProbe[] = snapshot?.networkHealth || []; + const latencyHistory: Array<{ timestamp: number; latency: number }> = snapshot?.latencyHistory || []; const averageLatency = latencyHistory.length ? Math.round(latencyHistory[latencyHistory.length - 1].latency) : null; - const openBreakers = networkHealth.reduce((count, network) => { + const openBreakers = networkHealth.reduce((count: number, network: NetworkHealthProbe) => { return ( count + (network.horizon.breakerState === "OPEN" ? 1 : 0) + @@ -138,7 +146,7 @@ export default function SystemHealth() {
- + {networkHealth.length > 0 && ( -
+
Network Probes
- {networkHealth.map((network) => ( + {networkHealth.map((netProbe: NetworkHealthProbe) => (
- {network.name} + {netProbe.name}
- - + +
))} @@ -224,7 +227,7 @@ export default function SystemHealth() { No active alerts.
)} - {alerts.map((alert) => ( + {alerts.map((alert: AlertEntry) => ( ))}
diff --git a/src/components/dashboard/TransactionBuilder.jsx b/src/components/dashboard/TransactionBuilder.tsx similarity index 100% rename from src/components/dashboard/TransactionBuilder.jsx rename to src/components/dashboard/TransactionBuilder.tsx diff --git a/src/components/dashboard/TransactionDetail.jsx b/src/components/dashboard/TransactionDetail.tsx similarity index 91% rename from src/components/dashboard/TransactionDetail.jsx rename to src/components/dashboard/TransactionDetail.tsx index 61dc69d5..d64d693f 100644 --- a/src/components/dashboard/TransactionDetail.jsx +++ b/src/components/dashboard/TransactionDetail.tsx @@ -1,23 +1,34 @@ import React, { useEffect, useState } from 'react' +import type { Horizon } from '@stellar/stellar-sdk' import { useStore } from '../../lib/store' import { fetchTransactionDetails, getOperationLabel, shortAddress } from '../../lib/stellar' import { getTransactionUrl } from '../../lib/externalExplorers' import CopyableValue from './CopyableValue' import { format } from 'date-fns' -export default function TransactionDetail({ txHash, onClose }) { +interface TransactionDetailData { + transaction: Horizon.ServerApi.TransactionRecord + operations: Horizon.ServerApi.OperationRecord[] +} + +interface TransactionDetailProps { + txHash: string + onClose: () => void +} + +export default function TransactionDetail({ txHash, onClose }: TransactionDetailProps) { const { network } = useStore() const [loading, setLoading] = useState(true) - const [data, setData] = useState(null) - const [error, setError] = useState(null) - + const [data, setData] = useState(null) + const [error, setError] = useState(null) + useEffect(() => { if (!txHash) return - + let isMounted = true setLoading(true) setError(null) - + fetchTransactionDetails(txHash, network) .then(res => { if (isMounted) { @@ -25,13 +36,13 @@ export default function TransactionDetail({ txHash, onClose }) { setLoading(false) } }) - .catch(err => { + .catch((err: Error) => { if (isMounted) { setError(err.message) setLoading(false) } }) - + return () => { isMounted = false } }, [txHash, network]) @@ -57,9 +68,8 @@ export default function TransactionDetail({ txHash, onClose }) { display: 'flex', flexDirection: 'column', animation: 'slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1)' - }} onClick={e => e.stopPropagation()}> - - {/* Header */} + }} onClick={(e: React.MouseEvent) => e.stopPropagation()}> +
- @@ -102,7 +112,6 @@ export default function TransactionDetail({ txHash, onClose }) {
- {/* Content */}
{loading ? (
@@ -112,9 +121,9 @@ export default function TransactionDetail({ txHash, onClose }) {
) : data && (
- +
-
- {/* Tx Overview */}

Overview

- +
Created At
{format(new Date(data.transaction.created_at), 'MMM d, yyyy HH:mm:ss')}
- +
Source Account
{shortAddress(data.transaction.source_account)}
- +
Fee Charged
{data.transaction.fee_charged} stroops
- +
Memo
{data.transaction.memo_type === 'none' ? None : `${data.transaction.memo_type}: ${data.transaction.memo}`} @@ -158,14 +166,13 @@ export default function TransactionDetail({ txHash, onClose }) {
- {/* Operations */}

Operations ({data.operations.length})

- {data.operations.map((op, i) => ( + {data.operations.map((op: Horizon.ServerApi.OperationRecord, i: number) => (
- diff --git a/src/components/dashboard/TransactionSigner.jsx b/src/components/dashboard/TransactionSigner.tsx similarity index 91% rename from src/components/dashboard/TransactionSigner.jsx rename to src/components/dashboard/TransactionSigner.tsx index b359a12f..8f5b495e 100644 --- a/src/components/dashboard/TransactionSigner.jsx +++ b/src/components/dashboard/TransactionSigner.tsx @@ -1,23 +1,25 @@ import React, { useState, useEffect } from 'react' +import type { ReactNode } from 'react' import { useStore } from '../../lib/store' import { signTransactionWithFreighter } from '../../lib/wallet/freighter' import { signXdrWithLedger, isLedgerSupported, getActiveLedgerSession } from '../../lib/wallet/ledger' import { NETWORKS } from '../../lib/stellar' import { measureAsync } from '../../lib/performanceMonitoring' import { loadPreferences, DEFAULT_PREFERENCES } from '../../lib/userPreferences' +import type { UserPreferences } from '../../lib/userPreferences' import Card from './Card' import EnhancedTransactionConfirmation from '../security/EnhancedTransactionConfirmation' export default function TransactionSigner() { const { walletConnected, walletType, walletPublicKey, network } = useStore() const [xdr, setXdr] = useState('') - const [signedXdr, setSignedXdr] = useState(null) + const [signedXdr, setSignedXdr] = useState(null) const [signing, setSigning] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const [copied, setCopied] = useState(false) const [ledgerPrompt, setLedgerPrompt] = useState(false) const [showConfirmation, setShowConfirmation] = useState(false) - const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES) + const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES) useEffect(() => { async function fetchPreferences() { @@ -27,7 +29,7 @@ export default function TransactionSigner() { fetchPreferences() }, []) - const networkPassphrase = NETWORKS[network]?.passphrase || NETWORKS.testnet.passphrase + const networkPassphrase: string = NETWORKS[network]?.passphrase || NETWORKS.testnet.passphrase const handleSign = async () => { if (!xdr.trim()) { @@ -49,7 +51,7 @@ export default function TransactionSigner() { setSignedXdr(null) try { - let result = null + let result: string | null = null if (walletType === 'freighter') { const networkName = network === 'mainnet' ? 'PUBLIC' : 'TESTNET' @@ -60,14 +62,14 @@ export default function TransactionSigner() { ) } else if (walletType === 'ledger') { await _signWithLedger() - return // _signWithLedger manages its own state + return } else { throw new Error('No wallet connected. Connect a wallet first.') } setSignedXdr(result) - } catch (err) { - setError(err.message) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) } finally { setSigning(false) } @@ -83,7 +85,6 @@ export default function TransactionSigner() { } const _signWithLedger = async () => { - // Check browser support first const supported = await isLedgerSupported() if (!supported) { setError( @@ -94,8 +95,6 @@ export default function TransactionSigner() { return } - // If we already have a live stellarApp session from WalletConnect, use it. - // Otherwise, prompt the user to reconnect via the Wallet tab. const { stellarApp, publicKey } = getActiveLedgerSession() if (!stellarApp) { setError( @@ -118,9 +117,9 @@ export default function TransactionSigner() { ), { network, walletType: 'ledger' }, ) - setSignedXdr(signed) - } catch (err) { - setError(err.message) + setSignedXdr(signed as string) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) } finally { setLedgerPrompt(false) setSigning(false) @@ -167,7 +166,6 @@ export default function TransactionSigner() { return (
- {/* Signer info */}
{walletType}
- {/* Ledger device prompt banner */} {ledgerPrompt && (
)} - {/* XDR input */}