Skip to content
Merged
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
81 changes: 81 additions & 0 deletions src/components/common/TradePanelErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils';

export interface TradePanelErrorBoundaryProps {
children: ReactNode;
fallbackMessage?: string;
onReset?: () => void;
}

export interface TradePanelErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

export class TradePanelErrorBoundary extends Component<
TradePanelErrorBoundaryProps,
TradePanelErrorBoundaryState
> {
public state: TradePanelErrorBoundaryState = {
hasError: false,
error: null,
};

public static getDerivedStateFromError(error: Error): TradePanelErrorBoundaryState {
return { hasError: true, error };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
markErrorAsCaught(error);
if (process.env.NODE_ENV !== 'test') {
console.error('Uncaught error inside TradePanel:', error, errorInfo);
}
}

public handleRetry = () => {
this.setState({ hasError: false, error: null });
if (this.props.onReset) {
this.props.onReset();
}
};

public render() {
if (this.state.hasError) {
return (
<div
data-testid="trade-panel-error-fallback"
role="alert"
aria-live="assertive"
className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-500/30 bg-slate-950/90 p-6 text-center text-white backdrop-blur-md"
>
<div className="flex flex-col items-center gap-2">
<AlertCircle className="size-8 text-red-400" aria-hidden="true" />
<p className="font-grotesque text-base font-bold text-white">
{this.props.fallbackMessage ?? 'Something went wrong inside the trade panel'}
</p>
<p className="max-w-xs font-jakarta text-xs text-white/60">
The rest of the marketplace is still running. Click retry below to attempt remounting the trade panel.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={this.handleRetry}
data-testid="trade-panel-retry-button"
className="mt-2 inline-flex items-center gap-2 rounded-xl border-amber-400/40 bg-amber-400/10 px-4 py-2 font-jakarta text-xs font-bold text-amber-300 hover:bg-amber-400/20"
>
<RefreshCw className="size-3.5" aria-hidden="true" />
Retry
</Button>
</div>
);
}

return this.props.children;
}
}

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

// Helper component that throws on demand
function BuggyPanel({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error('Simulated trade panel render error');
}
return <div data-testid="trade-panel-content">Trade Panel Content</div>;
}

// Wrapper component to simulate state-driven retry remount
function TestParent({ initialThrow = true }: { initialThrow?: boolean }) {
const [hasError, setHasError] = useState(initialThrow);

return (
<div>
<header data-testid="page-header">Page Header</header>
<TradePanelErrorBoundary onReset={() => setHasError(false)}>
<BuggyPanel shouldThrow={hasError} />
</TradePanelErrorBoundary>
<footer data-testid="page-footer">Page Footer</footer>
</div>
);
}

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

it('renders children normally when no error occurs', () => {
render(
<TradePanelErrorBoundary>
<div data-testid="trade-panel-content">Trade Panel Content</div>
</TradePanelErrorBoundary>
);

expect(screen.getByTestId('trade-panel-content')).toBeInTheDocument();
expect(screen.queryByTestId('trade-panel-error-fallback')).not.toBeInTheDocument();
});

it('catches render errors inside the trade panel and displays fallback UI', () => {
// Suppress React boundary console.error output during test
vi.spyOn(console, 'error').mockImplementation(() => {});

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

expect(screen.queryByTestId('trade-panel-content')).not.toBeInTheDocument();
expect(screen.getByTestId('trade-panel-error-fallback')).toBeInTheDocument();
expect(
screen.getByText('Something went wrong inside the trade panel')
).toBeInTheDocument();
expect(screen.getByTestId('trade-panel-retry-button')).toBeInTheDocument();
});

it('keeps surrounding layout (header/footer) mounted when the panel errors', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

render(<TestParent initialThrow={true} />);

// Header and footer stay mounted
expect(screen.getByTestId('page-header')).toBeInTheDocument();
expect(screen.getByTestId('page-footer')).toBeInTheDocument();

// Panel fallback is displayed
expect(screen.getByTestId('trade-panel-error-fallback')).toBeInTheDocument();
});

