352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
'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: 88 };
|
||
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() {
|
||
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
|
||
style={{
|
||
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"
|
||
style={{
|
||
position: 'absolute',
|
||
right: 52,
|
||
bottom: 0,
|
||
width: PANEL_WIDTH,
|
||
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>
|
||
{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>
|
||
)}
|
||
</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>
|
||
)}
|
||
|
||
{/* Кнопка-колокольчик */}
|
||
<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>
|
||
</>
|
||
);
|
||
}
|