mobile
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-18 01:15:06 +03:00
parent b4b99491ae
commit d4c4dbb087
37 changed files with 11862 additions and 11180 deletions

View File

@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware:
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
# Обновляем объект пользователя в запросе для текущего запроса
# Это позволяет использовать обновленное значение в текущем запросе
user.last_activity = now
# Учёт дня активности для реферальной программы (не чаще 1 раза в день на пользователя)
today = now.date()
day_cache_key = f'referral_activity_day:{user.id}:{today}'
if not cache.get(day_cache_key):
try:
from apps.referrals.models import UserActivityDay
UserActivityDay.objects.get_or_create(user=user, date=today)
cache.set(day_cache_key, 1, timeout=86400 * 2)
except Exception:
pass
except Exception as e:
# Логируем ошибку, но не прерываем выполнение запроса
logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True)

View File

@ -309,9 +309,37 @@ class User(AbstractUser):
def __str__(self):
return f"{self.get_full_name()} ({self.email})"
def _generate_universal_code(self):
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(100):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
return code
raise ValueError('Не удалось сгенерировать уникальный universal_code')
def save(self, *args, **kwargs):
if self.phone:
self.phone = normalize_phone(self.phone)
# Автоматическая генерация username из email, если не задан
if not self.username and self.email:
self.username = self.email.split('@')[0]
# Добавляем цифры, если username уже существует
counter = 1
original_username = self.username
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
self.username = f"{original_username}{counter}"
counter += 1
# Гарантируем 8-символьный код (universal_code)
if not self.universal_code:
try:
self.universal_code = self._generate_universal_code()
except Exception:
# Если не удалось сгенерировать, не прерываем сохранение
pass
super().save(*args, **kwargs)
@ -365,35 +393,7 @@ class Mentor(User):
"""Может ли пользователь получить доступ к админ-панели."""
return self.is_staff or self.is_superuser or self.role == 'admin'
def _generate_universal_code(self):
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(100):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
return code
raise ValueError('Не удалось сгенерировать уникальный universal_code')
def save(self, *args, **kwargs):
"""Переопределение save для автоматической генерации username и universal_code."""
if not self.username:
# Генерируем username из email, если не задан
self.username = self.email.split('@')[0]
# Добавляем цифры, если username уже существует
counter = 1
original_username = self.username
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
self.username = f"{original_username}{counter}"
counter += 1
if not self.universal_code:
try:
self.universal_code = self._generate_universal_code()
except Exception:
# Если не удалось сгенерировать, не прерываем сохранение
# Код будет сгенерирован при следующем запросе профиля или в RegisterView
pass
super().save(*args, **kwargs)
# Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User
class Client(models.Model):

View File

@ -1505,6 +1505,27 @@ class InvitationViewSet(viewsets.ViewSet):
city=city
)
# Гарантируем 8-символьный код для приглашений (ментор/студент)
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
try:
# Теперь метод _generate_universal_code определен в базовой модели User
student_user.universal_code = student_user._generate_universal_code()
student_user.save(update_fields=['universal_code'])
except Exception:
# Fallback на случай ошибок генерации
import string
import random
try:
alphabet = string.ascii_uppercase + string.digits
for _ in range(500):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
student_user.universal_code = code
student_user.save(update_fields=['universal_code'])
break
except Exception:
pass
# Генерируем персональный токен для входа
student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token'])
@ -1538,6 +1559,8 @@ class InvitationViewSet(viewsets.ViewSet):
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(student_user)
# Обновляем из БД, чтобы в ответе был актуальный universal_code
student_user.refresh_from_db()
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),

View File

@ -391,10 +391,10 @@ REST_FRAMEWORK = {
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour', # Для неавторизованных пользователей
'user': '1000/hour', # Для авторизованных пользователей
'anon': '200/hour', # Для неавторизованных пользователей
'user': '5000/hour', # Для авторизованных пользователей
'burst': '60/minute', # Для критичных endpoints (login, register)
'upload': '20/hour', # Для загрузки файлов
'upload': '60/hour', # Для загрузки файлов
},
}

View File

@ -84,28 +84,41 @@ RUN mkdir -p public
ENV NODE_ENV=production
RUN npm run build
# Проверяем, что standalone создался (выводим структуру для отладки)
RUN echo "=== Checking standalone output ===" && \
ls -la /app/.next/ || echo "No .next directory" && \
ls -la /app/.next/standalone/ 2>/dev/null || echo "No standalone directory" && \
test -f /app/.next/standalone/server.js || (echo "ERROR: server.js not found in standalone" && ls -la /app/.next/standalone/ && exit 1)
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Копируем собранное приложение (standalone mode)
COPY --from=production-build /app/.next/standalone ./
COPY --from=production-build /app/.next/static ./.next/static
COPY --from=production-build /app/public ./public
# В Next.js standalone структура: /app/.next/standalone/ содержит server.js, .next/server/, node_modules/
# Копируем всё содержимое standalone в корень /app
COPY --from=production-build --chown=nextjs:nodejs /app/.next/standalone ./
# Статические файлы (standalone их не включает автоматически)
COPY --from=production-build --chown=nextjs:nodejs /app/.next/static ./.next/static
# Public файлы (standalone их не включает автоматически)
COPY --from=production-build --chown=nextjs:nodejs /app/public ./public
# Проверяем, что server.js существует
RUN test -f /app/server.js || (echo "ERROR: server.js not found after copy" && ls -la /app/ && exit 1)
# Создаем непривилегированного пользователя
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
USER nextjs
# Открываем порт
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Запускаем production server
CMD ["node", "server.js"]

