diff --git a/src/App.tsx b/src/App.tsx index c40ee35..a91d4d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( + }> {/* Auth routes */} @@ -149,6 +151,7 @@ function App() { } /> + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9c4bfd8 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + showStack: false, + copied: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + 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 => { + 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 ( +
+
+
+
+ +
+
+ +

+ Something went wrong +

+

+ An unexpected error occurred. You can try refreshing the page or return to the home screen. +

+ + {error && showDetails && ( +
+
+ +
+

Error Message

+

{error.message}

+
+
+ + {error.stack && ( +
+ + + {showStack && ( +
+
+                          {error.stack}
+                        
+ +
+ )} +
+ )} +
+ )} + +
+ + + +
+ +

+ If the problem persists, please contact support with the error details above. +

+
+
+ ); + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts index 5024854..72dfc34 100644 --- a/src/modules/assistant/components/index.ts +++ b/src/modules/assistant/components/index.ts @@ -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'; diff --git a/src/services/chat.service.ts b/src/services/chat.service.ts index 878d4b5..1aae938 100644 --- a/src/services/chat.service.ts +++ b/src/services/chat.service.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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; }, }; diff --git a/src/services/education.service.ts b/src/services/education.service.ts index 662f372..5126862 100644 --- a/src/services/education.service.ts +++ b/src/services/education.service.ts @@ -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 diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index 4a548f2..4f6450e 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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; } diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 8bf1064..e0682bb 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -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 diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts index 06c443f..c6c766c 100644 --- a/src/services/payment.service.ts +++ b/src/services/payment.service.ts @@ -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 diff --git a/src/services/trading.service.ts b/src/services/trading.service.ts index 884934c..239a70d 100644 --- a/src/services/trading.service.ts +++ b/src/services/trading.service.ts @@ -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