mobile
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
b4b99491ae
commit
d4c4dbb087
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -309,9 +309,37 @@ class User(AbstractUser):
|
|||
def __str__(self):
|
||||
return f"{self.get_full_name()} ({self.email})"
|
||||
|
||||
def _generate_universal_code(self):
|
||||
"""Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z)."""
|
||||
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-символьного кода (цифры + латинские буквы A–Z)."""
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', # Для загрузки файлов
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ export default function RegisterPage() {
|
|||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className="auth-name-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
|
|
|
|||
|
|
@ -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,20 +168,44 @@ 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',
|
||||
}}
|
||||
>
|
||||
{/* Chat list: hidden on mobile when a chat is selected */}
|
||||
{!mobileShowChat && (
|
||||
<>
|
||||
{loading ? (
|
||||
<Box
|
||||
className="ios-glass-panel"
|
||||
|
|
@ -187,6 +213,7 @@ export default function ChatPage() {
|
|||
borderRadius: '20px',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flex: isMobile ? 1 : undefined,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
|
|
@ -204,6 +231,7 @@ export default function ChatPage() {
|
|||
borderRadius: '20px',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flex: isMobile ? 1 : undefined,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
|
|
@ -224,12 +252,18 @@ export default function ChatPage() {
|
|||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@ function ProfilePage() {
|
|||
<div
|
||||
className="page-profile"
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
padding: 24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
<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',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
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',
|
||||
<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={{
|
||||
{/* Weekday headers */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '4px',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
{weekDays.map(day => (
|
||||
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,30 +220,35 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar days grid */}
|
||||
<div style={{
|
||||
{/* Calendar days */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '4px',
|
||||
}}>
|
||||
{days.map((day, index) => {
|
||||
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
|
||||
border:
|
||||
isToday && !isSelected
|
||||
? '1px solid var(--md-sys-color-primary)'
|
||||
: 'none',
|
||||
borderRadius: '50%',
|
||||
|
|
@ -237,25 +256,15 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
|||
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
|
||||
: isCurrent
|
||||
? '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';
|
||||
}
|
||||
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)',
|
||||
{/* Actions */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(new Date())}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
transition: 'background 0.2s ease',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mt: 2,
|
||||
pt: 1.5,
|
||||
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => handleDateSelect(new Date())}
|
||||
variant="text"
|
||||
sx={{
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}}>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
<DatePicker
|
||||
value={formData.start_date}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
required
|
||||
onChange={(v) => handleDateChange(v)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
fontSize: '16px',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
required
|
||||
label="Дата начала"
|
||||
/>
|
||||
</md-outlined-field>
|
||||
</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"
|
||||
<TimePicker
|
||||
value={formData.start_time}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
required
|
||||
onChange={(v) => handleTimeChange(v)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
fontSize: '16px',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</md-outlined-field>
|
||||
</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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}}>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
|
|||
period={period}
|
||||
/>
|
||||
<div
|
||||
className="income-stats-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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,14 +279,49 @@ 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 && (
|
||||
{/* 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"
|
||||
|
|
@ -267,13 +337,14 @@ export function BottomNavigationBar({ userRole, user, navBadges, notificationsSl
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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,6 +225,7 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
|
|||
>
|
||||
Уведомления
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -243,6 +244,26 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
|
|||
Прочитать все
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue