242 lines
8.8 KiB
TypeScript
242 lines
8.8 KiB
TypeScript
'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, isLoading, isApiReady } = 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 || isLoading || !isApiReady) 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);
|
||
}
|
||
}, [isApiReady, isLoading, 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 || 'Новое приглашение в семью');
|
||
}
|
||
if (event.type === 'role_assigned' || event.type === 'role_removed') {
|
||
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}`);
|
||
return;
|
||
}
|
||
if (item.type === 'role_assigned' || item.type === 'role_removed') {
|
||
setOpen(false);
|
||
router.push('/admin');
|
||
}
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|