From fbca83a31fd2c52520e1425e59a66fef5fb555cb Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 18 Jan 2026 22:47:20 +0300 Subject: [PATCH 1/8] Update CI workflow to improve linting and type checking steps, rename job, and adjust branch names --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a593c22..9870661 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,27 @@ -name: CI - on: push: - branches: [main, dev] + branches: [main, develop] pull_request: - branches: [main, dev] + branches: [main, develop] jobs: - lint-and-build: + lint-and-typecheck: runs-on: ubuntu-latest + steps: + @@ -24,39 +24,10 @@ jobs: + run: npm ci + + - name: Run ESLint + run: npm run lint -- --max-warnings 50 + + - name: Type check + run: npm run type-check + + build: + runs-on: ubuntu-latest + needs: lint-and-typecheck + steps: - name: Checkout code uses: actions/checkout@v4 @@ -23,11 +35,17 @@ jobs: - name: Install dependencies run: npm ci - - name: Run ESLint - run: npm run lint - - - name: Run TypeScript check - run: npx tsc --noEmit - - - name: Build + - name: Build application run: npm run build + env: + VITE_API_URL: /api + VITE_TELEGRAM_BOT_USERNAME: test_bot + VITE_APP_NAME: Cabinet + VITE_APP_LOGO: V + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 7 \ No newline at end of file From 7bd4336e621c8e2c7b8c7203adf6b67977165a11 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 18 Jan 2026 22:58:09 +0300 Subject: [PATCH 2/8] Implement return URL handling for authentication; save current URL before redirecting to login and retrieve it after successful login. --- src/App.tsx | 13 ++++++++++--- src/pages/Login.tsx | 32 +++++++++++++++++++++++++------- src/utils/token.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c99b572..48f2c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { saveReturnUrl } from './utils/token' import Login from './pages/Login' import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' @@ -36,13 +37,16 @@ import AdminRemnawave from './pages/AdminRemnawave' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } return {children} @@ -50,13 +54,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading, isAdmin } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } if (!isAdmin) { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fd75065..c6e48ca 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +import { useState, useEffect, useMemo, useCallback } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' import { brandingApi, type BrandingInfo } from '../api/branding' +import { getAndClearReturnUrl } from '../utils/token' import LanguageSwitcher from '../components/LanguageSwitcher' import TelegramLoginButton from '../components/TelegramLoginButton' @@ -46,6 +47,7 @@ const cacheBranding = (data: BrandingInfo) => { export default function Login() { const { t } = useTranslation() const navigate = useNavigate() + const location = useLocation() const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore() const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram') const [email, setEmail] = useState('') @@ -54,6 +56,22 @@ export default function Login() { const [isLoading, setIsLoading] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) + // Получаем URL для возврата после авторизации + const getReturnUrl = useCallback(() => { + // Сначала проверяем state от React Router + const stateFrom = (location.state as { from?: string })?.from + if (stateFrom && stateFrom !== '/login') { + return stateFrom + } + // Затем проверяем сохранённый URL в sessionStorage (от safeRedirectToLogin) + const savedUrl = getAndClearReturnUrl() + if (savedUrl && savedUrl !== '/login') { + return savedUrl + } + // По умолчанию на главную + return '/' + }, [location.state]) + // Fetch branding const cachedBranding = useMemo(() => getCachedBranding(), []) @@ -82,9 +100,9 @@ export default function Login() { useEffect(() => { if (isAuthenticated) { - navigate('/') + navigate(getReturnUrl(), { replace: true }) } - }, [isAuthenticated, navigate]) + }, [isAuthenticated, navigate, getReturnUrl]) // Try Telegram WebApp authentication on mount useEffect(() => { @@ -97,7 +115,7 @@ export default function Login() { setIsLoading(true) try { await loginWithTelegram(tg.initData) - navigate('/') + navigate(getReturnUrl(), { replace: true }) } catch (err) { console.error('Telegram auth failed:', err) setError(t('auth.telegramRequired')) @@ -108,7 +126,7 @@ export default function Login() { } tryTelegramAuth() - }, [loginWithTelegram, navigate, t]) + }, [loginWithTelegram, navigate, t, getReturnUrl]) const handleEmailLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -117,7 +135,7 @@ export default function Login() { try { await loginWithEmail(email, password) - navigate('/') + navigate(getReturnUrl(), { replace: true }) } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } setError(error.response?.data?.detail || t('common.error')) diff --git a/src/utils/token.ts b/src/utils/token.ts index 62f32dc..eb0d254 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -252,9 +252,40 @@ class TokenRefreshManager { export const tokenRefreshManager = new TokenRefreshManager() +/** + * Ключ для сохранения URL для возврата после логина + */ +const RETURN_URL_KEY = 'auth_return_url' + +/** + * Сохраняет URL для возврата после авторизации + */ +export function saveReturnUrl(): void { + if (typeof window !== 'undefined') { + const currentPath = window.location.pathname + window.location.search + // Не сохраняем /login как return URL + if (currentPath && currentPath !== '/login') { + sessionStorage.setItem(RETURN_URL_KEY, currentPath) + } + } +} + +/** + * Получает и очищает сохранённый URL для возврата + */ +export function getAndClearReturnUrl(): string | null { + if (typeof window !== 'undefined') { + const url = sessionStorage.getItem(RETURN_URL_KEY) + sessionStorage.removeItem(RETURN_URL_KEY) + return url + } + return null +} + /** * Безопасный редирект на страницу логина * Валидирует URL чтобы предотвратить open redirect уязвимость + * Сохраняет текущий URL для возврата после авторизации */ export function safeRedirectToLogin(): void { // Разрешённые пути для редиректа (относительные пути нашего приложения) @@ -262,6 +293,8 @@ export function safeRedirectToLogin(): void { // Проверяем, что мы на том же origin if (typeof window !== 'undefined') { + // Сохраняем текущий URL для возврата после логина + saveReturnUrl() // Используем только относительный путь для безопасности window.location.href = loginPath } From 6751f418596ca98871a178e47abdc2e56f14c9aa Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sun, 18 Jan 2026 23:03:38 +0300 Subject: [PATCH 3/8] Refactor authentication flow to check admin status before updating loading state --- src/store/auth.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/store/auth.ts b/src/store/auth.ts index 9799c21..a31b075 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -145,6 +145,8 @@ export const useAuthStore = create()( const newToken = await tokenRefreshManager.refreshAccessToken() if (newToken) { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken: newToken, refreshToken, @@ -152,7 +154,6 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } else { tokenStorage.clearTokens() set({ @@ -168,6 +169,8 @@ export const useAuthStore = create()( try { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken, refreshToken, @@ -175,13 +178,14 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } catch { // Token might be invalid on server, try to refresh const newToken = await tokenRefreshManager.refreshAccessToken() if (newToken) { try { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken: newToken, refreshToken, @@ -189,7 +193,6 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } catch { tokenStorage.clearTokens() set({ From feb1f0fe509fbc551d4ff2b36cb06b49cafb791e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:04:42 +0300 Subject: [PATCH 4/8] Refactor admin page layout for improved user experience by adjusting component structure and styling. --- src/api/ticketNotifications.ts | 62 +++++ src/components/TicketNotificationBell.tsx | 265 ++++++++++++++++++++++ src/components/Toast.tsx | 154 +++++++++++++ src/hooks/useWebSocket.ts | 144 ++++++++++++ 4 files changed, 625 insertions(+) create mode 100644 src/api/ticketNotifications.ts create mode 100644 src/components/TicketNotificationBell.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/hooks/useWebSocket.ts diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts new file mode 100644 index 0000000..3adf58f --- /dev/null +++ b/src/api/ticketNotifications.ts @@ -0,0 +1,62 @@ +import apiClient from './client' +import type { TicketNotificationList, UnreadCountResponse } from '../types' + +export const ticketNotificationsApi = { + // User notifications + getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') + return response.data + }, + + markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/tickets/notifications/read-all') + return response.data + }, + + markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, + + // Admin notifications + getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getAdminUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') + return response.data + }, + + markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') + return response.data + }, + + markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, +} + +export default ticketNotificationsApi diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx new file mode 100644 index 0000000..d7c0704 --- /dev/null +++ b/src/components/TicketNotificationBell.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { ticketNotificationsApi } from '../api/ticketNotifications' +import { useAuthStore } from '../store/auth' +import { useToast } from './Toast' +import { useWebSocket, WSMessage } from '../hooks/useWebSocket' +import type { TicketNotification } from '../types' + +const BellIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +interface TicketNotificationBellProps { + isAdmin?: boolean +} + +export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { isAuthenticated } = useAuthStore() + const { showToast } = useToast() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + // Show toast for WebSocket notification + const showWSNotificationToast = useCallback((message: WSMessage) => { + const icon = message.type === 'ticket.new' ? '🎫' : + message.type === 'ticket.admin_reply' ? '💬' : '📨' + + const toastMessage = message.message || + (message.type === 'ticket.new' + ? t('notifications.newTicket', 'New ticket: {{title}}', { title: message.title }) + : t('notifications.newReply', 'New reply in ticket')) + + showToast({ + type: 'info', + message: toastMessage, + icon: {icon}, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) + }, + duration: 8000, + }) + }, [showToast, navigate, isAdmin, t]) + + // Handle WebSocket message + const handleWSMessage = useCallback((message: WSMessage) => { + // Check if this notification is relevant for this user type + const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply' + const isUserNotification = message.type === 'ticket.admin_reply' + + if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { + // Show toast + showWSNotificationToast(message) + + // Invalidate queries to refresh count and list + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] + }) + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] + }) + } + }, [isAdmin, showWSNotificationToast, queryClient]) + + // WebSocket connection + useWebSocket({ + onMessage: handleWSMessage, + }) + + // Fetch unread count (with slower polling as fallback when WS disconnects) + const { data: unreadData } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount, + enabled: isAuthenticated, + refetchInterval: 60000, // Poll every 60 seconds as fallback + staleTime: 30000, + }) + + // Fetch notifications when dropdown is open + const { data: notificationsData, isLoading } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + queryFn: () => isAdmin + ? ticketNotificationsApi.getAdminNotifications(false, 10) + : ticketNotificationsApi.getNotifications(false, 10), + enabled: isAuthenticated && isOpen, + staleTime: 5000, + }) + + // Mark all as read mutation + const markAllReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Mark single as read mutation + const markReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleNotificationClick = (notification: TicketNotification) => { + if (!notification.is_read) { + markReadMutation.mutate(notification.id) + } + setIsOpen(false) + navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`) + } + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 1) return t('notifications.justNow', 'Just now') + if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }) + if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }) + return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }) + } + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'new_ticket': + return 🎫 + case 'admin_reply': + return 💬 + case 'user_reply': + return 📨 + default: + return 🔔 + } + } + + const unreadCount = unreadData?.unread_count || 0 + + return ( +
+ {/* Bell button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

+ {t('notifications.ticketNotifications', 'Ticket Notifications')} +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Notifications list */} +
+ {isLoading ? ( +
+
+
+ ) : notificationsData?.items && notificationsData.items.length > 0 ? ( + notificationsData.items.map((notification) => ( + + )) + ) : ( +
+ +

{t('notifications.noNotifications', 'No notifications')}

+
+ )} +
+ + {/* Footer */} + {notificationsData?.items && notificationsData.items.length > 0 && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..fa0d3fd --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,154 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' + +interface Toast { + id: string + message: string + type: 'info' | 'success' | 'warning' | 'error' + icon?: ReactNode + onClick?: () => void + duration?: number +} + +interface ToastContextType { + showToast: (toast: Omit) => void + hideToast: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).substring(2, 9) + const newToast = { ...toast, id } + + setToasts(prev => [...prev, newToast]) + + // Auto remove after duration (default 6 seconds) + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, toast.duration || 6000) + }, []) + + const hideToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + return ( + + {children} + + + ) +} + +function ToastContainer({ toasts, onClose }: { toasts: Toast[], onClose: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ) +} + +function ToastItem({ toast, onClose }: { toast: Toast, onClose: (id: string) => void }) { + const getBgColor = () => { + switch (toast.type) { + case 'success': return 'bg-success-500/95' + case 'warning': return 'bg-warning-500/95' + case 'error': return 'bg-error-500/95' + default: return 'bg-accent-500/95' + } + } + + const handleClick = () => { + if (toast.onClick) { + toast.onClick() + onClose(toast.id) + } + } + + return ( +
+ {toast.icon && ( +
+ {toast.icon} +
+ )} +
+

{toast.message}

+ {toast.onClick && ( +

Click to view

+ )} +
+ +
+ ) +} + +// Hook for ticket notification toasts +export function useTicketToast() { + const { showToast } = useToast() + const navigate = useNavigate() + + const showNewReplyToast = useCallback((ticketId: number, message: string, isAdmin: boolean) => { + showToast({ + type: 'info', + message: message || `New reply in ticket #${ticketId}`, + icon: 💬, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${ticketId}` : `/support?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + const showNewTicketToast = useCallback((ticketId: number, title: string) => { + showToast({ + type: 'info', + message: `New ticket: ${title}`, + icon: 🎫, + onClick: () => { + navigate(`/admin/tickets?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + return { showNewReplyToast, showNewTicketToast } +} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..1630a63 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useAuthStore } from '../store/auth' + +export interface WSMessage { + type: string + ticket_id?: number + message?: string + title?: string + user_id?: number + is_admin?: boolean +} + +interface UseWebSocketOptions { + onMessage?: (message: WSMessage) => void + onConnect?: () => void + onDisconnect?: () => void +} + +export function useWebSocket(options: UseWebSocketOptions = {}) { + const { accessToken, isAuthenticated } = useAuthStore() + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const pingIntervalRef = useRef(null) + const [isConnected, setIsConnected] = useState(false) + const reconnectAttemptsRef = useRef(0) + const maxReconnectAttempts = 5 + const optionsRef = useRef(options) + + // 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:' + const host = import.meta.env.VITE_API_URL + ? new URL(import.meta.env.VITE_API_URL).host + : window.location.host + const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + 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) { + console.error('[WS] Failed to parse message:', e) + } + } + + ws.onclose = (event) => { + 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) + console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++ + connect() + }, delay) + } + } + + ws.onerror = (error) => { + console.error('[WS] Error:', error) + } + } catch (e) { + 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]) + + return { isConnected } +} From 6da090d79f521faf3f7b722e5a6e23af4ed12ead Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:08:38 +0300 Subject: [PATCH 5/8] Refactor useWebSocket hook to use ReturnType for timeout and interval references; add TicketNotification types for handling ticket notifications. --- src/hooks/useWebSocket.ts | 4 ++-- src/types/index.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 1630a63..88c9887 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -19,8 +19,8 @@ interface UseWebSocketOptions { export function useWebSocket(options: UseWebSocketOptions = {}) { const { accessToken, isAuthenticated } = useAuthStore() const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const pingIntervalRef = useRef(null) + const reconnectTimeoutRef = useRef | null>(null) + const pingIntervalRef = useRef | null>(null) const [isConnected, setIsConnected] = useState(false) const reconnectAttemptsRef = useRef(0) const maxReconnectAttempts = 5 diff --git a/src/types/index.ts b/src/types/index.ts index 64cb61f..d040af7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -516,3 +516,22 @@ export interface ManualCheckResponse { old_status: string | null new_status: string | null } + +// Ticket notification types +export interface TicketNotification { + id: number + ticket_id: number + notification_type: string + message: string + is_read: boolean + created_at: string +} + +export interface TicketNotificationList { + items: TicketNotification[] + total: number +} + +export interface UnreadCountResponse { + unread_count: number +} From 75499cc476e1e4d263556175fd1ea69f64e676ad Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:12:50 +0300 Subject: [PATCH 6/8] Add TicketNotificationBell component to layout for admin notifications --- src/components/layout/Layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index c1d2d98..ad725d1 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/auth' import LanguageSwitcher from '../LanguageSwitcher' import PromoDiscountBadge from '../PromoDiscountBadge' +import TicketNotificationBell from '../TicketNotificationBell' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -337,6 +338,7 @@ export default function Layout({ children }: LayoutProps) { )} + {/* Profile - Desktop */} From 429ab401976ec4a27ca35ba737b4e1bb764db0c7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:17:16 +0300 Subject: [PATCH 7/8] Update CI workflow for Node.js setup and wrap App component with ToastProvider for notifications --- .github/workflows/ci.yml | 11 ++++++++++- src/main.tsx | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29de04a..5c6ba58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,16 @@ jobs: runs-on: ubuntu-latest steps: - @@ -24,39 +24,10 @@ jobs: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies run: npm ci - name: Run ESLint diff --git a/src/main.tsx b/src/main.tsx index 53f0e75..6297c78 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import { ThemeColorsProvider } from './providers/ThemeColorsProvider' +import { ToastProvider } from './components/Toast' import './i18n' import './styles/globals.css' @@ -21,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + From 35ee63b351f4e26e3d30d5f40445bbff2b4df7e5 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:24:13 +0300 Subject: [PATCH 8/8] Add ticket notification system with user and admin notification settings; integrate ToastProvider in main app layout --- src/api/admin.ts | 4 + src/api/ticketNotifications.ts | 62 +++++ src/components/TicketNotificationBell.tsx | 265 ++++++++++++++++++++++ src/components/Toast.tsx | 154 +++++++++++++ src/components/layout/Layout.tsx | 2 + src/hooks/useWebSocket.ts | 144 ++++++++++++ src/locales/en.json | 21 +- src/locales/ru.json | 21 +- src/main.tsx | 5 +- src/pages/AdminTickets.tsx | 41 ++++ src/types/index.ts | 31 +++ 11 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 src/api/ticketNotifications.ts create mode 100644 src/components/TicketNotificationBell.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/hooks/useWebSocket.ts diff --git a/src/api/admin.ts b/src/api/admin.ts index acc3d91..cc935f6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -59,6 +59,8 @@ export interface TicketSettings { sla_check_interval_seconds: number sla_reminder_cooldown_minutes: number support_system_mode: string // tickets, contact, both + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean } export interface TicketSettingsUpdate { @@ -67,6 +69,8 @@ export interface TicketSettingsUpdate { sla_check_interval_seconds?: number sla_reminder_cooldown_minutes?: number support_system_mode?: string + cabinet_user_notifications_enabled?: boolean + cabinet_admin_notifications_enabled?: boolean } export interface AdminTicketListResponse { diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts new file mode 100644 index 0000000..3adf58f --- /dev/null +++ b/src/api/ticketNotifications.ts @@ -0,0 +1,62 @@ +import apiClient from './client' +import type { TicketNotificationList, UnreadCountResponse } from '../types' + +export const ticketNotificationsApi = { + // User notifications + getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') + return response.data + }, + + markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/tickets/notifications/read-all') + return response.data + }, + + markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, + + // Admin notifications + getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getAdminUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') + return response.data + }, + + markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') + return response.data + }, + + markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, +} + +export default ticketNotificationsApi diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx new file mode 100644 index 0000000..d7c0704 --- /dev/null +++ b/src/components/TicketNotificationBell.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { ticketNotificationsApi } from '../api/ticketNotifications' +import { useAuthStore } from '../store/auth' +import { useToast } from './Toast' +import { useWebSocket, WSMessage } from '../hooks/useWebSocket' +import type { TicketNotification } from '../types' + +const BellIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +interface TicketNotificationBellProps { + isAdmin?: boolean +} + +export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { isAuthenticated } = useAuthStore() + const { showToast } = useToast() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + // Show toast for WebSocket notification + const showWSNotificationToast = useCallback((message: WSMessage) => { + const icon = message.type === 'ticket.new' ? '🎫' : + message.type === 'ticket.admin_reply' ? '💬' : '📨' + + const toastMessage = message.message || + (message.type === 'ticket.new' + ? t('notifications.newTicket', 'New ticket: {{title}}', { title: message.title }) + : t('notifications.newReply', 'New reply in ticket')) + + showToast({ + type: 'info', + message: toastMessage, + icon: {icon}, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) + }, + duration: 8000, + }) + }, [showToast, navigate, isAdmin, t]) + + // Handle WebSocket message + const handleWSMessage = useCallback((message: WSMessage) => { + // Check if this notification is relevant for this user type + const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply' + const isUserNotification = message.type === 'ticket.admin_reply' + + if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { + // Show toast + showWSNotificationToast(message) + + // Invalidate queries to refresh count and list + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] + }) + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] + }) + } + }, [isAdmin, showWSNotificationToast, queryClient]) + + // WebSocket connection + useWebSocket({ + onMessage: handleWSMessage, + }) + + // Fetch unread count (with slower polling as fallback when WS disconnects) + const { data: unreadData } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount, + enabled: isAuthenticated, + refetchInterval: 60000, // Poll every 60 seconds as fallback + staleTime: 30000, + }) + + // Fetch notifications when dropdown is open + const { data: notificationsData, isLoading } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + queryFn: () => isAdmin + ? ticketNotificationsApi.getAdminNotifications(false, 10) + : ticketNotificationsApi.getNotifications(false, 10), + enabled: isAuthenticated && isOpen, + staleTime: 5000, + }) + + // Mark all as read mutation + const markAllReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Mark single as read mutation + const markReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleNotificationClick = (notification: TicketNotification) => { + if (!notification.is_read) { + markReadMutation.mutate(notification.id) + } + setIsOpen(false) + navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`) + } + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 1) return t('notifications.justNow', 'Just now') + if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }) + if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }) + return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }) + } + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'new_ticket': + return 🎫 + case 'admin_reply': + return 💬 + case 'user_reply': + return 📨 + default: + return 🔔 + } + } + + const unreadCount = unreadData?.unread_count || 0 + + return ( +
+ {/* Bell button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

+ {t('notifications.ticketNotifications', 'Ticket Notifications')} +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Notifications list */} +
+ {isLoading ? ( +
+
+
+ ) : notificationsData?.items && notificationsData.items.length > 0 ? ( + notificationsData.items.map((notification) => ( + + )) + ) : ( +
+ +

{t('notifications.noNotifications', 'No notifications')}

+
+ )} +
+ + {/* Footer */} + {notificationsData?.items && notificationsData.items.length > 0 && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..fa0d3fd --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,154 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' + +interface Toast { + id: string + message: string + type: 'info' | 'success' | 'warning' | 'error' + icon?: ReactNode + onClick?: () => void + duration?: number +} + +interface ToastContextType { + showToast: (toast: Omit) => void + hideToast: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).substring(2, 9) + const newToast = { ...toast, id } + + setToasts(prev => [...prev, newToast]) + + // Auto remove after duration (default 6 seconds) + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, toast.duration || 6000) + }, []) + + const hideToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + return ( + + {children} + + + ) +} + +function ToastContainer({ toasts, onClose }: { toasts: Toast[], onClose: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ) +} + +function ToastItem({ toast, onClose }: { toast: Toast, onClose: (id: string) => void }) { + const getBgColor = () => { + switch (toast.type) { + case 'success': return 'bg-success-500/95' + case 'warning': return 'bg-warning-500/95' + case 'error': return 'bg-error-500/95' + default: return 'bg-accent-500/95' + } + } + + const handleClick = () => { + if (toast.onClick) { + toast.onClick() + onClose(toast.id) + } + } + + return ( +
+ {toast.icon && ( +
+ {toast.icon} +
+ )} +
+

{toast.message}

+ {toast.onClick && ( +

Click to view

+ )} +
+ +
+ ) +} + +// Hook for ticket notification toasts +export function useTicketToast() { + const { showToast } = useToast() + const navigate = useNavigate() + + const showNewReplyToast = useCallback((ticketId: number, message: string, isAdmin: boolean) => { + showToast({ + type: 'info', + message: message || `New reply in ticket #${ticketId}`, + icon: 💬, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${ticketId}` : `/support?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + const showNewTicketToast = useCallback((ticketId: number, title: string) => { + showToast({ + type: 'info', + message: `New ticket: ${title}`, + icon: 🎫, + onClick: () => { + navigate(`/admin/tickets?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + return { showNewReplyToast, showNewTicketToast } +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index c1d2d98..c8d27d9 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/auth' import LanguageSwitcher from '../LanguageSwitcher' import PromoDiscountBadge from '../PromoDiscountBadge' +import TicketNotificationBell from '../TicketNotificationBell' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -337,6 +338,7 @@ export default function Layout({ children }: LayoutProps) { )} + {/* Profile - Desktop */} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..88c9887 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useAuthStore } from '../store/auth' + +export interface WSMessage { + type: string + ticket_id?: number + message?: string + title?: string + user_id?: number + is_admin?: boolean +} + +interface UseWebSocketOptions { + onMessage?: (message: WSMessage) => void + onConnect?: () => void + onDisconnect?: () => void +} + +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 optionsRef = useRef(options) + + // 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:' + const host = import.meta.env.VITE_API_URL + ? new URL(import.meta.env.VITE_API_URL).host + : window.location.host + const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + 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) { + console.error('[WS] Failed to parse message:', e) + } + } + + ws.onclose = (event) => { + 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) + console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++ + connect() + }, delay) + } + } + + ws.onerror = (error) => { + console.error('[WS] Error:', error) + } + } catch (e) { + 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]) + + return { isConnected } +} diff --git a/src/locales/en.json b/src/locales/en.json index 4e85c02..ab9d0ef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,20 @@ "info": "Info", "wheel": "Fortune Wheel" }, + "notifications": { + "ticketNotifications": "Ticket Notifications", + "markAllRead": "Mark all read", + "noNotifications": "No notifications", + "justNow": "Just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} h ago", + "daysAgo": "{{count}} d ago", + "viewAll": "View all tickets", + "newNotification": "New notification", + "clickToView": "Click to view", + "newTicket": "New ticket: {{title}}", + "newReply": "New reply in ticket" + }, "auth": { "login": "Login", "register": "Register", @@ -732,7 +746,12 @@ "reminderCooldown": "Reminder interval (minutes)", "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", "settingsUpdateError": "Error saving settings", - "copyTelegramId": "Click to copy Telegram ID" + "copyTelegramId": "Click to copy Telegram ID", + "cabinetNotifications": "Cabinet Notifications", + "userNotificationsEnabled": "User Notifications", + "userNotificationsEnabledDesc": "Send notifications to users about admin replies", + "adminNotificationsEnabled": "Admin Notifications", + "adminNotificationsEnabledDesc": "Send notifications to admins about new tickets and replies" }, "tariffs": { "title": "Tariff Management", diff --git a/src/locales/ru.json b/src/locales/ru.json index e46a052..9cb1f19 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -35,6 +35,20 @@ "info": "Информация", "wheel": "Колесо удачи" }, + "notifications": { + "ticketNotifications": "Уведомления о тикетах", + "markAllRead": "Прочитать все", + "noNotifications": "Нет уведомлений", + "justNow": "Только что", + "minutesAgo": "{{count}} мин. назад", + "hoursAgo": "{{count}} ч. назад", + "daysAgo": "{{count}} д. назад", + "viewAll": "Все тикеты", + "newNotification": "Новое уведомление", + "clickToView": "Нажмите для просмотра", + "newTicket": "Новый тикет: {{title}}", + "newReply": "Новый ответ в тикете" + }, "auth": { "login": "Вход", "register": "Регистрация", @@ -732,7 +746,12 @@ "reminderCooldown": "Интервал напоминаний (минуты)", "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", "settingsUpdateError": "Ошибка сохранения настроек", - "copyTelegramId": "Нажмите чтобы скопировать Telegram ID" + "copyTelegramId": "Нажмите чтобы скопировать Telegram ID", + "cabinetNotifications": "Уведомления в кабинете", + "userNotificationsEnabled": "Уведомления для пользователей", + "userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа", + "adminNotificationsEnabled": "Уведомления для админов", + "adminNotificationsEnabledDesc": "Отправлять уведомления админам о новых тикетах и ответах" }, "tariffs": { "title": "Управление тарифами", diff --git a/src/main.tsx b/src/main.tsx index 53f0e75..6297c78 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import { ThemeColorsProvider } from './providers/ThemeColorsProvider' +import { ToastProvider } from './components/Toast' import './i18n' import './styles/globals.css' @@ -21,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 9d6b622..1206b13 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -460,6 +460,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60, sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15, support_system_mode: settings?.support_system_mode ?? 'both', + cabinet_user_notifications_enabled: settings?.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings?.cabinet_admin_notifications_enabled ?? true, }) // Update form when settings load @@ -471,6 +473,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings.sla_check_interval_seconds, sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes, support_system_mode: settings.support_system_mode, + cabinet_user_notifications_enabled: settings.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings.cabinet_admin_notifications_enabled ?? true, }) } }, [settings]) @@ -526,6 +530,43 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {

{t('admin.tickets.supportModeDesc')}

+ {/* Cabinet Notifications */} +
+

{t('admin.tickets.cabinetNotifications')}

+ + {/* User Notifications */} +
+ +
+ + {/* Admin Notifications */} +
+ +
+
+

{t('admin.tickets.slaSettings')}

diff --git a/src/types/index.ts b/src/types/index.ts index 64cb61f..a71cefe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -516,3 +516,34 @@ export interface ManualCheckResponse { old_status: string | null new_status: string | null } + +// Ticket notifications types +export interface TicketNotification { + id: number + ticket_id: number + notification_type: 'new_ticket' | 'admin_reply' | 'user_reply' + message: string | null + is_read: boolean + created_at: string + read_at: string | null +} + +export interface TicketNotificationList { + items: TicketNotification[] + unread_count: number +} + +export interface UnreadCountResponse { + unread_count: number +} + +// Extended TicketSettings with cabinet notifications +export interface TicketSettings { + sla_enabled: boolean + sla_minutes: number + sla_check_interval_seconds: number + sla_reminder_cooldown_minutes: number + support_system_mode: string + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean +}