From feb1f0fe509fbc551d4ff2b36cb06b49cafb791e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:04:42 +0300 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 +} From af96eecac7bb487684e092168419991002b3bae9 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:41:55 +0300 Subject: [PATCH 06/17] Add ToastProvider to main app component, integrate TicketNotificationBell in Layout, and define ticket notification types in types index. Enhance globals.css with toast progress bar animation. --- src/api/ticketNotifications.ts | 62 +++++ src/components/TicketNotificationBell.tsx | 273 ++++++++++++++++++++++ src/components/Toast.tsx | 191 +++++++++++++++ src/components/layout/Layout.tsx | 2 + src/hooks/useWebSocket.ts | 144 ++++++++++++ src/main.tsx | 5 +- src/styles/globals.css | 6 + src/types/index.ts | 19 ++ 8 files changed, 701 insertions(+), 1 deletion(-) 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..6518769 --- /dev/null +++ b/src/components/TicketNotificationBell.tsx @@ -0,0 +1,273 @@ +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', + title: message.type === 'ticket.new' ? t('notifications.newTicketTitle', 'New Ticket') : t('notifications.newReplyTitle', 'New Reply'), + message: toastMessage, + 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: TicketNotification) => ( + + )) + ) : ( +
+
+ +
+

{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..7ce53e7 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,191 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' + +interface ToastOptions { + type?: 'success' | 'error' | 'info' | 'warning' + message: string + title?: string + icon?: ReactNode + duration?: number + onClick?: () => void +} + +interface Toast extends ToastOptions { + id: number +} + +interface ToastContextType { + showToast: (options: ToastOptions) => 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((options: ToastOptions) => { + const id = Date.now() + const toast: Toast = { id, duration: 5000, type: 'info', ...options } + + setToasts(prev => [...prev, toast]) + + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, toast.duration) + }, []) + + const removeToast = useCallback((id: number) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + return ( + + {children} + + {/* Toast Container */} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+
+ ) +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + const handleClick = () => { + if (toast.onClick) { + toast.onClick() + onClose() + } + } + + const typeStyles = { + success: { + bg: 'bg-gradient-to-r from-success-500/20 to-success-600/10', + border: 'border-success-500/30', + icon: 'text-success-400', + iconBg: 'bg-success-500/20', + }, + error: { + bg: 'bg-gradient-to-r from-error-500/20 to-error-600/10', + border: 'border-error-500/30', + icon: 'text-error-400', + iconBg: 'bg-error-500/20', + }, + warning: { + bg: 'bg-gradient-to-r from-warning-500/20 to-warning-600/10', + border: 'border-warning-500/30', + icon: 'text-warning-400', + iconBg: 'bg-warning-500/20', + }, + info: { + bg: 'bg-gradient-to-r from-accent-500/20 to-accent-600/10', + border: 'border-accent-500/30', + icon: 'text-accent-400', + iconBg: 'bg-accent-500/20', + }, + } + + const style = typeStyles[toast.type || 'info'] + + const defaultIcons = { + success: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ), + } + + return ( +
+ {/* Glow effect */} +
+ +
+
+ {/* Icon */} +
+ {toast.icon || defaultIcons[toast.type || 'info']} +
+ + {/* Content */} +
+ {toast.title && ( +

+ {toast.title} +

+ )} +

+ {toast.message} +

+
+ + {/* Close button */} + +
+ + {/* Progress bar */} +
+
+
+
+
+ ) +} 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 */} 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/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/styles/globals.css b/src/styles/globals.css index 84ad3af..ea7a288 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1034,3 +1034,9 @@ .animate-wheel-glow { animation: wheel-glow 2s ease-in-out infinite; } + +/* Toast progress bar animation */ +@keyframes shrink { + from { width: 100%; } + to { width: 0%; } +} 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 89a219c3efce8ab8c93de5aa7e83bd5eb332530b Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:56:23 +0300 Subject: [PATCH 07/17] Fix merge conflict markers in types --- src/types/index.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 099f748..7d3febb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -517,16 +517,6 @@ export interface ManualCheckResponse { new_status: string | null } -<<<<<<< HEAD -// Ticket notification types -export interface TicketNotification { - id: number - ticket_id: number - notification_type: string - message: string - is_read: boolean - created_at: string -======= // Ticket notifications types export interface TicketNotification { id: number @@ -536,25 +526,17 @@ export interface TicketNotification { is_read: boolean created_at: string read_at: string | null ->>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 } export interface TicketNotificationList { items: TicketNotification[] -<<<<<<< HEAD - total: number -======= unread_count: number ->>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 } export interface UnreadCountResponse { unread_count: number } -<<<<<<< HEAD -======= -// Extended TicketSettings with cabinet notifications export interface TicketSettings { sla_enabled: boolean sla_minutes: number @@ -564,4 +546,3 @@ export interface TicketSettings { cabinet_user_notifications_enabled: boolean cabinet_admin_notifications_enabled: boolean } ->>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 From 472cd27320964ead8cc68ecf221eca6874e193c0 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:15:48 +0300 Subject: [PATCH 08/17] Refactor TicketNotificationBell to improve toast notifications for new tickets and replies. Update localization files to include new notification strings for user replies and enhance existing messages. --- src/components/TicketNotificationBell.tsx | 31 +++++++++++++++++------ src/locales/en.json | 6 ++++- src/locales/fa.json | 18 +++++++++++++ src/locales/ru.json | 6 ++++- src/locales/zh.json | 18 +++++++++++++ 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 6518769..b2cd7da 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -35,22 +35,37 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi // Show toast for WebSocket notification const showWSNotificationToast = useCallback((message: WSMessage) => { - const icon = message.type === 'ticket.new' ? ( + const isNewTicket = message.type === 'ticket.new' + const isAdminReply = message.type === 'ticket.admin_reply' + const isUserReply = message.type === 'ticket.user_reply' + + const icon = isNewTicket ? ( 🎫 - ) : message.type === 'ticket.admin_reply' ? ( + ) : isAdminReply ? ( 💬 ) : ( 📨 ) - const toastMessage = message.message || - (message.type === 'ticket.new' - ? t('notifications.newTicket', 'New ticket: {{title}}', { title: message.title }) - : t('notifications.newReply', 'New reply in ticket')) + const ticketTitle = message.title || '' + + let toastTitle: string + let toastMessage: string + + if (isNewTicket) { + toastTitle = t('notifications.newTicketTitle', 'New Ticket') + toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle }) + } else if (isUserReply) { + toastTitle = t('notifications.newUserReplyTitle', 'User Reply') + toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle }) + } else { + toastTitle = t('notifications.newReplyTitle', 'New Reply') + toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle }) + } showToast({ type: 'info', - title: message.type === 'ticket.new' ? t('notifications.newTicketTitle', 'New Ticket') : t('notifications.newReplyTitle', 'New Reply'), + title: toastTitle, message: toastMessage, icon, onClick: () => { @@ -189,7 +204,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Dropdown */} {isOpen && ( -
+
{/* Header */}

