From af96eecac7bb487684e092168419991002b3bae9 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 00:41:55 +0300 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 acfebd78788b5a774a7c84fc2f75e144ed2483b7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 19 Jan 2026 01:31:29 +0300 Subject: [PATCH 4/4] 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 },