Refactor ticket notifications handling: improve API parameter management, enhance toast notifications with titles and icons, and update localization for new notification messages. Adjust layout to ensure proper admin state handling in TicketNotificationBell component.

This commit is contained in:
PEDZEO
2026-01-19 01:37:25 +03:00
11 changed files with 246 additions and 128 deletions

View File

@@ -8,18 +8,24 @@ jobs:
lint-and-build: lint-and-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run ESLint - name: Run ESLint
run: npm run lint run: npm run lint
- name: Run TypeScript check - name: Run TypeScript check
run: npx tsc --noEmit run: npx tsc --noEmit
- name: Build - name: Build
run: npm run build run: npm run build
env: env:
@@ -27,6 +33,7 @@ jobs:
VITE_TELEGRAM_BOT_USERNAME: test_bot VITE_TELEGRAM_BOT_USERNAME: test_bot
VITE_APP_NAME: Cabinet VITE_APP_NAME: Cabinet
VITE_APP_LOGO: V VITE_APP_LOGO: V
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

View File

@@ -32,9 +32,11 @@ export const ticketNotificationsApi = {
// Admin notifications // Admin notifications
getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => { getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { const params: Record<string, unknown> = { limit, offset }
params: { unread_only: unreadOnly, limit, offset } if (unreadOnly) {
}) params.unread_only = true
}
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
return response.data return response.data
}, },

View File

