fix: Centralize API clients and add global ErrorBoundary (Phase 3)

Migrate 6 services from duplicate Axios instances to centralized
apiClient (auto-refresh, multi-tab sync, proper 401 handling):
- trading.service.ts, notification.service.ts, payment.service.ts
- education.service.ts, chat.service.ts, investment.service.ts

Add global ErrorBoundary wrapping all App routes. Move assistant
module ErrorBoundary to shared components/ErrorBoundary.tsx.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 04:56:44 -06:00
parent 42d18759b5
commit 1d76747e9b
9 changed files with 245 additions and 157 deletions

View File

@ -4,6 +4,7 @@ import { Suspense, lazy } from 'react';
// Layout
import MainLayout from './components/layout/MainLayout';
import AuthLayout from './components/layout/AuthLayout';
import ErrorBoundary from './components/ErrorBoundary';
// Loading component
const LoadingSpinner = () => (
@ -68,6 +69,7 @@ const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
{/* Auth routes */}
@ -149,6 +151,7 @@ function App() {
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,206 @@
/**
* Global ErrorBoundary Component
* Catches JavaScript errors in child components and displays fallback UI
*/
import React, { Component, ErrorInfo, ReactNode } from 'react';
import {
AlertTriangle,
RefreshCw,
Home,
Bug,
ChevronDown,
ChevronUp,
Copy,
Check,
} from 'lucide-react';
export interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
onReset?: () => void;
showDetails?: boolean;
}
export interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
showStack: boolean;
copied: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
showStack: false,
copied: false,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({ errorInfo });
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
showStack: false,
});
if (this.props.onReset) {
this.props.onReset();
}
};
handleRefresh = (): void => {
window.location.reload();
};
handleGoHome = (): void => {
window.location.href = '/';
};
toggleStack = (): void => {
this.setState((prev) => ({ showStack: !prev.showStack }));
};
copyError = async (): Promise<void> => {
const { error, errorInfo } = this.state;
const errorText = `Error: ${error?.message}\n\nStack: ${error?.stack}\n\nComponent Stack: ${errorInfo?.componentStack}`;
try {
await navigator.clipboard.writeText(errorText);
this.setState({ copied: true });
setTimeout(() => this.setState({ copied: false }), 2000);
} catch (err) {
console.error('Failed to copy error:', err);
}
};
render(): ReactNode {
const { hasError, error, errorInfo, showStack, copied } = this.state;
const { children, fallback, showDetails = true } = this.props;
if (hasError) {
if (fallback) {
return fallback;
}
return (
<div className="min-h-[400px] flex items-center justify-center p-6 bg-gray-900/50 rounded-xl border border-gray-700">
<div className="max-w-lg w-full">
<div className="flex justify-center mb-6">
<div className="p-4 bg-red-500/20 rounded-full">
<AlertTriangle className="w-12 h-12 text-red-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-white text-center mb-2">
Something went wrong
</h2>
<p className="text-gray-400 text-center mb-6">
An unexpected error occurred. You can try refreshing the page or return to the home screen.
</p>
{error && showDetails && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-start gap-3">
<Bug className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-400 mb-1">Error Message</p>
<p className="text-sm text-gray-300 break-words">{error.message}</p>
</div>
</div>
{error.stack && (
<div className="mt-4">
<button
onClick={this.toggleStack}
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white transition-colors"
>
{showStack ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
{showStack ? 'Hide' : 'Show'} Stack Trace
</button>
{showStack && (
<div className="mt-2 relative">
<pre className="text-xs text-gray-400 bg-gray-800 p-3 rounded overflow-x-auto max-h-40">
{error.stack}
</pre>
<button
onClick={this.copyError}
className="absolute top-2 right-2 p-1.5 bg-gray-700 hover:bg-gray-600 rounded transition-colors"
title="Copy error"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4 text-gray-400" />
)}
</button>
</div>
)}
</div>
)}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={this.handleReset}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<RefreshCw className="w-5 h-5" />
Try Again
</button>
<button
onClick={this.handleRefresh}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
<RefreshCw className="w-5 h-5" />
Refresh Page
</button>
<button
onClick={this.handleGoHome}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
<Home className="w-5 h-5" />
Go Home
</button>
</div>
<p className="text-xs text-gray-500 text-center mt-4">
If the problem persists, please contact support with the error details above.
</p>
</div>
</div>
);
}
return children;
}
}
export default ErrorBoundary;

View File

