feat(NotificationsPage): implement tabbed navigation for notifications and improve loading state handling

This commit is contained in:
estifanos
2026-08-05 09:19:56 +00:00
parent 7d706aa990
commit bedc7d5f8c
2 changed files with 59 additions and 20 deletions

View File

@@ -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<Tab, string> = {
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<Tab>('all');
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
const [markRead] = useMarkNotificationReadMutation();
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
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'}
</Text>
{items.length === 0 ? (
<SegmentedControl
size="xs"
mb="md"
value={tab}
onChange={(v) => setTab(v as Tab)}
data={[
{ label: 'All', value: 'all' },
{ label: 'Unseen', value: 'unseen' },
{ label: 'Seen', value: 'seen' },
]}
/>
{isLoading ? (
<Center h={300}>
<Loader />
</Center>
) : items.length === 0 ? (
<Card withBorder padding="xl">
<Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">No notifications yet.</Text>
<Text c="dimmed">{EMPTY_COPY[tab]}</Text>
<Text size="sm" c="dimmed">
You will be notified as your applications progress.
</Text>

View File

@@ -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() {
}}
>
<AppSidebar
navItems={NAV_SECTIONS.map((section) => ({
label: section.label,
items: section.items.map(({ i18nKey, ...rest }) => ({
...rest,
label: t(i18nKey),
})),
}))}
navItems={sections}
collapsed={sidebarCollapsed}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}