Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/components/common/TradePanelErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import TransactionStatusIcon from '@/components/common/TransactionStatusIcon';
import { cn } from '@/lib/utils';

interface Props {
children: ReactNode;
className?: string;
}

interface State {
hasError: boolean;
}

class TradePanelErrorBoundary extends Component<Props, State> {
public state: State = { hasError: false };

public static getDerivedStateFromError(): State {
return { hasError: true };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error in trade panel:', error, errorInfo);
}

private handleRetry = () => {
this.setState({ hasError: false });
};

public render() {
if (this.state.hasError) {
return (
<div
role="alert"
aria-live="assertive"
className={cn(
'flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2',
this.props.className
)}
>
<TransactionStatusIcon status="failed" className="shrink-0" />
<p className="min-w-0 flex-1 truncate text-xs font-medium text-white/80">
Trading is unavailable right now.
</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={this.handleRetry}
className="shrink-0 gap-1.5 border-red-400/30 bg-transparent text-white hover:bg-red-500/10"
>
<RotateCcw className="size-3.5" />
Retry
</Button>
</div>
);
}

return this.props.children;
}
}

export default TradePanelErrorBoundary;
65 changes: 65 additions & 0 deletions src/components/common/__tests__/TradePanelErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TradePanelErrorBoundary from '@/components/common/TradePanelErrorBoundary';

const BuggyTradePanel = ({ shouldThrow = false }: { shouldThrow?: boolean }) => {
if (shouldThrow) {
throw new Error('Test trade panel error');
}
return <div>Buy Sell Buttons</div>;
};

describe('TradePanelErrorBoundary', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('renders children when no error occurs', () => {
render(
<TradePanelErrorBoundary>
<BuggyTradePanel />
</TradePanelErrorBoundary>
);
expect(screen.getByText('Buy Sell Buttons')).toBeInTheDocument();
});

it('renders fallback UI when a render error occurs', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<TradePanelErrorBoundary>
<BuggyTradePanel shouldThrow={true} />
</TradePanelErrorBoundary>
);

expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/trading is unavailable right now/i)).toBeInTheDocument();
expect(screen.queryByText('Buy Sell Buttons')).not.toBeInTheDocument();
expect(consoleSpy).toHaveBeenCalled();
});

