diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2a4b1d2..78c80cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import App from './App.tsx'; import './index.css'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'sonner'; import { WalletProvider } from './providers/WalletProvider.tsx'; import { NotificationProvider } from './providers/NotificationProvider.tsx'; import { SocketProvider } from './providers/SocketProvider.tsx'; @@ -13,6 +14,7 @@ import * as Sentry from '@sentry/react'; import GlobalErrorBoundary from './components/GlobalErrorBoundary'; import PageErrorFallback from './components/PageErrorFallback'; import './i18n'; +import './services/apiErrorInterceptor'; const sentryDsn = import.meta.env.VITE_SENTRY_DSN; @@ -39,6 +41,16 @@ ReactDOM.createRoot(document.getElementById('root')!).render( + diff --git a/frontend/src/services/apiErrorInterceptor.ts b/frontend/src/services/apiErrorInterceptor.ts new file mode 100644 index 0000000..16570e1 --- /dev/null +++ b/frontend/src/services/apiErrorInterceptor.ts @@ -0,0 +1,70 @@ +/** + * Global Axios Response Interceptor + * + * Intercepts HTTP 4xx/5xx responses and surfaces them as toast notifications + * so users get actionable feedback without checking the console. + * + * Import once from main.tsx to activate: + * import './services/apiErrorInterceptor'; + */ +import axios from 'axios'; +import { toast } from 'sonner'; + +const STATUS_MESSAGES: Record = { + 400: 'Bad request — check your input and try again.', + 401: 'Session expired. Please log in again.', + 403: 'You do not have permission to perform this action.', + 404: 'The requested resource was not found.', + 408: 'Request timed out. Please try again.', + 409: 'Conflict — the resource may have changed. Please refresh.', + 422: 'The submitted data is invalid. Please review your input.', + 429: 'Too many requests. Please wait a moment before trying again.', + 500: 'Internal server error. Please try again later.', + 502: 'Gateway error — the backend service is temporarily unavailable.', + 503: 'Service unavailable. Please try again later.', +}; + +function getDefaultMessage(status: number): string { + return STATUS_MESSAGES[status] || `Unexpected error (HTTP ${status}). Please try again.`; +} + +axios.interceptors.response.use( + (response) => response, + (error) => { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const url = error.config?.url ?? ''; + + // Skip toast for localhost/dev requests — they're expected to be noisy + if (url.includes('localhost') || url.includes('127.0.0.1')) { + return Promise.reject(error); + } + + // Skip toast for 401 on auth-related endpoints (handled by AuthProvider) + if (status === 401 && (url.includes('/auth') || url.includes('/login'))) { + return Promise.reject(error); + } + + // Skip toast for contract registry errors (handled by ContractService retry logic) + if (url.includes('/api/contracts')) { + return Promise.reject(error); + } + + const message = error.response?.data && + typeof error.response.data === 'object' && + 'message' in (error.response.data as Record) + ? (error.response.data as Record).message + : getDefaultMessage(status ?? 0); + + if (status && status >= 500) { + toast.error('Server error', { description: message }); + } else if (status === 401 || status === 403) { + toast.error('Access denied', { description: message }); + } else if (status && status >= 400) { + toast.error('Request failed', { description: message }); + } + } + + return Promise.reject(error); + } +); \ No newline at end of file