mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Add ticket notification system with user and admin notification settings; integrate ToastProvider in main app layout
This commit is contained in:
@@ -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 {
|
||||
|
||||
62
src/api/ticketNotifications.ts
Normal file
62
src/api/ticketNotifications.ts
Normal file
@@ -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<TicketNotificationList> => {
|
||||
const response = await apiClient.get('/cabinet/tickets/notifications', {
|
||||
params: { unread_only: unreadOnly, limit, offset }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||
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<TicketNotificationList> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications', {
|
||||
params: { unread_only: unreadOnly, limit, offset }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||
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
|
||||
265
src/components/TicketNotificationBell.tsx
Normal file
265
src/components/TicketNotificationBell.tsx
Normal file
@@ -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 = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
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<HTMLDivElement>(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: <span className="text-lg">{icon}</span>,
|
||||
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 <span className="text-lg">🎫</span>
|
||||
case 'admin_reply':
|
||||
return <span className="text-lg">💬</span>
|
||||
case 'user_reply':
|
||||
return <span className="text-lg">📨</span>
|
||||
default:
|
||||
return <span className="text-lg">🔔</span>
|
||||
}
|
||||
}
|
||||
|
||||
const unreadCount = unreadData?.unread_count || 0
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2.5 rounded-xl transition-all duration-200 hover:bg-dark-800/50 text-dark-400 hover:text-dark-100"
|
||||
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 sm:w-96 bg-dark-900 border border-dark-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-800/50">
|
||||
<h3 className="text-sm font-semibold text-dark-100">
|
||||
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
className="flex items-center gap-1 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('notifications.markAllRead', 'Mark all read')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications list */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-dark-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
||||
notificationsData.items.map((notification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-dark-800 last:border-b-0 hover:bg-dark-800/50 transition-colors ${
|
||||
!notification.is_read ? 'bg-accent-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{formatTime(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="w-2 h-2 bg-accent-500 rounded-full block"></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-dark-500">
|
||||
<BellIcon />
|
||||
<p className="mt-2 text-sm">{t('notifications.noNotifications', 'No notifications')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notificationsData?.items && notificationsData.items.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-dark-700 bg-dark-800/30">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
navigate(isAdmin ? '/admin/tickets' : '/support')
|
||||
}}
|
||||
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1"
|
||||
>
|
||||
{t('notifications.viewAll', 'View all tickets')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/components/Toast.tsx
Normal file
154
src/components/Toast.tsx
Normal file
@@ -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<Toast, 'id'>) => void
|
||||
hideToast: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(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<Toast[]>([])
|
||||
|
||||
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
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 (
|
||||
<ToastContext.Provider value={{ showToast, hideToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onClose={hideToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, onClose }: { toasts: Toast[], onClose: (id: string) => void }) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`
|
||||
${getBgColor()}
|
||||
${toast.onClick ? 'cursor-pointer hover:scale-[1.02]' : ''}
|
||||
pointer-events-auto
|
||||
backdrop-blur-sm
|
||||
text-white
|
||||
px-4 py-3
|
||||
rounded-xl
|
||||
shadow-xl
|
||||
flex items-start gap-3
|
||||
animate-slide-in-right
|
||||
transition-transform duration-200
|
||||
`}
|
||||
>
|
||||
{toast.icon && (
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{toast.icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
{toast.onClick && (
|
||||
<p className="text-xs opacity-80 mt-0.5">Click to view</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(toast.id) }}
|
||||
className="flex-shrink-0 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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: <span className="text-lg">💬</span>,
|
||||
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: <span className="text-lg">🎫</span>,
|
||||
onClick: () => {
|
||||
navigate(`/admin/tickets?ticket=${ticketId}`)
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
}, [showToast, navigate])
|
||||
|
||||
return { showNewReplyToast, showNewTicketToast }
|
||||
}
|
||||
@@ -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) {
|
||||
)}
|
||||
|
||||
<PromoDiscountBadge />
|
||||
<TicketNotificationBell isAdmin={isAdmin} />
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Profile - Desktop */}
|
||||
|
||||
144
src/hooks/useWebSocket.ts
Normal file
144
src/hooks/useWebSocket.ts
Normal file
@@ -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<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление тарифами",
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeColorsProvider>
|
||||
<App />
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ThemeColorsProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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 }) {
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Cabinet Notifications */}
|
||||
<div className="border-t border-dark-800/50 pt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.cabinetNotifications')}</h3>
|
||||
|
||||
{/* User Notifications */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.cabinet_user_notifications_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, cabinet_user_notifications_enabled: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-dark-100 font-medium">{t('admin.tickets.userNotificationsEnabled')}</div>
|
||||
<div className="text-sm text-dark-500">{t('admin.tickets.userNotificationsEnabledDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Admin Notifications */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.cabinet_admin_notifications_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, cabinet_admin_notifications_enabled: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-dark-100 font-medium">{t('admin.tickets.adminNotificationsEnabled')}</div>
|
||||
<div className="text-sm text-dark-500">{t('admin.tickets.adminNotificationsEnabledDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-800/50 pt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.slaSettings')}</h3>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user