feature: version one notification feature implemented.

This commit is contained in:
2026-07-18 01:09:54 +03:30
parent 0740493384
commit 9f6ec193d2
40 changed files with 1679 additions and 224 deletions

View File

@@ -0,0 +1,149 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Bell } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { NavBadgePill } from '@/components/ui/shared/NavBadgePill';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import type { UserNotificationItem } from '@/types/notifications';
const DROPDOWN_LIMIT = 10;
export function NotificationBell() {
const t = useTranslations('notifications');
const router = useRouter();
const { lastNotification, unreadCount: liveUnread, setUnreadCount } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [unreadCount, setLocalUnread] = useState(0);
const [loading, setLoading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const refreshUnread = useCallback(async () => {
try {
const res = await notificationsApi.inboxUnreadCount();
const count = res.data.count;
setLocalUnread(count);
setUnreadCount(count);
} catch {
/* ignore */
}
}, [setUnreadCount]);
const loadRecent = useCallback(async () => {
setLoading(true);
try {
const res = await notificationsApi.listInbox({ limit: DROPDOWN_LIMIT });
setItems(res.data.items);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshUnread();
}, [refreshUnread]);
useEffect(() => {
if (typeof liveUnread === 'number') {
setLocalUnread(liveUnread);
}
}, [liveUnread]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev].slice(0, DROPDOWN_LIMIT);
});
}, [lastNotification]);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
if (open) {
void loadRecent();
}
}, [open, loadRecent]);
const handleSelect = async (item: UserNotificationItem) => {
setOpen(false);
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
await refreshUnread();
}
} catch {
/* still navigate */
}
router.push(item.href);
};
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={t('bellAria')}
aria-expanded={open}
aria-haspopup="dialog"
>
<Bell className="h-[18px] w-[18px] icon-flat" />
{unreadCount > 0 ? (
<span className="absolute -top-1 -end-1">
<NavBadgePill count={unreadCount} ariaLabel={t('unreadCount', { count: unreadCount })} />
</span>
) : null}
</button>
{open ? (
<div
role="dialog"
aria-label={t('dropdownTitle')}
className="absolute end-0 z-[200] mt-2 w-[min(22rem,calc(100vw-1.5rem))] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-3 py-2">
<p className="text-sm font-medium text-text-primary">{t('dropdownTitle')}</p>
<Link
href="/notifications"
className="text-xs text-primary hover:underline"
onClick={() => setOpen(false)}
>
{t('viewAll')}
</Link>
</div>
<div className="max-h-[min(24rem,60vh)] overflow-y-auto p-2 space-y-1.5">
{loading && items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-xs text-text-muted px-2 py-3">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard key={item.id} item={item} onSelect={handleSelect} />
))
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { formatAppDateTime } from '@/lib/i18n/format';
import type { UserNotificationItem, UserNotificationType } from '@/types/notifications';
const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_SENT: 'typeCaseSent',
CLINIC_COMMENT: 'typeClinicComment',
LAB_COMMENT: 'typeLabComment',
LAB_COMMENT_CLINIC: 'typeLabCommentClinic',
CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned',
CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite',
};
export function NotificationCard({
item,
onSelect,
}: {
item: UserNotificationItem;
onSelect: (item: UserNotificationItem) => void;
}) {
const t = useTranslations('notifications');
const locale = useLocale();
const unread = !item.readAt;
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
return (
<button
type="button"
onClick={() => onSelect(item)}
className={`w-full text-start rounded-[var(--radius-md)] border px-3 py-2.5 transition-colors ${
unread
? 'border-primary/40 bg-primary/5 hover:border-primary/60'
: 'border-border/70 bg-background-secondary/40 hover:border-border'
}`}
>
<div className="flex items-start gap-2">
{unread ? (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg" aria-hidden />
) : (
<span className="mt-1.5 h-2 w-2 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text-primary">{t(titleKey)}</p>
<p className="text-[11px] text-text-muted mt-0.5">
{formatAppDateTime(item.createdAt, locale)}
</p>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { notificationsApi } from '@/lib/api/notifications';
import { useRealtime } from '@/lib/realtime/RealtimeProvider';
import { Button } from '@/components/ui/shared/Button';
import { NotificationCard } from '@/components/ui/notifications/NotificationCard';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { UserNotificationItem } from '@/types/notifications';
export function NotificationsPage() {
const t = useTranslations('notifications');
const tErrors = useTranslations('errors');
const router = useRouter();
const toast = useToast();
const { lastNotification, setUnreadCount } = useRealtime();
const [items, setItems] = useState<UserNotificationItem[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadPage = useCallback(async (cursor?: string | null, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const res = await notificationsApi.listInbox({
limit: 20,
cursor: cursor ?? undefined,
});
setItems((prev) => (append ? [...prev, ...res.data.items] : res.data.items));
setNextCursor(res.data.nextCursor);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorLoad')));
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [t, tErrors, toast]);
useEffect(() => {
void loadPage();
}, [loadPage]);
useEffect(() => {
if (!lastNotification) return;
setItems((prev) => {
if (prev.some((item) => item.id === lastNotification.id)) return prev;
return [lastNotification, ...prev];
});
}, [lastNotification]);
const handleSelect = async (item: UserNotificationItem) => {
try {
if (!item.readAt) {
await notificationsApi.markInboxRead(item.id);
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, readAt: new Date().toISOString() } : row,
),
);
const countRes = await notificationsApi.inboxUnreadCount();
setUnreadCount(countRes.data.count);
}
} catch {
/* still navigate */
}
router.push(item.href);
};
const handleMarkAll = async () => {
try {
await notificationsApi.markInboxReadAll();
setItems((prev) =>
prev.map((row) => ({ ...row, readAt: row.readAt ?? new Date().toISOString() })),
);
setUnreadCount(0);
} catch (error: unknown) {
toast.showError(getUserFacingError(error, tErrors, t('errorMarkRead')));
}
};
return (
<div className="space-y-4 max-w-2xl">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
<p className="text-sm text-text-muted mt-1">{t('pageSubtitle')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => void handleMarkAll()}>
{t('markAllRead')}
</Button>
</div>
<div className="space-y-2">
{loading ? (
<p className="text-sm text-text-muted">{t('loading')}</p>
) : items.length === 0 ? (
<p className="text-sm text-text-muted surface-card p-4">{t('empty')}</p>
) : (
items.map((item) => (
<NotificationCard key={item.id} item={item} onSelect={(row) => void handleSelect(row)} />
))
)}
</div>
{nextCursor ? (
<Button
variant="outline"
size="sm"
isLoading={loadingMore}
onClick={() => void loadPage(nextCursor, true)}
>
{t('loadMore')}
</Button>
) : null}
</div>
);
}