184 lines
7.0 KiB
TypeScript
184 lines
7.0 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, useState } from 'react';
|
|
import Skeleton from '@mui/material/Skeleton';
|
|
import Stack from '@mui/material/Stack';
|
|
import Typography from '@mui/material/Typography';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { useRouter } from 'next/navigation';
|
|
import AppButton from '@/components/common/AppButton';
|
|
import { AppIcon } from '@/components/common';
|
|
import { NOTIFICATIONS_PAGE_SIZE } from '@/services/notifications/constants';
|
|
import {
|
|
notificationDeepLink,
|
|
useMarkAllRead,
|
|
useMarkNotificationRead,
|
|
useNotifications,
|
|
} from '@/services/notifications';
|
|
import type { AppNotification } from '@/services/notifications/types';
|
|
import { formatRelativeTime, formatShamsiDate } from '@/utils';
|
|
import NotificationRow from './NotificationRow';
|
|
|
|
export interface NotificationCenterProps {
|
|
role: 'customer' | 'nurse';
|
|
}
|
|
|
|
interface NotificationGroup {
|
|
key: string;
|
|
label: string;
|
|
items: AppNotification[];
|
|
}
|
|
|
|
/**
|
|
* Buckets notifications into امروز/دیروز/این هفته, then per-day Shamsi headers for anything older —
|
|
* emitted **in list order** (the server's unread-first-then-newest ordering is preserved; §5 "keep the
|
|
* mark-read UX as is"), so a bucket can recur if an older unread item is pinned above newer read ones.
|
|
* Compares already-formatted date strings (not raw ms diffs) so a day boundary is calendar-exact.
|
|
*/
|
|
function groupByDay(items: AppNotification[], locale: string, todayLabel: string, yesterdayLabel: string, thisWeekLabel: string): NotificationGroup[] {
|
|
const now = new Date();
|
|
const todayStr = formatShamsiDate(now, locale);
|
|
const yesterday = new Date(now);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
const yesterdayStr = formatShamsiDate(yesterday, locale);
|
|
const thisWeekStrs = new Set<string>();
|
|
for (let i = 2; i < 7; i += 1) {
|
|
const d = new Date(now);
|
|
d.setDate(d.getDate() - i);
|
|
thisWeekStrs.add(formatShamsiDate(d, locale));
|
|
}
|
|
|
|
const groups: NotificationGroup[] = [];
|
|
for (const item of items) {
|
|
const dayStr = formatShamsiDate(item.createdAt, locale);
|
|
const bucket =
|
|
dayStr === todayStr
|
|
? { key: 'today', label: todayLabel }
|
|
: dayStr === yesterdayStr
|
|
? { key: 'yesterday', label: yesterdayLabel }
|
|
: thisWeekStrs.has(dayStr)
|
|
? { key: 'this_week', label: thisWeekLabel }
|
|
: { key: dayStr, label: dayStr };
|
|
|
|
const last = groups[groups.length - 1];
|
|
if (last && last.key === bucket.key) last.items.push(item);
|
|
else groups.push({ ...bucket, items: [item] });
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* The notification center — a paged, **unread-first** list, day-grouped (امروز/دیروز/این هفته, then Shamsi
|
|
* dates) with relative timestamps decaying to Shamsi. Each row **marks itself read on open** (optimistic);
|
|
* a row whose `data` deep-links renders interactive with a trailing chevron, a row with nothing to open
|
|
* renders as a plain, non-rippling surface. A "mark all read" action clears the badge at once. Empty /
|
|
* loading-skeleton / error→retry states. Shared by the customer and nurse notification pages (role decides
|
|
* only the deep-link shell).
|
|
* @component NotificationCenter
|
|
*/
|
|
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => {
|
|
const t = useTranslations('notifications');
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
const [limit, setLimit] = useState(NOTIFICATIONS_PAGE_SIZE);
|
|
|
|
const { data, isLoading, isError, refetch, isFetching } = useNotifications(limit);
|
|
const markRead = useMarkNotificationRead();
|
|
const markAll = useMarkAllRead();
|
|
|
|
const items = data?.items ?? [];
|
|
const total = data?.total ?? 0;
|
|
const hasUnread = items.some((n) => !n.isRead);
|
|
const groups = groupByDay(items, locale, t('group_today'), t('group_yesterday'), t('group_this_week'));
|
|
|
|
const openNotification = (notification: AppNotification) => {
|
|
if (!notification.isRead) markRead.mutate(notification.id);
|
|
const target = notificationDeepLink(notification, role);
|
|
if (target) router.push(`/${locale}${target}`);
|
|
};
|
|
|
|
return (
|
|
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
|
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
|
{t('title')}
|
|
</Typography>
|
|
{hasUnread ? (
|
|
<AppButton
|
|
variant="text"
|
|
color="primary"
|
|
onClick={() => markAll.mutate()}
|
|
disabled={markAll.isPending}
|
|
>
|
|
{t('mark_all_read')}
|
|
</AppButton>
|
|
) : null}
|
|
</Stack>
|
|
|
|
{isLoading ? (
|
|
<Stack sx={{ gap: 1 }}>
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} variant="rounded" height={72} />
|
|
))}
|
|
</Stack>
|
|
) : isError ? (
|
|
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
|
|
<AppIcon icon="error" size={32} color="var(--bal-error)" />
|
|
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
|
{t('error_body')}
|
|
</Typography>
|
|
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
|
{t('retry')}
|
|
</AppButton>
|
|
</Stack>
|
|
) : items.length === 0 ? (
|
|
<Stack sx={{ gap: 1, alignItems: 'center', py: 6 }}>
|
|
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
{t('empty_title')}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
|
{t('empty_body')}
|
|
</Typography>
|
|
</Stack>
|
|
) : (
|
|
<Stack sx={{ gap: 2 }}>
|
|
{groups.map((group) => (
|
|
<Stack key={`${group.key}-${group.items[0].id}`} sx={{ gap: 1 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-text-secondary)' }}>
|
|
{group.label}
|
|
</Typography>
|
|
<Stack sx={{ gap: 1 }}>
|
|
{group.items.map((notification) => {
|
|
const target = notificationDeepLink(notification, role);
|
|
return (
|
|
<NotificationRow
|
|
key={notification.id}
|
|
notification={notification}
|
|
timeLabel={formatRelativeTime(notification.createdAt, locale, formatShamsiDate)}
|
|
navigable={target != null}
|
|
onOpen={() => openNotification(notification)}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Stack>
|
|
))}
|
|
{total > items.length ? (
|
|
<AppButton
|
|
variant="text"
|
|
color="primary"
|
|
onClick={() => setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)}
|
|
disabled={isFetching}
|
|
sx={{ alignSelf: 'center' }}
|
|
>
|
|
{t('load_more')}
|
|
</AppButton>
|
|
) : null}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default NotificationCenter;
|