mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -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">
|
||||
<BellIcon />
|
||||
<p className="mt-2 text-sm">{t('notifications.noNotifications', 'No notifications')}</p>
|
||||
<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-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>
|
||||
)}
|
||||
<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 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 }
|
||||
}
|
||||
|
||||
@@ -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": "注册",
|
||||
|
||||
@@ -1044,3 +1044,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