diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 567243b..7ad8857 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,48 +1,8 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; -import { useAuthStore } from '../store/auth'; +import { useEffect, useRef } from 'react'; +import { useWebSocketContext, WSMessage } from '../providers/WebSocketProvider'; -const isDev = import.meta.env.DEV; - -export interface WSMessage { - type: string; - // Ticket events - ticket_id?: number; - title?: string; - // Common - message?: string; - user_id?: number; - is_admin?: boolean; - // Balance events - amount_kopeks?: number; - amount_rubles?: number; - new_balance_kopeks?: number; - new_balance_rubles?: number; - description?: string; - // Subscription events - expires_at?: string; - new_expires_at?: string; - tariff_name?: string; - days_left?: number; - // Device purchase events - devices_added?: number; - new_device_limit?: number; - // Traffic purchase events - traffic_gb_added?: number; - new_traffic_limit_gb?: number; - // Autopay events - required_kopeks?: number; - required_rubles?: number; - balance_kopeks?: number; - balance_rubles?: number; - reason?: string; - // Account events (ban/warning) - // Referral events - bonus_kopeks?: number; - bonus_rubles?: number; - referral_name?: string; - // Payment events - payment_method?: string; -} +// Re-export WSMessage type for convenience +export type { WSMessage }; interface UseWebSocketOptions { onMessage?: (message: WSMessage) => void; @@ -51,141 +11,39 @@ interface UseWebSocketOptions { } export function useWebSocket(options: UseWebSocketOptions = {}) { - const { accessToken, isAuthenticated } = useAuthStore(); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef | null>(null); - const pingIntervalRef = useRef | null>(null); - const [isConnected, setIsConnected] = useState(false); - const reconnectAttemptsRef = useRef(0); - const maxReconnectAttempts = 5; + const { isConnected, subscribe } = useWebSocketContext(); const optionsRef = useRef(options); + const wasConnectedRef = useRef(false); // Update options ref when they change useEffect(() => { optionsRef.current = options; }, [options]); - const cleanup = useCallback(() => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }, []); - - const connect = useCallback(() => { - if (!accessToken || !isAuthenticated) { - return; - } - - // Don't reconnect if already connected - if (wsRef.current?.readyState === WebSocket.OPEN) { - return; - } - - cleanup(); - - // Build WebSocket URL - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - let host = window.location.host; - - // Handle VITE_API_URL - can be absolute URL or relative path - const apiUrl = import.meta.env.VITE_API_URL; - if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) { - try { - host = new URL(apiUrl).host; - } catch { - // If URL parsing fails, use window.location.host - } - } - // If apiUrl is relative (like /api), use window.location.host - - const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`; - - try { - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - if (isDev) console.log('[WS] Connected'); - setIsConnected(true); - reconnectAttemptsRef.current = 0; - optionsRef.current.onConnect?.(); - - // Setup ping interval (every 25 seconds) - pingIntervalRef.current = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })); - } - }, 25000); - }; - - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data) as WSMessage; - - // Ignore pong messages - if (message.type === 'pong' || message.type === 'connected') { - return; - } - - optionsRef.current.onMessage?.(message); - } catch (e) { - if (isDev) console.error('[WS] Failed to parse message:', e); - } - }; - - ws.onclose = (event) => { - if (isDev) console.log('[WS] Disconnected:', event.code, event.reason); - setIsConnected(false); - optionsRef.current.onDisconnect?.(); - - if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } - - // Attempt to reconnect if not closed intentionally - if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { - const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); - if (isDev) - console.log( - `[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`, - ); - - reconnectTimeoutRef.current = setTimeout(() => { - reconnectAttemptsRef.current++; - connect(); - }, delay); - } - }; - - ws.onerror = (error) => { - if (isDev) console.error('[WS] Error:', error); - }; - } catch (e) { - if (isDev) console.error('[WS] Failed to connect:', e); - } - }, [accessToken, isAuthenticated, cleanup]); - - // Connect when authenticated + // Handle connection state changes useEffect(() => { - if (isAuthenticated && accessToken) { - connect(); - } else { - cleanup(); - setIsConnected(false); + if (isConnected && !wasConnectedRef.current) { + wasConnectedRef.current = true; + optionsRef.current.onConnect?.(); + } else if (!isConnected && wasConnectedRef.current) { + wasConnectedRef.current = false; + optionsRef.current.onDisconnect?.(); + } + }, [isConnected]); + + // Subscribe to messages + useEffect(() => { + if (!optionsRef.current.onMessage) { + return; } - return cleanup; - }, [isAuthenticated, accessToken, connect, cleanup]); + const handler = (message: WSMessage) => { + optionsRef.current.onMessage?.(message); + }; + + const unsubscribe = subscribe(handler); + return unsubscribe; + }, [subscribe]); return { isConnected }; } diff --git a/src/main.tsx b/src/main.tsx index ada5801..6897c17 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; import { PlatformProvider } from './platform/PlatformProvider'; import { ThemeColorsProvider } from './providers/ThemeColorsProvider'; +import { WebSocketProvider } from './providers/WebSocketProvider'; import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { initLogoPreload } from './api/branding'; @@ -35,7 +36,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/providers/WebSocketProvider.tsx b/src/providers/WebSocketProvider.tsx new file mode 100644 index 0000000..2712d9b --- /dev/null +++ b/src/providers/WebSocketProvider.tsx @@ -0,0 +1,217 @@ +import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'; +import { useAuthStore } from '../store/auth'; + +const isDev = import.meta.env.DEV; + +export interface WSMessage { + type: string; + // Ticket events + ticket_id?: number; + title?: string; + // Common + message?: string; + user_id?: number; + is_admin?: boolean; + // Balance events + amount_kopeks?: number; + amount_rubles?: number; + new_balance_kopeks?: number; + new_balance_rubles?: number; + description?: string; + // Subscription events + expires_at?: string; + new_expires_at?: string; + tariff_name?: string; + days_left?: number; + // Device purchase events + devices_added?: number; + new_device_limit?: number; + // Traffic purchase events + traffic_gb_added?: number; + new_traffic_limit_gb?: number; + // Autopay events + required_kopeks?: number; + required_rubles?: number; + balance_kopeks?: number; + balance_rubles?: number; + reason?: string; + // Account events (ban/warning) + // Referral events + bonus_kopeks?: number; + bonus_rubles?: number; + referral_name?: string; + // Payment events + payment_method?: string; +} + +type MessageHandler = (message: WSMessage) => void; + +interface WebSocketContextValue { + isConnected: boolean; + subscribe: (handler: MessageHandler) => () => void; +} + +const WebSocketContext = createContext(null); + +export function WebSocketProvider({ children }: { children: React.ReactNode }) { + const { accessToken, isAuthenticated } = useAuthStore(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const pingIntervalRef = useRef | null>(null); + const [isConnected, setIsConnected] = useState(false); + const reconnectAttemptsRef = useRef(0); + const maxReconnectAttempts = 5; + + // Store message handlers + const handlersRef = useRef>(new Set()); + + const cleanup = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + const connect = useCallback(() => { + if (!accessToken || !isAuthenticated) { + return; + } + + // Don't reconnect if already connected + if (wsRef.current?.readyState === WebSocket.OPEN) { + return; + } + + cleanup(); + + // Build WebSocket URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + let host = window.location.host; + + // Handle VITE_API_URL - can be absolute URL or relative path + const apiUrl = import.meta.env.VITE_API_URL; + if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) { + try { + host = new URL(apiUrl).host; + } catch { + // If URL parsing fails, use window.location.host + } + } + + const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`; + + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (isDev) console.log('[WS] Connected'); + setIsConnected(true); + reconnectAttemptsRef.current = 0; + + // Setup ping interval (every 25 seconds) + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 25000); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WSMessage; + + // Ignore pong messages + if (message.type === 'pong' || message.type === 'connected') { + return; + } + + // Notify all subscribers + handlersRef.current.forEach((handler) => { + try { + handler(message); + } catch (e) { + if (isDev) console.error('[WS] Handler error:', e); + } + }); + } catch (e) { + if (isDev) console.error('[WS] Failed to parse message:', e); + } + }; + + ws.onclose = (event) => { + if (isDev) console.log('[WS] Disconnected:', event.code, event.reason); + setIsConnected(false); + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + + // Attempt to reconnect if not closed intentionally + if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); + if (isDev) + console.log( + `[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`, + ); + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++; + connect(); + }, delay); + } + }; + + ws.onerror = (error) => { + if (isDev) console.error('[WS] Error:', error); + }; + } catch (e) { + if (isDev) console.error('[WS] Failed to connect:', e); + } + }, [accessToken, isAuthenticated, cleanup]); + + // Connect when authenticated + useEffect(() => { + if (isAuthenticated && accessToken) { + connect(); + } else { + cleanup(); + setIsConnected(false); + } + + return cleanup; + }, [isAuthenticated, accessToken, connect, cleanup]); + + // Subscribe function for components + const subscribe = useCallback((handler: MessageHandler) => { + handlersRef.current.add(handler); + + // Return unsubscribe function + return () => { + handlersRef.current.delete(handler); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useWebSocketContext() { + const context = useContext(WebSocketContext); + if (!context) { + throw new Error('useWebSocketContext must be used within a WebSocketProvider'); + } + return context; +} diff --git a/vite.config.ts b/vite.config.ts index 502175a..cec7785 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,5 +28,36 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + 'vendor-react': ['react', 'react-dom', 'react-router-dom'], + 'vendor-query': ['@tanstack/react-query'], + 'vendor-i18n': ['i18next', 'react-i18next', 'i18next-browser-languagedetector'], + 'vendor-motion': ['framer-motion'], + 'vendor-radix': [ + '@radix-ui/react-dialog', + '@radix-ui/react-tooltip', + '@radix-ui/react-select', + '@radix-ui/react-popover', + '@radix-ui/react-dropdown-menu', + '@radix-ui/react-tabs', + '@radix-ui/react-switch', + '@radix-ui/react-alert-dialog', + ], + 'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'], + 'vendor-telegram': ['@tma.js/sdk-react'], + 'vendor-utils': [ + 'axios', + 'zustand', + 'clsx', + 'tailwind-merge', + 'class-variance-authority', + ], + 'vendor-lottie': ['@lottiefiles/dotlottie-react'], + 'vendor-webgl': ['ogl'], + }, + }, + }, }, });