uchill/front_material/components/navigation/BottomNavigationBar.tsx

383 lines
13 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 { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { NavBadges } from '@/api/navBadges';
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
import { useIsMobile } from '@/hooks/useIsMobile';
interface NavigationItem {
label: string;
path: string;
icon: string;
isProfile?: boolean;
}
interface User {
id?: number;
first_name?: string;
last_name?: string;
email?: string;
avatar_url?: string | null;
avatar?: string | null;
}
interface BottomNavigationBarProps {
userRole?: string;
user?: User | null;
navBadges?: NavBadges | null;
/** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
notificationsSlot?: React.ReactNode;
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
slideout?: boolean;
onClose?: () => void;
}
function getAvatarUrl(user: User | null | undefined): string | null {
if (!user) return null;
const url = user.avatar_url || user.avatar;
if (!url) return null;
if (url.startsWith('http')) return url;
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
}
function getBadgeCount(item: NavigationItem, navBadges: NavBadges | null | undefined): number {
if (!navBadges) return 0;
switch (item.path) {
case '/schedule':
return navBadges.lessons_today;
case '/chat':
return navBadges.chat_unread;
case '/homework':
return navBadges.homework_pending;
case '/feedback':
return navBadges.feedback_pending;
case '/students':
return navBadges.mentorship_requests_pending ?? 0;
default:
return 0;
}
}
export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams?.get('tab');
const [activeIndex, setActiveIndex] = useState(0);
const [expanded, setExpanded] = useState(false);
const avatarUrl = getAvatarUrl(user);
const isMobile = useIsMobile();
// Swipe gesture handling (secondary to "More" button)
const navContainerRef = useRef<HTMLDivElement>(null);
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const SWIPE_THRESHOLD = 30;
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartY.current === null || touchStartX.current === null) return;
const deltaY = touchStartY.current - e.changedTouches[0].clientY;
const deltaX = Math.abs(touchStartX.current - e.changedTouches[0].clientX);
touchStartY.current = null;
touchStartX.current = null;
if (Math.abs(deltaY) < SWIPE_THRESHOLD || deltaX > Math.abs(deltaY)) return;
if (deltaY > 0) {
setExpanded(true);
} else {
setExpanded(false);
}
}, []);
// Определяем навигационные элементы в зависимости от роли
const navigationItems = useMemo<NavigationItem[]>(() => {
const baseItems: NavigationItem[] = [
{ label: 'Главная', path: '/dashboard', icon: 'home' },
{ label: 'Расписание', path: '/schedule', icon: 'calendar_month' },
{ label: 'Чат', path: '/chat', icon: 'chat' },
];
let roleItems: NavigationItem[] = [];
if (userRole === 'mentor') {
roleItems = [
{ label: 'Студенты', path: '/students', icon: 'group' },
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Обратная связь', path: '/feedback', icon: 'rate_review' },
];
} else if (userRole === 'client') {
roleItems = [
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
];
} else if (userRole === 'parent') {
// Родитель: те же страницы, что и студент, кроме материалов
roleItems = [
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
];
}
const common: NavigationItem[] = [
...baseItems,
...roleItems,
{ label: 'Профиль', path: '/profile', icon: 'person', isProfile: true },
];
// Аналитика, Тарифы и Рефералы только для ментора
if (userRole === 'mentor') {
common.push(
{ label: 'Аналитика', path: '/analytics', icon: 'analytics' },
{ label: 'Тарифы', path: '/payment', icon: 'credit_card' },
{ label: 'Рефералы', path: '/referrals', icon: 'group_add' }
);
}
return common;
}, [userRole]);
// Mobile: first 3 items + "More" button; Desktop: first 3/5 items + notifications
const MOBILE_FIRST_ROW_COUNT = 3;
const desktopFirstCount = notificationsSlot ? 3 : 5;
const firstRowItems = isMobile
? navigationItems.slice(0, MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(0, desktopFirstCount);
const restItems = isMobile
? navigationItems.slice(MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(desktopFirstCount);
const hasMore = restItems.length > 0;
// Подсветка активного таба по текущему URL
useEffect(() => {
const idx = navigationItems.findIndex((item) => {
if (item.path === '/payment') return pathname === '/payment';
if (item.path === '/analytics') return pathname === '/analytics';
if (item.path === '/referrals') return pathname === '/referrals';
if (item.path === '/feedback') return pathname === '/feedback';
if (item.path === '/homework') return pathname === '/homework';
if (item.path === '/profile') return pathname === '/profile' && !tabParam;
if (item.path === '/request-mentor') return pathname === '/request-mentor';
return pathname?.startsWith(item.path);
});
if (idx !== -1) setActiveIndex(idx);
}, [pathname, navigationItems, tabParam]);
const handleTabClick = (index: number) => {
const item = navigationItems[index];
if (!item) return;
setActiveIndex(index);
setExpanded(false);
router.push(item.path);
onClose?.();
};
if (!navigationItems.length) return null;
const renderButton = (item: NavigationItem, index: number) => {
const isActive = index === activeIndex;
const showAvatar = item.isProfile && (avatarUrl || user);
const badgeCount = getBadgeCount(item, navBadges);
return (
<button
key={item.path}
type="button"
onClick={() => handleTabClick(index)}
className={
'ios26-bottom-nav-button' +
(isActive ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
{showAvatar ? (
<span
className="ios26-bottom-nav-icon"
style={{
width: 24,
height: 24,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
fontSize: 12,
fontWeight: 600,
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
user && (user.first_name?.charAt(0) || user.email?.charAt(0) || 'У')
)}
</span>
) : (
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{item.icon}
</span>
)}
{badgeCount > 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',
}}
>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">
{item.label}
</span>
</button>
);
};
if (slideout) {
return (
<div className="ios26-bottom-nav-slideout">
<div className="ios26-bottom-nav ios26-bottom-nav-slideout-inner">
{userRole === 'parent' && (
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<ChildSelectorCompact />
</div>
)}
{navigationItems.map((item, i) => renderButton(item, i))}
</div>
</div>
);
}
// "More" button for mobile
const renderMoreButton = () => (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={
'ios26-bottom-nav-button' +
(expanded ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{expanded ? 'close' : 'more_horiz'}
</span>
</span>
<span className="ios26-bottom-nav-label">
{expanded ? 'Закрыть' : 'Ещё'}
</span>
</button>
);
// Index offset for rest items
const restIndexOffset = isMobile ? MOBILE_FIRST_ROW_COUNT : desktopFirstCount;
return (
<div
ref={navContainerRef}
className={
'ios26-bottom-nav-container' +
(expanded ? ' ios26-bottom-nav-container--expanded' : '')
}
onTouchStart={hasMore ? handleTouchStart : undefined}
onTouchEnd={hasMore ? handleTouchEnd : undefined}
>
{/* Desktop: swipe handle + arrow trigger */}
{hasMore && !isMobile && (
<>
<div
className="ios26-bottom-nav-swipe-handle"
onClick={() => setExpanded((e) => !e)}
>
<div className="ios26-bottom-nav-swipe-handle-bar" />
</div>
<button
type="button"
className="ios26-bottom-nav-expand-trigger"
onClick={() => setExpanded((e) => !e)}
aria-label={expanded ? 'Свернуть' : 'Развернуть'}
>
<span
className="material-symbols-outlined ios26-bottom-nav-arrow"
style={{
transform: expanded ? 'rotate(180deg)' : 'none',
}}
>
keyboard_arrow_up
</span>
</button>
</>
)}
<div className="ios26-bottom-nav">
<div
className={
'ios26-bottom-nav-first-row' +
(userRole === 'parent' ? ' ios26-bottom-nav-first-row--with-selector' : '') +
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
}
>
{userRole === 'parent' && <ChildSelectorCompact />}
{userRole === 'parent' ? (
<div
className={
'ios26-bottom-nav-first-row-buttons' +
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
}
>
{firstRowItems.map((item, i) => renderButton(item, i))}
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</div>
) : (
<>
{firstRowItems.map((item, i) => renderButton(item, i))}
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</>
)}
</div>
<div
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
>
{restItems.map((item, i) => renderButton(item, restIndexOffset + i))}
{/* On mobile: notifications at the end of expanded section */}
{isMobile && notificationsSlot && (
<div className="ios26-bottom-nav-notifications-in-rest">
{notificationsSlot}
</div>
)}
</div>
</div>
</div>
);
}