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)} />
</>
)
}