This commit is contained in:
2026-04-05 05:09:32 +03:30
parent ae1bbc126f
commit 3f15890393
7 changed files with 600 additions and 569 deletions
@@ -4,16 +4,11 @@ import Link from 'next/link'
// Third-party Imports
import classnames from 'classnames'
// Type Imports
import type { ShortcutsType } from '@components/layout/shared/ShortcutsDropdown'
import type { NotificationsType } from '@components/layout/shared/NotificationsDropdown'
// Component Imports
import NavToggle from './NavToggle'
import Logo from '@components/layout/shared/Logo'
import NavSearch from '@components/layout/shared/search'
import ModeDropdown from '@components/layout/shared/ModeDropdown'
import ShortcutsDropdown from '@components/layout/shared/ShortcutsDropdown'
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
import UserDropdown from '@components/layout/shared/UserDropdown'
@@ -23,105 +18,13 @@ import useHorizontalNav from '@menu/hooks/useHorizontalNav'
// Util Imports
import { horizontalLayoutClasses } from '@layouts/utils/layoutClasses'
// Vars
const shortcuts: ShortcutsType[] = [
{
url: '/apps/calendar',
icon: 'tabler-calendar',
title: 'Calendar',
subtitle: 'Appointments'
},
{
url: '/apps/invoice/list',
icon: 'tabler-file-dollar',
title: 'Invoice App',
subtitle: 'Manage Accounts'
},
{
url: '/apps/user/list',
icon: 'tabler-user',
title: 'Users',
subtitle: 'Manage Users'
},
{
url: '/apps/roles',
icon: 'tabler-users-group',
title: 'Role Management',
subtitle: 'Permissions'
},
{
url: '/',
icon: 'tabler-device-desktop-analytics',
title: 'Dashboard',
subtitle: 'User Dashboard'
},
{
url: '/pages/account-settings',
icon: 'tabler-settings',
title: 'Settings',
subtitle: 'Account Settings'
}
]
const notifications: NotificationsType[] = [
{
avatarImage: '/images/avatars/8.png',
title: 'Congratulations Flora 🎉',
subtitle: 'Won the monthly bestseller gold badge',
time: '1h ago',
read: false
},
{
title: 'Cecilia Becker',
avatarColor: 'secondary',
subtitle: 'Accepted your connection',
time: '12h ago',
read: false
},
{
avatarImage: '/images/avatars/3.png',
title: 'Bernard Woods',
subtitle: 'You have new message from Bernard Woods',
time: 'May 18, 8:26 AM',
read: true
},
{
avatarIcon: 'tabler-chart-bar',
title: 'Monthly report generated',
subtitle: 'July month financial report is generated',
avatarColor: 'info',
time: 'Apr 24, 10:30 AM',
read: true
},
{
avatarText: 'MG',
title: 'Application has been approved 🚀',
subtitle: 'Your Meta Gadgets project application has been approved.',
avatarColor: 'success',
time: 'Feb 17, 12:17 PM',
read: true
},
{
avatarIcon: 'tabler-mail',
title: 'New message from Harry',
subtitle: 'You have new message from Harry',
avatarColor: 'error',
time: 'Jan 6, 1:48 PM',
read: true
}
]
const NavbarContent = () => {
// Hooks
const { isBreakpointReached } = useHorizontalNav()
return (
<div
className={classnames(horizontalLayoutClasses.navbarContent, 'flex items-center justify-between gap-4 is-full')}
>
<div className={classnames(horizontalLayoutClasses.navbarContent, 'flex items-center justify-between gap-4 is-full')}>
<div className='flex items-center gap-4'>
<NavToggle />
{/* Hide Logo on Smaller screens */}
{!isBreakpointReached && (
<Link href='/'>
<Logo />
@@ -132,10 +35,8 @@ const NavbarContent = () => {
<div className='flex items-center'>
<NavSearch />
<ModeDropdown />
<ShortcutsDropdown shortcuts={shortcuts} />
<NotificationsDropdown notifications={notifications} />
<NotificationsDropdown />
<UserDropdown />
{/* Notification Dropdown, quick access menu dropdown, user dropdown will be placed here */}
</div>
</div>
)
@@ -1,10 +1,10 @@
'use client'
// React Imports
import { useRef, useState, useEffect } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import Link from 'next/link'
// MUI Imports
import IconButton from '@mui/material/IconButton'
import Badge from '@mui/material/Badge'
import Popper from '@mui/material/Popper'
@@ -13,32 +13,26 @@ import Paper from '@mui/material/Paper'
import ClickAwayListener from '@mui/material/ClickAwayListener'
import Typography from '@mui/material/Typography'
import Chip from '@mui/material/Chip'
import Tooltip from '@mui/material/Tooltip'
import Divider from '@mui/material/Divider'
import Avatar from '@mui/material/Avatar'
import useMediaQuery from '@mui/material/useMediaQuery'
import CircularProgress from '@mui/material/CircularProgress'
import Box from '@mui/material/Box'
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import useMediaQuery from '@mui/material/useMediaQuery'
import type { Theme } from '@mui/material/styles'
// Third Party Components
import classnames from 'classnames'
import PerfectScrollbar from 'react-perfect-scrollbar'
import { formatDistanceToNow } from 'date-fns'
// Type Imports
import type { ThemeColor } from '@core/types'
import type { CustomAvatarProps } from '@core/components/mui/Avatar'
// Component Imports
import CustomAvatar from '@core/components/mui/Avatar'
// Config Imports
import themeConfig from '@configs/themeConfig'
// Hook Imports
import { useSettings } from '@core/hooks/useSettings'
// Util Imports
import { getInitials } from '@/utils/getInitials'
import { useFarmHub } from '@/hooks/useFarmHub'
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
export type NotificationsType = {
title: string
@@ -69,107 +63,204 @@ export type NotificationsType = {
}
)
const ScrollWrapper = ({ children, hidden }: { children: ReactNode; hidden: boolean }) => {
if (hidden) {
return <div className='overflow-x-hidden bs-full'>{children}</div>
} else {
return (
<PerfectScrollbar className='bs-full' options={{ wheelPropagation: false, suppressScrollX: true }}>
{children}
</PerfectScrollbar>
)
}
const ScrollWrapper = ({ children }: { children: ReactNode }) => {
return <div className='overflow-y-auto overflow-x-hidden grow min-bs-0 max-bs-[360px]'>{children}</div>
}
const getAvatar = (
params: Pick<NotificationsType, 'avatarImage' | 'avatarIcon' | 'title' | 'avatarText' | 'avatarColor' | 'avatarSkin'>
) => {
const { avatarImage, avatarIcon, avatarText, title, avatarColor, avatarSkin } = params
const getAvatar = ({
avatarImage,
avatarIcon,
avatarText,
title,
avatarColor,
avatarSkin
}: Pick<NotificationsType, 'avatarImage' | 'avatarIcon' | 'title' | 'avatarText' | 'avatarColor' | 'avatarSkin'>) => {
if (avatarImage) {
return <Avatar src={avatarImage} />
} else if (avatarIcon) {
}
if (avatarIcon) {
return (
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
<i className={avatarIcon} />
</CustomAvatar>
)
} else {
}
return (
<CustomAvatar color={avatarColor} skin={avatarSkin || 'light-static'}>
{avatarText || getInitials(title)}
</CustomAvatar>
)
}
const getNotificationAppearance = (notification: NotificationItem): Pick<NotificationsType, 'avatarIcon' | 'avatarColor'> => {
switch (notification.level) {
case 'critical':
return { avatarIcon: 'tabler-alert-triangle', avatarColor: 'error' }
case 'warning':
return { avatarIcon: 'tabler-alert-circle', avatarColor: 'warning' }
default:
return { avatarIcon: 'tabler-info-circle', avatarColor: 'info' }
}
}
const NotificationDropdown = ({ notifications }: { notifications: NotificationsType[] }) => {
// States
const NotificationsDropdown = () => {
const [open, setOpen] = useState(false)
const [notificationsState, setNotificationsState] = useState(notifications)
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastSinceId, setLastSinceId] = useState<number | null>(null)
const [lastMarkedSliceId, setLastMarkedSliceId] = useState<number | null>(null)
// Vars
const notificationCount = notificationsState.filter(notification => !notification.read).length
const readAll = notificationsState.every(notification => notification.read)
// Refs
const anchorRef = useRef<HTMLButtonElement>(null)
const ref = useRef<HTMLDivElement | null>(null)
const pollingAbortRef = useRef(false)
const { farmHub } = useFarmHub()
// Hooks
const hidden = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg'))
const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'))
const { settings } = useSettings()
const farmUuid = farmHub?.farm_uuid ?? null
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
const latestUnreadSinceId = useMemo(() => {
return notifications.reduce<number | null>((latest, item) => {
if (item.is_read) {
return latest
}
if (latest === null || item.since_id > latest) {
return item.since_id
}
return latest
}, null)
}, [notifications])
const markNotificationsAsRead = useCallback(
async (sliceId: number) => {
if (!farmUuid || sliceId <= 0 || lastMarkedSliceId === sliceId) {
return
}
try {
await notificationsService.markAsRead({ farmUuid, sliceId })
setNotifications(current => current.map(item => (item.since_id <= sliceId ? { ...item, is_read: true } : item)))
setLastMarkedSliceId(sliceId)
} catch (markError) {
console.error('Failed to mark notifications as read', markError)
}
},
[farmUuid, lastMarkedSliceId]
)
const fetchNotifications = useCallback(
async (timeout: number, sinceId?: number) => {
if (!farmUuid) {
setNotifications([])
setLastSinceId(null)
return [] as NotificationItem[]
}
const response = await notificationsService.longPoll({ farmUuid, sinceId, timeout })
if (response.length > 0 || sinceId === undefined) {
setNotifications(response)
}
if (response.length > 0) {
setLastSinceId(response[response.length - 1]?.since_id ?? sinceId ?? null)
}
return response
},
[farmUuid]
)
useEffect(() => {
pollingAbortRef.current = false
if (!farmUuid) {
setNotifications([])
setError(null)
setIsLoading(false)
setLastSinceId(null)
setLastMarkedSliceId(null)
return
}
const poll = async () => {
setIsLoading(true)
setError(null)
try {
const initialItems = await fetchNotifications(0)
const initialLastSinceId = initialItems[initialItems.length - 1]?.since_id ?? null
let nextSinceId = initialLastSinceId
if (!pollingAbortRef.current) {
setIsLoading(false)
}
while (!pollingAbortRef.current) {
const newItems = await notificationsService.longPoll({ farmUuid, sinceId: nextSinceId ?? undefined, timeout: 15 })
if (pollingAbortRef.current) {
return
}
if (newItems.length > 0) {
setNotifications(newItems)
nextSinceId = newItems[newItems.length - 1]?.since_id ?? nextSinceId
setLastSinceId(nextSinceId)
}
}
} catch (pollError) {
if (!pollingAbortRef.current) {
setError(pollError instanceof Error ? pollError.message : 'Unable to load notifications.')
setIsLoading(false)
}
}
}
poll()
return () => {
pollingAbortRef.current = true
}
}, [farmUuid, fetchNotifications])
useEffect(() => {
const adjustPopoverHeight = () => {
if (ref.current) {
const availableHeight = window.innerHeight - 100
ref.current.style.height = `${Math.min(availableHeight, 550)}px`
}
}
adjustPopoverHeight()
window.addEventListener('resize', adjustPopoverHeight)
return () => {
window.removeEventListener('resize', adjustPopoverHeight)
}
}, [])
useEffect(() => {
if (open && latestUnreadSinceId) {
void markNotificationsAsRead(latestUnreadSinceId)
}
}, [open, latestUnreadSinceId, markNotificationsAsRead])
const handleClose = () => {
setOpen(false)
}
const handleToggle = () => {
setOpen(prevOpen => !prevOpen)
setOpen(previousOpen => !previousOpen)
}
// Read notification when notification is clicked
const handleReadNotification = (event: MouseEvent<HTMLElement>, value: boolean, index: number) => {
event.stopPropagation()
const newNotifications = [...notificationsState]
newNotifications[index].read = value
setNotificationsState(newNotifications)
}
// Remove notification when close icon is clicked
const handleRemoveNotification = (event: MouseEvent<HTMLElement>, index: number) => {
event.stopPropagation()
const newNotifications = [...notificationsState]
newNotifications.splice(index, 1)
setNotificationsState(newNotifications)
}
// Read or unread all notifications when read all icon is clicked
const readAllNotifications = () => {
const newNotifications = [...notificationsState]
newNotifications.forEach(notification => {
notification.read = !readAll
})
setNotificationsState(newNotifications)
}
useEffect(() => {
const adjustPopoverHeight = () => {
if (ref.current) {
// Calculate available height, subtracting any fixed UI elements' height as necessary
const availableHeight = window.innerHeight - 100
ref.current.style.height = `${Math.min(availableHeight, 550)}px`
}
}
window.addEventListener('resize', adjustPopoverHeight)
}, [])
return (
<>
<IconButton ref={anchorRef} onClick={handleToggle} className='text-textPrimary'>
@@ -178,10 +269,8 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
className='cursor-pointer'
variant='dot'
overlap='circular'
invisible={notificationCount === 0}
sx={{
'& .MuiBadge-dot': { top: 6, right: 5, boxShadow: 'var(--mui-palette-background-paper) 0px 0px 0px 2px' }
}}
invisible={unreadCount === 0}
sx={{ '& .MuiBadge-dot': { top: 6, right: 5, boxShadow: 'var(--mui-palette-background-paper) 0px 0px 0px 2px' } }}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<i className='tabler-bell' />
@@ -196,110 +285,69 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
anchorEl={anchorRef.current}
{...(isSmallScreen
? {
className: 'is-full !mbs-3 z-[1] max-bs-[550px] bs-[550px]',
modifiers: [
{
name: 'preventOverflow',
options: {
padding: themeConfig.layoutPadding
className: 'is-full !mbs-4 z-[1] max-bs-[550px] bs-[calc(100dvh-theme(spacing.6))] max-is-[calc(100dvw-theme(spacing.6))]'
}
}
]
}
: { className: 'is-96 !mbs-3 z-[1] max-bs-[550px] bs-[550px]' })}
: {
className: 'is-[380px] !mbs-4 z-[1] max-bs-[550px]'
})}
>
{({ TransitionProps, placement }) => (
<Fade {...TransitionProps} style={{ transformOrigin: placement === 'bottom-end' ? 'right top' : 'left top' }}>
<Paper className={classnames('bs-full', settings.skin === 'bordered' ? 'border shadow-none' : 'shadow-lg')}>
<Paper className={classnames('bs-full overflow-hidden', settings.skin === 'bordered' ? 'border shadow-none' : 'shadow-lg')}>
<ClickAwayListener onClickAway={handleClose}>
<div className='bs-full flex flex-col'>
<div className='flex items-center justify-between plb-3.5 pli-4 is-full gap-2'>
<Typography variant='h6' className='flex-auto'>
Notifications
</Typography>
{notificationCount > 0 && (
<Chip size='small' variant='tonal' color='primary' label={`${notificationCount} New`} />
)}
<Tooltip
title={readAll ? 'Mark all as unread' : 'Mark all as read'}
placement={placement === 'bottom-end' ? 'left' : 'right'}
slotProps={{
popper: {
sx: {
'& .MuiTooltip-tooltip': {
transformOrigin:
placement === 'bottom-end' ? 'right center !important' : 'right center !important'
}
}
}
}}
>
{notificationsState.length > 0 ? (
<IconButton size='small' onClick={() => readAllNotifications()} className='text-textPrimary'>
<i className={readAll ? 'tabler-mail' : 'tabler-mail-opened'} />
</IconButton>
) : (
<></>
)}
</Tooltip>
<div className='flex items-center justify-between plb-4 pli-6'>
<div className='flex items-center gap-3'>
<Typography variant='h5'>Notifications</Typography>
{unreadCount > 0 ? <Chip size='small' variant='tonal' color='primary' label={`${unreadCount} new`} /> : null}
</div>
{isLoading ? <CircularProgress size={18} /> : null}
</div>
<Divider />
<ScrollWrapper hidden={hidden}>
{notificationsState.map((notification, index) => {
const {
title,
subtitle,
time,
read,
avatarImage,
avatarIcon,
avatarText,
avatarColor,
avatarSkin
} = notification
<ScrollWrapper>
{error ? (
<Box className='p-4'>
<Alert severity='error'>{error}</Alert>
</Box>
) : !farmUuid ? (
<Box className='p-6'>
<Typography color='text.secondary'>To see notifications, first select an active farm.</Typography>
</Box>
) : notifications.length === 0 && !isLoading ? (
<Box className='p-6'>
<Typography color='text.secondary'>No notifications yet.</Typography>
</Box>
) : (
notifications.map(notification => {
const appearance = getNotificationAppearance(notification)
return (
<div
key={index}
className={classnames('flex plb-3 pli-4 gap-3 cursor-pointer hover:bg-actionHover group', {
'border-be': index !== notificationsState.length - 1
<div key={notification.uuid} className={classnames('cursor-pointer pli-6 plb-4 flex items-start gap-4', { 'bg-actionHover': !notification.is_read })}>
{getAvatar({
title: notification.title,
avatarIcon: appearance.avatarIcon,
avatarColor: appearance.avatarColor
})}
onClick={e => handleReadNotification(e, true, index)}
>
{getAvatar({ avatarImage, avatarIcon, title, avatarText, avatarColor, avatarSkin })}
<div className='flex flex-col flex-auto'>
<Typography variant='body2' className='font-medium mbe-1' color='text.primary'>
{title}
<div className='flex grow flex-col gap-1'>
<Typography className='font-medium' color='text.primary'>
{notification.title}
</Typography>
<Typography variant='caption' color='text.secondary' className='mbe-2'>
{subtitle}
<Typography variant='body2' color='text.secondary'>
{notification.message}
</Typography>
<Typography variant='caption' color='text.disabled'>
{time}
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</Typography>
</div>
<div className='flex flex-col items-end gap-2'>
<Badge
variant='dot'
color={read ? 'secondary' : 'primary'}
onClick={e => handleReadNotification(e, !read, index)}
className={classnames('mbs-1 mie-1', {
'invisible group-hover:visible': read
})}
/>
<i
className='tabler-x text-xl invisible group-hover:visible'
onClick={e => handleRemoveNotification(e, index)}
/>
</div>
</div>
)
})}
})
)}
</ScrollWrapper>
<Divider />
<div className='p-4'>
<Button fullWidth variant='contained' size='small'>
View All Notifications
<Button fullWidth component={Link} href='/pages/account-settings' variant='contained' onClick={handleClose}>
View all notifications
</Button>
</div>
</div>
@@ -312,4 +360,4 @@ const NotificationDropdown = ({ notifications }: { notifications: NotificationsT
)
}
export default NotificationDropdown
export default NotificationsDropdown
@@ -1,116 +1,19 @@
'use client'
// Third-party Imports
import classnames from 'classnames'
import { useContext } from 'react'
// Context Imports
import NavbarSlotContext from '@/contexts/navbarSlotContext'
// Type Imports
import type { ShortcutsType } from '@components/layout/shared/ShortcutsDropdown'
import type { NotificationsType } from '@components/layout/shared/NotificationsDropdown'
// Component Imports
import NavToggle from './NavToggle'
import NavSearch from '@components/layout/shared/search'
import ThemeColorDropdown from '@components/layout/shared/ThemeColorDropdown'
import ShortcutsDropdown from '@components/layout/shared/ShortcutsDropdown'
import NotificationsDropdown from '@components/layout/shared/NotificationsDropdown'
import UserDropdown from '@components/layout/shared/UserDropdown'
// Util Imports
import { verticalLayoutClasses } from '@layouts/utils/layoutClasses'
import ModeDropdown from '../shared/ModeDropdown'
// Vars
const shortcuts: ShortcutsType[] = [
{
url: '/apps/calendar',
icon: 'tabler-calendar',
title: 'Calendar',
subtitle: 'Appointments'
},
{
url: '/apps/invoice/list',
icon: 'tabler-file-dollar',
title: 'Invoice App',
subtitle: 'Manage Accounts'
},
{
url: '/apps/user/list',
icon: 'tabler-user',
title: 'Users',
subtitle: 'Manage Users'
},
{
url: '/apps/roles',
icon: 'tabler-users-group',
title: 'Role Management',
subtitle: 'Permissions'
},
{
url: '/',
icon: 'tabler-device-desktop-analytics',
title: 'Dashboard',
subtitle: 'User Dashboard'
},
{
url: '/pages/account-settings',
icon: 'tabler-settings',
title: 'Settings',
subtitle: 'Account Settings'
}
]
const notifications: NotificationsType[] = [
{
avatarImage: '/images/avatars/8.png',
title: 'Congratulations Flora 🎉',
subtitle: 'Won the monthly bestseller gold badge',
time: '1h ago',
read: false
},
{
title: 'Cecilia Becker',
avatarColor: 'secondary',
subtitle: 'Accepted your connection',
time: '12h ago',
read: false
},
{
avatarImage: '/images/avatars/3.png',
title: 'Bernard Woods',
subtitle: 'You have new message from Bernard Woods',
time: 'May 18, 8:26 AM',
read: true
},
{
avatarIcon: 'tabler-chart-bar',
title: 'Monthly report generated',
subtitle: 'July month financial report is generated',
avatarColor: 'info',
time: 'Apr 24, 10:30 AM',
read: true
},
{
avatarText: 'MG',
title: 'Application has been approved 🚀',
subtitle: 'Your Meta Gadgets project application has been approved.',
avatarColor: 'success',
time: 'Feb 17, 12:17 PM',
read: true
},
{
avatarIcon: 'tabler-mail',
title: 'New message from Harry',
subtitle: 'You have new message from Harry',
avatarColor: 'error',
time: 'Jan 6, 1:48 PM',
read: true
}
]
const NavbarContent = () => {
const { slotContent } = useContext(NavbarSlotContext)
@@ -122,11 +25,9 @@ const NavbarContent = () => {
</div>
<div className='flex items-center'>
{slotContent}
<ModeDropdown />
<ThemeColorDropdown />
{/* <NotificationsDropdown notifications={notifications} /> */}
<NotificationsDropdown />
<UserDropdown />
</div>
</div>
@@ -0,0 +1,79 @@
import { apiClient } from '../client'
const PREFIX = '/api/notifications'
export type NotificationLevel = 'info' | 'warning' | 'critical' | string
export interface NotificationItem {
uuid: string
farm_uuid: string
since_id: number
title: string
message: string
level: NotificationLevel
is_read: boolean
metadata?: Record<string, unknown> | null
created_at: string
}
export interface NotificationsLongPollResponse {
code: number
msg: string
data: NotificationItem[]
}
export interface NotificationsListResponse {
count: number
next: string | null
previous: string | null
results?: {
code: number
msg: string
data: NotificationItem[]
}
}
export interface MarkNotificationsAsReadResponse {
code: number
msg: string
marked_count: number
}
export const notificationsService = {
async longPoll(params: { farmUuid: string; sinceId?: number; timeout?: number }): Promise<NotificationItem[]> {
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid })
if (typeof params.sinceId === 'number') {
searchParams.set('since_id', String(params.sinceId))
}
if (typeof params.timeout === 'number') {
searchParams.set('timeout', String(params.timeout))
}
const response = await apiClient.get<NotificationsLongPollResponse>(`${PREFIX}/long-poll/?${searchParams.toString()}`)
return Array.isArray(response.data) ? response.data : []
},
async list(params: { farmUuid: string; page?: number; pageSize?: number }): Promise<NotificationsListResponse> {
const searchParams = new URLSearchParams({ farm_uuid: params.farmUuid })
if (typeof params.page === 'number') {
searchParams.set('page', String(params.page))
}
if (typeof params.pageSize === 'number') {
searchParams.set('page_size', String(params.pageSize))
}
return apiClient.get<NotificationsListResponse>(`${PREFIX}/list/?${searchParams.toString()}`)
},
async markAsRead(payload: { farmUuid: string; sliceId: number }): Promise<MarkNotificationsAsReadResponse> {
return apiClient.post<MarkNotificationsAsReadResponse>(`${PREFIX}/mark-as-read/`, {
farm_uuid: payload.farmUuid,
slice_id: payload.sliceId
})
}
}
@@ -1,82 +1,128 @@
// MUI Imports
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import Alert from '@mui/material/Alert'
import CircularProgress from '@mui/material/CircularProgress'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { format } from 'date-fns'
type tableData = { type: string; email: boolean; app: boolean }
type CardProps = {
title: string
data: tableData[]
}
// Vars
const customerData: tableData[] = [
{ type: 'New customer sign up', email: true, app: false },
{ type: 'Customer account password reset', email: false, app: true },
{ type: 'Customer account invite', email: false, app: false }
]
const shippingData: tableData[] = [
{ type: 'Picked up', email: true, app: false },
{ type: 'Shipping update ', email: false, app: true },
{ type: 'Delivered', email: false, app: false }
]
const ordersData: tableData[] = [
{ type: 'Order purchase', email: true, app: false },
{ type: 'Order cancelled', email: false, app: true },
{ type: 'Order refund request', email: false, app: false },
{ type: 'Order confirmation', email: false, app: true },
{ type: 'Payment error', email: false, app: true }
]
const TableCard = (props: CardProps) => {
// Props
const { title, data } = props
return (
<div className='flex flex-col gap-4'>
<Typography variant='h5'>{title}</Typography>
<div className='border rounded overflow-x-auto'>
<table className={tableStyles.table}>
<thead className='border-0'>
<tr>
<th className='is-2/4'>Type</th>
<th className='is-1/4'>Email</th>
<th className='is-1/4'>App</th>
</tr>
</thead>
<tbody>
{data.map((data, index) => (
<tr key={index}>
<td className='text-textPrimary'>{data.type}</td>
<td>
<Checkbox defaultChecked={data.email} />
</td>
<td>
<Checkbox defaultChecked={data.app} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
import { useFarmHub } from '@/hooks/useFarmHub'
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
const Notifications = () => {
const { farmHub } = useFarmHub()
const farmUuid = farmHub?.farm_uuid ?? null
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [loading, setLoading] = useState(false)
const [markingAll, setMarkingAll] = useState(false)
const [error, setError] = useState<string | null>(null)
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
const latestSinceId = useMemo(() => notifications.reduce((latest, item) => Math.max(latest, item.since_id), 0), [notifications])
const loadNotifications = useCallback(async () => {
if (!farmUuid) {
setNotifications([])
return
}
setLoading(true)
setError(null)
try {
const items = await notificationsService.longPoll({ farmUuid, timeout: 0 })
setNotifications(items.sort((first, second) => new Date(second.created_at).getTime() - new Date(first.created_at).getTime()))
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load notifications.')
} finally {
setLoading(false)
}
}, [farmUuid])
const markAllAsRead = useCallback(async () => {
if (!farmUuid || latestSinceId <= 0 || unreadCount === 0) {
return
}
setMarkingAll(true)
try {
await notificationsService.markAsRead({ farmUuid, sliceId: latestSinceId })
setNotifications(current => current.map(item => ({ ...item, is_read: true })))
} catch (markError) {
setError(markError instanceof Error ? markError.message : 'Unable to mark notifications as read.')
} finally {
setMarkingAll(false)
}
}, [farmUuid, latestSinceId, unreadCount])
useEffect(() => {
void loadNotifications()
}, [loadNotifications])
return (
<Card>
<CardContent className='flex flex-col gap-6'>
<TableCard title='Customer' data={customerData} />
<TableCard title='Orders' data={ordersData} />
<TableCard title='Shipping' data={shippingData} />
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<Typography variant='h5'>Notifications</Typography>
<Typography color='text.secondary'>Live farm alerts for the currently selected farm appear here.</Typography>
</div>
<Stack direction='row' spacing={2} alignItems='center'>
<Chip color={unreadCount > 0 ? 'error' : 'default'} label={`${unreadCount} unread`} />
<Button variant='outlined' onClick={() => void loadNotifications()} disabled={loading || !farmUuid}>
Refresh
</Button>
<Button variant='contained' onClick={() => void markAllAsRead()} disabled={markingAll || unreadCount === 0 || !farmUuid}>
Mark all as read
</Button>
</Stack>
</div>
<Divider />
{!farmUuid ? (
<Alert severity='info'>برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید.</Alert>
) : error ? (
<Alert severity='error'>{error}</Alert>
) : loading ? (
<div className='flex items-center justify-center py-10'>
<CircularProgress />
</div>
) : notifications.length === 0 ? (
<Alert severity='success'>هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است.</Alert>
) : (
<div className='flex flex-col gap-4'>
{notifications.map(notification => (
<Card key={notification.uuid} variant='outlined'>
<CardContent className='flex flex-col gap-3'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex flex-col gap-1'>
<Typography variant='h6'>{notification.title}</Typography>
<Typography color='text.secondary'>{notification.message}</Typography>
</div>
<Stack direction='row' spacing={1} alignItems='center'>
<Chip size='small' color={notification.is_read ? 'default' : 'error'} label={notification.is_read ? 'Read' : 'Unread'} />
<Chip size='small' color={notification.level === 'critical' ? 'error' : notification.level === 'warning' ? 'warning' : 'info'} label={notification.level} />
</Stack>
</div>
<Typography variant='caption' color='text.disabled'>
{format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
)
+14 -4
View File
@@ -1,8 +1,9 @@
'use client'
// React Imports
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams } from 'next/navigation'
import type { SyntheticEvent, ReactElement } from 'react'
// MUI Imports
@@ -16,7 +17,15 @@ import CustomTabList from '@core/components/mui/TabList'
const AccountSettings = ({ tabContentList }: { tabContentList: { [key: string]: ReactElement } }) => {
const t = useTranslations('accountSettings')
const [activeTab, setActiveTab] = useState('account')
const searchParams = useSearchParams()
const initialTab = searchParams.get('tab') ?? 'account'
const [activeTab, setActiveTab] = useState(initialTab)
useEffect(() => {
const nextTab = searchParams.get('tab') ?? 'account'
setActiveTab(nextTab)
}, [searchParams])
const handleChange = (event: SyntheticEvent, value: string) => {
setActiveTab(value)
@@ -35,14 +44,15 @@ const AccountSettings = ({ tabContentList }: { tabContentList: { [key: string]:
icon={<i className='tabler-bookmark' />}
iconPosition='start'
value='billing-plans'
/>
>
*/}
<Tab
label='Notifications'
icon={<i className='tabler-bell' />}
iconPosition='start'
value='notifications'
/>
<Tab label='Connections' icon={<i className='tabler-link' />} iconPosition='start' value='connections' /> */}
{/*<Tab label='Connections' icon={<i className='tabler-link' />} iconPosition='start' value='connections' /> */}
</CustomTabList>
</Grid>
<Grid size={{ xs: 12 }}>
@@ -1,126 +1,172 @@
'use client'
import { useTranslations } from 'next-intl'
import { useCallback, useEffect, useMemo, useState } from 'react'
// MUI Imports
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Checkbox from '@mui/material/Checkbox'
import MenuItem from '@mui/material/MenuItem'
import Grid from '@mui/material/Grid2'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardHeader from '@mui/material/CardHeader'
import Chip from '@mui/material/Chip'
import CircularProgress from '@mui/material/CircularProgress'
import Divider from '@mui/material/Divider'
import Pagination from '@mui/material/Pagination'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
// Component Imports
import Link from '@components/Link'
import Form from '@components/Form'
import CustomTextField from '@core/components/mui/TextField'
import { format } from 'date-fns'
// Style Imports
import tableStyles from '@core/styles/table.module.css'
import { useFarmHub } from '@/hooks/useFarmHub'
import { notificationsService, type NotificationItem } from '@/libs/api/services/notificationsService'
type TableDataType = {
type: string
app: boolean
email: boolean
browser: boolean
const PAGE_SIZE = 10
const getLevelColor = (level: NotificationItem['level']) => {
switch (level) {
case 'critical':
return 'error'
case 'warning':
return 'warning'
default:
return 'info'
}
// Vars
const tableData: TableDataType[] = [
{
app: true,
email: true,
browser: true,
type: 'New for you'
},
{
app: true,
email: true,
browser: true,
type: 'Account activity'
},
{
app: false,
email: true,
browser: true,
type: 'A new browser used to sign in'
},
{
app: false,
email: true,
browser: false,
type: 'A new device is linked'
}
]
const Notifications = () => {
const t = useTranslations('accountSettings')
const tNotif = useTranslations('accountSettings.notifications')
const { farmHub } = useFarmHub()
const farmUuid = farmHub?.farm_uuid ?? null
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [loading, setLoading] = useState(false)
const [markingAll, setMarkingAll] = useState(false)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [count, setCount] = useState(0)
const unreadCount = useMemo(() => notifications.filter(item => !item.is_read).length, [notifications])
const latestSinceId = useMemo(() => notifications.reduce((latest, item) => Math.max(latest, item.since_id), 0), [notifications])
const totalPages = useMemo(() => Math.max(1, Math.ceil(count / PAGE_SIZE)), [count])
const loadNotifications = useCallback(
async (nextPage: number) => {
if (!farmUuid) {
setNotifications([])
setCount(0)
return
}
setLoading(true)
setError(null)
try {
const response = await notificationsService.list({ farmUuid, page: nextPage, pageSize: PAGE_SIZE })
setNotifications(Array.isArray(response.results?.data) ? response.results.data : [])
setCount(typeof response.count === 'number' ? response.count : 0)
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load notifications.')
} finally {
setLoading(false)
}
},
[farmUuid]
)
const markAllAsRead = useCallback(async () => {
if (!farmUuid || latestSinceId <= 0 || unreadCount === 0) {
return
}
setMarkingAll(true)
try {
await notificationsService.markAsRead({ farmUuid, sliceId: latestSinceId })
setNotifications(current => current.map(item => ({ ...item, is_read: true })))
} catch (markError) {
setError(markError instanceof Error ? markError.message : 'Unable to mark notifications as read.')
} finally {
setMarkingAll(false)
}
}, [farmUuid, latestSinceId, unreadCount])
useEffect(() => {
setPage(1)
}, [farmUuid])
useEffect(() => {
void loadNotifications(page)
}, [loadNotifications, page])
return (
<Card>
<CardHeader
title={tNotif('recentDevices')}
subheader={
<>
{tNotif('permissionRequest')}
<Link className='text-primary'> {tNotif('requestPermission')}</Link>
</>
title='Notifications'
subheader='All notifications for the currently selected farm are listed here.'
action={
<Stack direction='row' spacing={2} alignItems='center' className='flex-wrap justify-end'>
<Chip color={unreadCount > 0 ? 'error' : 'default'} label={`${unreadCount} unread`} />
<Button variant='outlined' onClick={() => void loadNotifications(page)} disabled={loading || !farmUuid}>
Refresh
</Button>
<Button variant='contained' onClick={() => void markAllAsRead()} disabled={markingAll || unreadCount === 0 || !farmUuid}>
Mark page as read
</Button>
</Stack>
}
/>
<Form>
<div className='overflow-x-auto'>
<table className={tableStyles.table}>
<thead>
<tr>
<th>{tNotif('type')}</th>
<th>{t('email')}</th>
<th>Browser</th>
<th>App</th>
</tr>
</thead>
<tbody className='border-be'>
{tableData.map((data, index) => (
<tr key={index}>
<td>
<Typography color='text.primary'>{data.type}</Typography>
</td>
<td>
<Checkbox defaultChecked={data.email} />
</td>
<td>
<Checkbox defaultChecked={data.browser} />
</td>
<td>
<Checkbox defaultChecked={data.app} />
</td>
</tr>
))}
</tbody>
</table>
<CardContent className='flex flex-col gap-6'>
<Divider />
{!farmUuid ? (
<Alert severity='info'>برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید.</Alert>
) : error ? (
<Alert severity='error'>{error}</Alert>
) : loading ? (
<div className='flex items-center justify-center py-10'>
<CircularProgress />
</div>
<CardContent>
<Typography className='mbe-6 font-medium'>{tNotif('whenToSend')}</Typography>
<Grid container spacing={6}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<CustomTextField select fullWidth defaultValue='online'>
<MenuItem value='online'>{tNotif('onlyWhenOnline')}</MenuItem>
<MenuItem value='anytime'>{tNotif('anytime')}</MenuItem>
</CustomTextField>
</Grid>
<Grid size={{ xs: 12 }} className='flex gap-4 flex-wrap'>
<Button variant='contained' type='submit'>
{t('saveChanges')}
</Button>
<Button variant='tonal' color='secondary' type='reset'>
{tNotif('discard')}
</Button>
</Grid>
</Grid>
) : notifications.length === 0 ? (
<Alert severity='success'>هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است.</Alert>
) : (
<>
<div className='flex flex-col gap-4'>
{notifications.map(notification => (
<Card key={notification.uuid} variant='outlined'>
<CardContent className='flex flex-col gap-3'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex flex-col gap-1'>
<Typography variant='h6'>{notification.title}</Typography>
<Typography color='text.secondary'>{notification.message}</Typography>
</div>
<Stack direction='row' spacing={1} alignItems='center' className='flex-wrap justify-end'>
<Chip size='small' color={notification.is_read ? 'default' : 'error'} label={notification.is_read ? 'Read' : 'Unread'} />
<Chip size='small' color={getLevelColor(notification.level)} label={notification.level} />
</Stack>
</div>
<Box className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Typography variant='caption' color='text.disabled'>
{format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')}
</Typography>
<Typography variant='caption' color='text.disabled'>
Since ID: {notification.since_id}
</Typography>
</Box>
</CardContent>
</Card>
))}
</div>
{totalPages > 1 ? (
<Box className='flex items-center justify-center pt-2'>
<Pagination count={totalPages} page={page} color='primary' onChange={(_, value) => setPage(value)} />
</Box>
) : null}
</>
)}
</CardContent>
</Form>
</Card>
)
}