feat: notification center frontend core (PR-3)

- Bell icon in icon registry (Phosphor Bell)
- NotificationProvider context with polling (15s unread / 30s list)
- useNotifications() hook with optimistic updates
- NotificationCenter component: bell + badge + dropdown panel
- NotificationItem component: severity icon, title, relative time, actions
- AppShell integration: mount in header-actions, hidden on mobile
- CSS styles: dropdown, items, unread/read states, empty state
- formatRelativeTime utility (custom, no new deps)
- 25 frontend tests (9 hook + 6 item + 10 center)

Quality gates: vitest 25 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-29 13:14:50 +02:00
parent cbd3436ff7
commit 2bec205a30
15 changed files with 1974 additions and 402 deletions
@@ -0,0 +1,64 @@
import { Icon } from "./icon";
import { formatRelativeTime } from "../utils/time";
import type { NotificationItem as NotificationItemType } from "../api/notifications";
export interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}
import type { IconName } from "../utils/icons";
const severityIconMap: Record<string, IconName> = {
info: "info",
warning: "warning",
error: "error",
success: "success",
};
export function NotificationItem({
notification,
onMarkRead,
onDismiss,
}: NotificationItemProps) {
const isUnread = notification.read_at === null;
const iconName = severityIconMap[notification.severity] ?? "info";
return (
<li
role="listitem"
className={`notification-item ${isUnread ? "notification-item--unread" : "notification-item--read"}`}
>
<div className="notification-item-icon">
<Icon name={iconName} size="md" />
</div>
<div className="notification-item-content">
<div className="notification-item-title">{notification.title}</div>
<div className="notification-item-time">
{formatRelativeTime(notification.created_at)}
</div>
</div>
<div className="notification-item-actions">
{isUnread && (
<button
type="button"
className="notification-item-action"
onClick={() => onMarkRead(notification.id)}
aria-label="Mark read"
>
Mark read
</button>
)}
<button
type="button"
className="notification-item-action"
onClick={() => onDismiss(notification.id)}
aria-label="Dismiss"
>
Dismiss
</button>
</div>
</li>
);
}