View File

@ -8,6 +8,7 @@ export default function AuthLayout({
return (
<AuthRedirect>
<div
className="auth-layout"
data-no-nav
style={{
minHeight: '100vh',
@ -17,6 +18,7 @@ export default function AuthLayout({
>
{/* Левая колонка — пустая, фон как у body */}
<div
className="auth-layout-logo"
style={{
minHeight: '100vh',
display: 'flex',
@ -38,6 +40,7 @@ export default function AuthLayout({
{/* Правая колонка — форма на белом фоне */}
<div
className="auth-layout-form"
style={{
minHeight: '100vh',
background: '#fff',

View File

@ -234,6 +234,7 @@ export default function RegisterPage() {
<form onSubmit={handleSubmit}>
<div
className="auth-name-grid"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',

View File

@ -10,6 +10,7 @@ import { ChatWindow } from '@/components/chat/ChatWindow';
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
import { useAuth } from '@/contexts/AuthContext';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import { useIsMobile } from '@/hooks/useIsMobile';
export default function ChatPage() {
const { user } = useAuth();
@ -17,6 +18,7 @@ export default function ChatPage() {
const pathname = usePathname();
const searchParams = useSearchParams();
const uuidFromUrl = searchParams.get('uuid');
const isMobile = useIsMobile();
const [loading, setLoading] = React.useState(true);
const [chats, setChats] = React.useState<Chat[]>([]);
@ -166,70 +168,102 @@ export default function ChatPage() {
}
}, [normalizeChat, refreshNavBadges]);
const handleBackToList = React.useCallback(() => {
setSelected(null);
router.replace(pathname ?? '/chat');
}, [router, pathname]);
// Mobile: show only list or only chat
const mobileShowChat = isMobile && selected != null;
// Hide bottom navigation when a chat is open on mobile
React.useEffect(() => {
if (mobileShowChat) {
document.documentElement.classList.add('mobile-chat-open');
} else {
document.documentElement.classList.remove('mobile-chat-open');
}
return () => {
document.documentElement.classList.remove('mobile-chat-open');
};
}, [mobileShowChat]);
return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: '16px' }}>
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}>
<Box
className="ios26-chat-layout"
sx={{
display: 'grid',
gridTemplateColumns: '320px 1fr',
display: isMobile ? 'flex' : 'grid',
gridTemplateColumns: isMobile ? undefined : '320px 1fr',
flexDirection: isMobile ? 'column' : undefined,
gap: 'var(--ios26-spacing)',
alignItems: 'stretch',
height: 'calc(90vh - 32px)',
maxHeight: 'calc(90vh - 32px)',
height: isMobile ? '100%' : 'calc(90vh - 32px)',
maxHeight: isMobile ? undefined : 'calc(90vh - 32px)',
overflow: 'hidden',
}}
>
{loading ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
}}
>
<Typography>Загрузка</Typography>
</Box>
) : error ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
}}
>
<Typography>{error}</Typography>
</Box>
) : (
<ChatList
chats={chats}
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
onSelect={handleSelectChat}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
/>
{/* Chat list: hidden on mobile when a chat is selected */}
{!mobileShowChat && (
<>
{loading ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
flex: isMobile ? 1 : undefined,
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
}}
>
<Typography>Загрузка</Typography>
</Box>
) : error ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
flex: isMobile ? 1 : undefined,
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--ios26-glass)',
border: '1px solid var(--ios26-glass-border)',
backdropFilter: 'var(--ios26-blur)',
}}
>
<Typography>{error}</Typography>
</Box>
) : (
<ChatList
chats={chats}
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
onSelect={handleSelectChat}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
/>
)}
</>
)}
<ChatWindow
chat={selected}
currentUserId={user?.id ?? null}
onMessagesMarkedAsRead={refreshChatListUnread}
/>
{/* Chat window: on mobile only visible when a chat is selected */}
{(!isMobile || mobileShowChat) && (
<ChatWindow
chat={selected}
currentUserId={user?.id ?? null}
onBack={isMobile ? handleBackToList : undefined}
onMessagesMarkedAsRead={refreshChatListUnread}
/>
)}
</Box>
</div>
);

View File

