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.

This commit is contained in:
PEDZEO
2026-01-19 00:41:55 +03:00
parent 8667945501
commit af96eecac7
8 changed files with 701 additions and 1 deletions

View 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

View File

@@ -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 = () => (
<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' ? (
<span className="text-lg">🎫</span>
) : message.type === 'ticket.admin_reply' ? (
<span className="text-lg">💬</span>
) : (
<span className="text-lg">📨</span>
)
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 <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 animate-scale-in-bounce">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute right-0 mt-2 w-80 sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
<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.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
>
<CheckIcon />
{t('notifications.markAllRead', 'Mark all read')}
</button>
)}
</div>
{/* Notifications list */}
<div className="max-h-80 overflow-y-auto">
{isLoading ? (
<div className="p-8 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: TicketNotification) => (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${
!notification.is_read ? 'bg-accent-500/5' : ''
}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
{getNotificationIcon(notification.notification_type)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-relaxed ${!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 pt-1">
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
</div>
)}
</div>
</button>
))
) : (
<div className="p-8 text-center">
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500">
<BellIcon />
</div>
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
</div>
)}
</div>
{/* Footer */}
{notificationsData?.items && notificationsData.items.length > 0 && (
<div className="px-4 py-3 border-t border-dark-700/50 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 transition-colors"
>
{t('notifications.viewAll', 'View all tickets')}
</button>
</div>
)}
</div>
)}
</div>
)
}

191
src/components/Toast.tsx Normal file
View File

@@ -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<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((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 (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
)
}
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: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
}
return (
<div
className={`
pointer-events-auto
w-80 sm:w-96
${style.bg}
backdrop-blur-xl
border ${style.border}
rounded-2xl
shadow-2xl shadow-black/20
overflow-hidden
animate-slide-in-right
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
transition-transform duration-200
`}
onClick={handleClick}
>
{/* Glow effect */}
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
<div className="relative p-4">
<div className="flex gap-3">
{/* Icon */}
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}>
{toast.icon || defaultIcons[toast.type || 'info']}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pt-0.5">
{toast.title && (
<p className="text-sm font-semibold text-dark-100 mb-0.5">
{toast.title}
</p>
)}
<p className="text-sm text-dark-300 leading-relaxed">
{toast.message}
</p>
</div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation()
onClose()
}}
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 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>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
<div
className={`h-full ${style.icon.replace('text-', 'bg-')} opacity-60`}
style={{
animation: `shrink ${toast.duration}ms linear forwards`,
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import LanguageSwitcher from '../LanguageSwitcher' import LanguageSwitcher from '../LanguageSwitcher'
import PromoDiscountBadge from '../PromoDiscountBadge' import PromoDiscountBadge from '../PromoDiscountBadge'
import TicketNotificationBell from '../TicketNotificationBell'
import { contestsApi } from '../../api/contests' import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls' import { pollsApi } from '../../api/polls'
import { brandingApi } from '../../api/branding' import { brandingApi } from '../../api/branding'
@@ -337,6 +338,7 @@ export default function Layout({ children }: LayoutProps) {
)} )}
<PromoDiscountBadge /> <PromoDiscountBadge />
<TicketNotificationBell isAdmin={isAdminActive()} />
<LanguageSwitcher /> <LanguageSwitcher />
{/* Profile - Desktop */} {/* Profile - Desktop */}

144
src/hooks/useWebSocket.ts Normal file
View 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 }
}

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App' import App from './App'
import { ThemeColorsProvider } from './providers/ThemeColorsProvider' import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
import { ToastProvider } from './components/Toast'
import './i18n' import './i18n'
import './styles/globals.css' import './styles/globals.css'
@@ -21,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<ThemeColorsProvider> <ThemeColorsProvider>
<App /> <ToastProvider>
<App />
</ToastProvider>
</ThemeColorsProvider> </ThemeColorsProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -1034,3 +1034,9 @@
.animate-wheel-glow { .animate-wheel-glow {
animation: wheel-glow 2s ease-in-out infinite; animation: wheel-glow 2s ease-in-out infinite;
} }
/* Toast progress bar animation */
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}

View File

@@ -516,3 +516,22 @@ export interface ManualCheckResponse {
old_status: string | null old_status: string | null
new_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
}