diff --git a/src/components/common/TradePanelErrorBoundary.tsx b/src/components/common/TradePanelErrorBoundary.tsx
new file mode 100644
index 0000000..31f985d
--- /dev/null
+++ b/src/components/common/TradePanelErrorBoundary.tsx
@@ -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 (
+
+
+
+
+ {this.props.fallbackMessage ?? 'Something went wrong inside the trade panel'}
+
+
+ The rest of the marketplace is still running. Click retry below to attempt remounting the trade panel.
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default TradePanelErrorBoundary;
diff --git a/src/components/common/__tests__/TradePanelErrorBoundary.test.tsx b/src/components/common/__tests__/TradePanelErrorBoundary.test.tsx
new file mode 100644
index 0000000..755ecd8
--- /dev/null
+++ b/src/components/common/__tests__/TradePanelErrorBoundary.test.tsx
@@ -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 Trade Panel Content
;
+}
+
+// Wrapper component to simulate state-driven retry remount
+function TestParent({ initialThrow = true }: { initialThrow?: boolean }) {
+ const [hasError, setHasError] = useState(initialThrow);
+
+ return (
+
+
+ setHasError(false)}>
+
+
+
+
+ );
+}
+
+describe('TradePanelErrorBoundary', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders children normally when no error occurs', () => {
+ render(
+
+ Trade Panel Content
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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();
+
+ // 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();
+
+ 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();
+ });
+});
diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts
index 94f40d9..65202b7 100644
--- a/src/hooks/useWallet.ts
+++ b/src/hooks/useWallet.ts
@@ -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
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 03387f4..dead635 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -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';
@@ -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';
@@ -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(
@@ -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'}...`
@@ -1796,16 +1780,18 @@ function LandingPage() {
-
+
+
+
({
+ 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';
@@ -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
@@ -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 }
);
@@ -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' },
@@ -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');
@@ -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' } });
@@ -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...'
@@ -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 }
);
@@ -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);
});
diff --git a/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx
index e4c74f7..bcb549f 100644
--- a/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx
+++ b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx
@@ -13,6 +13,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';
@@ -196,7 +200,10 @@ describe('LandingPage sell flow end-to-end (#644)', () => {
await screen.findByText('3 keys · 0.05 XLM');
// Open the trade panel on the sell side
- const [sellButton] = screen.getAllByRole('button', { name: 'Sell' });
+ const [sellButton] = screen.getAllByRole('button', {
+ name: 'Sell',
+ hidden: true,
+ });
fireEvent.click(sellButton);
// Enter quantity 2
@@ -224,13 +231,16 @@ describe('LandingPage sell flow end-to-end (#644)', () => {
// 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 [sellButton] = screen.getAllByRole('button', { name: 'Sell' });
+ const [sellButton] = screen.getAllByRole('button', {
+ name: 'Sell',
+ hidden: true,
+ });
fireEvent.click(sellButton);
fireEvent.change(await screen.findByTestId('trade-dialog-amount'), {
target: { value: '2' },
@@ -240,13 +250,16 @@ describe('LandingPage sell flow end-to-end (#644)', () => {
expect(mockShowToast.loading).toHaveBeenCalledWith(
'Submitting sell for 2 keys...'
);
- });
+ }, 15000);
it('uses the singular key wording when selling exactly one', async () => {
renderLandingPage();
await screen.findByText('3 keys · 0.05 XLM');
- const [sellButton] = screen.getAllByRole('button', { name: 'Sell' });
+ const [sellButton] = screen.getAllByRole('button', {
+ name: 'Sell',
+ hidden: true,
+ });
fireEvent.click(sellButton);
fireEvent.change(await screen.findByTestId('trade-dialog-amount'), {
target: { value: '1' },
@@ -261,5 +274,5 @@ describe('LandingPage sell flow end-to-end (#644)', () => {
),
{ timeout: 5000 }
);
- });
+ }, 15000);
});
diff --git a/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx b/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx
index ff89040..8b78ba9 100644
--- a/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx
+++ b/src/pages/__tests__/LandingPage.tradeConfirmToast.integration.test.tsx
@@ -3,10 +3,16 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import LandingPage from '@/pages/LandingPage';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { courseService } from '@/services/course.service';
import showToast from '@/utils/toast.util';
+vi.mock('wagmi', () => ({
+ useAccount: vi.fn(() => ({ address: undefined, isConnected: false })),
+}));
+
+import LandingPage from '@/pages/LandingPage';
+
vi.mock('@/services/course.service', () => ({
courseService: { getCourses: vi.fn() },
}));
@@ -121,10 +127,13 @@ describe('LandingPage trade confirmation toast (#540)', () => {
it('shows a success toast with quantity and creator name after a confirmed buy', async () => {
mockGetCourses.mockResolvedValue([]);
const user = userEvent.setup();
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
-
-
-
+
+
+
+
+
);
const buyButtons = await screen.findAllByRole('button', {
@@ -141,22 +150,24 @@ describe('LandingPage trade confirmation toast (#540)', () => {
await waitFor(
() =>
- expect(mockShowToast.transactionSuccess).toHaveBeenCalledTimes(1),
- { timeout: 3000 }
+ expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
+ 'Trade confirmed',
+ 'Bought 5 keys from Alex Rivers'
+ ),
+ { timeout: 8000 }
);
- expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
- 'Trade confirmed',
- expect.stringMatching(/^Bought 5 keys from .+$/)
- );
- });
+ }, 15000);
it('shows a success toast with quantity and creator name after a confirmed sell', async () => {
mockGetCourses.mockResolvedValue([]);
const user = userEvent.setup();
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
-
-
-
+
+
+
+
+
);
const sellButtons = await screen.findAllByRole('button', {
@@ -173,12 +184,11 @@ describe('LandingPage trade confirmation toast (#540)', () => {
await waitFor(
() =>
- expect(mockShowToast.transactionSuccess).toHaveBeenCalledTimes(1),
- { timeout: 3000 }
+ expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
+ 'Trade confirmed',
+ 'Sold 1 key from Alex Rivers'
+ ),
+ { timeout: 8000 }
);
- expect(mockShowToast.transactionSuccess).toHaveBeenCalledWith(
- 'Trade confirmed',
- expect.stringMatching(/^Sold 1 key from .+$/)
- );
- });
+ }, 15000);
});
diff --git a/src/utils/toast.util.tsx b/src/utils/toast.util.tsx
index bcaa5d1..b7a46e2 100644
--- a/src/utils/toast.util.tsx
+++ b/src/utils/toast.util.tsx
@@ -2,7 +2,7 @@ import toast from 'react-hot-toast';
import type { ToastOptions } from 'react-hot-toast';
import TransactionHashRow from '@/components/common/TransactionHashRow';
-const TRANSACTION_TOAST_DURATION_MS = 6_000;
+const TRANSACTION_TOAST_DURATION_MS = 4_000;
const showToast = {
message: (message: string, options?: ToastOptions) => {
@@ -11,7 +11,7 @@ const showToast = {
},
success: (message: string, options?: ToastOptions) => {
toast.remove();
- toast.success(message, options);
+ toast.success(message, { duration: TRANSACTION_TOAST_DURATION_MS, ...options });
},
error: (message: string, options?: ToastOptions) => {
toast.remove();