uchill/front_material/components/notifications/NotificationBell.tsx

426 lines
14 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 { useState, useRef, useEffect } from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import type { Notification } from '@/api/notifications';
const BELL_POSITION = { right: 24, bottom: 25 };
const PANEL_WIDTH = 360;
const PANEL_MAX_HEIGHT = '70vh';
function formatDate(s: string) {
try {
const d = new Date(s);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
function NotificationItem({
notification,
onHover,
onClick,
}: {
notification: Notification;
onHover: () => void;
onClick: () => void;
}) {
const hoveredRef = useRef(false);
const handleMouseEnter = () => {
hoveredRef.current = true;
onHover();
};
return (
<div
role="button"
tabIndex={0}
onMouseEnter={handleMouseEnter}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--md-sys-color-outline-variant, #eee)',
cursor: notification.action_url ? 'pointer' : 'default',
backgroundColor: notification.is_read
? 'transparent'
: '#bfa9ff',
transition: 'background-color 0.15s ease',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: 14,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 4,
}}
>
{notification.title || 'Уведомление'}
</div>
<div
style={{
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.4,
}}
>
{notification.message}
</div>
</div>
<span
style={{
fontSize: 11,
color: 'var(--md-sys-color-on-surface-variant)',
whiteSpace: 'nowrap',
}}
>
{formatDate(notification.created_at)}
</span>
</div>
</div>
);
}
const SCROLL_LOAD_MORE_THRESHOLD = 80;
export function NotificationBell({ embedded }: { embedded?: boolean }) {
const refreshNavBadges = useNavBadgesRefresh();
const {
list,
unreadCount,
loading,
loadingMore,
hasMore,
fetchList,
fetchMore,
markOneAsRead,
markAllAsRead,
} = useNotifications({ onNavBadgesUpdate: refreshNavBadges ?? undefined });
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) fetchList();
}, [open, fetchList]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el || !hasMore || loadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollTop + clientHeight >= scrollHeight - SCROLL_LOAD_MORE_THRESHOLD) {
fetchMore();
}
};
// Порядок не сортируем: API уже отдаёт is_read, -created_at. Иначе при подгрузке непрочитанные из новой страницы уезжали бы наверх, а скролл остаётся внизу.
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest('[data-notification-bell]')
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<>
<style>{`
.notification-panel-enter {
transform: translateX(calc(100% + 16px));
opacity: 0;
}
.notification-panel-enter-active {
transform: translateX(0);
opacity: 1;
transition: transform 0.25s ease-out, opacity 0.2s ease-out;
}
.notification-panel-exit {
transform: translateX(0);
opacity: 1;
}
.notification-panel-exit-active {
transform: translateX(calc(100% + 16px));
opacity: 0;
transition: transform 0.2s ease-in, opacity 0.15s ease-in;
}
`}</style>
<div
data-notification-bell
data-tour="notifications-bell"
style={
embedded
? {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}
: {
position: 'fixed',
right: BELL_POSITION.right,
bottom: BELL_POSITION.bottom,
zIndex: 9998,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'flex-end',
flexDirection: 'column',
}
}
>
{/* Панель уведомлений — выезжает справа от колокольчика */}
{open && (
<div
ref={panelRef}
className="notification-panel-enter-active notification-panel"
style={{
position: embedded ? 'fixed' : 'absolute',
...(embedded
? { bottom: 'auto', left: 8, right: 8, top: 'auto', transform: 'none' }
: { right: 52, bottom: 0 }),
width: embedded ? 'auto' : `min(${PANEL_WIDTH}px, calc(100vw - 32px))`,
maxHeight: PANEL_MAX_HEIGHT,
backgroundColor: 'var(--md-sys-color-surface)',
borderRadius: 'var(--ios26-radius-md, 24px)',
boxShadow: 'var(--ios-shadow-deep, 0 18px 50px rgba(0,0,0,0.12))',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 9999,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '16px 16px 12px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span
style={{
fontWeight: 600,
fontSize: 18,
color: 'var(--md-sys-color-on-surface)',
}}
>
Уведомления
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
style={{
padding: '6px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Прочитать все
</button>
)}
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Закрыть"
style={{
all: 'unset',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
color: 'var(--md-sys-color-on-surface-variant)',
flexShrink: 0,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>close</span>
</button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
overflowY: 'auto',
overflowX: 'hidden',
flex: 1,
minHeight: 120,
}}
>
{loading ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Загрузка
</div>
) : list.length === 0 ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет уведомлений
</div>
) : (
<>
{list.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onHover={() => !n.is_read && markOneAsRead(n.id)}
onClick={() => {
/* Не переходим по action_url — только открыта панель уведомлений */
}}
/>
))}
{loadingMore && (
<div
style={{
padding: 12,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 13,
}}
>
Загрузка
</div>
)}
</>
)}
</div>
</div>
)}
{/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
{embedded ? (
<button
type="button"
className="ios26-bottom-nav-button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
notifications
</span>
{unreadCount > 0 && (
<span
className="ios26-bottom-nav-badge"
style={{
position: 'absolute',
top: -8,
right: -16,
minWidth: 18,
height: 18,
borderRadius: 9,
background: 'var(--md-sys-color-error, #b3261e)',
color: '#fff',
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
boxSizing: 'border-box',
}}
title={`${unreadCount} непрочитанных`}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">Уведомления</span>
</button>
) : (
<button
type="button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
style={{
position: 'relative',
width: 48,
height: 48,
borderRadius: '50%',
border: 'none',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--ios-shadow-soft)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
notifications
</span>
{unreadCount > 0 && (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 9,
backgroundColor: 'var(--md-sys-color-error, #c00)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: 48,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={`${unreadCount} непрочитанных`}
>
{unreadCount}
</span>
)}
</button>
)}
</div>
</>
);
}