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

View File

@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import { useAuth } from '@/components/id/auth-provider';
import { WS_URL, type ChatMessage } from '@/lib/api';
export interface RealtimeEvent {
userId?: string;
type: string;
title: string;
message: string;
payload?: Record<string, unknown> & {
notificationId?: string;
roomId?: string;
message?: ChatMessage;
inviteId?: string;
groupId?: string;
};
}
type Listener = (event: RealtimeEvent) => void;
interface RealtimeContextValue {
unreadCount: number;
setUnreadCount: React.Dispatch<React.SetStateAction<number>>;
subscribe: (listener: Listener) => () => void;
connected: boolean;
}
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const { user, token, isPinLocked } = useAuth();
const [unreadCount, setUnreadCount] = React.useState(0);
const [connected, setConnected] = React.useState(false);
const listenersRef = React.useRef(new Set<Listener>());
const socketRef = React.useRef<WebSocket | null>(null);
const reconnectTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const subscribe = React.useCallback((listener: Listener) => {
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
}, []);
const emit = React.useCallback((event: RealtimeEvent) => {
listenersRef.current.forEach((listener) => listener(event));
}, []);
React.useEffect(() => {
if (!user || !token || isPinLocked) {
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
return;
}
let cancelled = false;
function connect() {
if (cancelled) return;
const url = `${WS_URL}?token=${encodeURIComponent(token!)}`;
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => setConnected(true);
socket.onclose = () => {
setConnected(false);
if (!cancelled) {
reconnectTimer.current = setTimeout(connect, 3000);
}
};
socket.onerror = () => socket.close();
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as RealtimeEvent;
emit(data);
if (data.type === 'family_invite' || (data.type === 'chat_message' && data.payload?.notificationId)) {
setUnreadCount((count) => count + 1);
}
} catch {
// ignore malformed payloads
}
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
socketRef.current?.close();
socketRef.current = null;
setConnected(false);
};
}, [emit, isPinLocked, token, user]);
return (
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
{children}
</RealtimeContext.Provider>
);
}
export function useRealtime() {
const context = React.useContext(RealtimeContext);
if (!context) {
throw new Error('useRealtime должен использоваться внутри RealtimeProvider');
}
return context;
}