@ -1,19 +1,7 @@
'use client';
import { useEffect, useState, useCallback, Suspense } from 'react';
const MOBILE_BREAKPOINT = 767;
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
setIsMobile(mq.matches);
const listener = () => setIsMobile(mq.matches);
mq.addEventListener('change', listener);
return () => mq.removeEventListener('change', listener);
}, []);
return isMobile;
}
import { useIsMobile } from '@/hooks/useIsMobile';
import { useRouter, usePathname } from 'next/navigation';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
import { TopNavigationBar } from '@/components/navigation/TopNavigationBar';
@ -91,9 +79,9 @@ export default function ProtectedLayout({
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname });
if (!loading && !user && !token) {
console.log('[ProtectedLayout] Redirecting to login');
router.push('/login');
if (!loading && !user) {
console.log('[ProtectedLayout] No user found, redirecting to login');
router.replace('/login');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading]);
@ -114,7 +102,14 @@ export default function ProtectedLayout({
}
if (!user) {
return null;
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--md-sys-color-background)' }}>
<div style={{ textAlign: 'center', color: 'var(--md-sys-color-on-surface)' }}>
<LoadingSpinner size="large" />
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.8 }}>Проверка авторизации...</p>
</div>
</div>
);
}
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
@ -153,24 +148,13 @@ export default function ProtectedLayout({
return (
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
<SelectedChildProvider>
<div
className="protected-layout-root"
style={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
height: '100vh',
}}
>
<div className="protected-layout-root">
{!isFullWidthPage && <TopNavigationBar user={user} />}
<main
className="protected-main"
data-no-nav={isLiveKit ? true : undefined}
data-full-width={isFullWidthPage ? true : undefined}
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: isFullWidthPage ? '0' : '16px',
maxWidth: isFullWidthPage ? '100%' : '1200px',
margin: isFullWidthPage ? '0' : '0 auto',

View File

@ -573,7 +573,7 @@ export default function MaterialsPage() {
}
return (
<div style={{ padding: '24px', minHeight: '100vh' }}>
<div style={{ padding: '24px' }}>
<div
style={{
display: 'flex',
@ -796,9 +796,10 @@ export default function MaterialsPage() {
</md-elevated-card>
) : (
<div
className="materials-cards-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(340px, 100%), 1fr))',
gap: '24px',
opacity: materialsLoading ? 0.7 : 1,
transition: 'opacity 0.2s ease',
@ -1482,13 +1483,14 @@ export default function MaterialsPage() {
{/* Sidebar */}
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '100%',
maxWidth: '400px',
maxWidth: 'min(400px, 100vw)',
background: 'var(--md-sys-color-surface)',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.15)',
zIndex: 101,
@ -1905,6 +1907,7 @@ export default function MaterialsPage() {
}}
/>
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,

View File

@ -287,7 +287,7 @@ export default function MyProgressPage() {
};
return (
<div style={{ width: '100%', minHeight: '100vh' }}>
<div style={{ width: '100%' }}>
<DashboardLayout className="ios26-dashboard-grid">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md">
@ -325,7 +325,7 @@ export default function MyProgressPage() {
<LoadingSpinner size="medium" />
</div>
) : (
<div className="ios26-stat-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
<div className="ios26-stat-grid my-progress-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Занятий проведено</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>

View File

@ -383,7 +383,6 @@ function ProfilePage() {
<div
className="page-profile"
style={{
minHeight: '100vh',
padding: 24,
position: 'relative',
overflow: 'hidden',

View File

@ -5,13 +5,14 @@ import { ReferralsPageContent } from '@/components/referrals/ReferralsPageConten
export default function ReferralsPage() {
return (
<div
className="page-referrals"
style={{
minHeight: '100vh',
padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
}}
>
<div
className="page-referrals-card"
style={{
background: '#fff',
borderRadius: 20,

View File

@ -121,7 +121,6 @@ export default function RequestMentorPage() {
<div
style={{
padding: '24px',
minHeight: '100vh',
}}
>
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
@ -211,6 +210,7 @@ export default function RequestMentorPage() {
</div>
)}
<div
className="students-cards-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -364,6 +364,7 @@ export default function RequestMentorPage() {
</md-elevated-card>
) : (
<div
className="students-cards-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -468,6 +469,7 @@ export default function RequestMentorPage() {
)
) : (
<div
className="students-cards-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
@ -734,12 +736,13 @@ export default function RequestMentorPage() {
{/* Боковая панель с формой (как на странице студентов) */}
{showAddPanel && (
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100vh',
width: '420px',
width: 'min(420px, 100vw)',
padding: '20px 20px 24px',
background: 'var(--md-sys-color-surface)',
boxShadow: '0 0 24px rgba(0,0,0,0.18)',

View File

@ -415,7 +415,7 @@ export default function SchedulePage() {
alignItems: 'stretch',
// стабилизируем высоту секции (без фиксированных px),
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'calc(100vh - 160px)',
minHeight: 'min(calc(100vh - 160px), 600px)',
}}>
<div className="ios26-schedule-calendar-wrap">
<Calendar

View File

@ -416,18 +416,20 @@ export default function StudentsPage() {
className="page-students"
style={{
padding: '24px',
minHeight: '100vh',
}}
>
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
<div
className="students-tabs"
style={{
display: 'flex',
gap: 4,
marginBottom: 24,
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
paddingBottom: 0,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<button
@ -1408,8 +1410,8 @@ export default function StudentsPage() {
sx: {
mt: 1,
borderRadius: 12,
minWidth: 300,
maxWidth: 360,
minWidth: { xs: 'calc(100vw - 32px)', sm: 300 },
maxWidth: { xs: 'calc(100vw - 32px)', sm: 360 },
overflow: 'hidden',
},
},

View File

@ -142,7 +142,7 @@ export default function InvitationPage() {
style={{ width: '120px', height: 'auto' }}
/>
</div>
<h1 style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1>
<h1 className="invite-title" style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1>
<p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}>
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
</p>
@ -157,9 +157,9 @@ export default function InvitationPage() {
)}
</div>
<div style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
<div className="invite-form-card" style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="auth-name-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<md-filled-text-field
label="Имя"
value={formData.first_name}

View File

@ -6,12 +6,24 @@ import '@/styles/material-theme.css';
export const metadata: Metadata = {
title: 'Uchill Platform',
description: 'Образовательная платформа',
viewport: 'width=device-width, initial-scale=1, maximum-scale=5',
viewport: 'width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover',
themeColor: '#7444FD',
manifest: '/manifest.json',
icons: {
icon: '/favicon.png',
shortcut: '/favicon.png',
apple: '/favicon.png',
icon: '/icon.svg',
shortcut: '/icon.svg',
apple: '/icon.svg',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Uchill',
},
formatDetection: {
telephone: false,
},
other: {
'mobile-web-app-capable': 'yes',
},
};

View File

@ -38,7 +38,21 @@ export function AuthRedirect({ children }: { children: React.ReactNode }) {
}
if (user) {
return null; // редирект уже идёт
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Вы уже вошли. Перенаправление...
</div>
</div>
);
}
return <>{children}</>;

View File

@ -136,6 +136,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
return (
<div
className="checklesson-root"
style={{
position: 'relative',
width: '100%',
@ -385,6 +386,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
<>
<form
onSubmit={onSubmit}
className="checklesson-form"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',

View File

@ -1,18 +1,32 @@
/**
* Material Design 3 Date Picker
* Material Design 3 Date Picker Dialog variant.
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
*/
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, startOfWeek, endOfWeek } from 'date-fns';
import React, { useState, useMemo } from 'react';
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameDay,
isSameMonth,
addMonths,
subMonths,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps {
value: string; // YYYY-MM-DD format
onChange: (value: string) => void;
disabled?: boolean;
required?: boolean;
label?: string;
}
export const DatePicker: React.FC<DatePickerProps> = ({
@ -20,66 +34,65 @@ export const DatePicker: React.FC<DatePickerProps> = ({
onChange,
disabled = false,
required = false,
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(value ? new Date(value) : new Date());
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(
value ? new Date(value + 'T00:00:00') : new Date(),
);
const selectedDate = value ? new Date(value) : null;
const selectedDate = useMemo(
() => (value ? new Date(value + 'T00:00:00') : null),
[value],
);
// Закрываем picker при клике вне компонента
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const openPicker = () => {
if (disabled) return;
setDisplayMonth(selectedDate ?? new Date());
setOpen(true);
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const closePicker = () => setOpen(false);
const handleDateSelect = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
onChange(`${year}-${month}-${day}`);
setIsOpen(false);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
onChange(`${y}-${m}-${d}`);
closePicker();
};
// Получаем дни для отображения в календаре
const getDaysInMonth = () => {
const days = useMemo(() => {
const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth);
const startDate = startOfWeek(start, { locale: ru });
const endDate = endOfWeek(end, { locale: ru });
return eachDayOfInterval({
start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }),
});
}, [displayMonth]);
return eachDayOfInterval({ start: startDate, end: endDate });
};
const days = getDaysInMonth();
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату';
return (
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
{/* Input field */}
<>
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
onClick={openPicker}
disabled={disabled}
aria-required={required}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
color: value ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)',
color: value
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--md-sys-color-surface)',
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
borderWidth: isOpen ? '2px' : '1px',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: '4px',
fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer',
@ -92,44 +105,46 @@ export const DatePicker: React.FC<DatePickerProps> = ({
textAlign: 'left',
}}
>
<span>
{selectedDate ? format(selectedDate, 'd MMMM yyyy', { locale: ru }) : 'Выберите дату'}
<span>{displayValue}</span>
<span
className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }}
>
calendar_today
</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</button>
{/* Calendar dropdown */}
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: '16px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
zIndex: 1000,
padding: '16px',
minWidth: '320px',
}}>
{/* Header with month/year navigation */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}>
<Dialog
open={open}
onClose={closePicker}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: '24px',
overflow: 'visible',
bgcolor: 'var(--md-sys-color-surface)',
},
},
}}
>
<DialogContent sx={{ p: 2 }}>
{/* Month/year header */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<button
type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{
width: '32px',
height: '32px',
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
@ -139,30 +154,30 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
transition: 'background 0.2s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_left
</span>
</button>
<div style={{
fontSize: '16px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
}}>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize',
}}
>
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
</div>
</span>
<button
type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{
width: '32px',
height: '32px',
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
@ -172,33 +187,32 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
transition: 'background 0.2s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_right
</span>
</button>
</div>
</Box>
{/* Week days header */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
marginBottom: '8px',
}}>
{weekDays.map(day => (
{/* Weekday headers */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
marginBottom: 4,
}}
>
{weekDays.map((day) => (
<div
key={day}
style={{
textAlign: 'center',
fontSize: '12px',
fontWeight: '500',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
padding: '8px 0',
padding: '6px 0',
}}
>
{day}
@ -206,56 +220,51 @@ export const DatePicker: React.FC<DatePickerProps> = ({
))}
</div>
{/* Calendar days grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
}}>
{days.map((day, index) => {
{/* Calendar days */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
}}
>
{days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrentMonth = isSameMonth(day, displayMonth);
const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date());
return (
<button
key={index}
key={idx}
type="button"
onClick={() => handleDateSelect(day)}
style={{
width: '40px',
height: '40px',
width: '100%',
aspectRatio: '1',
maxWidth: 40,
margin: '0 auto',
padding: 0,
background: isSelected
? 'var(--md-sys-color-primary)'
: 'transparent',
border: isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
border:
isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: isSelected ? '500' : '400',
fontSize: 14,
fontWeight: isSelected ? 600 : 400,
color: isSelected
? 'var(--md-sys-color-on-primary)'
: isCurrentMonth
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrentMonth ? 1 : 0.4,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'transparent';
}
: isCurrent
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrent ? 1 : 0.35,
transition: 'background 0.15s',
}}
>
{format(day, 'd')}
@ -264,36 +273,44 @@ export const DatePicker: React.FC<DatePickerProps> = ({
})}
</div>
{/* Today button */}
<div style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid var(--md-sys-color-outline-variant)',
display: 'flex',
justifyContent: 'center',
}}>
<button
type="button"
{/* Actions */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 2,
pt: 1.5,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<Button
onClick={() => handleDateSelect(new Date())}
style={{
padding: '8px 16px',
background: 'transparent',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
variant="text"
sx={{
color: 'var(--md-sys-color-primary)',
transition: 'background 0.2s ease',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-primary-container)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
Сегодня
</button>
</div>
</div>
)}
</div>
</Button>
<Button
onClick={closePicker}
variant="text"
sx={{
color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Отмена
</Button>
</Box>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -78,7 +78,6 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
width: '100%',
maxWidth: '100%',
padding: '16px',
minHeight: '100vh'
}}>
{/* Статистика студента */}
<div style={{
@ -162,9 +161,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
)}
{/* Домашние задания и расписание */}
<div style={{
<div className="client-dashboard-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(400px, 100%), 1fr))',
gap: '16px',
marginBottom: '24px'
}}>

View File

@ -11,6 +11,8 @@ import { getStudents, Student } from '@/api/students';
import { getSubjects, getMentorSubjects, createMentorSubject, Subject, MentorSubject } from '@/api/subjects';
import { getCurrentUser, User } from '@/api/auth';
import { format } from 'date-fns';
import { DatePicker } from '@/components/common/DatePicker';
import { TimePicker } from '@/components/common/TimePicker';
interface CreateLessonDialogProps {
open: boolean;
@ -339,9 +341,31 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
'--md-dialog-supporting-text-color': 'var(--md-sys-color-on-surface-variant)',
} as React.CSSProperties}
>
<div slot="headline">Создать занятие</div>
<div slot="headline" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>Создать занятие</span>
<button
type="button"
onClick={handleClose}
className="create-lesson-dialog-close"
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: 22 }}>close</span>
</button>
</div>
<form slot="content" method="dialog" onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: '500px', maxWidth: '600px' }}>
<form slot="content" method="dialog" onSubmit={(e) => { e.preventDefault(); handleSubmit(e); }} className="create-lesson-dialog" style={{ display: 'flex', flexDirection: 'column', gap: '20px', minWidth: 'min(500px, 90vw)', maxWidth: '600px' }}>
{error && (
<div style={{
padding: '12px',
@ -521,7 +545,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Дополнительная информация о занятии"
disabled={loading}
rows={3}
rows={2}
style={{
border: 'none',
outline: 'none',
@ -529,7 +553,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
resize: 'none',
fontFamily: 'inherit',
}}
/>
@ -547,29 +571,17 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
}}>
Дата начала *
</label>
<md-outlined-field label="Дата" style={{ width: '100%' }}>
<input
slot="input"
type="date"
value={formData.start_date}
onChange={(e) => handleDateChange(e.target.value)}
required
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
}}
/>
</md-outlined-field>
<DatePicker
value={formData.start_date}
onChange={(v) => handleDateChange(v)}
disabled={loading}
required
label="Дата начала"
/>
</div>
{/* Время начала и длительность */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="create-lesson-time-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<label style={{
display: 'block',
@ -580,25 +592,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
}}>
Время начала *
</label>
<md-outlined-field label="Время" style={{ width: '100%' }}>
<input
slot="input"
type="time"
value={formData.start_time}
onChange={(e) => handleTimeChange(e.target.value)}
required
disabled={loading}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: '16px',
color: 'var(--md-sys-color-on-surface)',
fontFamily: 'inherit',
}}
/>
</md-outlined-field>
<TimePicker
value={formData.start_time}
onChange={(v) => handleTimeChange(v)}
disabled={loading}
required
/>
</div>
<div>
@ -702,7 +701,7 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
md-dialog {
--md-dialog-container-shape: 28px;
--md-dialog-container-max-width: 600px;
--md-dialog-container-min-width: 500px;
--md-dialog-container-min-width: min(500px, 90vw);
}
md-dialog::part(container) {

View File

@ -66,7 +66,6 @@ export const ParentDashboard: React.FC = () => {
maxWidth: '100%',
padding: '16px',
background: 'var(--md-sys-color-background)',
minHeight: '100vh'
}}>
{/* Приветствие */}
<div style={{
@ -76,7 +75,7 @@ export const ParentDashboard: React.FC = () => {
marginBottom: '24px',
color: 'var(--md-sys-color-on-primary)'
}}>
<h1 style={{
<h1 className="parent-dashboard-title" style={{
fontSize: '28px',
fontWeight: '500',
margin: '0 0 8px 0'
@ -94,9 +93,9 @@ export const ParentDashboard: React.FC = () => {
{/* Статистика по детям */}
{stats?.children_stats && stats.children_stats.length > 0 && (
<div style={{
<div className="parent-children-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(300px, 100%), 1fr))',
gap: '16px',
marginBottom: '24px'
}}>

View File

@ -59,6 +59,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
period={period}
/>
<div
className="income-stats-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',

View File

@ -142,6 +142,7 @@ export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> =
</div>
) : (
<div
className="recent-submissions-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',

View File

@ -124,6 +124,7 @@ export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
</div>
) : (
<div
className="upcoming-lessons-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',

View File

@ -23,10 +23,9 @@ export interface StatsGridProps {
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
return (
<div
className="dashboard-stats-grid"
style={{
display: 'grid',
// Для дашборда ментора: 4 карточки в один ряд,
// чтобы верхняя строка занимала всю ширину.
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 'var(--ios26-spacing)',
}}

View File

@ -1,9 +1,10 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
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;
@ -67,6 +68,33 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
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[]>(() => {
@ -115,8 +143,15 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
return common;
}, [userRole]);
const firstRowItems = navigationItems.slice(0, notificationsSlot ? 3 : 5);
const restItems = navigationItems.slice(notificationsSlot ? 3 : 5);
// 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
@ -244,36 +279,72 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
);
}
// "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}
>
{hasMore && (
<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',
}}
{/* Desktop: swipe handle + arrow trigger */}
{hasMore && !isMobile && (
<>
<div
className="ios26-bottom-nav-swipe-handle"
onClick={() => setExpanded((e) => !e)}
>
keyboard_arrow_up
</span>
</button>
<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' : '') +
(notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
}
>
{userRole === 'parent' && <ChildSelectorCompact />}
@ -281,23 +352,29 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
<div
className={
'ios26-bottom-nav-first-row-buttons' +
(notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
}
>
{firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot}
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</div>
) : (
<>
{firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot}
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
</>
)}
</div>
<div
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
>
{restItems.map((item, i) => renderButton(item, (notificationsSlot ? 3 : 5) + i))}
{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>

View File

@ -189,13 +189,13 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
{open && (
<div
ref={panelRef}
className="notification-panel-enter-active"
className="notification-panel-enter-active notification-panel"
style={{
position: 'absolute',
position: embedded ? 'fixed' : 'absolute',
...(embedded
? { bottom: '100%', marginBottom: 8, left: '50%', transform: 'translateX(-50%)' }
? { bottom: 'auto', left: 8, right: 8, top: 'auto', transform: 'none' }
: { right: 52, bottom: 0 }),
width: PANEL_WIDTH,
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)',
@ -225,24 +225,45 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
>
Уведомления
</span>
{unreadCount > 0 && (
<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={markAllAsRead}
onClick={() => setOpen(false)}
aria-label="Закрыть"
style={{
padding: '6px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
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}

View File

@ -68,13 +68,14 @@ export function ReferralsPageContent() {
>
РЕФЕРАЛЬНАЯ ССЫЛКА
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="referral-link-row" style={{ display: 'flex', gap: 8 }}>
<input
type="text"
readOnly
value={profile.referral_link || ''}
style={{
flex: 1,
minWidth: 0,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
@ -108,6 +109,7 @@ export function ReferralsPageContent() {
</div>
{stats && (
<div
className="referral-stats-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
@ -169,9 +171,9 @@ export function ReferralsPageContent() {
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Прямые рефералы ({referralsList.direct.length})
</div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.direct.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li>
))}
@ -183,9 +185,9 @@ export function ReferralsPageContent() {
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Рефералы ваших рефералов ({referralsList.indirect.length})
</div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.indirect.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li>
))}

View File

@ -0,0 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
export const MOBILE_BREAKPOINT = 767;
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
setIsMobile(mq.matches);
const listener = () => setIsMobile(mq.matches);
mq.addEventListener('change', listener);
return () => mq.removeEventListener('change', listener);
}, [breakpoint]);
return isMobile;
}

View File

@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@ -0,0 +1,19 @@
{
"name": "Uchill Platform",
"short_name": "Uchill",
"description": "Образовательная платформа",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#7444FD",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

View File

@ -169,8 +169,12 @@ body:has([data-no-nav]) {
padding-bottom: 0;
}
/* Protected layout: body/html не скроллятся, скролл только внутри .protected-main */
html:has(.protected-layout-root),
body:has(.protected-layout-root) {
padding-bottom: 0;
height: 100vh;
overflow: hidden;
}
body > * {
@ -298,18 +302,21 @@ img {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Protected layout: контент скроллится сверху, снизу меню. На мобильном — меню в потоке; ноутбук+ — fixed */
/* Protected layout: контент скроллится сверху, снизу меню. Скролл только в .protected-main */
.protected-layout-root {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
height: 100dvh; /* dynamic viewport height — учитывает адресную строку мобильного браузера */
overflow: hidden;
}
.protected-layout-root .protected-main {
flex: 1;
min-height: 0;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */
@ -328,7 +335,7 @@ img {
}
}
/* Мобильный: меню в потоке, на всю ширину, прижато к низу */
/* Мобильный: меню в потоке, flex-shrink: 0 — навигация НИКОГДА не исчезает и не перекрывает контент */
@media (max-width: 767px) {
.protected-layout-root .ios26-bottom-nav-container {
position: relative;
@ -360,6 +367,40 @@ img {
.protected-layout-root .ios26-bottom-nav-rest {
grid-template-columns: repeat(4, 1fr);
}
/* На мобильном отступ снизу в main не нужен — навигация в потоке */
.protected-layout-root .protected-main {
padding-bottom: 0 !important;
}
}
/* Swipe handle — visual indicator for swipe gesture (mobile only) */
.ios26-bottom-nav-swipe-handle {
display: none;
align-items: center;
justify-content: center;
padding: 6px 0 2px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ios26-bottom-nav-swipe-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--md-sys-color-outline-variant, rgba(0,0,0,0.18));
transition: width 0.2s ease, background 0.2s ease;
}
.ios26-bottom-nav-container--expanded .ios26-bottom-nav-swipe-handle-bar {
width: 28px;
background: var(--md-sys-color-primary, #7444FD);
}
@media (max-width: 767px) {
/* Hide both swipe handle and arrow on mobile — "More" button replaces them */
.ios26-bottom-nav-swipe-handle,
.ios26-bottom-nav-expand-trigger {
display: none !important;
}
}
.ios26-bottom-nav-expand-trigger {
@ -438,6 +479,15 @@ img {
gap: 4px 8px;
}
/* Notifications slot inside expanded section on mobile */
.ios26-bottom-nav-notifications-in-rest {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 4px 0;
}
.ios26-bottom-nav-rest {
display: grid;
grid-template-columns: repeat(5, 1fr);
@ -1552,8 +1602,8 @@ img {
padding: 12px !important;
}
.students-cards-grid {
grid-template-columns: 1fr;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.page-profile {
padding: 12px !important;
@ -1620,36 +1670,60 @@ img {
.ios26-schedule-layout {
grid-template-rows: 1fr !important;
min-height: auto !important;
height: 100% !important;
flex: 1 !important;
}
.ios26-schedule-right-wrap {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-height: 0 !important;
height: 100% !important;
}
.checklesson-root {
min-height: auto !important;
flex: 1 !important;
height: 100% !important;
}
/* Обе стороны «куба» — полная высота */
.checklesson-root > div {
height: 100% !important;
}
.checklesson-root .ios-glass-panel {
height: 100% !important;
padding: 12px !important;
border-radius: 12px !important;
}
.ios26-schedule-page {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
flex: 1 !important;
min-height: 0 !important;
}
}
/* Chat: список + окно чата — на планшете и телефоне одна колонка, список сверху */
@media (max-width: 900px) {
/* Chat: на мобильном — только список или только окно чата (управляется из JS) */
@media (max-width: 767px) {
.ios26-chat-page {
padding: 10px !important;
padding: 4px !important;
height: 100%;
display: flex;
flex-direction: column;
}
.ios26-chat-layout {
grid-template-columns: 1fr !important;
grid-template-rows: auto 1fr;
height: calc(100vh - 120px) !important;
flex: 1;
min-height: 0;
height: auto !important;
max-height: none !important;
}
.ios26-chat-layout > div:first-of-type {
max-height: 38vh;
min-height: 180px;
overflow: auto;
/* Скрыть навигацию когда открыт чат */
html.mobile-chat-open .ios26-bottom-nav-container {
display: none !important;
}
}
@media (max-width: 480px) {
.ios26-chat-page {
padding: 8px !important;
}
.ios26-chat-layout {
height: calc(100vh - 100px) !important;
}
.ios26-chat-layout > div:first-of-type {
max-height: 35vh;
min-height: 160px;
/* Контент занимает всю высоту без навигации */
html.mobile-chat-open .protected-main {
padding-bottom: 0 !important;
}
}
@ -1685,6 +1759,354 @@ img {
}
}
/* ========== Адаптив: авторизация (login, register, invite) ========== */
/* Планшет: auth layout — уменьшаем колонку формы */
@media (max-width: 900px) {
.auth-layout {
grid-template-columns: 1fr minmax(0, 420px) !important;
}
.auth-layout-form {
padding: 20px 24px !important;
}
}
/* Телефон: auth layout — одна колонка, скрываем лого слева */
@media (max-width: 767px) {
.auth-layout {
grid-template-columns: 1fr !important;
}
.auth-layout-logo {
display: none !important;
}
.auth-layout-form {
min-height: 100vh;
padding: 20px 16px !important;
}
/* Имя + Фамилия в колонку на узких экранах */
.auth-name-grid {
grid-template-columns: 1fr !important;
}
/* Invite page: уменьшаем заголовок */
.invite-title {
font-size: 24px !important;
}
.invite-form-card {
padding: 20px !important;
}
}
/* Маленький телефон */
@media (max-width: 480px) {
.auth-layout-form {
padding: 16px 12px !important;
}
.auth-name-grid {
grid-template-columns: 1fr !important;
gap: 12px !important;
}
.invite-title {
font-size: 22px !important;
}
.invite-form-card {
padding: 16px !important;
}
}
/* ========== Адаптив: материалы, мой прогресс, диалоги ========== */
/* Материалы: grid minmax слишком большой для маленьких экранов */
@media (max-width: 480px) {
.materials-cards-grid {
grid-template-columns: 1fr !important;
}
}
/* Мой прогресс: 2 колонки → 1 на телефоне */
@media (max-width: 767px) {
.my-progress-grid {
grid-template-columns: 1fr !important;
}
}
/* CheckLesson форма (создание/редактирование занятия): одна колонка на мобильном */
@media (max-width: 600px) {
.checklesson-form {
grid-template-columns: 1fr !important;
gap: 8px !important;
}
.checklesson-form > div {
grid-column: 1 / -1 !important;
}
.checklesson-form > div > label {
font-size: 11px !important;
margin-bottom: 2px !important;
}
.checklesson-form h3 {
font-size: 17px !important;
}
.checklesson-form textarea {
rows: 1;
font-size: 14px !important;
padding: 8px 12px !important;
}
.checklesson-form select,
.checklesson-form input[type="number"] {
font-size: 14px !important;
padding: 8px 12px !important;
}
.checklesson-form button[type="button"],
.checklesson-form button[type="submit"] {
padding: 8px 16px !important;
font-size: 13px !important;
}
/* DatePicker / TimePicker trigger buttons */
.checklesson-form button[aria-required] {
padding: 8px 12px !important;
font-size: 14px !important;
}
/* Header row: reduce margin */
.checklesson-form > div:first-child {
margin-bottom: 0 !important;
}
/* Switch row: reduce padding */
.checklesson-form > div:has(> [class*="switch"]),
.checklesson-form > div:has(> div > input[type="checkbox"]) {
padding: 4px 0 !important;
}
/* Actions row: reduce margin */
.checklesson-form > div:last-child {
margin-top: 4px !important;
}
}
/* Диалог создания/редактирования урока: полноэкранный на мобильном */
@media (max-width: 600px) {
.create-lesson-dialog {
min-width: unset !important;
width: auto !important;
max-width: none !important;
gap: 6px !important;
}
/* Скрываем отдельные лейблы — у md-outlined-field есть свой label */
.create-lesson-dialog > div > label {
display: none !important;
}
/* Внутренний gap у предмета (select + custom input) */
.create-lesson-dialog > div > div {
gap: 6px !important;
}
/* Время + длительность в одну колонку */
.create-lesson-time-row {
grid-template-columns: 1fr !important;
gap: 6px !important;
}
/* Лейблы внутри time-row тоже скрываем */
.create-lesson-time-row label {
display: none !important;
}
/* Переключатель "постоянное занятие" компактнее */
.create-lesson-dialog > div:last-child {
gap: 8px !important;
}
.create-lesson-dialog > div:last-child label {
display: inline !important;
font-size: 13px !important;
}
md-dialog {
--md-dialog-container-shape: 0px !important;
--md-dialog-container-max-width: 100vw !important;
--md-dialog-container-min-width: 100vw !important;
max-width: 100vw !important;
width: 100vw !important;
margin: 0 !important;
}
md-dialog::part(container) {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
height: 100dvh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
margin: 0 !important;
top: 0 !important;
left: 0 !important;
transform: none !important;
display: flex !important;
flex-direction: column !important;
}
md-dialog [slot="headline"] {
padding: 10px 12px !important;
font-size: 16px !important;
flex-shrink: 0 !important;
}
md-dialog form {
padding: 8px 12px !important;
overflow-y: auto !important;
flex: 1 !important;
min-height: 0 !important;
-webkit-overflow-scrolling: touch;
}
md-dialog [slot="actions"] {
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0)) !important;
flex-shrink: 0 !important;
}
md-dialog input,
md-dialog select,
md-dialog textarea {
font-size: 14px !important;
padding: 8px 0 !important;
}
md-dialog textarea {
min-height: 32px !important;
max-height: 48px !important;
}
}
/* ========== Адаптив: дашборд компоненты ========== */
/* StatsGrid: 4 колонки → 2 на планшете → 1 на маленьком телефоне */
@media (max-width: 1024px) {
.dashboard-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
@media (max-width: 480px) {
.dashboard-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* IncomeSection: 3 колонки → 1 на телефоне */
@media (max-width: 767px) {
.income-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* UpcomingLessons / RecentSubmissions: 2 колонки → 1 на телефоне */
@media (max-width: 600px) {
.upcoming-lessons-grid,
.recent-submissions-grid {
grid-template-columns: 1fr !important;
}
}
/* ParentDashboard: уменьшаем заголовок на мобильном */
@media (max-width: 767px) {
.parent-dashboard-title {
font-size: 22px !important;
}
}
/* ========== Адаптив: студенты — табы и высота карточек ========== */
/* Табы студентов: горизонтальный скролл на мобильных */
.students-tabs {
scrollbar-width: none;
-ms-overflow-style: none;
}
.students-tabs::-webkit-scrollbar {
display: none;
}
.students-tabs button {
white-space: nowrap;
flex-shrink: 0;
}
/* Студенты: карточки — уменьшаем сетку и высоту фото на мобильных */
@media (max-width: 1024px) {
.students-cards-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
@media (max-width: 767px) {
.students-tabs button {
padding: 10px 14px !important;
font-size: 14px !important;
}
.students-cards-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.students-cards-grid md-elevated-card {
border-radius: 16px !important;
}
.students-cards-grid md-elevated-card > div:first-child {
height: 100px !important;
}
.students-cards-grid md-elevated-card > div:last-child {
padding: 8px 10px 10px !important;
}
.students-cards-grid md-elevated-card > div:last-child > div:first-child {
font-size: 14px !important;
margin-bottom: 2px !important;
}
.students-cards-grid md-elevated-card > div:last-child > div:nth-child(2) {
font-size: 12px !important;
}
}
@media (max-width: 480px) {
.students-cards-grid md-elevated-card > div:first-child {
height: 80px !important;
}
}
/* ========== Адаптив: рефералы ========== */
@media (max-width: 767px) {
.page-referrals {
padding: 12px !important;
}
.page-referrals-card {
padding: 16px !important;
border-radius: 16px !important;
}
.referral-link-row {
flex-direction: column !important;
}
.referral-link-row input {
width: 100% !important;
}
.referral-link-row button {
width: 100% !important;
}
.referral-stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.referral-list {
padding-left: 16px !important;
}
.referral-list li {
font-size: 13px !important;
}
}
@media (max-width: 480px) {
.page-referrals {
padding: 8px !important;
}
.page-referrals-card {
padding: 12px !important;
border-radius: 14px !important;
}
.referral-stats-grid {
grid-template-columns: 1fr !important;
}
}
/* ========== Адаптив: уведомления — панель fixed на мобильном (embedded) ========== */
@media (max-width: 767px) {
.notification-panel {
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
top: auto !important;
width: auto !important;
max-width: none !important;
max-height: 70vh !important;
transform: none !important;
}
}
/* Flip-карточка эффект */
.flip-card {
position: relative;