Skip to content

Commit ac3a43e

Browse files
committed
fix: hoist icon components, add a11y aria-labels, add QueryError fallback
Issue entrius#33 — refactor(OrderbookDepth): hoist BtcIcon, TaoIcon, AssetIcon to module scope so they are not recreated on every parent render. Each icon calls useTheme() internally instead of closing over the parent component's theme variables. Issue entrius#31 — a11y: add aria-label to 4 icon-only interactive controls: - Docs link button (aria-label="Documentation") - Theme toggle button (dynamic: "Switch to dark/light mode") - Orderbook info button (aria-label="Orderbook depth information") - EventFeed scroll-to-top button (aria-label="Scroll to top of event feed") Issue entrius#36 — fix: add QueryError component with retry button and wire it into all four dashboard panels (StatsPanel, MinerRatesTable, OrderbookDepth, EventFeed). Panels now render a QueryError with a refetch button instead of staying on a shimmer skeleton when useApiQuery errors. Closes entrius#31, entrius#33, entrius#36
1 parent be52a80 commit ac3a43e

6 files changed

Lines changed: 134 additions & 61 deletions

File tree

src/components/QueryError.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react';
2+
import { Box, Button, Typography, useTheme } from '@mui/material';
3+
import { FONTS } from '../theme';
4+
5+
interface QueryErrorProps {
6+
onRetry: () => void;
7+
title?: string;
8+
}
9+
10+
export const QueryError: React.FC<QueryErrorProps> = ({
11+
onRetry,
12+
title = 'Failed to load data',
13+
}) => {
14+
const theme = useTheme();
15+
return (
16+
<Box
17+
sx={{
18+
p: 3,
19+
border: '1px solid',
20+
borderColor: 'error.main',
21+
backgroundColor: 'background.paper',
22+
textAlign: 'center',
23+
}}
24+
>
25+
<Typography
26+
sx={{
27+
fontFamily: FONTS.mono,
28+
fontSize: '0.9rem',
29+
fontWeight: 600,
30+
color: 'error.main',
31+
mb: 2,
32+
}}
33+
>
34+
{title}
35+
</Typography>
36+
<Button
37+
onClick={onRetry}
38+
size="small"
39+
variant="outlined"
40+
sx={{
41+
fontFamily: FONTS.mono,
42+
fontSize: '0.7rem',
43+
textTransform: 'uppercase',
44+
letterSpacing: '0.05em',
45+
borderRadius: 0,
46+
borderColor: 'primary.main',
47+
color: 'primary.main',
48+
'&:hover': {
49+
backgroundColor: `${theme.palette.primary.main}11`,
50+
},
51+
}}
52+
>
53+
Retry
54+
</Button>
55+
</Box>
56+
);
57+
};

src/components/dashboard/EventFeed.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { Link as RouterLink } from 'react-router-dom';
33
import { Box, Button, Chip, Stack, Typography, useTheme } from '@mui/material';
44
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
55
import { useLatestEvents } from '../../api';
6+
import type { ContractEvent } from '../../api/models/Events';
67
import { FONTS } from '../../theme';
78
import CopyableAddress from '../CopyableAddress';
9+
import { QueryError } from '../QueryError';
810
import { EventFeedSkeleton } from './Skeletons';
911

