import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router'; 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 { useTelegramSDK } from '../hooks/useTelegramSDK'; 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); const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); // Calculate dropdown top position (account for fullscreen safe area + TG buttons) const dropdownTop = isFullscreen ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header : 64; // default header height // Show toast for WebSocket notification const showWSNotificationToast = useCallback( (message: WSMessage) => { const isNewTicket = message.type === 'ticket.new'; const isAdminReply = message.type === 'ticket.admin_reply'; const isUserReply = message.type === 'ticket.user_reply'; 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, 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 */} setIsOpen(!isOpen)} className={`relative rounded-xl border p-2 transition-all duration-200 ${ isOpen ? 'border-dark-600 bg-dark-700 text-accent-400' : 'border-dark-700/50 bg-dark-800/50 text-dark-400 hover:bg-dark-700 hover:text-accent-400' }`} title={t('notifications.ticketNotifications', 'Ticket notifications')} > {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} {/* Dropdown */} {isOpen && ( {/* Header */} {t('notifications.ticketNotifications', 'Ticket Notifications')} {unreadCount > 0 && ( markAllReadMutation.mutate()} disabled={markAllReadMutation.isPending} className="flex items-center gap-1.5 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50" > {t('notifications.markAllRead', 'Mark all read')} )} {/* Notifications list */} {isLoading ? ( ) : notificationsData?.items && notificationsData.items.length > 0 ? ( notificationsData.items.map((notification: TicketNotification) => ( handleNotificationClick(notification)} className={`w-full border-b border-dark-800/50 px-4 py-3 text-left transition-all duration-200 last:border-b-0 hover:bg-dark-800/50 ${ !notification.is_read ? 'bg-accent-500/5' : '' }`} > {getNotificationIcon(notification.notification_type)} {notification.message} {formatTime(notification.created_at)} {!notification.is_read && ( )} )) ) : ( {t('notifications.noNotifications', 'No notifications')} )} {/* Footer */} {notificationsData?.items && notificationsData.items.length > 0 && ( { setIsOpen(false); navigate(isAdmin ? '/admin/tickets' : '/support'); }} className="w-full py-1 text-center text-sm text-accent-400 transition-colors hover:text-accent-300" > {t('notifications.viewAll', 'View all tickets')} )} )} ); }
{notification.message}
{formatTime(notification.created_at)}
{t('notifications.noNotifications', 'No notifications')}