From feb1f0fe509fbc551d4ff2b36cb06b49cafb791e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:04:42 +0300 Subject: [PATCH] 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 } +}