1012
const getEventColor = (
@@ -39,7 +41,7 @@ const getEventColor = (
3941

4042
const EventFeed: React.FC = () => {
4143
const theme = useTheme();
42-
const { data: events, isLoading } = useLatestEvents();
44+
const { data: events, isLoading, isError, refetch } = useLatestEvents();
4345
const scrollRef = useRef<HTMLDivElement>(null);
4446
const [scrolled, setScrolled] = useState(false);
4547

@@ -52,9 +54,11 @@ const EventFeed: React.FC = () => {
5254
scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
5355
}, []);
5456

55-
return isLoading || !events ? (
56-
<EventFeedSkeleton />
57-
) : (
57+
if (isLoading) return <EventFeedSkeleton />;
58+
if (isError) return <QueryError onRetry={() => refetch()} title="Failed to load events" />;
59+
if (!events) return <EventFeedSkeleton />;
60+
61+
return (
5862
<Box sx={{ position: 'relative' }}>
5963
<Typography
6064
variant="h6"
@@ -76,7 +80,7 @@ const EventFeed: React.FC = () => {
7680
}}
7781
>
7882
<Stack spacing={1}>
79-
{events?.map((event) => (
83+
{events?.map((event: ContractEvent) => (
8084
<Box
8185
key={event.id}
8286
sx={{
@@ -199,6 +203,7 @@ const EventFeed: React.FC = () => {
199203
<Button
200204
onClick={scrollToTop}
201205
size="small"
206+
aria-label="Scroll to top of event feed"
202207
sx={{
203208
position: 'absolute',
204209
bottom: 8,

src/components/dashboard/MinerRatesTable.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SearchIcon from '@mui/icons-material/Search';
2020
import { useMiners, type Miner } from '../../api';
2121
import { FONTS } from '../../theme';
2222
import CopyableAddress from '../CopyableAddress';
23+
import { QueryError } from '../QueryError';
2324
import { MinerRatesTableSkeleton } from './Skeletons';
2425

2526
type SortKey = 'uid' | 'pair' | 'rate' | 'collateral' | 'status' | 'hotkey';
@@ -110,7 +111,7 @@ const MinerRatesTable: React.FC = () => {
110111
borderBottom: `1px solid ${theme.palette.divider}`,
111112
};
112113

113-
const { data: miners, isLoading } = useMiners();
114+
const { data: miners, isLoading, isError, refetch } = useMiners();
114115
const [sortKey, setSortKey] = useState<SortKey>('rate');
115116
const [sortDir, setSortDir] = useState<SortDir>('desc');
116117
const [search, setSearch] = useState('');
@@ -265,9 +266,11 @@ const MinerRatesTable: React.FC = () => {
265266
return hasForward !== hasReverse;
266267
};
267268

268-
return isLoading || !miners ? (
269-
<MinerRatesTableSkeleton />
270-
) : (
269+
if (isLoading) return <MinerRatesTableSkeleton />;
270+
if (isError) return <QueryError onRetry={() => refetch()} title="Failed to load miner rates" />;
271+
if (!miners) return <MinerRatesTableSkeleton />;
272+
273+
return (
271274
<Box>
272275
<Box
273276
sx={{

src/components/dashboard/OrderbookDepth.tsx

Lines changed: 51 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,71 +17,72 @@ import {
1717
} from '@mui/material';
1818
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
1919
import { useMiners } from '../../api';
20+
import type { Miner } from '../../api/models/Miners';
2021
import { FONTS } from '../../theme';
2122
import { OrderbookDepthSkeleton } from './Skeletons';
23+
import { QueryError } from '../QueryError';
2224

23-
const OrderbookDepth: React.FC = () => {
25+
const BtcIcon = ({ size = 16 }: { size?: number }) => {
2426
const theme = useTheme();
25-
26-
const TAO_COLOR = theme.palette.asset.tao;
27-
const BTC_COLOR = theme.palette.asset.btc;
28-
29-
const BtcIcon = ({ size = 16 }: { size?: number }) => (
27+
return (
3028
<svg viewBox="0 0 32 32" width={size} height={size}>
31-
<circle cx="16" cy="16" r="16" fill={BTC_COLOR} />
29+
<circle cx="16" cy="16" r="16" fill={theme.palette.asset.btc} />
3230
<path
3331
fill={theme.palette.common.white}
3432
fillRule="evenodd"
3533
d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z"
3634
/>
3735
</svg>
3836
);
37+
};
3938

40-
const TaoIcon = ({ size = 16, color }: { size?: number; color?: string }) => (
39+
const TaoIcon = ({ size = 16, color }: { size?: number; color?: string }) => {
40+
const theme = useTheme();
41+
const fill = color || theme.palette.asset.tao;
42+
return (
4143
<svg viewBox="0 0 21.6 23.1" width={size} height={size}>
4244
<path
43-
fill={color || TAO_COLOR}
45+
fill={fill}
4446
d="M13.1,17.7V8.3c0-2.4-1.9-4.3-4.3-4.3v15.1c0,2.2,1.7,4,3.9,4c0.1,0,0.1,0,0.2,0c1,0.1,2.1-0.2,2.9-0.9C13.3,22,13.1,20.5,13.1,17.7L13.1,17.7z"
4547
/>
4648
<path
47-
fill={color || TAO_COLOR}
49+
fill={fill}
4850
d="M3.9,0C1.8,0,0,1.8,0,4h17.6c2.2,0,3.9-1.8,3.9-4C21.6,0,3.9,0,3.9,0z"
4951
/>
5052
</svg>
5153
);
54+
};
5255

53-
const AssetIcon = ({
54-
asset,
55-
size = 16,
56-
}: {
57-
asset: string;
58-
size?: number;
59-
}) => {
60-
if (asset.toUpperCase() === 'BTC') return <BtcIcon size={size} />;
61-
return (
62-
<Box
56+
const AssetIcon = ({ asset, size = 16 }: { asset: string; size?: number }) => {
57+
const theme = useTheme();
58+
if (asset.toUpperCase() === 'BTC') return <BtcIcon size={size} />;
59+
return (
60+
<Box
61+
sx={{
62+
width: size,
63+
height: size,
64+
borderRadius: '50%',
65+
backgroundColor: theme.palette.text.secondary,
66+
display: 'flex',
67+
alignItems: 'center',
68+
justifyContent: 'center',
69+
}}
70+
>
71+
<Typography
6372
sx={{
64-
width: size,
65-
height: size,
66-
borderRadius: '50%',
67-
backgroundColor: theme.palette.text.secondary,
68-
display: 'flex',
69-
alignItems: 'center',
70-
justifyContent: 'center',
73+
fontSize: size * 0.6,
74+
color: theme.palette.background.paper,
75+
fontWeight: 'bold',
7176
}}
7277
>
73-
<Typography
74-
sx={{
75-
fontSize: size * 0.6,
76-
color: theme.palette.background.paper,
77-
fontWeight: 'bold',
78-
}}
79-
>
80-
{asset[0]?.toUpperCase()}
81-
</Typography>
82-
</Box>
83-
);
84-
};
78+
{asset[0]?.toUpperCase()}
79+
</Typography>
80+
</Box>
81+
);
82+
};
83+
84+
const OrderbookDepth: React.FC = () => {
85+
const theme = useTheme();
8586

8687
const headerSx = {
8788
fontFamily: FONTS.mono,
@@ -99,12 +100,12 @@ const OrderbookDepth: React.FC = () => {
99100
borderBottom: `1px solid ${theme.palette.divider}`,
100101
};
101102

102-
const { data: miners, isLoading } = useMiners();
103+
const { data: miners, isLoading, isError, refetch } = useMiners();
103104
const [selectedPair, setSelectedPair] = useState<string>('');
104105

105106
const uniqueAssets = useMemo(() => {
106107
const assets = new Set<string>();
107-
miners?.forEach((m) => {
108+
miners?.forEach((m: Miner) => {
108109
const s = m.sourceChain?.toLowerCase();
109110
const d = m.destChain?.toLowerCase();
110111
if (!s || !d) return;
@@ -139,7 +140,7 @@ const OrderbookDepth: React.FC = () => {
139140
const forwardGroups: Record<string, number> = {}; // key = rate, val = capacity TAO
140141
const reverseGroups: Record<string, number> = {}; // key = counterRate, val = capacity TAO
141142

142-
miners.forEach((m) => {
143+
miners.forEach((m: Miner) => {
143144
if (!m.collateralRao) return;
144145
const s = m.sourceChain?.toLowerCase();
145146
const d = m.destChain?.toLowerCase();
@@ -204,9 +205,11 @@ const OrderbookDepth: React.FC = () => {
204205
const getAssetSymbol = () =>
205206
selectedPair ? selectedPair.replace('/TAO', '').trim() : '';
206207

207-
return isLoading || !miners ? (
208-
<OrderbookDepthSkeleton />
209-
) : (
208+
if (isLoading) return <OrderbookDepthSkeleton />;
209+
if (isError) return <QueryError onRetry={() => refetch()} title="Failed to load orderbook data" />;
210+
if (!miners) return <OrderbookDepthSkeleton />;
211+
212+
return (
210213
<Box>
211214
<Box
212215
sx={{
@@ -243,7 +246,7 @@ const OrderbookDepth: React.FC = () => {
243246
arrow
244247
placement="right"
245248
>
246-
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }}>
249+
<IconButton size="small" sx={{ p: 0, color: 'text.secondary' }} aria-label="Orderbook depth information">
247250
<InfoOutlinedIcon fontSize="small" />
248251
</IconButton>
249252
</Tooltip>
@@ -370,9 +373,9 @@ const OrderbookDepth: React.FC = () => {
370373
};
371374

372375
const assetThemeColor = isBtc
373-
? BTC_COLOR
376+
? theme.palette.asset.btc
374377
: theme.palette.primary.main;
375-
const taoThemeColor = TAO_COLOR;
378+
const taoThemeColor = theme.palette.asset.tao;
376379

377380
const leftGradColor = hexToRgba(assetThemeColor, 0.1);
378381
const rightGradColor =

src/components/dashboard/StatsPanel.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
22
import { Box, Grid, Typography, keyframes } from '@mui/material';
33
import { useStats } from '../../api';
44
import { FONTS } from '../../theme';
5+
import { QueryError } from '../QueryError';
56
import { StatsPanelSkeleton } from './Skeletons';
67

78
const slideOut = keyframes`
@@ -131,13 +132,15 @@ const StatCard: React.FC<{ label: string; value: string }> = ({
131132
);
132133

133134
const StatsPanel: React.FC = () => {
134-
const { data: stats, isLoading } = useStats();
135+
const { data: stats, isLoading, isError, refetch } = useStats();
135136

136137
const volume = stats ? parseFloat(stats.totalVolumeTao).toFixed(2) : '0';
137138

138-
return isLoading || !stats ? (
139-
<StatsPanelSkeleton />
140-
) : (
139+
if (isLoading) return <StatsPanelSkeleton />;
140+
if (isError) return <QueryError onRetry={() => refetch()} title="Failed to load statistics" />;
141+
if (!stats) return <StatsPanelSkeleton />;
142+
143+
return (
141144
<Grid container spacing={1.5}>
142145
<Grid item xs={12} sm={6} md={3}>
143146
<StatCard label="Successful Swaps" value={String(stats.totalSwaps)} />

src/pages/DashboardPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const DashboardPage: React.FC = () => {
7070
href={docsUrl}
7171
target="_blank"
7272
rel="noopener noreferrer"
73+
aria-label="Documentation"
7374
sx={{
7475
color: 'text.secondary',
7576
border: '1px solid',
@@ -88,6 +89,7 @@ const DashboardPage: React.FC = () => {
8889
</Tooltip>
8990
<IconButton
9091
onClick={toggleTheme}
92+
aria-label={mode === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
9193
sx={{
9294
color: 'text.secondary',
9395
border: '1px solid',

0 commit comments

Comments
 (0)