@ -63,9 +63,9 @@ export type { LLMConfig, ModelInfo, ConfigPreset, ModelId, ReasoningStyle, Analy
export { default as ContextMemoryDisplay } from './ContextMemoryDisplay';
export type { ContextMessage, ContextSummary, ContextMemoryState } from './ContextMemoryDisplay';
// Error Handling & Status (OQI-007)
export { default as ErrorBoundary } from './ErrorBoundary';
export type { ErrorBoundaryProps, ErrorBoundaryState } from './ErrorBoundary';
// Error Handling & Status (OQI-007) - Re-export from shared component
export { default as ErrorBoundary } from '../../../components/ErrorBoundary';
export type { ErrorBoundaryProps, ErrorBoundaryState } from '../../../components/ErrorBoundary';
export { default as ConnectionStatus } from './ConnectionStatus';
export type { ConnectionState, ConnectionMetrics, ConnectionStatusProps } from './ConnectionStatus';

View File

@ -3,52 +3,15 @@
* API client for LLM Copilot chat endpoints
*/
import axios from 'axios';
import { apiClient } from '../lib/apiClient';
import type {
ChatSession,
SendMessageResponse,
CreateSessionResponse,
} from '../types/chat.types';
// ============================================================================
// API Configuration
// ============================================================================
const API_BASE_URL = import.meta.env?.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_BASE_URL}/api/v1/llm`,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor for auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
// All paths prefixed with /llm/ since apiClient baseURL is /api/v1
// ============================================================================
// Chat Service
@ -59,7 +22,7 @@ export const chatService = {
* Create a new chat session
*/
async createSession(): Promise<CreateSessionResponse> {
const response = await api.post('/sessions');
const response = await apiClient.post('/llm/sessions');
return response.data;
},
@ -67,7 +30,7 @@ export const chatService = {
* Get all chat sessions for the current user
*/
async getSessions(): Promise<ChatSession[]> {
const response = await api.get('/sessions');
const response = await apiClient.get('/llm/sessions');
return response.data;
},
@ -75,7 +38,7 @@ export const chatService = {
* Get a specific chat session with all messages
*/
async getSession(sessionId: string): Promise<ChatSession> {
const response = await api.get(`/sessions/${sessionId}`);
const response = await apiClient.get(`/llm/sessions/${sessionId}`);
return response.data;
},
@ -86,7 +49,7 @@ export const chatService = {
sessionId: string,
message: string
): Promise<SendMessageResponse> {
const response = await api.post(`/sessions/${sessionId}/chat`, {
const response = await apiClient.post(`/llm/sessions/${sessionId}/chat`, {
message,
});
return response.data;
@ -96,14 +59,14 @@ export const chatService = {
* Delete a chat session
*/
async deleteSession(sessionId: string): Promise<void> {
await api.delete(`/sessions/${sessionId}`);
await apiClient.delete(`/llm/sessions/${sessionId}`);
},
/**
* Quick analysis of a symbol (public endpoint, no auth required)
*/
async analyzeSymbol(symbol: string): Promise<{ analysis: string }> {
const response = await api.get(`/analyze/${symbol}`);
const response = await apiClient.get(`/llm/analyze/${symbol}`);
return response.data;
},
};

View File

@ -3,7 +3,7 @@
* API client for courses, lessons, quizzes, and gamification
*/
import axios from 'axios';
import { apiClient as api } from '../lib/apiClient';
import type {
CourseListItem,
CourseDetail,
@ -27,23 +27,7 @@ import type {
GamificationSummary,
} from '../types/education.types';
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
// ============================================================================
// Categories

View File

@ -3,9 +3,10 @@
* API client for investment accounts, products, transactions, and withdrawals
*/
import axios from 'axios';
import { apiClient } from '../lib/apiClient';
const API_BASE = '/api/v1/investment';
// apiClient baseURL is /api/v1, so prefix paths with /investment
const API_PREFIX = '/investment';
// ============================================================================
// Types
@ -120,12 +121,12 @@ export interface AccountDetail extends InvestmentAccount {
export async function getProducts(riskProfile?: string): Promise<Product[]> {
const params = riskProfile ? { riskProfile } : {};
const response = await axios.get(`${API_BASE}/products`, { params });
const response = await apiClient.get(`${API_PREFIX}/products`, { params });
return response.data.data;
}
export async function getProductById(productId: string): Promise<Product> {
const response = await axios.get(`${API_BASE}/products/${productId}`);
const response = await apiClient.get(`${API_PREFIX}/products/${productId}`);
return response.data.data;
}
@ -133,7 +134,7 @@ export async function getProductPerformance(
productId: string,
period: 'week' | 'month' | '3months' | 'year' = 'month'
): Promise<ProductPerformance[]> {
const response = await axios.get(`${API_BASE}/products/${productId}/performance`, {
const response = await apiClient.get(`${API_PREFIX}/products/${productId}/performance`, {
params: { period },
});
return response.data.data;
@ -144,27 +145,27 @@ export async function getProductPerformance(
// ============================================================================
export async function getUserAccounts(): Promise<InvestmentAccount[]> {
const response = await axios.get(`${API_BASE}/accounts`);
const response = await apiClient.get(`${API_PREFIX}/accounts`);
return response.data.data;
}
export async function getAccountSummary(): Promise<AccountSummary> {
const response = await axios.get(`${API_BASE}/accounts/summary`);
const response = await apiClient.get(`${API_PREFIX}/accounts/summary`);
return response.data.data;
}
export async function getAccountById(accountId: string): Promise<AccountDetail> {
const response = await axios.get(`${API_BASE}/accounts/${accountId}`);
const response = await apiClient.get(`${API_PREFIX}/accounts/${accountId}`);
return response.data.data;
}
export async function createAccount(productId: string, initialDeposit: number): Promise<InvestmentAccount> {
const response = await axios.post(`${API_BASE}/accounts`, { productId, initialDeposit });
const response = await apiClient.post(`${API_PREFIX}/accounts`, { productId, initialDeposit });
return response.data.data;
}
export async function closeAccount(accountId: string): Promise<void> {
await axios.post(`${API_BASE}/accounts/${accountId}/close`);
await apiClient.post(`${API_PREFIX}/accounts/${accountId}/close`);
}
// ============================================================================
@ -180,14 +181,14 @@ export async function getTransactions(
offset?: number;
}
): Promise<{ transactions: Transaction[]; total: number }> {
const response = await axios.get(`${API_BASE}/accounts/${accountId}/transactions`, {
const response = await apiClient.get(`${API_PREFIX}/accounts/${accountId}/transactions`, {
params: options,
});
return response.data.data;
}
export async function createDeposit(accountId: string, amount: number): Promise<Transaction> {
const response = await axios.post(`${API_BASE}/accounts/${accountId}/deposit`, { amount });
const response = await apiClient.post(`${API_PREFIX}/accounts/${accountId}/deposit`, { amount });
return response.data.data;
}
@ -199,7 +200,7 @@ export async function createWithdrawal(
cryptoInfo?: { network: string; address: string };
}
): Promise<Withdrawal> {
const response = await axios.post(`${API_BASE}/accounts/${accountId}/withdraw`, {
const response = await apiClient.post(`${API_PREFIX}/accounts/${accountId}/withdraw`, {
amount,
...destination,
});
@ -211,7 +212,7 @@ export async function createWithdrawal(
// ============================================================================
export async function getDistributions(accountId: string): Promise<Distribution[]> {
const response = await axios.get(`${API_BASE}/accounts/${accountId}/distributions`);
const response = await apiClient.get(`${API_PREFIX}/accounts/${accountId}/distributions`);
return response.data.data;
}
@ -221,7 +222,7 @@ export async function getDistributions(accountId: string): Promise<Distribution[
export async function getWithdrawals(status?: string): Promise<Withdrawal[]> {
const params = status ? { status } : {};
const response = await axios.get(`${API_BASE}/withdrawals`, { params });
const response = await apiClient.get(`${API_PREFIX}/withdrawals`, { params });
return response.data.data;
}

View File

@ -3,25 +3,7 @@
* API client for notifications management
*/
import axios from 'axios';
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
import { apiClient as api } from '../lib/apiClient';
// ============================================================================
// Types

View File

@ -3,7 +3,7 @@
* API client for subscriptions, payments, billing, and wallet
*/
import axios from 'axios';
import { apiClient as api } from '../lib/apiClient';
import type {
PricingPlan,
Subscription,
@ -24,23 +24,7 @@ import type {
PlanInterval,
} from '../types/payment.types';
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
// ============================================================================
// Pricing Plans

View File

@ -3,7 +3,7 @@
* API client for trading and market data endpoints
*/
import axios from 'axios';
import { apiClient } from '../lib/apiClient';
import type {
Candle,
Ticker,
@ -26,43 +26,8 @@ import type {
AccountSummary,
} from '../types/trading.types';
// ============================================================================
// API Configuration
// ============================================================================
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor for auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
const api = apiClient;
// ============================================================================
// Market Data API