first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
'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>
);
}