diff --git a/src/pages/BattlePage.jsx b/src/pages/BattlePage.jsx
index 244fc10..e726ca6 100644
--- a/src/pages/BattlePage.jsx
+++ b/src/pages/BattlePage.jsx
@@ -4,46 +4,60 @@ import { useUser } from '../context/UserContext';
import Header from './components/layout/Header';
import Footer from './components/layout/Footer';
import SettingsModal from './components/ui/SettingsModal';
+import QueueModal from './components/ui/QueueModal';
+import { useQueueSSE } from './useQueueSSE';
import './BattlePage.css';
-import '../api/axiosConfig.js'
if (typeof global === 'undefined') {
window.global = window;
}
export default function BattlePage() {
- const navigate = useNavigate();
- const { logout } = useUser();
- const [showSettings, setShowSettings] = useState(false);
-
-
- const handleLogout = () => {
- logout();
- navigate('/login');
- };
-
- return (
-
- );
+ const navigate = useNavigate();
+ const { logout } = useUser();
+ const [showSettings, setShowSettings] = useState(false);
+
+ const { isModalOpen, queueSize, waitSeconds, isConnecting, enterQueue, leaveQueue } =
+ useQueueSSE();
+
+ const handleLogout = () => {
+ logout();
+ navigate('/login');
+ };
+
+ return (
+
+ );
}
\ No newline at end of file
diff --git a/src/pages/BattlePage.test.jsx b/src/pages/BattlePage.test.jsx
index 9500a38..cde494e 100644
--- a/src/pages/BattlePage.test.jsx
+++ b/src/pages/BattlePage.test.jsx
@@ -39,14 +39,6 @@ describe('BattlePage', () => {
const renderComponent = () => render();
- it('кнопка "В БОЙ!" перенаправляет на /workspace', async () => {
- const user = userEvent.setup();
- renderComponent();
-
- await user.click(screen.getByRole('button', { name: 'В БОЙ!' }));
- expect(mockNavigate).toHaveBeenCalledWith('/workspace');
- });
-
it('нажатие на PVP в Header перенаправляет на /battle (остается на месте)', async () => {
const user = userEvent.setup();
renderComponent();
diff --git a/src/pages/components/ui/QueueModal.css b/src/pages/components/ui/QueueModal.css
new file mode 100644
index 0000000..e5d07db
--- /dev/null
+++ b/src/pages/components/ui/QueueModal.css
@@ -0,0 +1,82 @@
+.queue-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.queue-modal {
+ background: var(--bg-card, #1e1e2e);
+ border: 1px solid rgba(255, 82, 82, 0.3);
+ border-radius: 12px;
+ padding: 40px 48px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 28px;
+ min-width: 320px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
+}
+
+/* Спиннер */
+.queue-modal__spinner {
+ width: 56px;
+ height: 56px;
+ border: 4px solid rgba(255, 82, 82, 0.2);
+ border-top-color: #ff5252;
+ border-radius: 50%;
+ animation: queue-spin 0.9s linear infinite;
+}
+
+@keyframes queue-spin {
+ to { transform: rotate(360deg); }
+}
+
+.queue-modal__title {
+ font-family: var(--font-ui);
+ font-size: 1.4rem;
+ color: var(--text-primary);
+ margin: 0;
+ letter-spacing: 1px;
+}
+
+.queue-modal__stats {
+ display: flex;
+ gap: 40px;
+}
+
+.queue-modal__stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+
+.queue-modal__stat-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.queue-modal__stat-value {
+ font-size: 1.8rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+}
+
+.queue-modal__timer {
+ color: #ff5252;
+ font-variant-numeric: tabular-nums;
+}
+
+.queue-modal__leave {
+ margin-top: 4px;
+ padding: 10px 32px;
+ font-size: 0.95rem;
+}
\ No newline at end of file
diff --git a/src/pages/components/ui/QueueModal.jsx b/src/pages/components/ui/QueueModal.jsx
new file mode 100644
index 0000000..c35234e
--- /dev/null
+++ b/src/pages/components/ui/QueueModal.jsx
@@ -0,0 +1,38 @@
+import './QueueModal.css';
+
+const formatTime = (totalSeconds) => {
+ const m = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
+ const s = (totalSeconds % 60).toString().padStart(2, '0');
+ return `${m}:${s}`;
+};
+
+export default function QueueModal({ isOpen, queueSize, waitSeconds, onLeave }) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
Поиск соперника
+
+
+
+ В очереди
+ {queueSize}
+
+
+ Ожидание
+
+ {formatTime(waitSeconds)}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/useQueueSSE.js b/src/pages/useQueueSSE.js
new file mode 100644
index 0000000..39a3cd7
--- /dev/null
+++ b/src/pages/useQueueSSE.js
@@ -0,0 +1,117 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { api } from '../api/axiosConfig';
+
+const BASE_URL = 'http://localhost:8080';
+
+export const useQueueSSE = () => {
+ const navigate = useNavigate();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [queueSize, setQueueSize] = useState(0);
+ const [waitSeconds, setWaitSeconds] = useState(0);
+ const [isConnecting, setIsConnecting] = useState(false);
+
+ const esRef = useRef(null);
+ const timerRef = useRef(null);
+ const waitSecondsRef = useRef(0);
+
+ const startTimer = useCallback((initialSeconds = 0) => {
+ waitSecondsRef.current = initialSeconds;
+ setWaitSeconds(initialSeconds);
+ clearInterval(timerRef.current);
+ timerRef.current = setInterval(() => {
+ waitSecondsRef.current += 1;
+ setWaitSeconds(waitSecondsRef.current);
+ }, 1000);
+ }, []);
+
+ const stopTimer = useCallback(() => {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }, []);
+
+ const openSSE = useCallback(() => {
+ if (esRef.current) return;
+
+ const es = new EventSource(`${BASE_URL}/matchmaking/queue/stream`, {
+ withCredentials: true,
+ });
+
+ es.addEventListener('queue-size', (e) => {
+ setQueueSize(Number(e.data));
+ });
+
+ es.addEventListener('match-found', (e) => {
+ const sessionId = e.data;
+ stopTimer();
+ closeSSE();
+ setIsModalOpen(false);
+ navigate(`/match/${sessionId}/draft`);
+ });
+
+ es.onerror = () => {
+ };
+
+ esRef.current = es;
+ }, [navigate, stopTimer]);
+
+ const closeSSE = useCallback(() => {
+ if (esRef.current) {
+ esRef.current.close();
+ esRef.current = null;
+ }
+ }, []);
+
+ const enterQueue = useCallback(async () => {
+ setIsConnecting(true);
+ try {
+ const { data } = await api.get('/matchmaking/queue/status');
+
+ if (data.status === 'WAITING') {
+ setQueueSize(data.queueSize);
+ openSSE();
+ startTimer(data.waitingSeconds);
+ } else {
+ await api.post('/matchmaking/queue');
+ setQueueSize(data.queueSize + 1);
+ openSSE();
+ startTimer(0);
+ }
+ setIsModalOpen(true);
+ } catch (err) {
+ console.error('[Queue] enterQueue error:', err);
+ } finally {
+ setIsConnecting(false);
+ }
+ }, [openSSE, startTimer]);
+
+ const leaveQueue = useCallback(async () => {
+ try {
+ await api.delete('/matchmaking/queue');
+ } catch (err) {
+ console.error('[Queue] leaveQueue error:', err);
+ } finally {
+ stopTimer();
+ closeSSE();
+ setIsModalOpen(false);
+ setWaitSeconds(0);
+ }
+ }, [closeSSE, stopTimer]);
+
+ useEffect(() => {
+ return () => {
+ stopTimer();
+ closeSSE();
+ };
+ }, [stopTimer, closeSSE]);
+
+ return {
+ isModalOpen,
+ queueSize,
+ waitSeconds,
+ isConnecting,
+ enterQueue,
+ leaveQueue,
+ };
+};
\ No newline at end of file