it('resets error state and remounts the panel when Retry is clicked', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const { rerender } = render(
<TradePanelErrorBoundary>
<BuggyTradePanel shouldThrow={true} />
</TradePanelErrorBoundary>
);

expect(screen.getByText(/trading is unavailable right now/i)).toBeInTheDocument();

rerender(
<TradePanelErrorBoundary>
<BuggyTradePanel shouldThrow={false} />
</TradePanelErrorBoundary>
);

fireEvent.click(screen.getByRole('button', { name: /retry/i }));

expect(
screen.queryByText(/trading is unavailable right now/i)
).not.toBeInTheDocument();
expect(screen.getByText('Buy Sell Buttons')).toBeInTheDocument();
});
});
145 changes: 75 additions & 70 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import PrecisionModeToggle, {
} from '@/components/common/PrecisionModeToggle';
import ScrollToTop from '@/components/common/ScrollToTop';
import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
import TradePanelErrorBoundary from '@/components/common/TradePanelErrorBoundary';
import StaleDataWarning from '@/components/common/StaleDataWarning';
import { useScrollPreservation } from '@/hooks/useScrollPreservation';
import { useStaleData } from '@/hooks/useStaleData';
Expand Down Expand Up @@ -1278,44 +1279,46 @@ function LandingPage() {
}
/>
{isNetworkMismatch && <NetworkMismatchBanner />}
<div className="relative">
<div
className={cn(
'hidden md:flex items-center gap-3 transition-opacity duration-200',
tradeSubmitting &&
'pointer-events-none select-none opacity-60'
)}
aria-busy={tradeSubmitting || undefined}
>
<Button
className="rounded-xl"
onClick={() => openTradeDialog('buy')}
disabled={
isNetworkMismatch || tradeSubmitting
}
>
Buy
</Button>
<Button
className="rounded-xl"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={
isNetworkMismatch || tradeSubmitting
}
<TradePanelErrorBoundary>
<div className="relative">
<div
className={cn(
'hidden md:flex items-center gap-3 transition-opacity duration-200',
tradeSubmitting &&
'pointer-events-none select-none opacity-60'
)}
aria-busy={tradeSubmitting || undefined}
>
Sell
</Button>
</div>
{tradeSubmitting && (
<div className="absolute inset-0 hidden items-center justify-center rounded-[1.25rem] border border-white/10 bg-slate-950/65 backdrop-blur-sm md:flex">
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-slate-950/80 px-3 py-1.5 text-xs font-bold text-white/85 shadow-lg">
<div className="size-3.5 animate-spin rounded-full border-2 border-amber-400/25 border-t-amber-400" />
Submitting trade
</div>
<Button
className="rounded-xl"
onClick={() => openTradeDialog('buy')}
disabled={
isNetworkMismatch || tradeSubmitting
}
>
Buy
</Button>
<Button
className="rounded-xl"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={
isNetworkMismatch || tradeSubmitting
}
>
Sell
</Button>
</div>
)}
</div>
{tradeSubmitting && (
<div className="absolute inset-0 hidden items-center justify-center rounded-[1.25rem] border border-white/10 bg-slate-950/65 backdrop-blur-sm md:flex">
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-slate-950/80 px-3 py-1.5 text-xs font-bold text-white/85 shadow-lg">
<div className="size-3.5 animate-spin rounded-full border-2 border-amber-400/25 border-t-amber-400" />
Submitting trade
</div>
</div>
)}
</div>
</TradePanelErrorBoundary>
</div>
</MarketplaceSection>
)}
Expand Down Expand Up @@ -1357,42 +1360,44 @@ function LandingPage() {
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'flex items-center gap-2 transition-opacity duration-200',
tradeSubmitting &&
'pointer-events-none select-none opacity-60'
)}
aria-busy={tradeSubmitting || undefined}
>
<Button
className="rounded-xl"
size="sm"
onClick={() => openTradeDialog('buy')}
disabled={isNetworkMismatch || tradeSubmitting}
>
Buy
</Button>
<Button
className="rounded-xl"
size="sm"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={isNetworkMismatch || tradeSubmitting}
<TradePanelErrorBoundary>
<div className="relative">
<div
className={cn(
'flex items-center gap-2 transition-opacity duration-200',
tradeSubmitting &&
'pointer-events-none select-none opacity-60'
)}
aria-busy={tradeSubmitting || undefined}
>
Sell
</Button>
</div>
{tradeSubmitting && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl border border-white/10 bg-slate-950/65 px-3 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-slate-950/80 px-3 py-1.5 text-[11px] font-bold text-white/85 shadow-lg">
<div className="size-3 animate-spin rounded-full border-2 border-amber-400/25 border-t-amber-400" />
Submitting trade
</div>
<Button
className="rounded-xl"
size="sm"
onClick={() => openTradeDialog('buy')}
disabled={isNetworkMismatch || tradeSubmitting}
>
Buy
</Button>
<Button
className="rounded-xl"
size="sm"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={isNetworkMismatch || tradeSubmitting}
>
Sell
</Button>
</div>
)}
</div>
{tradeSubmitting && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl border border-white/10 bg-slate-950/65 px-3 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-full border border-white/10 bg-slate-950/80 px-3 py-1.5 text-[11px] font-bold text-white/85 shadow-lg">
<div className="size-3 animate-spin rounded-full border-2 border-amber-400/25 border-t-amber-400" />
Submitting trade
</div>
</div>
)}
</div>
</TradePanelErrorBoundary>
</div>
</div>
</div>
Expand Down
Loading