@@ -35,18 +35,39 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
// Show toast for WebSocket notification // Show toast for WebSocket notification
const showWSNotificationToast = useCallback((message: WSMessage) => { const showWSNotificationToast = useCallback((message: WSMessage) => {
const icon = message.type === 'ticket.new' ? '🎫' : const isNewTicket = message.type === 'ticket.new'
message.type === 'ticket.admin_reply' ? '💬' : '📨' const isAdminReply = message.type === 'ticket.admin_reply'
const isUserReply = message.type === 'ticket.user_reply'
const toastMessage = message.message || const icon = isNewTicket ? (
(message.type === 'ticket.new' <span className="text-lg">🎫</span>
? t('notifications.newTicket', 'New ticket: {{title}}', { title: message.title }) ) : isAdminReply ? (
: t('notifications.newReply', 'New reply in ticket')) <span className="text-lg">💬</span>
) : (
<span className="text-lg">📨</span>
)
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({ showToast({
type: 'info', type: 'info',
title: toastTitle,
message: toastMessage, message: toastMessage,
icon: <span className="text-lg">{icon}</span>, icon,
onClick: () => { onClick: () => {
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`)
}, },
@@ -175,7 +196,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
> >
<BellIcon /> <BellIcon />
{unreadCount > 0 && ( {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"> <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} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
)} )}
@@ -183,9 +204,9 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Dropdown */} {/* Dropdown */}
{isOpen && ( {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"> <div className="fixed sm:absolute top-16 sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto 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 */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-800/50"> <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"> <h3 className="text-sm font-semibold text-dark-100">
{t('notifications.ticketNotifications', 'Ticket Notifications')} {t('notifications.ticketNotifications', 'Ticket Notifications')}
</h3> </h3>
@@ -193,7 +214,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
<button <button
onClick={() => markAllReadMutation.mutate()} onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending} disabled={markAllReadMutation.isPending}
className="flex items-center gap-1 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50" className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
> >
<CheckIcon /> <CheckIcon />
{t('notifications.markAllRead', 'Mark all read')} {t('notifications.markAllRead', 'Mark all read')}
@@ -204,24 +225,24 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Notifications list */} {/* Notifications list */}
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{isLoading ? ( {isLoading ? (
<div className="p-4 text-center text-dark-500"> <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 className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
</div> </div>
) : notificationsData?.items && notificationsData.items.length > 0 ? ( ) : notificationsData?.items && notificationsData.items.length > 0 ? (
notificationsData.items.map((notification) => ( notificationsData.items.map((notification: TicketNotification) => (
<button <button
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} 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 ${ 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' : '' !notification.is_read ? 'bg-accent-500/5' : ''
}`} }`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
{getNotificationIcon(notification.notification_type)} {getNotificationIcon(notification.notification_type)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}> <p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
{notification.message} {notification.message}
</p> </p>
<p className="text-xs text-dark-500 mt-1"> <p className="text-xs text-dark-500 mt-1">
@@ -229,30 +250,32 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
</p> </p>
</div> </div>
{!notification.is_read && ( {!notification.is_read && (
<div className="flex-shrink-0"> <div className="flex-shrink-0 pt-1">
<span className="w-2 h-2 bg-accent-500 rounded-full block"></span> <span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
</div> </div>
)} )}
</div> </div>
</button> </button>
)) ))
) : ( ) : (
<div className="p-8 text-center text-dark-500"> <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 /> <BellIcon />
<p className="mt-2 text-sm">{t('notifications.noNotifications', 'No notifications')}</p> </div>
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
</div> </div>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
{notificationsData?.items && notificationsData.items.length > 0 && ( {notificationsData?.items && notificationsData.items.length > 0 && (
<div className="px-4 py-2 border-t border-dark-700 bg-dark-800/30"> <div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30">
<button <button
onClick={() => { onClick={() => {
setIsOpen(false) setIsOpen(false)
navigate(isAdmin ? '/admin/tickets' : '/support') navigate(isAdmin ? '/admin/tickets' : '/support')
}} }}
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1" className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors"
> >
{t('notifications.viewAll', 'View all tickets')} {t('notifications.viewAll', 'View all tickets')}
</button> </button>

View File

@@ -1,18 +1,20 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react' import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
interface Toast { interface ToastOptions {
id: string type?: 'success' | 'error' | 'info' | 'warning'
message: string message: string
type: 'info' | 'success' | 'warning' | 'error' title?: string
icon?: ReactNode icon?: ReactNode
onClick?: () => void
duration?: number duration?: number
onClick?: () => void
}
interface Toast extends ToastOptions {
id: number
} }
interface ToastContextType { interface ToastContextType {
showToast: (toast: Omit<Toast, 'id'>) => void showToast: (options: ToastOptions) => void
hideToast: (id: string) => void
} }
const ToastContext = createContext<ToastContextType | null>(null) const ToastContext = createContext<ToastContextType | null>(null)
@@ -28,127 +30,162 @@ export function useToast() {
export function ToastProvider({ children }: { children: ReactNode }) { export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const showToast = useCallback((toast: Omit<Toast, 'id'>) => { const showToast = useCallback((options: ToastOptions) => {
const id = Math.random().toString(36).substring(2, 9) const id = Date.now()
const newToast = { ...toast, id } 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(() => { setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) 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)) setToasts(prev => prev.filter(t => t.id !== id))
}, []) }, [])
return ( return (
<ToastContext.Provider value={{ showToast, hideToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {children}
<ToastContainer toasts={toasts} onClose={hideToast} />
{/* 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> </ToastContext.Provider>
) )
} }
function ToastContainer({ toasts, onClose }: { toasts: Toast[], onClose: (id: string) => void }) { function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => 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 = () => { const handleClick = () => {
if (toast.onClick) { if (toast.onClick) {
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: (
<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 ( return (
<div <div
onClick={handleClick}
className={` className={`
${getBgColor()}
${toast.onClick ? 'cursor-pointer hover:scale-[1.02]' : ''}
pointer-events-auto pointer-events-auto
backdrop-blur-sm w-80 sm:w-96
text-white ${style.bg}
px-4 py-3 backdrop-blur-xl
rounded-xl border ${style.border}
shadow-xl rounded-2xl
flex items-start gap-3 shadow-2xl shadow-black/20
overflow-hidden
animate-slide-in-right animate-slide-in-right
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
transition-transform duration-200 transition-transform duration-200
`} `}
onClick={handleClick}
> >
{toast.icon && ( {/* Glow effect */}
<div className="flex-shrink-0 mt-0.5"> <div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
{toast.icon}
<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> </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>
)} )}
<div className="flex-1 min-w-0"> <p className="text-sm text-dark-300 leading-relaxed">
<p className="text-sm font-medium">{toast.message}</p> {toast.message}
{toast.onClick && ( </p>
<p className="text-xs opacity-80 mt-0.5">Click to view</p>
)}
</div> </div>
{/* Close button */}
<button <button
onClick={(e) => { e.stopPropagation(); onClose(toast.id) }} onClick={(e) => {
className="flex-shrink-0 text-white/70 hover:text-white transition-colors" 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}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </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>
) )
} }
// 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 }
}

View File

@@ -338,7 +338,7 @@ export default function Layout({ children }: LayoutProps) {
)} )}
<PromoDiscountBadge /> <PromoDiscountBadge />
<TicketNotificationBell isAdmin={isAdmin} /> <TicketNotificationBell isAdmin={isAdminActive()} />
<LanguageSwitcher /> <LanguageSwitcher />
{/* Profile - Desktop */} {/* Profile - Desktop */}

View File

@@ -47,7 +47,11 @@
"newNotification": "New notification", "newNotification": "New notification",
"clickToView": "Click to view", "clickToView": "Click to view",
"newTicket": "New ticket: {{title}}", "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": { "auth": {
"login": "Login", "login": "Login",

View File

@@ -33,6 +33,24 @@
"info": "اطلاعات", "info": "اطلاعات",
"wheel": "چرخ شانس" "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": { "auth": {
"login": "ورود", "login": "ورود",
"register": "ثبت نام", "register": "ثبت نام",

View File

@@ -47,7 +47,11 @@
"newNotification": "Новое уведомление", "newNotification": "Новое уведомление",
"clickToView": "Нажмите для просмотра", "clickToView": "Нажмите для просмотра",
"newTicket": "Новый тикет: {{title}}", "newTicket": "Новый тикет: {{title}}",
"newReply": "Новый ответ в тикете" "newReply": "Новый ответ в тикете: {{title}}",
"newTicketTitle": "Новый тикет",
"newReplyTitle": "Новый ответ",
"newUserReply": "Ответ пользователя в тикете: {{title}}",
"newUserReplyTitle": "Ответ пользователя"
}, },
"auth": { "auth": {
"login": "Вход", "login": "Вход",

View File

@@ -33,6 +33,24 @@
"info": "信息", "info": "信息",
"wheel": "幸运转盘" "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": { "auth": {
"login": "登录", "login": "登录",
"register": "注册", "register": "注册",

View File

@@ -1044,3 +1044,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

@@ -537,7 +537,6 @@ export interface UnreadCountResponse {
unread_count: number unread_count: number
} }
// Extended TicketSettings with cabinet notifications
export interface TicketSettings { export interface TicketSettings {
sla_enabled: boolean sla_enabled: boolean
sla_minutes: number sla_minutes: number