From bedc7d5f8c035d0a9c21448dae90ac5690d4932c Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 5 Aug 2026 09:19:56 +0000 Subject: [PATCH] feat(NotificationsPage): implement tabbed navigation for notifications and improve loading state handling --- .../notifications/pages/NotificationsPage.tsx | 49 ++++++++++++++----- apps/portal/src/app/layouts/PortalLayout.tsx | 30 +++++++++--- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx index 175dec7e5..b2b109de1 100644 --- a/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx +++ b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Badge, @@ -7,6 +8,7 @@ import { Container, Group, Loader, + SegmentedControl, Stack, Text, Title, @@ -15,9 +17,18 @@ import { IconBellOff, IconCheck } from '@tabler/icons-react'; import { localized, useGetNotificationsQuery, + useGetUnseenNotificationsQuery, useMarkNotificationReadMutation, } from '@ema-platform/api'; +type Tab = 'all' | 'unseen' | 'seen'; + +const EMPTY_COPY: Record = { + all: 'No notifications yet.', + unseen: 'Nothing unread.', + seen: 'No read notifications.', +}; + /** * The applicant's notification inbox. * @@ -26,19 +37,15 @@ import { */ export function NotificationsPage() { const navigate = useNavigate(); - const { data, isLoading } = useGetNotificationsQuery(); + const [tab, setTab] = useState('all'); + const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' }); + const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' }); const [markRead] = useMarkNotificationReadMutation(); - if (isLoading) { - return ( -
- -
- ); - } - - const items = data?.items ?? []; - const unread = items.filter((n) => !n.isSeen).length; + const { data, isLoading } = tab === 'unseen' ? unseen : all; + const items = + tab === 'seen' ? (data?.items ?? []).filter((n) => n.isSeen) : (data?.items ?? []); + const unread = all.data?.items.filter((n) => !n.isSeen).length ?? 0; async function open(id: string, seen: boolean, link?: string) { if (!seen) await markRead(id); @@ -52,11 +59,27 @@ export function NotificationsPage() { {unread > 0 ? `${unread} unread` : 'You are all caught up'} - {items.length === 0 ? ( + setTab(v as Tab)} + data={[ + { label: 'All', value: 'all' }, + { label: 'Unseen', value: 'unseen' }, + { label: 'Seen', value: 'seen' }, + ]} + /> + + {isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( - No notifications yet. + {EMPTY_COPY[tab]} You will be notified as your applications progress. diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 7c340930f..38474e143 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -16,14 +16,18 @@ import { IconUserCircle, } from "@tabler/icons-react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; import { notify, AppHeader, AppSidebar } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui"; import { BrandMark, logout } from "@ema-platform/auth"; +import { useGetUnseenNotificationsQuery } from "@ema-platform/api"; import { SUPPORTED_LANGUAGES } from "../i18n/config"; import { useAppSelector } from "../store/hooks"; +const BADGE_POLL_MS = 60_000; + type PortalNavItem = NavItem & { i18nKey: string }; /** @@ -170,6 +174,24 @@ export function PortalLayout() { const user = useAppSelector((state) => state.auth.user); const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure(); const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false); + const { data: unseen } = useGetUnseenNotificationsQuery(undefined, { + pollingInterval: BADGE_POLL_MS, + refetchOnMountOrArgChange: false, + }); + + const sections = useMemo( + () => + NAV_SECTIONS.map((section) => ({ + label: section.label, + items: section.items.map(({ i18nKey, ...rest }) => ({ + ...rest, + label: t(i18nKey), + badge: + rest.to === "/notifications" && unseen?.count ? unseen.count : undefined, + })), + })), + [t, unseen?.count], + ); // Breadcrumb trail const segments = location.pathname.split("/").filter(Boolean); @@ -246,13 +268,7 @@ export function PortalLayout() { }} > ({ - label: section.label, - items: section.items.map(({ i18nKey, ...rest }) => ({ - ...rest, - label: t(i18nKey), - })), - }))} + navItems={sections} collapsed={sidebarCollapsed} activePath={location.pathname} onToggleCollapse={toggleSidebar}