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 }, diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index d7c0704..b2cd7da 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -35,18 +35,39 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi // Show toast for WebSocket notification const showWSNotificationToast = useCallback((message: WSMessage) => { - const icon = message.type === 'ticket.new' ? '🎫' : - message.type === 'ticket.admin_reply' ? '💬' : '📨' + const isNewTicket = message.type === 'ticket.new' + const isAdminReply = message.type === 'ticket.admin_reply' + const isUserReply = message.type === 'ticket.user_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')) + const icon = isNewTicket ? ( + 🎫 + ) : isAdminReply ? ( + 💬 + ) : ( + 📨 + ) + + 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: toastTitle, message: toastMessage, - icon: {icon}, + icon, onClick: () => { navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) }, @@ -175,7 +196,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi > {unreadCount > 0 && ( - + {unreadCount > 99 ? '99+' : unreadCount} )} @@ -183,9 +204,9 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Dropdown */} {isOpen && ( -
+
{/* Header */} -
+

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

@@ -193,7 +214,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi )) ) : ( -
- -

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

+
+
+ +
+

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

)}
{/* Footer */} {notificationsData?.items && notificationsData.items.length > 0 && ( -
+
diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index fa0d3fd..7ce53e7 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,18 +1,20 @@ import { createContext, useContext, useState, useCallback, ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' -interface Toast { - id: string +interface ToastOptions { + type?: 'success' | 'error' | 'info' | 'warning' message: string - type: 'info' | 'success' | 'warning' | 'error' + title?: string icon?: ReactNode - onClick?: () => void duration?: number + onClick?: () => void +} + +interface Toast extends ToastOptions { + id: number } interface ToastContextType { - showToast: (toast: Omit) => void - hideToast: (id: string) => void + showToast: (options: ToastOptions) => void } const ToastContext = createContext(null) @@ -28,127 +30,162 @@ export function useToast() { 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 } + const showToast = useCallback((options: ToastOptions) => { + const id = Date.now() + const toast: Toast = { id, duration: 5000, type: 'info', ...options } - setToasts(prev => [...prev, newToast]) + setToasts(prev => [...prev, toast]) - // Auto remove after duration (default 6 seconds) setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)) - }, toast.duration || 6000) + }, toast.duration) }, []) - const hideToast = useCallback((id: string) => { + const removeToast = useCallback((id: number) => { setToasts(prev => prev.filter(t => t.id !== id)) }, []) return ( - + {children} - + + {/* Toast Container */} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
) } -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' - } - } - +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { const handleClick = () => { if (toast.onClick) { toast.onClick() - onClose(toast.id) + 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 (
- {toast.icon && ( -
- {toast.icon} + {/* Glow effect */} +
+ +
+
+ {/* Icon */} +
+ {toast.icon || defaultIcons[toast.type || 'info']} +
+ + {/* Content */} +
+ {toast.title && ( +

+ {toast.title} +

+ )} +

+ {toast.message} +

+
+ + {/* Close button */} + +
+ + {/* Progress bar */} +
+
- )} -
-

{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 c8d27d9..ad725d1 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -338,7 +338,7 @@ export default function Layout({ children }: LayoutProps) { )} - + {/* Profile - Desktop */} 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": "注册", 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 a71cefe..7d3febb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -537,7 +537,6 @@ export interface UnreadCountResponse { unread_count: number } -// Extended TicketSettings with cabinet notifications export interface TicketSettings { sla_enabled: boolean sla_minutes: number