diff --git a/src/locales/en.json b/src/locales/en.json index ab9d0ef..0e96d05 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -47,7 +47,11 @@ "newNotification": "New notification", "clickToView": "Click to view", "newTicket": "New ticket: {{title}}", - "newReply": "New reply in ticket" + "newReply": "New reply in ticket: {{title}}", + "newTicketTitle": "New Ticket", + "newReplyTitle": "New Reply", + "newUserReply": "User replied in ticket: {{title}}", + "newUserReplyTitle": "User Reply" }, "auth": { "login": "Login", diff --git a/src/locales/fa.json b/src/locales/fa.json index d0b5598..c0f1918 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -33,6 +33,24 @@ "info": "اطلاعات", "wheel": "چرخ شانس" }, + "notifications": { + "ticketNotifications": "اعلان‌های تیکت", + "markAllRead": "خواندن همه", + "noNotifications": "اعلانی وجود ندارد", + "justNow": "همین الان", + "minutesAgo": "{{count}} دقیقه پیش", + "hoursAgo": "{{count}} ساعت پیش", + "daysAgo": "{{count}} روز پیش", + "viewAll": "مشاهده همه تیکت‌ها", + "newNotification": "اعلان جدید", + "clickToView": "برای مشاهده کلیک کنید", + "newTicket": "تیکت جدید: {{title}}", + "newReply": "پاسخ جدید در تیکت: {{title}}", + "newTicketTitle": "تیکت جدید", + "newReplyTitle": "پاسخ جدید", + "newUserReply": "کاربر در تیکت پاسخ داد: {{title}}", + "newUserReplyTitle": "پاسخ کاربر" + }, "auth": { "login": "ورود", "register": "ثبت نام", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9cb1f19..1513774 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -47,7 +47,11 @@ "newNotification": "Новое уведомление", "clickToView": "Нажмите для просмотра", "newTicket": "Новый тикет: {{title}}", - "newReply": "Новый ответ в тикете" + "newReply": "Новый ответ в тикете: {{title}}", + "newTicketTitle": "Новый тикет", + "newReplyTitle": "Новый ответ", + "newUserReply": "Ответ пользователя в тикете: {{title}}", + "newUserReplyTitle": "Ответ пользователя" }, "auth": { "login": "Вход", diff --git a/src/locales/zh.json b/src/locales/zh.json index bb0e0d2..8ce491a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -33,6 +33,24 @@ "info": "信息", "wheel": "幸运转盘" }, + "notifications": { + "ticketNotifications": "工单通知", + "markAllRead": "全部标记已读", + "noNotifications": "暂无通知", + "justNow": "刚刚", + "minutesAgo": "{{count}}分钟前", + "hoursAgo": "{{count}}小时前", + "daysAgo": "{{count}}天前", + "viewAll": "查看所有工单", + "newNotification": "新通知", + "clickToView": "点击查看", + "newTicket": "新工单:{{title}}", + "newReply": "工单新回复:{{title}}", + "newTicketTitle": "新工单", + "newReplyTitle": "新回复", + "newUserReply": "用户回复了工单:{{title}}", + "newUserReplyTitle": "用户回复" + }, "auth": { "login": "登录", "register": "注册", From 85f935a6b08948d31ccf88a2f90d0bf5cdb5f9f4 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:28:52 +0300 Subject: [PATCH 09/17] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 4 ++-- src/components/TopUpModal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index dc784ed..702a4fa 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod const { formatAmount, currencySymbol } = useCurrency() return ( -
+
-
+
{/* Header */}
{t('balance.selectPaymentMethod')} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 2486d1b..6cf974b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -132,7 +132,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return ( -
+
From acfebd78788b5a774a7c84fc2f75e144ed2483b7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:31:29 +0300 Subject: [PATCH 10/17] Refactor getAdminNotifications to streamline parameter handling for API requests, improving readability and maintainability. --- src/api/ticketNotifications.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts index 3adf58f..36a30b3 100644 --- a/src/api/ticketNotifications.ts +++ b/src/api/ticketNotifications.ts @@ -32,9 +32,11 @@ export const ticketNotificationsApi = { // 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 } - }) + const params: Record = { limit, offset } + if (unreadOnly) { + params.unread_only = true + } + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params }) return response.data }, From 3fc22d2b4de6e615c0ed15494cdde064d283f5cc Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:40:50 +0300 Subject: [PATCH 11/17] Refactor WebSocket URL handling in useWebSocket hook to support both absolute and relative API URLs, improving flexibility and error handling for host resolution. --- src/hooks/useWebSocket.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 88c9887..04bb2cf 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -60,9 +60,19 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { // 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 + 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 { From 5f123a39d9ab361636d6252a15f9cd9d2b74023d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:51:52 +0300 Subject: [PATCH 12/17] Refactor App component to implement lazy loading for user and admin pages, enhancing performance and reducing initial load time. Introduce LazyPage wrapper for suspense handling during component loading. --- src/App.tsx | 120 +++++++++++++++++++++----------------- src/components/Toast.tsx | 25 +++++++- src/hooks/useWebSocket.ts | 14 +++-- 3 files changed, 98 insertions(+), 61 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 48f2c17..d827e18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,39 +1,46 @@ +import { lazy, Suspense } from 'react' 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' + +// Auth pages - load immediately (small) import Login from './pages/Login' import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect' -import Dashboard from './pages/Dashboard' -import Subscription from './pages/Subscription' -import Balance from './pages/Balance' -import Referral from './pages/Referral' -import Support from './pages/Support' -import Profile from './pages/Profile' -import AdminTickets from './pages/AdminTickets' -import AdminSettings from './pages/AdminSettings' -import AdminApps from './pages/AdminApps' import VerifyEmail from './pages/VerifyEmail' -import Contests from './pages/Contests' -import Polls from './pages/Polls' -import Info from './pages/Info' -import Wheel from './pages/Wheel' -import AdminWheel from './pages/AdminWheel' -import AdminTariffs from './pages/AdminTariffs' -import AdminServers from './pages/AdminServers' -import AdminPanel from './pages/AdminPanel' -import AdminDashboard from './pages/AdminDashboard' -import AdminBanSystem from './pages/AdminBanSystem' -import AdminBroadcasts from './pages/AdminBroadcasts' -import AdminPromocodes from './pages/AdminPromocodes' -import AdminCampaigns from './pages/AdminCampaigns' -import AdminUsers from './pages/AdminUsers' -import AdminPayments from './pages/AdminPayments' -import AdminPromoOffers from './pages/AdminPromoOffers' -import AdminRemnawave from './pages/AdminRemnawave' + +// User pages - lazy load +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Subscription = lazy(() => import('./pages/Subscription')) +const Balance = lazy(() => import('./pages/Balance')) +const Referral = lazy(() => import('./pages/Referral')) +const Support = lazy(() => import('./pages/Support')) +const Profile = lazy(() => import('./pages/Profile')) +const Contests = lazy(() => import('./pages/Contests')) +const Polls = lazy(() => import('./pages/Polls')) +const Info = lazy(() => import('./pages/Info')) +const Wheel = lazy(() => import('./pages/Wheel')) + +// Admin pages - lazy load (only for admins) +const AdminPanel = lazy(() => import('./pages/AdminPanel')) +const AdminTickets = lazy(() => import('./pages/AdminTickets')) +const AdminSettings = lazy(() => import('./pages/AdminSettings')) +const AdminApps = lazy(() => import('./pages/AdminApps')) +const AdminWheel = lazy(() => import('./pages/AdminWheel')) +const AdminTariffs = lazy(() => import('./pages/AdminTariffs')) +const AdminServers = lazy(() => import('./pages/AdminServers')) +const AdminDashboard = lazy(() => import('./pages/AdminDashboard')) +const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')) +const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')) +const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) +const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) +const AdminUsers = lazy(() => import('./pages/AdminUsers')) +const AdminPayments = lazy(() => import('./pages/AdminPayments')) +const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) +const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -73,6 +80,15 @@ function AdminRoute({ children }: { children: React.ReactNode }) { return {children} } +// Suspense wrapper for lazy components +function LazyPage({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ) +} + function App() { return ( @@ -90,7 +106,7 @@ function App() { path="/" element={ - + } /> @@ -98,7 +114,7 @@ function App() { path="/subscription" element={ - + } /> @@ -106,7 +122,7 @@ function App() { path="/balance" element={ - + } /> @@ -114,7 +130,7 @@ function App() { path="/referral" element={ - + } /> @@ -122,7 +138,7 @@ function App() { path="/support" element={ - + } /> @@ -130,7 +146,7 @@ function App() { path="/profile" element={ - + } /> @@ -138,7 +154,7 @@ function App() { path="/contests" element={ - + } /> @@ -146,7 +162,7 @@ function App() { path="/polls" element={ - + } /> @@ -154,7 +170,7 @@ function App() { path="/info" element={ - + } /> @@ -162,7 +178,7 @@ function App() { path="/wheel" element={ - + } /> @@ -172,7 +188,7 @@ function App() { path="/admin" element={ - + } /> @@ -180,7 +196,7 @@ function App() { path="/admin/tickets" element={ - + } /> @@ -188,7 +204,7 @@ function App() { path="/admin/settings" element={ - + } /> @@ -196,7 +212,7 @@ function App() { path="/admin/apps" element={ - + } /> @@ -204,7 +220,7 @@ function App() { path="/admin/wheel" element={ - + } /> @@ -212,7 +228,7 @@ function App() { path="/admin/tariffs" element={ - + } /> @@ -220,7 +236,7 @@ function App() { path="/admin/servers" element={ - + } /> @@ -228,7 +244,7 @@ function App() { path="/admin/dashboard" element={ - + } /> @@ -236,7 +252,7 @@ function App() { path="/admin/ban-system" element={ - + } /> @@ -244,7 +260,7 @@ function App() { path="/admin/broadcasts" element={ - + } /> @@ -252,7 +268,7 @@ function App() { path="/admin/promocodes" element={ - + } /> @@ -260,7 +276,7 @@ function App() { path="/admin/campaigns" element={ - + } /> @@ -268,7 +284,7 @@ function App() { path="/admin/users" element={ - + } /> @@ -276,7 +292,7 @@ function App() { path="/admin/payments" element={ - + } /> @@ -284,7 +300,7 @@ function App() { path="/admin/promo-offers" element={ - + } /> @@ -292,7 +308,7 @@ function App() { path="/admin/remnawave" element={ - + } /> diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 7ce53e7..950025a 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react' interface ToastOptions { type?: 'success' | 'error' | 'info' | 'warning' @@ -29,22 +29,41 @@ export function useToast() { export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) const showToast = useCallback((options: ToastOptions) => { - const id = Date.now() + const id = Date.now() + Math.random() // Avoid ID collision const toast: Toast = { id, duration: 5000, type: 'info', ...options } setToasts(prev => [...prev, toast]) - setTimeout(() => { + const timer = setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)) + timersRef.current.delete(id) }, toast.duration) + + timersRef.current.set(id, timer) }, []) const removeToast = useCallback((id: number) => { + // Clear timer when manually removing + const timer = timersRef.current.get(id) + if (timer) { + clearTimeout(timer) + timersRef.current.delete(id) + } setToasts(prev => prev.filter(t => t.id !== id)) }, []) + // Cleanup all timers on unmount + useEffect(() => { + const timers = timersRef.current + return () => { + timers.forEach(timer => clearTimeout(timer)) + timers.clear() + } + }, []) + return ( {children} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 04bb2cf..da267a6 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useCallback, useState } from 'react' import { useAuthStore } from '../store/auth' +const isDev = import.meta.env.DEV + export interface WSMessage { type: string ticket_id?: number @@ -80,7 +82,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { wsRef.current = ws ws.onopen = () => { - console.log('[WS] Connected') + if (isDev) console.log('[WS] Connected') setIsConnected(true) reconnectAttemptsRef.current = 0 optionsRef.current.onConnect?.() @@ -104,12 +106,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { optionsRef.current.onMessage?.(message) } catch (e) { - console.error('[WS] Failed to parse message:', e) + if (isDev) console.error('[WS] Failed to parse message:', e) } } ws.onclose = (event) => { - console.log('[WS] Disconnected:', event.code, event.reason) + if (isDev) console.log('[WS] Disconnected:', event.code, event.reason) setIsConnected(false) optionsRef.current.onDisconnect?.() @@ -121,7 +123,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { // 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})`) + if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current++ @@ -131,10 +133,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { } ws.onerror = (error) => { - console.error('[WS] Error:', error) + if (isDev) console.error('[WS] Error:', error) } } catch (e) { - console.error('[WS] Failed to connect:', e) + if (isDev) console.error('[WS] Failed to connect:', e) } }, [accessToken, isAuthenticated, cleanup]) From f904a6b88bf5159bfbd5b4da4ebb19e9b14e5bc7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 02:11:49 +0300 Subject: [PATCH 13/17] Enhance AdminBanSystem error handling UI with a new design, including a card layout, error message display, and action buttons for Telegram contact and navigation. Improve user experience with animations and responsive design elements. --- src/pages/AdminBanSystem.tsx | 72 ++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 99a87c7..9113f03 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -384,9 +384,75 @@ export default function AdminBanSystem() { if (error && !status?.enabled) { return ( -
-
{error}
-

{t('banSystem.configureHint')}

+
+
+ {/* Card */} +
+ {/* Icon */} +
+
+
+ + + +
+
+ + + +
+
+
+ + {/* Title */} +

+ {t('banSystem.title')} +

+ + {/* Error message */} +

+ {error} +

+ + {/* Hint */} +

+ {t('banSystem.configureHint')} +

+ + {/* Buttons */} +
+ {/* Telegram Button */} + + + + + {t('banSystem.contactTelegram', { defaultValue: 'Написать в Telegram' })} + + + {/* Back Button */} + +
+
+ + {/* Decorative elements */} +
+
+
+
+
) } From 540931750163c945b333e8995de2de65e80c8bde Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:17:49 +0300 Subject: [PATCH 14/17] Add VITE_APP_VERSION environment variable and integrate versioning into the application - Introduced VITE_APP_VERSION in Dockerfile and GitHub workflows to capture the version from Git tags. - Updated vite.config.ts to define a global constant for the app version. - Enhanced Layout component to display the app version in the footer for admin pages. - Added logic to conditionally render the LanguageSwitcher based on active promotions. --- .github/workflows/docker.yml | 1 + .github/workflows/release.yml | 1 + Dockerfile | 2 + src/components/layout/Layout.tsx | 70 ++++++++++++++++++++++---------- src/vite-env.d.ts | 3 ++ vite.config.ts | 17 ++++++++ 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 57fbd02..00c644b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,3 +62,4 @@ jobs: VITE_TELEGRAM_BOT_USERNAME= VITE_APP_NAME=Cabinet VITE_APP_LOGO=V + VITE_APP_VERSION=${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe4ab04..8122984 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: VITE_APP_NAME: Bedolaga Cabinet VITE_APP_LOGO: V + VITE_APP_VERSION: ${{ github.ref_name }} - name: Create dist archive run: | diff --git a/Dockerfile b/Dockerfile index 8293e10..47c7a63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,12 +18,14 @@ ARG VITE_API_URL=/api ARG VITE_TELEGRAM_BOT_USERNAME ARG VITE_APP_NAME=Cabinet ARG VITE_APP_LOGO=V +ARG VITE_APP_VERSION # Set environment variables for build ENV VITE_API_URL=$VITE_API_URL ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO +ENV VITE_APP_VERSION=$VITE_APP_VERSION # Build the application RUN npm run build diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ad725d1..d3a4734 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -11,6 +11,7 @@ import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' import { wheelApi } from '../../api/wheel' import { themeColorsApi } from '../../api/themeColors' +import { promoApi } from '../../api/promo' import { useTheme } from '../../hooks/useTheme' // Fallback branding from environment variables @@ -210,6 +211,17 @@ export default function Layout({ children }: LayoutProps) { retry: false, }) + // Fetch active discount to determine mobile layout + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + enabled: isAuthenticated, + staleTime: 30000, + }) + + // Check if promo is active (to hide language switcher on mobile) + const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent + const navItems = useMemo(() => { const items = [ { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, @@ -339,7 +351,10 @@ export default function Layout({ children }: LayoutProps) { - + {/* Hide language switcher on mobile when promo is active */} +
+ +
{/* Profile - Desktop */}
@@ -389,29 +404,33 @@ export default function Layout({ children }: LayoutProps) {
{/* User info */} -
- {userPhotoUrl ? ( - Avatar { - e.currentTarget.style.display = 'none' - e.currentTarget.nextElementSibling?.classList.remove('hidden') - }} - /> - ) : null} -
- -
-
-
- {user?.first_name || user?.username} +
+
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} +
+
-
- @{user?.username || `ID: ${user?.telegram_id}`} +
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+ {/* Language switcher in mobile menu when promo is active */} + {isPromoActive && }
{/* Nav items */} @@ -480,6 +499,15 @@ export default function Layout({ children }: LayoutProps) {
{children}
+ + {/* Version footer for admin pages */} + {isAdminActive() && ( +
+ + Cabinet {__APP_VERSION__} + +
+ )} {/* Mobile Bottom Navigation - only core items */} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c61a7b6..8add16d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,8 @@ /// +// Global constant defined in vite.config.ts +declare const __APP_VERSION__: string + interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_TELEGRAM_BOT_USERNAME?: string diff --git a/vite.config.ts b/vite.config.ts index 01d8f8a..45054e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,26 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { execSync } from 'child_process' + +// Get version from Git tag or fallback to 'dev' +function getGitVersion(): string { + try { + // Try to get version from git tag + return execSync('git describe --tags --always', { encoding: 'utf-8' }).trim() + } catch { + return 'dev' + } +} + +const appVersion = process.env.VITE_APP_VERSION || getGitVersion() // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + // Define global constants + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, // Base path - use '/' for standalone Docker deployment // Change to '/cabinet/' if serving from a sub-path base: '/', From 638f1d54560fcd558dc867c03cef246b36d15655 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:39:09 +0300 Subject: [PATCH 15/17] Refactor Dockerfile and Vite configuration to remove VITE_APP_VERSION and streamline build process - Removed VITE_APP_VERSION from Dockerfile and GitHub workflows. - Simplified Vite configuration by eliminating the Git version retrieval function and global constant for app version. - Updated ColorPicker and other components to enhance accessibility with aria-labels. - Introduced unique identifiers for buttons in the AppEditorModal for better React key management. - Improved error handling in the Login component to provide user-friendly messages without exposing sensitive data. --- .github/workflows/docker.yml | 1 - .github/workflows/release.yml | 1 - Dockerfile | 3 - src/api/adminApps.ts | 1 + src/api/wheel.ts | 31 +- src/components/ColorPicker.tsx | 17 +- src/components/ConnectionModal.tsx | 2 +- src/components/InsufficientBalancePrompt.tsx | 2 +- src/components/Onboarding.tsx | 4 +- src/components/TopUpModal.tsx | 83 ++++- src/components/layout/Layout.tsx | 12 +- src/pages/AdminApps.tsx | 10 +- src/pages/AdminDashboard.tsx | 4 +- src/pages/AdminPanel.tsx | 348 +++++++++++-------- src/pages/AdminWheel.tsx | 4 +- src/pages/DeepLinkRedirect.tsx | 34 +- src/pages/Login.tsx | 16 +- src/pages/Support.tsx | 52 ++- src/types/index.ts | 1 + src/vite-env.d.ts | 3 - vite.config.ts | 17 - 21 files changed, 401 insertions(+), 245 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 00c644b..57fbd02 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,4 +62,3 @@ jobs: VITE_TELEGRAM_BOT_USERNAME= VITE_APP_NAME=Cabinet VITE_APP_LOGO=V - VITE_APP_VERSION=${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8122984..fe4ab04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,6 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: VITE_APP_NAME: Bedolaga Cabinet VITE_APP_LOGO: V - VITE_APP_VERSION: ${{ github.ref_name }} - name: Create dist archive run: | diff --git a/Dockerfile b/Dockerfile index 47c7a63..de296d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /app COPY package.json package-lock.json* ./ # Install dependencies -# Use npm install if no lock file, npm ci if lock file exists RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi # Copy source code @@ -18,14 +17,12 @@ ARG VITE_API_URL=/api ARG VITE_TELEGRAM_BOT_USERNAME ARG VITE_APP_NAME=Cabinet ARG VITE_APP_LOGO=V -ARG VITE_APP_VERSION # Set environment variables for build ENV VITE_API_URL=$VITE_API_URL ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_LOGO=$VITE_APP_LOGO -ENV VITE_APP_VERSION=$VITE_APP_VERSION # Build the application RUN npm run build diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 51a69af..b57a78c 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -9,6 +9,7 @@ export interface LocalizedText { } export interface AppButton { + id?: string // Unique identifier for React key (client-side only) buttonLink: string buttonText: LocalizedText } diff --git a/src/api/wheel.ts b/src/api/wheel.ts index c076e3b..7511f94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -98,6 +98,22 @@ export interface WheelPrizeAdmin { updated_at: string | null } +// Type for creating a new prize (excludes id, config_id which are auto-generated) +export interface CreateWheelPrizeData { + prize_type: string + prize_value: number + display_name: string + emoji?: string + color?: string + prize_value_kopeks: number + sort_order?: number + manual_probability?: number | null + is_active?: boolean + promo_balance_bonus_kopeks?: number + promo_subscription_days?: number + promo_traffic_gb?: number +} + export interface AdminWheelConfig { id: number is_enabled: boolean @@ -223,20 +239,7 @@ export const adminWheelApi = { }, // Create prize - createPrize: async (data: { - prize_type: string - prize_value: number - display_name: string - emoji?: string - color?: string - prize_value_kopeks: number - sort_order?: number - manual_probability?: number | null - is_active?: boolean - promo_balance_bonus_kopeks?: number - promo_subscription_days?: number - promo_traffic_gb?: number - }): Promise => { + createPrize: async (data: CreateWheelPrizeData): Promise => { const response = await apiClient.post('/cabinet/admin/wheel/prizes', data) return response.data }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 9889dd0..e841b7a 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -81,14 +81,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C {description &&

{description}

}
- {/* Color preview button */} + {/* Color preview button - min 44px for touch accessibility */}
diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2ac3e2b..ef89184 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -286,7 +286,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{t('subscription.connection.title')}

- @@ -382,6 +384,8 @@ export default function Layout({ children }: LayoutProps) { @@ -500,14 +504,6 @@ export default function Layout({ children }: LayoutProps) { {children}
- {/* Version footer for admin pages */} - {isAdminActive() && ( -
- - Cabinet {__APP_VERSION__} - -
- )} {/* Mobile Bottom Navigation - only core items */} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 0a25598..b702355 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { const step = editedApp[stepKey] || emptyAppStep() const buttons = step.buttons || [] - updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) + // Generate unique id for React key + const newButton: AppButton = { + id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + buttonLink: '', + buttonText: emptyLocalizedText() + } + updateButtons(stepKey, [...buttons, newButton]) } const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => { @@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
{buttons.map((button, index) => ( -
+
{t('admin.apps.button')} #{index + 1} @@ -141,6 +142,29 @@ export default function Support() { const createFileInputRef = useRef(null) const replyFileInputRef = useRef(null) + // Cleanup function to revoke object URLs and prevent memory leaks + const clearAttachment = useCallback(( + attachment: MediaAttachment | null, + setAttachment: (a: MediaAttachment | null) => void + ) => { + if (attachment?.preview) { + URL.revokeObjectURL(attachment.preview) + } + setAttachment(null) + }, []) + + // Cleanup on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (createAttachment?.preview) { + URL.revokeObjectURL(createAttachment.preview) + } + if (replyAttachment?.preview) { + URL.revokeObjectURL(replyAttachment.preview) + } + } + }, []) // Empty deps - only run on unmount + // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ queryKey: ['support-config'], @@ -162,8 +186,14 @@ export default function Support() { // Handle file selection const handleFileSelect = async ( file: File, - setAttachment: (a: MediaAttachment | null) => void + setAttachment: (a: MediaAttachment | null) => void, + currentAttachment: MediaAttachment | null ) => { + // Revoke old object URL before creating new one + if (currentAttachment?.preview) { + URL.revokeObjectURL(currentAttachment.preview) + } + // Validate file type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] if (!allowedTypes.includes(file.type)) { @@ -224,7 +254,7 @@ export default function Support() { setShowCreateForm(false) setNewTitle('') setNewMessage('') - setCreateAttachment(null) + clearAttachment(createAttachment, setCreateAttachment) setSelectedTicket(ticket) }, }) @@ -242,7 +272,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }) setReplyMessage('') - setReplyAttachment(null) + clearAttachment(replyAttachment, setReplyAttachment) }, }) @@ -443,7 +473,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true) setSelectedTicket(null) - setCreateAttachment(null) + clearAttachment(createAttachment, setCreateAttachment) }} className="btn-primary" > @@ -469,7 +499,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail) setShowCreateForm(false) - setReplyAttachment(null) + clearAttachment(replyAttachment, setReplyAttachment) }} className={`w-full text-left p-4 rounded-xl border transition-all ${ selectedTicket?.id === ticket.id @@ -555,14 +585,14 @@ export default function Support() { className="hidden" onChange={(e) => { const file = e.target.files?.[0] - if (file) handleFileSelect(file, setCreateAttachment) + if (file) handleFileSelect(file, setCreateAttachment, createAttachment) e.target.value = '' }} /> {createAttachment ? ( setCreateAttachment(null)} + onRemove={() => clearAttachment(createAttachment, setCreateAttachment)} /> ) : (
@@ -347,7 +366,9 @@ export default function AdminDashboard() {
- +
+ +

{t('adminDashboard.nodes.title')}

@@ -395,7 +416,9 @@ export default function AdminDashboard() { {/* Revenue Chart */}

- +
+ +

{t('adminDashboard.revenue.title')}

{t('adminDashboard.revenue.last7Days')}

@@ -417,7 +440,9 @@ export default function AdminDashboard() { {/* Subscription Stats */}
- +
+ +

{t('adminDashboard.subscriptions.title')}

{t('adminDashboard.subscriptions.subtitle')}

@@ -478,7 +503,9 @@ export default function AdminDashboard() { {stats?.servers && (
- +
+ +

{t('adminDashboard.servers.title')}

@@ -506,7 +533,9 @@ export default function AdminDashboard() { {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
- +
+ +

{t('adminDashboard.tariffs.title')}

{t('adminDashboard.tariffs.subtitle')}

diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index e386d8a..2743827 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,393 +1,331 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' -// Icons - smaller versions for mobile -const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +// Modern icons with consistent styling +const ChartBarIcon = () => ( + + + +) + +const BanknotesIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const ChatBubbleIcon = () => ( + + + +) + +const NoSymbolIcon = () => ( + + + +) + +const CreditCardIcon = () => ( + + + +) + +const TicketIcon = () => ( + ) -const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const GiftIcon = () => ( + + + +) + +const MegaphoneIcon = () => ( + + + +) + +const PaperAirplaneIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CogIcon = () => ( + ) -const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const DevicePhoneMobileIcon = () => ( + ) -const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const ServerStackIcon = () => ( + ) -const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - +const CubeTransparentIcon = () => ( + + ) const ChevronRightIcon = () => ( - + ) -interface AdminSection { +interface AdminItem { to: string icon: React.ReactNode - mobileIcon: React.ReactNode title: string description: string - color: string - bgColor: string - textColor: string } -// Mobile compact card -function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) { +interface AdminGroup { + id: string + title: string + icon: string + gradient: string + borderColor: string + iconBg: string + iconColor: string + items: AdminItem[] +} + +function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) { return ( -
-
- {mobileIcon} -
-
- {title} - - ) -} - -// Desktop card -function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) { - return ( - -
-
- {icon} -
+
+ {icon}
-

{title}

-

{description}

+

{title}

+

{description}

+
+
+
- ) } -// Section group definition -interface SectionGroup { - title: string - emoji: string - sections: AdminSection[] -} - -export default function AdminPanel() { - const { t } = useTranslation() - - // Grouped admin sections - const sectionGroups: SectionGroup[] = [ - { - title: 'Аналитика', - emoji: '📊', - sections: [ - { - to: '/admin/dashboard', - icon: , - mobileIcon: , - title: t('admin.nav.dashboard'), - description: t('admin.panel.dashboardDesc'), - color: 'success', - bgColor: 'bg-emerald-500/20', - textColor: 'text-emerald-400' - }, - { - to: '/admin/payments', - icon: , - mobileIcon: , - title: t('admin.nav.payments', 'Платежи'), - description: t('admin.panel.paymentsDesc', 'Проверка платежей'), - color: 'lime', - bgColor: 'bg-lime-500/20', - textColor: 'text-lime-400' - }, - ] - }, - { - title: 'Пользователи', - emoji: '👥', - sections: [ - { - to: '/admin/users', - icon: , - mobileIcon: , - title: t('admin.nav.users', 'Пользователи'), - description: t('admin.panel.usersDesc', 'Управление пользователями'), - color: 'indigo', - bgColor: 'bg-indigo-500/20', - textColor: 'text-indigo-400' - }, - { - to: '/admin/tickets', - icon: , - mobileIcon: , - title: t('admin.nav.tickets'), - description: t('admin.panel.ticketsDesc'), - color: 'warning', - bgColor: 'bg-amber-500/20', - textColor: 'text-amber-400' - }, - { - to: '/admin/ban-system', - icon: , - mobileIcon: , - title: t('admin.nav.banSystem'), - description: t('admin.panel.banSystemDesc'), - color: 'error', - bgColor: 'bg-red-500/20', - textColor: 'text-red-400' - }, - ] - }, - { - title: 'Тарифы и продажи', - emoji: '💰', - sections: [ - { - to: '/admin/tariffs', - icon: , - mobileIcon: , - title: t('admin.nav.tariffs'), - description: t('admin.panel.tariffsDesc'), - color: 'info', - bgColor: 'bg-cyan-500/20', - textColor: 'text-cyan-400' - }, - { - to: '/admin/promocodes', - icon: , - mobileIcon: , - title: t('admin.nav.promocodes', 'Промокоды'), - description: t('admin.panel.promocodesDesc', 'Управление промокодами'), - color: 'violet', - bgColor: 'bg-violet-500/20', - textColor: 'text-violet-400' - }, - { - to: '/admin/promo-offers', - icon: , - mobileIcon: , - title: t('admin.nav.promoOffers', 'Промопредложения'), - description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - ] - }, - { - title: 'Маркетинг', - emoji: '📣', - sections: [ - { - to: '/admin/campaigns', - icon: , - mobileIcon: , - title: t('admin.nav.campaigns', 'Кампании'), - description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/broadcasts', - icon: , - mobileIcon: , - title: t('admin.nav.broadcasts'), - description: t('admin.panel.broadcastsDesc'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/wheel', - icon: , - mobileIcon: , - title: t('admin.nav.wheel'), - description: t('admin.panel.wheelDesc'), - color: 'error', - bgColor: 'bg-rose-500/20', - textColor: 'text-rose-400' - }, - ] - }, - { - title: 'Система', - emoji: '⚙️', - sections: [ - { - to: '/admin/settings', - icon: , - mobileIcon: , - title: t('admin.nav.settings'), - description: t('admin.panel.settingsDesc'), - color: 'accent', - bgColor: 'bg-blue-500/20', - textColor: 'text-blue-400' - }, - { - to: '/admin/apps', - icon: , - mobileIcon: , - title: t('admin.nav.apps'), - description: t('admin.panel.appsDesc'), - color: 'success', - bgColor: 'bg-teal-500/20', - textColor: 'text-teal-400' - }, - { - to: '/admin/servers', - icon: , - mobileIcon: , - title: t('admin.nav.servers'), - description: t('admin.panel.serversDesc'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' - }, - { - to: '/admin/remnawave', - icon: , - mobileIcon: , - title: t('admin.nav.remnawave', 'RemnaWave'), - description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' - }, - ] - }, - ] - - // Flatten all sections for mobile view - const allSections = sectionGroups.flatMap(group => group.sections) - +function GroupSection({ group }: { group: AdminGroup }) { return ( -
- {/* Header - compact on mobile */} -
-
- -
-
-

{t('admin.panel.title')}

-

{t('admin.panel.subtitle')}

+
+ {/* Group Header */} +
+
+ {group.icon} +

{group.title}

- {/* Mobile: Compact grid (all sections) */} -
- {allSections.map((section) => ( - - ))} -
- - {/* Tablet/Desktop: Grouped sections */} -
- {sectionGroups.map((group) => ( -
- {/* Group header */} -
- {group.emoji} -

{group.title}

-
- - {/* Group cards */} -
- {group.sections.map((section) => ( - - ))} -
-
+ {/* Group Items */} +
+ {group.items.map((item) => ( + + ))} +
+
+ ) +} + +export default function AdminPanel() { + const { t } = useTranslation() + + const groups: AdminGroup[] = [ + { + id: 'analytics', + title: t('admin.groups.analytics', 'Аналитика'), + icon: '📊', + gradient: 'from-emerald-500/10 to-emerald-500/5', + borderColor: 'border-emerald-500/20', + iconBg: 'bg-emerald-500/20', + iconColor: 'text-emerald-400', + items: [ + { + to: '/admin/dashboard', + icon: , + title: t('admin.nav.dashboard'), + description: t('admin.panel.dashboardDesc'), + }, + { + to: '/admin/payments', + icon: , + title: t('admin.nav.payments', 'Платежи'), + description: t('admin.panel.paymentsDesc', 'История и проверка платежей'), + }, + ], + }, + { + id: 'users', + title: t('admin.groups.users', 'Пользователи'), + icon: '👥', + gradient: 'from-blue-500/10 to-blue-500/5', + borderColor: 'border-blue-500/20', + iconBg: 'bg-blue-500/20', + iconColor: 'text-blue-400', + items: [ + { + to: '/admin/users', + icon: , + title: t('admin.nav.users', 'Пользователи'), + description: t('admin.panel.usersDesc', 'Управление пользователями'), + }, + { + to: '/admin/tickets', + icon: , + title: t('admin.nav.tickets'), + description: t('admin.panel.ticketsDesc'), + }, + { + to: '/admin/ban-system', + icon: , + title: t('admin.nav.banSystem'), + description: t('admin.panel.banSystemDesc'), + }, + ], + }, + { + id: 'tariffs', + title: t('admin.groups.tariffs', 'Тарифы и продажи'), + icon: '💰', + gradient: 'from-amber-500/10 to-amber-500/5', + borderColor: 'border-amber-500/20', + iconBg: 'bg-amber-500/20', + iconColor: 'text-amber-400', + items: [ + { + to: '/admin/tariffs', + icon: , + title: t('admin.nav.tariffs'), + description: t('admin.panel.tariffsDesc'), + }, + { + to: '/admin/promocodes', + icon: , + title: t('admin.nav.promocodes', 'Промокоды'), + description: t('admin.panel.promocodesDesc', 'Управление промокодами'), + }, + { + to: '/admin/promo-offers', + icon: , + title: t('admin.nav.promoOffers', 'Промопредложения'), + description: t('admin.panel.promoOffersDesc', 'Персональные скидки'), + }, + ], + }, + { + id: 'marketing', + title: t('admin.groups.marketing', 'Маркетинг'), + icon: '📣', + gradient: 'from-rose-500/10 to-rose-500/5', + borderColor: 'border-rose-500/20', + iconBg: 'bg-rose-500/20', + iconColor: 'text-rose-400', + items: [ + { + to: '/admin/campaigns', + icon: , + title: t('admin.nav.campaigns', 'Кампании'), + description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), + }, + { + to: '/admin/broadcasts', + icon: , + title: t('admin.nav.broadcasts'), + description: t('admin.panel.broadcastsDesc'), + }, + { + to: '/admin/wheel', + icon: , + title: t('admin.nav.wheel'), + description: t('admin.panel.wheelDesc'), + }, + ], + }, + { + id: 'system', + title: t('admin.groups.system', 'Система'), + icon: '⚙️', + gradient: 'from-violet-500/10 to-violet-500/5', + borderColor: 'border-violet-500/20', + iconBg: 'bg-violet-500/20', + iconColor: 'text-violet-400', + items: [ + { + to: '/admin/settings', + icon: , + title: t('admin.nav.settings'), + description: t('admin.panel.settingsDesc'), + }, + { + to: '/admin/apps', + icon: , + title: t('admin.nav.apps'), + description: t('admin.panel.appsDesc'), + }, + { + to: '/admin/servers', + icon: , + title: t('admin.nav.servers'), + description: t('admin.panel.serversDesc'), + }, + { + to: '/admin/remnawave', + icon: , + title: t('admin.nav.remnawave', 'RemnaWave'), + description: t('admin.panel.remnawaveDesc', 'Управление панелью'), + }, + ], + }, + ] + + return ( +
+ {/* Header */} +
+

{t('admin.panel.title')}

+

{t('admin.panel.subtitle')}

+
+ + {/* Groups Grid */} +
+ {groups.map((group) => ( + ))}
From 3bab058983a537e1c9b799cc590456e35c572c33 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 07:51:17 +0300 Subject: [PATCH 17/17] Enhance AdminPanel with modern icon components for improved visual consistency - Introduced new icon components for analytics, users, tariffs, marketing, and system sections. - Updated AdminGroup interface to accept React nodes for icons, enhancing flexibility. - Refactored icon rendering in GroupSection for better styling and layout. --- src/pages/AdminPanel.tsx | 48 ++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 2743827..3708998 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,6 +1,38 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' +// Group header icons +const AnalyticsGroupIcon = () => ( + + + +) + +const UsersGroupIcon = () => ( + + + +) + +const TariffsGroupIcon = () => ( + + + +) + +const MarketingGroupIcon = () => ( + + + +) + +const SystemGroupIcon = () => ( + + + + +) + // Modern icons with consistent styling const ChartBarIcon = () => ( @@ -109,7 +141,7 @@ interface AdminItem { interface AdminGroup { id: string title: string - icon: string + icon: React.ReactNode gradient: string borderColor: string iconBg: string @@ -143,7 +175,9 @@ function GroupSection({ group }: { group: AdminGroup }) { {/* Group Header */}
- {group.icon} +
+ {group.icon} +

{group.title}

@@ -170,7 +204,7 @@ export default function AdminPanel() { { id: 'analytics', title: t('admin.groups.analytics', 'Аналитика'), - icon: '📊', + icon: , gradient: 'from-emerald-500/10 to-emerald-500/5', borderColor: 'border-emerald-500/20', iconBg: 'bg-emerald-500/20', @@ -193,7 +227,7 @@ export default function AdminPanel() { { id: 'users', title: t('admin.groups.users', 'Пользователи'), - icon: '👥', + icon: , gradient: 'from-blue-500/10 to-blue-500/5', borderColor: 'border-blue-500/20', iconBg: 'bg-blue-500/20', @@ -222,7 +256,7 @@ export default function AdminPanel() { { id: 'tariffs', title: t('admin.groups.tariffs', 'Тарифы и продажи'), - icon: '💰', + icon: , gradient: 'from-amber-500/10 to-amber-500/5', borderColor: 'border-amber-500/20', iconBg: 'bg-amber-500/20', @@ -251,7 +285,7 @@ export default function AdminPanel() { { id: 'marketing', title: t('admin.groups.marketing', 'Маркетинг'), - icon: '📣', + icon: , gradient: 'from-rose-500/10 to-rose-500/5', borderColor: 'border-rose-500/20', iconBg: 'bg-rose-500/20', @@ -280,7 +314,7 @@ export default function AdminPanel() { { id: 'system', title: t('admin.groups.system', 'Система'), - icon: '⚙️', + icon: , gradient: 'from-violet-500/10 to-violet-500/5', borderColor: 'border-violet-500/20', iconBg: 'bg-violet-500/20',