408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState, useRef } from "react";
|
||
import { Bell, Settings, X, Trash2 } from "lucide-react";
|
||
import { useNotifications } from "@/hooks/useApi";
|
||
import { formatDistanceToNow } from "date-fns";
|
||
import { ru } from "date-fns/locale/ru";
|
||
import { getVerbName } from "@/lib/verbTranslations";
|
||
import { resolveNotificationActionUrl } from "@/lib/notifications/actionUrl";
|
||
import { getRegulationNotificationScopeLabel } from "@/lib/notifications/regulationScope";
|
||
import Link from "next/link";
|
||
import type { NotificationItem } from "@/contexts/NotificationsContext";
|
||
|
||
interface NotificationCenterProps {
|
||
variant?: "default" | "mobile";
|
||
isOpen?: boolean;
|
||
onToggle?: () => void;
|
||
}
|
||
|
||
function getNotificationTitle(notification: NotificationItem): string {
|
||
const dataTitle = notification.data?.title;
|
||
return (
|
||
notification.title ||
|
||
(typeof dataTitle === "string" ? dataTitle : "") ||
|
||
getVerbName(notification.verb || "")
|
||
);
|
||
}
|
||
|
||
function getNotificationMessage(notification: NotificationItem): string {
|
||
return (
|
||
notification.description ||
|
||
notification.short_message ||
|
||
notification.message ||
|
||
""
|
||
);
|
||
}
|
||
|
||
function getNotificationTimestamp(
|
||
notification: NotificationItem,
|
||
): string | null {
|
||
return notification.timestamp || notification.created_at || null;
|
||
}
|
||
|
||
export function NotificationCenter({
|
||
variant = "default",
|
||
isOpen: externalIsOpen,
|
||
onToggle,
|
||
}: NotificationCenterProps) {
|
||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||
const isControlled = externalIsOpen !== undefined;
|
||
const isOpen = isControlled ? externalIsOpen : internalIsOpen;
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
const {
|
||
notifications: notificationsData,
|
||
unreadCount,
|
||
markAsRead,
|
||
markAllAsRead,
|
||
deleteNotification,
|
||
loading,
|
||
} = useNotifications();
|
||
|
||
const notifications = Array.isArray(notificationsData)
|
||
? notificationsData
|
||
: [];
|
||
|
||
const toggleOpen = () => {
|
||
if (onToggle) {
|
||
onToggle();
|
||
} else {
|
||
setInternalIsOpen((v) => !v);
|
||
}
|
||
};
|
||
|
||
const close = useCallback(() => {
|
||
if (onToggle && isOpen) {
|
||
onToggle();
|
||
} else {
|
||
setInternalIsOpen(false);
|
||
}
|
||
}, [isOpen, onToggle]);
|
||
|
||
// Закрытие при клике вне компонента (только для desktop варианта)
|
||
useEffect(() => {
|
||
// Для mobile варианта не используем этот обработчик,
|
||
// т.к. панель уведомлений рендерится отдельно в AppShell
|
||
if (!isOpen || variant === "mobile") return;
|
||
|
||
function handleClickOutside(event: MouseEvent) {
|
||
if (
|
||
dropdownRef.current &&
|
||
!dropdownRef.current.contains(event.target as Node)
|
||
) {
|
||
close();
|
||
}
|
||
}
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [close, isOpen, variant]);
|
||
|
||
const handleNotificationClick = async (notification: NotificationItem) => {
|
||
if (!notification.is_read) {
|
||
await markAsRead(notification.id);
|
||
}
|
||
|
||
// Навигация если есть action_url
|
||
const actionUrl = resolveNotificationActionUrl(notification);
|
||
if (actionUrl) {
|
||
window.location.assign(actionUrl);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="relative" ref={dropdownRef}>
|
||
{/* Кнопка уведомлений */}
|
||
<button
|
||
onClick={toggleOpen}
|
||
className={
|
||
variant === "mobile"
|
||
? "app-icon-button relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
|
||
: "app-icon-button relative flex h-10 w-10 items-center justify-center rounded-full"
|
||
}
|
||
aria-label="Уведомления"
|
||
>
|
||
<Bell size={18} />
|
||
{unreadCount > 0 && (
|
||
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center font-bold">
|
||
{unreadCount > 9 ? "9+" : unreadCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Dropdown — только для desktop */}
|
||
{isOpen && variant === "default" && (
|
||
<div className="app-menu absolute right-0 top-12 z-[60] flex max-h-[600px] w-80 flex-col rounded-xl sm:w-96 animate-fade-in">
|
||
{/* Заголовок */}
|
||
<div className="app-divider flex items-center justify-between border-b p-4">
|
||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
|
||
Уведомления
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
{unreadCount > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
markAllAsRead();
|
||
}}
|
||
className="app-link-accent text-xs font-medium"
|
||
>
|
||
Прочитать все
|
||
</button>
|
||
)}
|
||
<Link
|
||
href="/notifications/settings"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="app-icon-button rounded p-1"
|
||
aria-label="Настройки"
|
||
title="Настройки уведомлений"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
</Link>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
close();
|
||
}}
|
||
className="app-icon-button rounded p-1"
|
||
aria-label="Закрыть"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список уведомлений */}
|
||
<div className="overflow-y-auto flex-1">
|
||
{loading ? (
|
||
<div className="app-text-muted p-8 text-center text-sm">
|
||
Загрузка...
|
||
</div>
|
||
) : notifications.length === 0 ? (
|
||
<div className="app-text-muted p-10 text-center">
|
||
<Bell className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||
<p className="text-sm font-medium">Нет уведомлений</p>
|
||
</div>
|
||
) : (
|
||
<ul>
|
||
{notifications.slice(0, 20).map((notification) => {
|
||
const isUnread = !notification.is_read;
|
||
const timestamp = getNotificationTimestamp(notification);
|
||
const title = getNotificationTitle(notification);
|
||
const message = getNotificationMessage(notification);
|
||
const regulationScope =
|
||
getRegulationNotificationScopeLabel(notification);
|
||
|
||
return (
|
||
<li
|
||
key={notification.id}
|
||
className={`app-divider group border-b p-3 transition-colors hover:bg-[var(--surface-secondary)] ${
|
||
isUnread ? "app-unread-surface" : ""
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-2.5">
|
||
{isUnread && (
|
||
<div className="app-dot-accent mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0" />
|
||
)}
|
||
<div
|
||
onClick={() => handleNotificationClick(notification)}
|
||
className="flex-1 min-w-0 cursor-pointer"
|
||
>
|
||
<h4 className="mb-0.5 truncate text-sm font-medium text-[var(--foreground)]">
|
||
{title}
|
||
</h4>
|
||
<p className="app-text-muted mb-1 line-clamp-2 text-xs">
|
||
{message}
|
||
</p>
|
||
{regulationScope ? (
|
||
<p
|
||
className="app-accent-text mb-1 truncate text-[10px] font-medium"
|
||
title={regulationScope}
|
||
>
|
||
{regulationScope}
|
||
</p>
|
||
) : null}
|
||
<p className="app-text-muted text-[10px]">
|
||
{timestamp
|
||
? formatDistanceToNow(new Date(timestamp), {
|
||
addSuffix: true,
|
||
locale: ru,
|
||
})
|
||
: "Только что"}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
deleteNotification(notification.id);
|
||
}}
|
||
className="app-action-ghost flex-shrink-0 rounded p-1.5 transition-colors hover:bg-transparent"
|
||
aria-label="Удалить"
|
||
title="Удалить уведомление"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 text-[var(--muted-foreground)] transition-colors hover:text-[var(--danger-foreground)]" />
|
||
</button>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Футер */}
|
||
<div className="app-divider border-t p-3 text-center">
|
||
<a
|
||
href="/notifications"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="app-link-accent text-xs font-medium"
|
||
>
|
||
Показать все уведомления
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Встраиваемая панель уведомлений (для выдвижных блоков) */
|
||
export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||
const {
|
||
notifications: notificationsData,
|
||
unreadCount,
|
||
markAsRead,
|
||
markAllAsRead,
|
||
loading,
|
||
} = useNotifications();
|
||
const notifications = Array.isArray(notificationsData)
|
||
? notificationsData
|
||
: [];
|
||
|
||
const handleNotificationClick = async (notification: NotificationItem) => {
|
||
if (!notification.is_read) {
|
||
await markAsRead(notification.id);
|
||
}
|
||
const actionUrl = resolveNotificationActionUrl(notification);
|
||
if (actionUrl) {
|
||
window.location.assign(actionUrl);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="app-menu flex max-h-[60vh] flex-col overflow-hidden rounded-xl">
|
||
<div className="app-divider flex items-center justify-between border-b p-3">
|
||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">
|
||
Уведомления
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
{unreadCount > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
markAllAsRead();
|
||
}}
|
||
className="app-link-accent text-xs font-medium"
|
||
>
|
||
Прочитать все
|
||
</button>
|
||
)}
|
||
<Link
|
||
href="/notifications/settings"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="app-icon-button rounded p-1"
|
||
aria-label="Настройки"
|
||
title="Настройки уведомлений"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
</Link>
|
||
{onClose && (
|
||
<button
|
||
onClick={onClose}
|
||
className="app-icon-button rounded p-1"
|
||
aria-label="Закрыть"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto flex-1">
|
||
{loading ? (
|
||
<div className="app-text-muted p-8 text-center text-sm">
|
||
Загрузка...
|
||
</div>
|
||
) : notifications.length === 0 ? (
|
||
<div className="app-text-muted p-10 text-center">
|
||
<Bell className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||
<p className="text-sm font-medium">Нет уведомлений</p>
|
||
</div>
|
||
) : (
|
||
<ul>
|
||
{notifications.slice(0, 20).map((notification) => {
|
||
const isUnread = !notification.is_read;
|
||
const timestamp = getNotificationTimestamp(notification);
|
||
const title = getNotificationTitle(notification);
|
||
const message = getNotificationMessage(notification);
|
||
const regulationScope =
|
||
getRegulationNotificationScopeLabel(notification);
|
||
|
||
return (
|
||
<li
|
||
key={notification.id}
|
||
onClick={() => handleNotificationClick(notification)}
|
||
className={`app-divider cursor-pointer border-b p-3 transition-colors hover:bg-[var(--surface-secondary)] ${
|
||
isUnread ? "app-unread-surface" : ""
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
{isUnread && (
|
||
<div className="app-dot-accent mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0" />
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<h4 className="mb-0.5 truncate text-sm font-medium text-[var(--foreground)]">
|
||
{title}
|
||
</h4>
|
||
<p className="app-text-muted mb-1 line-clamp-2 text-xs">
|
||
{message}
|
||
</p>
|
||
{regulationScope ? (
|
||
<p
|
||
className="app-accent-text mb-1 truncate text-[10px] font-medium"
|
||
title={regulationScope}
|
||
>
|
||
{regulationScope}
|
||
</p>
|
||
) : null}
|
||
<p className="app-text-muted text-[10px]">
|
||
{timestamp
|
||
? formatDistanceToNow(new Date(timestamp), {
|
||
addSuffix: true,
|
||
locale: ru,
|
||
})
|
||
: "Только что"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
<div className="app-divider border-t p-2 text-center">
|
||
<a
|
||
href="/notifications"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="app-link-accent text-xs font-medium"
|
||
>
|
||
Показать все
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|