diff --git a/web/Dockerfile b/web/Dockerfile index 38bd4ff0..61f8cd3d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -4,6 +4,8 @@ COPY package*.json ./ RUN npm ci COPY . . ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij +ARG NEXT_PUBLIC_BACKEND_URL +ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL RUN npm run build FROM node:22-alpine diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js index 8bfe0bb8..a4cd4127 100644 --- a/web/app/ai-chat/page.js +++ b/web/app/ai-chat/page.js @@ -4,9 +4,9 @@ import dynamic from "next/dynamic"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +import { createStompClient } from "@/lib/chatSocket"; const API_BASE = ""; -const POLL_INTERVAL = 2500; function AiChatPage() { const { user, token, loading: authLoading } = useAuth(); @@ -27,7 +27,7 @@ function AiChatPage() { const messagesEndRef = useRef(null); const messagesAreaRef = useRef(null); const inputRef = useRef(null); - const pollRef = useRef(null); + const stompRef = useRef(null); const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); @@ -100,37 +100,30 @@ function AiChatPage() { } }, [token]); - const startPolling = useCallback((convId) => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = setInterval(async () => { - if (!token || !convId) return; - try { - const [msgsRes, convRes] = await Promise.all([ - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { - headers: { Authorization: `Bearer ${token}` }, - }), - ]); - if (msgsRes.ok) { - const data = await msgsRes.json(); - if (Array.isArray(data)) { - const lastId = data.length > 0 ? data[data.length - 1].id : null; - if (lastId !== lastMessageIdRef.current) { - lastMessageIdRef.current = lastId; - setMessages(data); - } - } - } - if (convRes.ok) { - const convData = await convRes.json(); - setConversation(convData); - } - } catch { - // silent - } - }, POLL_INTERVAL); + const connectStomp = useCallback((convId) => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } + const client = createStompClient(token); + client.onConnect = () => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; + } catch { /* silent */ } + }); + client.subscribe(`/topic/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setConversation(conv); + setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c)); + } catch { /* silent */ } + }); + }; + stompRef.current = client; + client.activate(); }, [token]); useEffect(() => { @@ -191,16 +184,16 @@ function AiChatPage() { fetchConversations(), ]); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } init(); return () => { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; - }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations, router]); + }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]); async function handleSend(e) { e?.preventDefault(); @@ -313,7 +306,7 @@ function AiChatPage() { } async function handleNewConversation() { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setError(null); setLoadingConv(true); try { @@ -335,7 +328,7 @@ function AiChatPage() { setConversation(conv); await Promise.all([fetchMessages(conv.id), fetchConversations()]); setLoadingConv(false); - startPolling(conv.id); + connectStomp(conv.id); router.replace(`/ai-chat?id=${conv.id}`, { scroll: false }); } catch { setError("Network error. Please try again."); @@ -344,14 +337,14 @@ function AiChatPage() { } async function switchConversation(convId) { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); setLoadingConv(true); await fetchConversation(convId); await fetchMessages(convId); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } diff --git a/web/app/chat/page.js b/web/app/chat/page.js index 7feeb5dd..cc3fb717 100644 --- a/web/app/chat/page.js +++ b/web/app/chat/page.js @@ -4,9 +4,9 @@ import dynamic from "next/dynamic"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +import { createStompClient } from "@/lib/chatSocket"; const API_BASE = ""; -const POLL_INTERVAL = 2500; function ChatPage() { const { user, token, loading: authLoading } = useAuth(); @@ -27,7 +27,7 @@ function ChatPage() { const messagesEndRef = useRef(null); const messagesAreaRef = useRef(null); const inputRef = useRef(null); - const pollRef = useRef(null); + const stompRef = useRef(null); const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); @@ -114,39 +114,30 @@ function ChatPage() { } }, [token]); - const startPolling = useCallback((convId) => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = setInterval(async () => { - if (!token || !convId) return; - try { - const [msgsRes, convRes] = await Promise.all([ - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { - headers: { Authorization: `Bearer ${token}` }, - }), - ]); - if (msgsRes.ok) { - const data = await msgsRes.json(); - if (Array.isArray(data)) { - const lastId = data.length > 0 ? data[data.length - 1].id : null; - if (lastId !== lastMessageIdRef.current) { - lastMessageIdRef.current = lastId; - setMessages(data); - } - } - } - if (convRes.ok) { - const convData = await convRes.json(); - setConversation(convData); - } - } - - catch { - //Silent - } - }, POLL_INTERVAL); + const connectStomp = useCallback((convId) => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } + const client = createStompClient(token); + client.onConnect = () => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; + } catch { /* silent */ } + }); + client.subscribe(`/topic/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setConversation(conv); + setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c)); + } catch { /* silent */ } + }); + }; + stompRef.current = client; + client.activate(); }, [token]); useEffect(() => { @@ -188,15 +179,15 @@ function ChatPage() { fetchConversations(), ]); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); } init(); return () => { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; - }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]); + }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]); async function handleSend(e) { e?.preventDefault(); @@ -340,7 +331,7 @@ function ChatPage() { setConversation(conv); await Promise.all([fetchMessages(conv.id), fetchConversations()]); setLoadingConv(false); - startPolling(conv.id); + connectStomp(conv.id); router.replace(`/chat?id=${conv.id}`, { scroll: false }); } catch { setError("Network error. Please try again."); @@ -349,14 +340,14 @@ function ChatPage() { } async function switchConversation(convId) { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); setLoadingConv(true); await fetchConversation(convId); await fetchMessages(convId); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/chat?id=${convId}`, { scroll: false }); } diff --git a/web/context/ChatWidgetContext.js b/web/context/ChatWidgetContext.js index 82943654..5eb72f45 100644 --- a/web/context/ChatWidgetContext.js +++ b/web/context/ChatWidgetContext.js @@ -1,6 +1,7 @@ "use client"; import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"; +import { createStompClient } from "@/lib/chatSocket"; const ChatWidgetContext = createContext(null); const API_BASE = ""; @@ -60,24 +61,30 @@ export function ChatWidgetProvider({ children }) { const [liveSending, setLiveSending] = useState(false); const [switchingToHuman, setSwitchingToHuman] = useState(false); - const pollRef = useRef(null); + const stompRef = useRef(null); const activeConvIdRef = useRef(null); - const tokenRef = useRef(null); // FIX: store token so polling can restart + const tokenRef = useRef(null); - const stopPolling = useCallback(() => { - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + const disconnectStomp = useCallback(() => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } }, []); - const fetchLiveMessages = useCallback(async (convId, token) => { - if (!convId || !token) return; - try { - const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) return; - const data = await res.json(); - if (Array.isArray(data)) setLiveMessages(data); - } catch { /* silent */ } + const subscribeToConversation = useCallback((client, convId) => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + } catch { /* silent */ } + }); + client.subscribe(`/user/queue/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setActiveConv(conv); + } catch { /* silent */ } + }); }, []); const loadConversations = useCallback(async (token) => { @@ -97,21 +104,35 @@ export function ChatWidgetProvider({ children }) { const openLiveConversation = useCallback(async (convId, token) => { if (!convId || !token) return; - stopPolling(); - tokenRef.current = token; // FIX: save token for polling restart + disconnectStomp(); + tokenRef.current = token; setActiveConvId(convId); activeConvIdRef.current = convId; setLiveMessages([]); setView("live"); + try { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) setActiveConv(await res.json()); } catch { /* silent */ } - await fetchLiveMessages(convId, token); - pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500); - }, [stopPolling, fetchLiveMessages]); + + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) setLiveMessages(data); + } + } catch { /* silent */ } + + const client = createStompClient(token); + client.onConnect = () => subscribeToConversation(client, convId); + stompRef.current = client; + client.activate(); + }, [disconnectStomp, subscribeToConversation]); const sendLiveMessage = useCallback(async (text, token, convId) => { if (!text.trim() || liveSending || !token || !convId) return; @@ -122,11 +143,14 @@ export function ChatWidgetProvider({ children }) { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: text }), }); - if (res.ok) await fetchLiveMessages(convId, token); + if (res.ok) { + const msg = await res.json(); + setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + } } catch { /* silent */ } finally { setLiveSending(false); } - }, [liveSending, fetchLiveMessages]); + }, [liveSending]); const startLiveChat = useCallback(async (token) => { if (!token || switchingToHuman) return; @@ -149,19 +173,22 @@ export function ChatWidgetProvider({ children }) { } }, [switchingToHuman, openLiveConversation]); - // FIX: Single effect that handles both stopping AND restarting polling useEffect(() => { if (!isOpen || view !== "live") { - stopPolling(); - } else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current) { - stopPolling(); - fetchLiveMessages(activeConvIdRef.current, tokenRef.current); - pollRef.current = setInterval( - () => fetchLiveMessages(activeConvIdRef.current, tokenRef.current), - 2500 - ); + disconnectStomp(); + } else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) { + const convId = activeConvIdRef.current; + const token = tokenRef.current; + const client = createStompClient(token); + client.onConnect = () => subscribeToConversation(client, convId); + stompRef.current = client; + client.activate(); } - }, [isOpen, view, stopPolling, fetchLiveMessages]); + }, [isOpen, view, disconnectStomp, subscribeToConversation]); + + useEffect(() => { + return () => disconnectStomp(); + }, [disconnectStomp]); const toggleOpen = useCallback(() => setIsOpen((o) => !o), []); const openView = useCallback((v) => setView(v), []); @@ -185,4 +212,4 @@ export function useChatWidget() { const ctx = useContext(ChatWidgetContext); if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider"); return ctx; -} \ No newline at end of file +} diff --git a/web/lib/chatSocket.js b/web/lib/chatSocket.js new file mode 100644 index 00000000..72d8a5d0 --- /dev/null +++ b/web/lib/chatSocket.js @@ -0,0 +1,16 @@ +import { Client } from "@stomp/stompjs"; + +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + +export function createStompClient(token) { + return new Client({ + webSocketFactory: () => { + const SockJS = require("sockjs-client"); + return new SockJS(`${BACKEND_URL}/ws/chat-sockjs`); + }, + connectHeaders: { Authorization: `Bearer ${token}` }, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + }); +} diff --git a/web/package-lock.json b/web/package-lock.json index 411fd1f9..b9b378cb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,11 +8,13 @@ "name": "threaded-pets", "version": "0.1.0", "dependencies": { + "@stomp/stompjs": "^7.3.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "next": "^16.2.2", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1232,6 +1234,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@stripe/react-stripe-js": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", @@ -3474,6 +3482,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3535,6 +3552,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3920,6 +3949,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3957,6 +3992,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4932,7 +4973,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5402,6 +5442,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5494,6 +5540,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5590,6 +5642,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5847,6 +5919,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6414,6 +6514,39 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 29d0960e..43f69b4e 100644 --- a/web/package.json +++ b/web/package.json @@ -9,11 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@stomp/stompjs": "^7.3.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "next": "^16.2.2", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@tailwindcss/postcss": "^4",