diff --git a/src/components/layout/horizontal/NavbarContent.tsx b/src/components/layout/horizontal/NavbarContent.tsx index 82a48b9..540d1ba 100644 --- a/src/components/layout/horizontal/NavbarContent.tsx +++ b/src/components/layout/horizontal/NavbarContent.tsx @@ -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 ( -
+
- {/* Hide Logo on Smaller screens */} {!isBreakpointReached && ( @@ -132,10 +35,8 @@ const NavbarContent = () => {
- - + - {/* Notification Dropdown, quick access menu dropdown, user dropdown will be placed here */}
) diff --git a/src/components/layout/shared/NotificationsDropdown.tsx b/src/components/layout/shared/NotificationsDropdown.tsx index b616ded..d21ba5f 100644 --- a/src/components/layout/shared/NotificationsDropdown.tsx +++ b/src/components/layout/shared/NotificationsDropdown.tsx @@ -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
{children}
- } else { - return ( - - {children} - - ) - } +const ScrollWrapper = ({ children }: { children: ReactNode }) => { + return
{children}
} -const getAvatar = ( - params: Pick -) => { - const { avatarImage, avatarIcon, avatarText, title, avatarColor, avatarSkin } = params - +const getAvatar = ({ + avatarImage, + avatarIcon, + avatarText, + title, + avatarColor, + avatarSkin +}: Pick) => { if (avatarImage) { return - } else if (avatarIcon) { + } + + if (avatarIcon) { return ( ) - } else { - return ( - - {avatarText || getInitials(title)} - - ) + } + + return ( + + {avatarText || getInitials(title)} + + ) +} + +const getNotificationAppearance = (notification: NotificationItem): Pick => { + 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([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [lastSinceId, setLastSinceId] = useState(null) + const [lastMarkedSliceId, setLastMarkedSliceId] = useState(null) - // Vars - const notificationCount = notificationsState.filter(notification => !notification.read).length - const readAll = notificationsState.every(notification => notification.read) - - // Refs const anchorRef = useRef(null) const ref = useRef(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((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, 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, 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 ( <> @@ -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' }} > @@ -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 }) => ( - +
-
- - Notifications - - {notificationCount > 0 && ( - - )} - - {notificationsState.length > 0 ? ( - readAllNotifications()} className='text-textPrimary'> - - - ) : ( - <> - )} - +
+
+ Notifications + {unreadCount > 0 ? : null} +
+ {isLoading ? : null}
-
- {slotContent} - - - + {slotContent} + - {/* */} +
diff --git a/src/libs/api/services/notificationsService.ts b/src/libs/api/services/notificationsService.ts new file mode 100644 index 0000000..c22317d --- /dev/null +++ b/src/libs/api/services/notificationsService.ts @@ -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 | 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 { + 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(`${PREFIX}/long-poll/?${searchParams.toString()}`) + + return Array.isArray(response.data) ? response.data : [] + }, + + async list(params: { farmUuid: string; page?: number; pageSize?: number }): Promise { + 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(`${PREFIX}/list/?${searchParams.toString()}`) + }, + + async markAsRead(payload: { farmUuid: string; sliceId: number }): Promise { + return apiClient.post(`${PREFIX}/mark-as-read/`, { + farm_uuid: payload.farmUuid, + slice_id: payload.sliceId + }) + } +} diff --git a/src/views/apps/ecommerce/settings/Notifications.tsx b/src/views/apps/ecommerce/settings/Notifications.tsx index 9a8d79b..5adf415 100644 --- a/src/views/apps/ecommerce/settings/Notifications.tsx +++ b/src/views/apps/ecommerce/settings/Notifications.tsx @@ -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 ( -
- {title} -
- - - - - - - - - - {data.map((data, index) => ( - - - - - - ))} - -
TypeEmailApp
{data.type} - - - -
-
-
- ) -} +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([]) + const [loading, setLoading] = useState(false) + const [markingAll, setMarkingAll] = useState(false) + const [error, setError] = useState(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 ( - - - +
+
+ Notifications + Live farm alerts for the currently selected farm appear here. +
+ + 0 ? 'error' : 'default'} label={`${unreadCount} unread`} /> + + + +
+ + + + {!farmUuid ? ( + برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید. + ) : error ? ( + {error} + ) : loading ? ( +
+ +
+ ) : notifications.length === 0 ? ( + هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است. + ) : ( +
+ {notifications.map(notification => ( + + +
+
+ {notification.title} + {notification.message} +
+ + + + +
+ + {format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')} + +
+
+ ))} +
+ )}
) diff --git a/src/views/pages/account-settings/index.tsx b/src/views/pages/account-settings/index.tsx index c959565..6cb1d94 100644 --- a/src/views/pages/account-settings/index.tsx +++ b/src/views/pages/account-settings/index.tsx @@ -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={} iconPosition='start' value='billing-plans' - /> + > + */} } iconPosition='start' value='notifications' /> - } iconPosition='start' value='connections' /> */} + {/*} iconPosition='start' value='connections' /> */} diff --git a/src/views/pages/account-settings/notifications/index.tsx b/src/views/pages/account-settings/notifications/index.tsx index 7e78d48..7b5015e 100644 --- a/src/views/pages/account-settings/notifications/index.tsx +++ b/src/views/pages/account-settings/notifications/index.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [markingAll, setMarkingAll] = useState(false) + const [error, setError] = useState(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 ( - {tNotif('permissionRequest')} - {tNotif('requestPermission')} - + title='Notifications' + subheader='All notifications for the currently selected farm are listed here.' + action={ + + 0 ? 'error' : 'default'} label={`${unreadCount} unread`} /> + + + } /> -
-
- - - - - - - - - - - {tableData.map((data, index) => ( - - - - - - + + + + + {!farmUuid ? ( + برای دیدن نوتیفیکیشن ها، ابتدا یک مزرعه فعال انتخاب کنید. + ) : error ? ( + {error} + ) : loading ? ( +
+ +
+ ) : notifications.length === 0 ? ( + هنوز نوتیفیکیشنی برای این مزرعه ثبت نشده است. + ) : ( + <> +
+ {notifications.map(notification => ( + + +
+
+ {notification.title} + {notification.message} +
+ + + + +
+ + + + {format(new Date(notification.created_at), 'yyyy-MM-dd HH:mm')} + + + Since ID: {notification.since_id} + + +
+
))} -
-
{tNotif('type')}{t('email')}BrowserApp
- {data.type} - - - - - - -
-
- - {tNotif('whenToSend')} - - - - {tNotif('onlyWhenOnline')} - {tNotif('anytime')} - - - - - - - - -
+
+ + {totalPages > 1 ? ( + + setPage(value)} /> + + ) : null} + + )} + ) }