Files
IdP/apps/frontend/components/notifications/notification-bell.tsx
2026-06-24 14:37:15 +03:00

234 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, Loader2, Trash2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
AppNotification,
fetchNotifications,
fetchUnreadNotificationCount,
getApiErrorMessage,
markAllNotificationsRead,
markNotificationRead,
respondFamilyInvite
} from '@/lib/api';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { cn } from '@/lib/utils';
function formatTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' }).format(
new Date(value)
);
}
export function NotificationBell() {
const router = useRouter();
const { token, isPinLocked } = useAuth();
const { showToast } = useToast();
const { unreadCount, setUnreadCount, subscribe } = useRealtime();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<AppNotification[]>([]);
const [loading, setLoading] = useState(false);
const [respondingId, setRespondingId] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token || isPinLocked) return;
setLoading(true);
try {
const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]);
setItems(list.notifications ?? []);
setUnreadCount(count.count ?? 0);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить уведомления');
if (message) showToast(message);
} finally {
setLoading(false);
}
}, [isPinLocked, setUnreadCount, showToast, token]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return subscribe((event) => {
void load();
if (event.type === 'chat_message' && event.title) {
showToast(`${event.title}: ${event.message}`);
}
if (event.type === 'family_invite') {
showToast(event.message || 'Новое приглашение в семью');
}
});
}, [load, showToast, subscribe]);
async function openItem(item: AppNotification) {
if (!token) return;
await markNotificationRead(item.id, token);
setUnreadCount((count) => Math.max(0, count - 1));
setItems((current) => current.filter((entry) => entry.id !== item.id));
let payload: Record<string, unknown> = {};
try {
payload = item.payloadJson ? JSON.parse(item.payloadJson) : {};
} catch {
payload = {};
}
if (payload.groupId) {
setOpen(false);
router.push(`/family/${payload.groupId}`);
}
}
function removeInviteNotification(inviteId: string) {
setItems((current) => {
let removedUnread = 0;
const next = current.filter((item) => {
if (item.type !== 'family_invite') return true;
try {
const payload = item.payloadJson ? JSON.parse(item.payloadJson) as { inviteId?: string } : {};
if (payload.inviteId === inviteId) {
if (!item.isRead) removedUnread += 1;
return false;
}
} catch {
// keep item if payload is malformed
}
return true;
});
if (removedUnread > 0) {
setUnreadCount((count) => Math.max(0, count - removedUnread));
}
return next;
});
}
async function acceptInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
const invite = await respondFamilyInvite(inviteId, true, token);
removeInviteNotification(inviteId);
showToast('Вы присоединились к семье');
setOpen(false);
router.push(`/family/${invite.groupId}`);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось принять приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function declineInvite(inviteId: string) {
if (!token) return;
setRespondingId(inviteId);
try {
await respondFamilyInvite(inviteId, false, token);
removeInviteNotification(inviteId);
showToast('Приглашение отклонено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось отклонить приглашение');
if (message) showToast(message);
} finally {
setRespondingId(null);
}
}
async function markAllRead() {
if (!token) return;
await markAllNotificationsRead(token);
setUnreadCount(0);
setItems([]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-[#f4f5f8] transition hover:bg-[#eceef4]"
aria-label="Уведомления"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#3390ec] px-1 text-[10px] font-semibold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[360px] rounded-[24px] p-0">
<div className="flex items-center justify-between border-b border-[#eceef4] px-4 py-3">
<h3 className="font-semibold">Уведомления</h3>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs" onClick={() => void markAllRead()}>
<Trash2 className="h-3.5 w-3.5" />
Очистить все
</Button>
</div>
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка...
</div>
) : items.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-[#667085]">Уведомлений пока нет</p>
) : (
items.map((item) => {
let inviteId: string | undefined;
try {
inviteId = item.payloadJson ? (JSON.parse(item.payloadJson).inviteId as string) : undefined;
} catch {
inviteId = undefined;
}
return (
<div
key={item.id}
className={cn(
'border-b border-[#eceef4] px-4 py-3 transition hover:bg-[#fafbfd]',
!item.isRead && 'bg-[#f0f8ff]/60'
)}
>
<button type="button" className="w-full text-left" onClick={() => void openItem(item)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium">{item.title}</p>
<p className="mt-1 text-sm text-[#667085]">{item.message}</p>
</div>
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatTime(item.createdAt)}</span>
</div>
</button>
{item.type === 'family_invite' && inviteId ? (
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="h-8 flex-1 rounded-xl"
disabled={respondingId === inviteId}
onClick={() => void acceptInvite(inviteId!)}
>
{respondingId === inviteId ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Принять'}
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 flex-1 rounded-xl"
onClick={() => void declineInvite(inviteId!)}
>
Отклонить
</Button>
</div>
) : null}
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}