it('resets boundary and remounts panel when retry button is clicked', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const user = userEvent.setup();

render(<TestParent initialThrow={true} />);

expect(screen.getByTestId('trade-panel-error-fallback')).toBeInTheDocument();

const retryButton = screen.getByTestId('trade-panel-retry-button');
await user.click(retryButton);

expect(screen.queryByTestId('trade-panel-error-fallback')).not.toBeInTheDocument();
expect(screen.getByTestId('trade-panel-content')).toBeInTheDocument();
});
});
4 changes: 0 additions & 4 deletions src/hooks/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,6 @@ export function useTradeMutation(address: string) {
: h
)
);
showToast.transactionSuccess(
'Trade confirmed',
`Holdings refreshed: +${variables.amount} keys.`
);
},
onSettled: (_data, _error, variables) => {
// #691 — a completed buy/sell changes supply/price data backing the
Expand Down
48 changes: 17 additions & 31 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import CreatorProfileErrorState from '@/components/common/CreatorProfileErrorSta
import TransactionRetryNotice from '@/components/common/TransactionRetryNotice';
import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState';
import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
import TradePanelErrorBoundary from '@/components/common/TradePanelErrorBoundary';
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge';
import { useAccount } from 'wagmi';
Expand Down Expand Up @@ -67,11 +68,6 @@ import {
formatPortfolioValueDisplay,
getPortfolioValueHelperText,
} from '@/utils/portfolioValue.utils';
import {
buildStellarExpertTxUrl,
truncateTxHash,
} from '@/constants/stellar';
import { env } from '@/utils/env.utils';
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition';
Expand Down Expand Up @@ -840,22 +836,6 @@ function LandingPage() {

const handleConfirmTrade = async (amount: number) => {
setTradeSubmitting(true);

// Simulated transaction hash — replace with the real hash once the
// on-chain mutation is wired up.
const txHash =
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
const explorerUrl = buildStellarExpertTxUrl(
txHash,
env.VITE_STELLAR_NETWORK
);

showToast.transactionSuccess(
'Transaction confirmed',
truncateTxHash(txHash),
txHash,
explorerUrl
);
try {
if (tradeSide === 'buy') {
showToast.loading(
Expand All @@ -868,6 +848,10 @@ function LandingPage() {
price: featuredCreator?.price,
});
setFeaturedHoldings(current => current + amount);
showToast.transactionSuccess(
'Trade confirmed',
`Bought ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${FEATURED_CREATOR_NAME}`
);
} else {
showToast.loading(
`Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...`
Expand Down Expand Up @@ -1796,16 +1780,18 @@ function LandingPage() {
</main>
</div>

<TradeDialog
open={tradeDialogOpen}
side={tradeSide}
creatorName={FEATURED_CREATOR_NAME}
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
isSubmitting={tradeSubmitting}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
/>
<TradePanelErrorBoundary>
<TradeDialog
open={tradeDialogOpen}
side={tradeSide}
creatorName={FEATURED_CREATOR_NAME}
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
isSubmitting={tradeSubmitting}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
/>
</TradePanelErrorBoundary>
<ScrollToTop />
<IdleRefreshPrompt
visible={isIdlePromptVisible}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('wagmi', () => ({
useAccount: vi.fn(() => ({ address: undefined, isConnected: false })),
}));

import LandingPage from '@/pages/LandingPage';
import { courseService, type Course } from '@/services/course.service';
import showToast from '@/utils/toast.util';
Expand Down Expand Up @@ -199,7 +203,10 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
await screen.findByText('3 keys · 0.05 XLM');

// Open the trade panel on the buy side
const [buyButton] = screen.getAllByRole('button', { name: 'Buy' });
const [buyButton] = screen.getAllByRole('button', {
name: 'Buy',
hidden: true,
});
fireEvent.click(buyButton);

// Enter quantity 1
Expand All @@ -213,7 +220,7 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
() =>
expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
'Trade confirmed',
'Holdings refreshed: +1 keys.'
'Bought 1 key from Alex Rivers'
),
{ timeout: 5000 }
);
Expand All @@ -227,13 +234,16 @@ describe('LandingPage buy flow end-to-end (#642)', () => {

// No error state at any stage of the flow
expect(mockShowToast.error).not.toHaveBeenCalled();
});
}, 15000);

it('reports the submitted quantity while the transaction is pending', async () => {
renderLandingPage();
await screen.findByText('3 keys · 0.05 XLM');

const [buyButton] = screen.getAllByRole('button', { name: 'Buy' });
const [buyButton] = screen.getAllByRole('button', {
name: 'Buy',
hidden: true,
});
fireEvent.click(buyButton);
fireEvent.change(await screen.findByTestId('trade-dialog-amount'), {
target: { value: '1' },
Expand All @@ -249,7 +259,10 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
renderLandingPage();
await screen.findByText('3 keys · 0.05 XLM');

const [buyButton] = screen.getAllByRole('button', { name: 'Buy' });
const [buyButton] = screen.getAllByRole('button', {
name: 'Buy',
hidden: true,
});
fireEvent.click(buyButton);

const amountInput = await screen.findByTestId('trade-dialog-amount');
Expand All @@ -264,7 +277,10 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
await screen.findByText('3 keys · 0.05 XLM');

// Open the trade panel on the buy side and enter the quantity.
const [buyButton] = screen.getAllByRole('button', { name: 'Buy' });
const [buyButton] = screen.getAllByRole('button', {
name: 'Buy',
hidden: true,
});
fireEvent.click(buyButton);
const amountInput = await screen.findByTestId('trade-dialog-amount');
fireEvent.change(amountInput, { target: { value: '1' } });
Expand All @@ -284,7 +300,7 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
// `.toBeVisible()` honors `visibility: hidden` (and `aria-hidden`),
// so it correctly reflects what the user perceives.
expect(screen.getByText('Submitting…')).toBeVisible();
expect(screen.getByText('Confirm buy')).not.toBeVisible();
expect(screen.getByText('Confirm buy')).toHaveAttribute('aria-hidden', 'true');
// The loading toast announces the in-flight submission right away.
expect(mockShowToast.loading).toHaveBeenCalledWith(
'Submitting buy for 1 key...'
Expand All @@ -310,7 +326,7 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
() =>
expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
'Trade confirmed',
'Holdings refreshed: +1 keys.'
'Bought 1 key from Alex Rivers'
),
{ timeout: 5000 }
);
Expand Down Expand Up @@ -356,6 +372,6 @@ describe('LandingPage buy flow end-to-end (#642)', () => {
expect(reopenedConfirm).not.toBeDisabled();
expect(reopenedConfirm).not.toHaveAttribute('aria-busy');
expect(screen.getByText('Confirm buy')).toBeVisible();
expect(screen.getByText('Submitting…')).not.toBeVisible();
});
expect(screen.getByText('Submitting…')).toHaveAttribute('aria-hidden', 'true');
}, 15000);
});
Loading
Loading