diff --git a/src/App.tsx b/src/App.tsx index 47d2e20d8..0a06a9648 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,61 +1,19 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { store, persistor } from './store'; -import Welcome from './pages/Welcome'; -import Login from './pages/Login'; -import Onboarding from './pages/onboarding/Onboarding'; -import Home from './pages/Home'; -import PublicRoute from './components/PublicRoute'; -import ProtectedRoute from './components/ProtectedRoute'; -import DefaultRedirect from './components/DefaultRedirect'; +import SocketProvider from './providers/SocketProvider'; +import AppRoutes from './AppRoutes'; function App() { return ( - - - {/* Public routes - redirect to /home or /onboarding if logged in */} - - - - } - /> - - - - } - /> - - {/* Protected routes */} - - - - } - /> - - - - } - /> - - {/* Default redirect based on auth status */} - } /> - - + + + + + ); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx new file mode 100644 index 000000000..f6199d629 --- /dev/null +++ b/src/AppRoutes.tsx @@ -0,0 +1,55 @@ +import { Routes, Route } from 'react-router-dom'; +import Welcome from './pages/Welcome'; +import Login from './pages/Login'; +import Onboarding from './pages/onboarding/Onboarding'; +import Home from './pages/Home'; +import PublicRoute from './components/PublicRoute'; +import ProtectedRoute from './components/ProtectedRoute'; +import DefaultRedirect from './components/DefaultRedirect'; + +const AppRoutes = () => { + return ( + + {/* Public routes - redirect to /home or /onboarding if logged in */} + + + + } + /> + + + + } + /> + + {/* Protected routes */} + + + + } + /> + + + + } + /> + + {/* Default redirect based on auth status */} + } /> + + ); +}; + +export default AppRoutes; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index be234726c..cafbc333f 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -1,13 +1,12 @@ import { useEffect, useRef } from 'react'; import { socketService } from '../services/socketService'; +import { useAppSelector } from '../store/hooks'; import type { Socket } from 'socket.io-client'; -interface UseSocketOptions { - autoConnect?: boolean; -} - /** * React hook for using the Socket.IO connection + * Note: The socket connection is managed by SocketProvider based on JWT token. + * This hook provides access to the socket instance and methods. * * @example * ```tsx @@ -24,15 +23,11 @@ interface UseSocketOptions { * }, [on, off]); * ``` */ -export const useSocket = (options: UseSocketOptions = {}) => { - const { autoConnect = true } = options; +export const useSocket = () => { const listenersRef = useRef void }>>([]); + const socketStatus = useAppSelector((state) => state.socket.status); useEffect(() => { - if (autoConnect) { - socketService.connect(); - } - return () => { // Cleanup: remove all listeners registered through this hook listenersRef.current.forEach(({ event, callback }) => { @@ -40,7 +35,7 @@ export const useSocket = (options: UseSocketOptions = {}) => { }); listenersRef.current = []; }; - }, [autoConnect]); + }, []); const emit = (event: string, data?: unknown) => { socketService.emit(event, data); @@ -68,12 +63,11 @@ export const useSocket = (options: UseSocketOptions = {}) => { return { socket: socketService.getSocket() as Socket | null, - isConnected: socketService.isConnected(), + isConnected: socketStatus === 'connected', + status: socketStatus, emit, on, off, once, - connect: () => socketService.connect(), - disconnect: () => socketService.disconnect(), }; }; diff --git a/src/providers/SocketProvider.tsx b/src/providers/SocketProvider.tsx new file mode 100644 index 000000000..d8844cc62 --- /dev/null +++ b/src/providers/SocketProvider.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { useAppSelector } from '../store/hooks'; +import { socketService } from '../services/socketService'; + +/** + * SocketProvider manages the socket connection based on JWT token + * - Connects when token is set + * - Disconnects when token is unset + */ +const SocketProvider = ({ children }: { children: React.ReactNode }) => { + const token = useAppSelector((state) => state.auth.token); + const previousTokenRef = useRef(null); + + useEffect(() => { + const previousToken = previousTokenRef.current; + + // Token was set - connect + if (token && token !== previousToken) { + socketService.connect(token); + previousTokenRef.current = token; + } + + // Token was unset - disconnect + if (!token && previousToken) { + socketService.disconnect(); + previousTokenRef.current = null; + } + }, [token]); + + // Cleanup on unmount + useEffect(() => { + return () => { + socketService.disconnect(); + }; + }, []); + + return <>{children}; +}; + +export default SocketProvider; diff --git a/src/services/socketService.ts b/src/services/socketService.ts new file mode 100644 index 000000000..730263284 --- /dev/null +++ b/src/services/socketService.ts @@ -0,0 +1,132 @@ +import { io, Socket } from 'socket.io-client'; +import { BACKEND_URL } from '../utils/config'; +import { store } from '../store'; +import { setStatus, setSocketId, reset } from '../store/socketSlice'; + +class SocketService { + private socket: Socket | null = null; + private token: string | null = null; + + /** + * Connect to the socket server with authentication + */ + connect(token: string): void { + // Don't connect if already connected with the same token + if (this.socket?.connected && this.token === token) { + return; + } + + // Disconnect existing connection if token changed + if (this.socket && this.token !== token) { + this.disconnect(); + } + + // Don't connect if socket exists and is not disconnected (already connecting or connected) + if (this.socket && !this.socket.disconnected) { + return; + } + + this.token = token; + + // Update status to connecting + store.dispatch(setStatus('connecting')); + + // Create socket connection with auth token + this.socket = io(BACKEND_URL, { + auth: { + token, + }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 5, + }); + + // Connection event handlers + this.socket.on('connect', () => { + const socketId = this.socket?.id || null; + store.dispatch(setStatus('connected')); + store.dispatch(setSocketId(socketId)); + }); + + this.socket.on('disconnect', () => { + store.dispatch(setStatus('disconnected')); + store.dispatch(setSocketId(null)); + }); + + this.socket.on('connect_error', (error) => { + console.error('[Socket] Connection error:', error); + store.dispatch(setStatus('disconnected')); + }); + } + + /** + * Disconnect from the socket server + */ + disconnect(): void { + if (this.socket) { + this.socket.disconnect(); + this.socket = null; + this.token = null; + store.dispatch(reset()); + } + } + + /** + * Get the current socket instance + */ + getSocket(): Socket | null { + return this.socket; + } + + /** + * Check if socket is connected + */ + isConnected(): boolean { + return this.socket?.connected || false; + } + + /** + * Emit an event to the server + */ + emit(event: string, data?: unknown): void { + if (this.socket?.connected) { + this.socket.emit(event, data); + } else { + console.warn(`[Socket] Cannot emit '${event}': socket not connected`); + } + } + + /** + * Listen to an event from the server + */ + on(event: string, callback: (...args: unknown[]) => void): void { + if (this.socket) { + this.socket.on(event, callback); + } + } + + /** + * Remove an event listener + */ + off(event: string, callback?: (...args: unknown[]) => void): void { + if (this.socket) { + if (callback) { + this.socket.off(event, callback); + } else { + this.socket.off(event); + } + } + } + + /** + * Listen to an event once + */ + once(event: string, callback: (...args: unknown[]) => void): void { + if (this.socket) { + this.socket.once(event, callback); + } + } +} + +export const socketService = new SocketService();