mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -32,9 +32,11 @@ export const ticketNotificationsApi = {
|
||||
|
||||
// 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 }
|
||||
})
|
||||
const params: Record<string, unknown> = { limit, offset }
|
||||
if (unreadOnly) {
|
||||
params.unread_only = true
|
||||
}
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<span className="text-lg">🎫</span>
|
||||
) : isAdminReply ? (
|
||||
<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({
|
||||
type: 'info',
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
icon: <span className="text-lg">{icon}</span>,
|
||||
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
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@@ -183,9 +204,9 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
|
||||
{/* 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">
|
||||
<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 */}
|
||||
<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">
|
||||
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
||||
</h3>
|
||||
@@ -193,7 +214,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
<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"
|
||||
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')}
|
||||
@@ -204,24 +225,24 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
{/* Notifications list */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{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>
|
||||
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
||||
notificationsData.items.map((notification) => (
|
||||
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 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' : ''
|
||||
}`}
|
||||
>
|
||||
<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)}
|
||||
</div>
|
||||
<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}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
@@ -229,30 +250,32 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="w-2 h-2 bg-accent-500 rounded-full block"></span>
|
||||
<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 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 />
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
{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
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
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')}
|
||||
</button>
|
||||
|
||||
@@ -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<Toast, 'id'>) => void
|
||||
hideToast: (id: string) => void
|
||||
showToast: (options: ToastOptions) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
@@ -28,127 +30,162 @@ export function useToast() {
|
||||
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 }
|
||||
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 (
|
||||
<ToastContext.Provider value={{ showToast, hideToast }}>
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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: (
|
||||
<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
|
||||
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
|
||||
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}
|
||||
>
|
||||
{toast.icon && (
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{toast.icon}
|
||||
{/* 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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<p className="text-sm text-dark-300 leading-relaxed">
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(toast.id) }}
|
||||
className="flex-shrink-0 text-white/70 hover:text-white transition-colors"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
)}
|
||||
|
||||
<PromoDiscountBadge />
|
||||
<TicketNotificationBell isAdmin={isAdmin} />
|
||||
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Profile - Desktop */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ثبت نام",
|
||||
|
||||
@@ -47,7 +47,11 @@
|
||||
"newNotification": "Новое уведомление",
|
||||
"clickToView": "Нажмите для просмотра",
|
||||
"newTicket": "Новый тикет: {{title}}",
|
||||
"newReply": "Новый ответ в тикете"
|
||||
"newReply": "Новый ответ в тикете: {{title}}",
|
||||
"newTicketTitle": "Новый тикет",
|
||||
"newReplyTitle": "Новый ответ",
|
||||
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
||||
"newUserReplyTitle": "Ответ пользователя"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Вход",
|
||||
|
||||
@@ -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": "注册",
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -537,7 +537,6 @@ export interface UnreadCountResponse {
|
||||
unread_count: number
|
||||
}
|
||||
|
||||
// Extended TicketSettings with cabinet notifications
|
||||
export interface TicketSettings {
|
||||
sla_enabled: boolean
|
||||
sla_minutes: number
|
||||
|
||||
Reference in New Issue
Block a user