diff --git a/src/components/QueryError.tsx b/src/components/QueryError.tsx new file mode 100644 index 0000000..7692b3e --- /dev/null +++ b/src/components/QueryError.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Button, Stack, Typography } from '@mui/material'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { FONTS } from '../theme'; + +interface QueryErrorProps { + /** + * Optional one-line description of what failed to load. Falls back to a + * generic "Couldn't load data" message. + */ + label?: string; + /** + * Called when the user clicks Retry. Pass the consumer's `refetch` from + * react-query. + */ + onRetry: () => void; +} + +const QueryError: React.FC = ({ + label = "Couldn't load data", + onRetry, +}) => ( + + + + {label} + + + +); + +export default QueryError; diff --git a/src/components/dashboard/EventFeed.tsx b/src/components/dashboard/EventFeed.tsx index 590a6ff..d1bb928 100644 --- a/src/components/dashboard/EventFeed.tsx +++ b/src/components/dashboard/EventFeed.tsx @@ -15,6 +15,7 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { displayEventType, useLatestEvents } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { EventFeedSkeleton } from './Skeletons'; const getEventColor = ( @@ -56,7 +57,7 @@ const getEventColor = ( const EventFeed: React.FC = () => { const theme = useTheme(); - const { data: events, isLoading } = useLatestEvents(); + const { data: events, isLoading, isError, refetch } = useLatestEvents(); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); @@ -69,6 +70,10 @@ const EventFeed: React.FC = () => { scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }, []); + if (isError && !events) { + return ; + } + return isLoading || !events ? ( ) : ( diff --git a/src/components/dashboard/MinerRatesTable.tsx b/src/components/dashboard/MinerRatesTable.tsx index 1ecc7ab..faf60ef 100644 --- a/src/components/dashboard/MinerRatesTable.tsx +++ b/src/components/dashboard/MinerRatesTable.tsx @@ -23,6 +23,7 @@ import SearchIcon from '@mui/icons-material/Search'; import { useMiners, type Miner } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { MinerRatesTableSkeleton } from './Skeletons'; import { formatRate } from '../../utils/format'; @@ -130,7 +131,7 @@ const MinerRatesTable: React.FC = () => { collateral: { textAlign: 'center' }, }; - const { data: miners, isLoading } = useMiners(); + const { data: miners, isLoading, isError, refetch } = useMiners(); const [sortKey, setSortKey] = useState('rate'); const [sortDir, setSortDir] = useState('desc'); const [search, setSearch] = useState(''); @@ -345,6 +346,10 @@ const MinerRatesTable: React.FC = () => { return hasForward !== hasReverse; }; + if (isError && !miners) { + return ; + } + return isLoading || !miners ? ( ) : ( diff --git a/src/components/dashboard/OrderbookDepth.tsx b/src/components/dashboard/OrderbookDepth.tsx index 6fa12bf..f9877d8 100644 --- a/src/components/dashboard/OrderbookDepth.tsx +++ b/src/components/dashboard/OrderbookDepth.tsx @@ -18,6 +18,7 @@ import { import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useMiners } from '../../api'; import { FONTS } from '../../theme'; +import QueryError from '../QueryError'; import { OrderbookDepthSkeleton } from './Skeletons'; const OrderbookDepth: React.FC = () => { @@ -99,7 +100,7 @@ const OrderbookDepth: React.FC = () => { borderBottom: `1px solid ${theme.palette.divider}`, }; - const { data: miners, isLoading } = useMiners(); + const { data: miners, isLoading, isError, refetch } = useMiners(); type Direction = 'forward' | 'reverse'; type DirectionOption = { asset: string; @@ -193,6 +194,12 @@ const OrderbookDepth: React.FC = () => { [depthData], ); + if (isError && !miners) { + return ( + + ); + } + return isLoading || !miners ? ( ) : ( diff --git a/src/components/dashboard/StatsPanel.tsx b/src/components/dashboard/StatsPanel.tsx index daa9eb5..1fdbec0 100644 --- a/src/components/dashboard/StatsPanel.tsx +++ b/src/components/dashboard/StatsPanel.tsx @@ -3,6 +3,7 @@ import { Box, Grid, Typography } from '@mui/material'; import { useStats } from '../../api'; import { FONTS } from '../../theme'; import { RollingValue } from '../animated'; +import QueryError from '../QueryError'; import { StatsPanelSkeleton } from './Skeletons'; const StatCard: React.FC<{ label: string; value: string }> = ({ @@ -46,7 +47,11 @@ const StatCard: React.FC<{ label: string; value: string }> = ({ ); const StatsPanel: React.FC = () => { - const { data: stats, isLoading } = useStats(); + const { data: stats, isLoading, isError, refetch } = useStats(); + + if (isError && !stats) { + return ; + } const volume = stats ? parseFloat(stats.totalVolumeTao).toFixed(2) : '0'; diff --git a/src/components/dashboard/SwapTracker.tsx b/src/components/dashboard/SwapTracker.tsx index f79f695..ff04b8a 100644 --- a/src/components/dashboard/SwapTracker.tsx +++ b/src/components/dashboard/SwapTracker.tsx @@ -14,6 +14,7 @@ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useAllSwaps, useSwapDetail } from '../../api'; import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; +import QueryError from '../QueryError'; import { SwapTrackerSkeleton } from './Skeletons'; import { formatAmount } from '../../utils/format'; @@ -66,14 +67,26 @@ const SwapTracker: React.FC = () => { const idMatch = debouncedSearch.trim().match(/^#?(\d+)$/); const exactSwapId = idMatch?.[1] ?? ''; - const { data: detail, isLoading: detailLoading } = useSwapDetail(exactSwapId); - const { data: fuzzy, isLoading: fuzzyLoading } = useAllSwaps( + const { + data: detail, + isLoading: detailLoading, + isError: detailError, + refetch: refetchDetail, + } = useSwapDetail(exactSwapId); + const { + data: fuzzy, + isLoading: fuzzyLoading, + isError: fuzzyError, + refetch: refetchFuzzy, + } = useAllSwaps( { search: debouncedSearch || undefined, limit }, !exactSwapId, ); const swaps = exactSwapId ? (detail?.swap ? [detail.swap] : []) : fuzzy; const isLoading = exactSwapId ? detailLoading : fuzzyLoading; + const isError = exactSwapId ? detailError : fuzzyError; + const refetch = exactSwapId ? refetchDetail : refetchFuzzy; // Reset limit when search changes React.useEffect(() => { @@ -91,6 +104,10 @@ const SwapTracker: React.FC = () => { } }, [hasMore]); + if (isError && !swaps) { + return ; + } + return isLoading && !swaps ? ( ) : ( diff --git a/src/components/index.ts b/src/components/index.ts index d634208..30471fa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { default as Card } from './Card'; export { default as HoverCard } from './HoverCard'; export { default as LabelValue } from './LabelValue'; export { default as PageWrapper } from './PageWrapper'; +export { default as QueryError } from './QueryError'; export { TimelineStep, SectionTitle } from './Timeline'; export type { TimelineStepState } from './Timeline'; export { SEO } from './SEO'; diff --git a/src/pages/SwapDetailPage.tsx b/src/pages/SwapDetailPage.tsx index 1bca50b..1ce8b21 100644 --- a/src/pages/SwapDetailPage.tsx +++ b/src/pages/SwapDetailPage.tsx @@ -22,6 +22,7 @@ import { Card, LabelValue, PageWrapper, + QueryError, SectionTitle, TimelineStep, type TimelineStepState, @@ -70,11 +71,22 @@ const SwapDetailPage: React.FC = () => { const { swapId } = useParams<{ swapId: string }>(); const theme = useTheme(); - const { data, isLoading } = useSwapDetail(swapId ?? ''); + const { data, isLoading, isError, refetch } = useSwapDetail(swapId ?? ''); const { data: protocol } = useProtocolConstants(); const { data: chainState } = useChainState(); const currentBlock = chainState?.currentBlock ?? 0; + if (isError && !data) { + return ( + + + + ); + } + if (isLoading) { return (