import { useEffect, useRef, useCallback } from 'react'; import { useStore } from '@/store/useStore'; import { WS_URL } from '@/lib/api'; import type { QDScoreUpdate, VaultOpenedEvent } from '@/types'; const SENTINEL_WS_ROOT = `${WS_URL}/api/sentinel/ws`; type WsEventType = | 'WS_ASSET_OPENED' | 'QD_UPDATED' | 'LEAD_TAGGED' | 'system' | 'ack'; interface WsMessage { type: WsEventType; data?: Record; timestamp?: string; } interface UseVelocitySocketOptions { channel?: 'notifications' | 'perception'; onConnect?: () => void; onDisconnect?: () => void; onMessage?: (msg: WsMessage) => void; } export function useVelocitySocket(options: UseVelocitySocketOptions = {}) { const { channel = 'notifications', onConnect, onDisconnect, onMessage } = options; const { addNotification } = useStore(); const wsRef = useRef(null); const retryCountRef = useRef(0); const retryTimerRef = useRef | null>(null); const pendingBufferRef = useRef([]); const isMountedRef = useRef(true); const handleMessage = useCallback( (event: MessageEvent) => { let msg: WsMessage; try { msg = JSON.parse(event.data as string) as WsMessage; } catch { return; } onMessage?.(msg); switch (msg.type) { case 'WS_ASSET_OPENED': { const d = msg.data as Partial; addNotification({ type: 'velocity_link_opened', title: 'Velocity Link Opened', body: `${d.lead_name ?? 'A prospect'} just opened ${d.asset_name ?? 'your asset'}.`, leadId: d.lead_id, }); break; } case 'QD_UPDATED': { const d = msg.data as Partial; if ((d.qd_score ?? 0) >= 75) { addNotification({ type: 'qd_spike', title: 'QD Score Spike', body: `QD Score jumped to ${d.qd_score}. ${d.reasoning ?? ''}`.trim(), leadId: d.lead_id, qdScore: d.qd_score, }); } break; } case 'LEAD_TAGGED': { const d = msg.data as { lead_id?: string; lead_name?: string; tags?: string[] }; if (d.tags?.length) { addNotification({ type: 'lead_tagged', title: 'Lead Tagged', body: `${d.lead_name ?? 'Lead'} tagged as ${d.tags.join(', ')}.`, leadId: d.lead_id, tags: d.tags, }); } break; } default: break; } }, [addNotification, onMessage], ); const connect = useCallback(() => { if (!isMountedRef.current) return; const url = `${SENTINEL_WS_ROOT}/${channel}`; const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => { retryCountRef.current = 0; pendingBufferRef.current.forEach((msg) => ws.send(msg)); pendingBufferRef.current = []; onConnect?.(); }; ws.onmessage = handleMessage; ws.onclose = () => { onDisconnect?.(); if (!isMountedRef.current) return; if (retryCountRef.current >= 5) return; const delay = Math.min(1000 * 2 ** retryCountRef.current, 30_000); retryCountRef.current += 1; retryTimerRef.current = setTimeout(connect, delay); }; ws.onerror = () => ws.close(); }, [channel, handleMessage, onConnect, onDisconnect]); useEffect(() => { isMountedRef.current = true; connect(); return () => { isMountedRef.current = false; if (retryTimerRef.current) clearTimeout(retryTimerRef.current); wsRef.current?.close(); }; }, [connect]); const sendPacket = useCallback((payload: unknown) => { const str = JSON.stringify(payload); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(str); } else { pendingBufferRef.current.push(str); if (pendingBufferRef.current.length > 100) { pendingBufferRef.current.shift(); } } }, []); return { sendPacket }; }