feat(notifications): TODO 7 — draft decision toast + email + bell badge

This commit is contained in:
Александр Зимин
2026-05-14 14:40:35 +00:00
parent 50d263745a
commit 9050e2427e
21 changed files with 1992 additions and 82 deletions
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from '@tanstack/react-router'
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
import { useDictionaries, useDictionaryDetail, useMyNotifications } from '@/api/queries'
import { VersionBadge } from '@/components/version/VersionBadge'
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
import { NotificationsDrawer } from '@/components/notifications/NotificationsDrawer'
import { toast } from '@/ui'
import { Bell, Menu } from 'lucide-react'
/**
@@ -101,25 +104,72 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
}
/**
* Placeholder notifications bell — будет показывать N unread сообщений
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
* Сейчас static bell без counter — wire'ится когда backend notifications
* stream появится.
* Notifications bell с unread counter + drawer. TODO 7 wiring:
* <ul>
* <li>{@link useMyNotifications} с {@code unreadOnly=true} polls /me/notifications
* каждые 30s (cheap query — фильтр backend-side).</li>
* <li>Counter badge показывается если {@code totalUnread > 0}. Capped на "9+"
* чтобы layout не плыл при бурном reviewer flow.</li>
* <li>Click → opens {@link NotificationsDrawer} с full feed (unreadOnly=false).</li>
* <li>Delta-detection: если {@code totalUnread} вырос между poll'ами и drawer
* закрыт — fires `toast.info` с preview top-most new item. Reviewer'у
* не нужно держать tab focused чтобы заметить approve.</li>
* </ul>
*
* <p>«Что нового» (WhatsNewButton) — adjacent button показывает release
* notes, отделён от operational notifications (разные mental models).
*/
function NotificationsBell() {
const { t } = useTranslation()
const [drawerOpen, setDrawerOpen] = useState(false)
// Light query: только totalUnread + top item, used to drive badge + toast.
const q = useMyNotifications(true, 5)
const totalUnread = q.data?.totalUnread ?? 0
// Delta toast: detect новые notifications между polls. Hold prev count
// в ref чтобы не trigger'ить toast на первый mount (initial load).
const prevUnreadRef = useRef<number | null>(null)
useEffect(() => {
const prev = prevUnreadRef.current
if (prev != null && totalUnread > prev && !drawerOpen) {
const top = q.data?.items[0]
toast(
top?.payloadPreview ??
t('notifications.newDefault', { defaultValue: 'Новое уведомление' }),
{
description: t('notifications.toastDescription', {
defaultValue: 'Откройте колокольчик чтобы увидеть подробности.',
}),
},
)
}
prevUnreadRef.current = totalUnread
}, [totalUnread, drawerOpen, q.data, t])
return (
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
</button>
<>
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
onClick={() => setDrawerOpen(true)}
className="relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
{totalUnread > 0 && (
<span
aria-label={t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})}
className="absolute -top-1 -right-1 min-w-[1rem] h-[1rem] px-1 rounded-full bg-pink text-on-accent text-[0.625rem] leading-none font-medium tabular-nums inline-flex items-center justify-center"
>
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</button>
<NotificationsDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
)
}
@@ -0,0 +1,225 @@
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import {
Badge,
Button,
Drawer,
EmptyState,
LoadingBlock,
QueryErrorState,
} from '@/ui'
import { BellIcon, CheckIcon } from '@phosphor-icons/react'
import { useMyNotifications } from '@/api/queries'
import {
useMarkAllNotificationsRead,
useMarkNotificationRead,
} from '@/api/mutations'
import type { NotificationItem } from '@/api/client'
type Props = {
open: boolean
onClose: () => void
}
/**
* Right-side drawer показывает recent notifications для current user.
* Rendered поверх TopBar bell click. Polled через {@link useMyNotifications}
* (30s interval same как /reviews queue).
*
* <p>Каждая row показывает: channel icon → preview text → relative time →
* unread dot. Click → either deep-link (e.g. /reviews/{draftId}) ИЛИ просто
* mark-as-read inline. Backend providing `href` для approval-related events
* (draft decision toast spec из TODO 7).
*
* <p>Footer: "Mark all as read" button когда есть unread; иначе hidden.
*/
export function NotificationsDrawer({ open, onClose }: Props) {
const { t } = useTranslation()
const q = useMyNotifications(false, 20)
const markOne = useMarkNotificationRead()
const markAll = useMarkAllNotificationsRead()
const items = q.data?.items ?? []
const totalUnread = q.data?.totalUnread ?? 0
return (
<Drawer
isOpen={open}
onClose={onClose}
side="right"
widthClassName="w-full max-w-md"
title={t('notifications.title', { defaultValue: 'Уведомления' })}
description={
totalUnread > 0
? t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})
: t('notifications.allRead', { defaultValue: 'Всё прочитано' })
}
>
<div className="space-y-3">
{q.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{q.error && <QueryErrorState error={q.error} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.error && items.length === 0 && (
<EmptyState
icon={<BellIcon weight="duotone" size={28} />}
title={t('notifications.empty', {
defaultValue: 'Уведомлений пока нет',
})}
description={t('notifications.emptyDescription', {
defaultValue:
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
})}
/>
)}
{items.length > 0 && (
<ul className="divide-y divide-line">
{items.map((n) => (
<NotificationRow
key={n.id}
item={n}
onMarkRead={() => markOne.mutate(n.id)}
onClose={onClose}
/>
))}
</ul>
)}
{totalUnread > 0 && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<Button
variant="secondary"
size="sm"
leftIcon={<CheckIcon weight="bold" size={14} />}
onClick={() => markAll.mutate()}
loading={markAll.isPending}
disabled={markAll.isPending}
>
{t('notifications.markAllRead', {
defaultValue: 'Прочитать все',
})}
</Button>
</div>
)}
</div>
</Drawer>
)
}
function NotificationRow({
item,
onMarkRead,
onClose,
}: {
item: NotificationItem
onMarkRead: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const content = (
<div className="flex items-start gap-3 py-3 group">
{/* Unread dot — visual cue для еще-не-прочитанных. Скрыт когда read.
aria-label чтобы screen reader озвучил статус. */}
<span
aria-label={
item.read
? t('notifications.read', { defaultValue: 'Прочитано' })
: t('notifications.unread', { defaultValue: 'Непрочитано' })
}
className={
'mt-1.5 size-2 shrink-0 rounded-full ' +
(item.read ? 'bg-transparent' : 'bg-accent')
}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={badgeVariant(item.eventType)}>
{humanEventType(item.eventType)}
</Badge>
<Badge variant="outline" className="lowercase">
{item.channel}
</Badge>
</div>
<p className={'text-cell ' + (item.read ? 'text-ink-2' : 'text-ink font-medium')}>
{item.payloadPreview}
</p>
<p className="text-cap text-mute tabular-nums">
{new Date(item.sentAt).toLocaleString()}
</p>
</div>
{!item.read && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMarkRead()
}}
aria-label={t('notifications.markRead', {
defaultValue: 'Отметить прочитанным',
})}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity text-mute hover:text-ink shrink-0"
>
<CheckIcon weight="bold" size={14} />
</button>
)}
</div>
)
// Deep-link case: row is a Link, click closes drawer + navigates +
// implicitly marks-as-read через server (backend should mark read on
// resource load OR we add explicit POST here).
if (item.href) {
return (
<li>
<Link
to={item.href}
onClick={() => {
if (!item.read) onMarkRead()
onClose()
}}
className="block hover:bg-line/30 -mx-3 px-3 rounded-sm transition-colors"
>
{content}
</Link>
</li>
)
}
return <li>{content}</li>
}
/**
* Event type → Badge variant mapping. Decision-positive (approved/published)
* → success, negative (rejected/withdrawn) → danger, neutral progress
* (submitted/changes_requested) → info.
*/
function badgeVariant(
eventType: string,
): 'success' | 'danger' | 'info' | 'warning' | 'default' {
if (/Approved|Published/i.test(eventType)) return 'success'
if (/Rejected|Withdrawn/i.test(eventType)) return 'danger'
if (/Submitted|ChangesRequested/i.test(eventType)) return 'info'
return 'default'
}
/**
* Event type → short russian label для chip. Server-side resolution лучше,
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
* человеческий текст, chip это просто категория.
*/
function humanEventType(eventType: string): string {
const map: Record<string, string> = {
RecordDraftSubmitted: 'submit',
RecordDraftApproved: 'approved',
RecordDraftRejected: 'rejected',
RecordDraftWithdrawn: 'withdrawn',
SchemaDraftSubmitted: 'schema submit',
SchemaDraftApproved: 'schema approved',
SchemaDraftRejected: 'schema rejected',
SchemaDraftPublished: 'schema published',
}
return map[eventType] ?? eventType
}