tur
Deploy to Production / deploy-production (push) Successful in 29s Details

This commit is contained in:
root 2026-02-23 23:21:14 +03:00
parent a167683bd9
commit 835bd76479
59 changed files with 3000 additions and 2322 deletions

62
DOCKER-SERVER-CONFIG.md Normal file
View File

@ -0,0 +1,62 @@
# Конфигурация Docker на сервере
## Рекомендации для серверов с ограниченной RAM (8 GB)
### 1. Ограничение BuildKit cache
Чтобы BuildKit cache не раздувался до 80+ GB:
```bash
# Создать или отредактировать
sudo nano /etc/docker/daemon.json
```
Содержимое:
```json
{
"builder": {
"gc": {
"defaultKeepStorage": "10GB",
"enabled": true
}
},
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
```
Затем перезапуск:
```bash
sudo systemctl restart docker
```
### 2. Очистка build cache
Если нужно освободить место вручную:
```bash
# Удалить неиспользуемый build cache (осторожно: следующая сборка будет дольше)
docker builder prune -af
# Или с ограничением по возрасту (старше 7 дней)
docker builder prune -af --filter "until=168h"
```
### 3. Frontend Dockerfile — лимит памяти Node.js
В `front_material/Dockerfile` уже задано:
```dockerfile
ENV NODE_OPTIONS="--max-old-space-size=2048"
```
Это ограничивает heap Node.js до 2 GB при сборке и снижает риск OOM и тяжёлого swapping на машинах с 8 GB RAM.
### 4. Swap
Рекомендуется swap 48 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках.

View File

@ -84,6 +84,10 @@ class UserAdmin(BaseUserAdmin):
'telegram_notifications' 'telegram_notifications'
) )
}), }),
(_('Онбординг'), {
'fields': ('onboarding_tours_seen',),
'description': 'Прогресс подсказок по платформе (JSON). Чтобы сбросить — очистите поле или укажите {}.'
}),
(_('Блокировка'), { (_('Блокировка'), {
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
'classes': ('collapse',) 'classes': ('collapse',)
@ -142,6 +146,10 @@ class MentorAdmin(BaseUserAdmin):
'notifications_enabled', 'email_notifications', 'telegram_notifications', 'notifications_enabled', 'email_notifications', 'telegram_notifications',
'ai_trust_draft', 'ai_trust_publish') 'ai_trust_draft', 'ai_trust_publish')
}), }),
(_('Онбординг'), {
'fields': ('onboarding_tours_seen',),
'description': 'Прогресс подсказок (JSON). Чтобы сбросить — очистите или введите {}.'
}),
(_('Важные даты'), { (_('Важные даты'), {
'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'), 'fields': ('last_login', 'last_activity', 'date_joined', 'created_at', 'updated_at'),
'classes': ('collapse',) 'classes': ('collapse',)

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.7
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0010_user_login_token"),
]
operations = [
migrations.AddField(
model_name="user",
name="onboarding_tours_seen",
field=models.JSONField(
blank=True,
default=dict,
help_text="Страницы, для которых уже показан приветственный тур",
verbose_name="Просмотренные туры онбординга",
),
),
]

View File

@ -241,6 +241,15 @@ class User(AbstractUser):
verbose_name='Последняя активность' verbose_name='Последняя активность'
) )
# Прогресс онбординга (какие страницы уже показывали подсказки)
# Формат: {"dashboard": true, "schedule": true, "students": false, ...}
onboarding_tours_seen = models.JSONField(
default=dict,
blank=True,
verbose_name='Просмотренные туры онбординга',
help_text='Страницы, для которых уже показан приветственный тур',
)
# Настройки уведомлений # Настройки уведомлений
notifications_enabled = models.BooleanField( notifications_enabled = models.BooleanField(
default=True, default=True,

View File

@ -568,6 +568,7 @@ class ProfileViewSet(viewsets.ViewSet):
'ai_trust_draft': getattr(user, 'ai_trust_draft', False), 'ai_trust_draft': getattr(user, 'ai_trust_draft', False),
'ai_trust_publish': getattr(user, 'ai_trust_publish', False), 'ai_trust_publish': getattr(user, 'ai_trust_publish', False),
} }
settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {}
return Response(settings) return Response(settings)
@ -842,6 +843,14 @@ class ProfileViewSet(viewsets.ViewSet):
if 'ai_trust_publish' in mentor_ai: if 'ai_trust_publish' in mentor_ai:
user.ai_trust_publish = bool(mentor_ai['ai_trust_publish']) user.ai_trust_publish = bool(mentor_ai['ai_trust_publish'])
# Онбординг: отметка просмотренных туров
if 'onboarding_tours_seen' in request.data:
tours = request.data['onboarding_tours_seen']
if isinstance(tours, dict):
current = getattr(user, 'onboarding_tours_seen', None) or {}
merged = {**current, **{k: bool(v) for k, v in tours.items()}}
user.onboarding_tours_seen = merged
user.save() user.save()
return Response({'message': 'Настройки успешно обновлены'}) return Response({'message': 'Настройки успешно обновлены'})

View File

@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
'country', 'city', 'country', 'city',
'email_verified', 'is_active', 'email_verified', 'is_active',
'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором 'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором
'onboarding_tours_seen',
'invitation_link_token', 'invitation_link', 'invitation_link_token', 'invitation_link',
'login_token', 'login_link', 'login_token', 'login_link',
'notifications_enabled', 'email_notifications', 'telegram_notifications', 'notifications_enabled', 'email_notifications', 'telegram_notifications',

View File

@ -72,7 +72,8 @@ ENV NEXT_PUBLIC_EXCALIDRAW_URL=$NEXT_PUBLIC_EXCALIDRAW_URL
COPY package*.json ./ COPY package*.json ./
# Устанавливаем все зависимости для сборки # Устанавливаем все зависимости для сборки
RUN npm ci # npm install вместо npm ci: package-lock.json может быть не синхронизирован после добавления driver.js
RUN npm install
# Копируем исходный код # Копируем исходный код
COPY . . COPY . .

View File

@ -117,6 +117,7 @@ front_material/
├── contexts/ # React Context ├── contexts/ # React Context
│ ├── AuthContext.tsx # Контекст аутентификации │ ├── AuthContext.tsx # Контекст аутентификации
│ ├── OnboardingContext.tsx # Онбординг-туры (Driver.js, привязка к страницам и ролям)
│ ├── ThemeContext.tsx # Контекст темы (light/dark) │ ├── ThemeContext.tsx # Контекст темы (light/dark)
│ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей) │ └── SelectedChildContext.tsx # Контекст выбранного ребенка (для родителей)
@ -143,6 +144,7 @@ front_material/
├── lib/ # Утилиты ├── lib/ # Утилиты
│ ├── material-components.ts # Импорт всех Material компонентов │ ├── material-components.ts # Импорт всех Material компонентов
│ ├── onboarding-steps.ts # Шаги онбординга по страницам/ролям (ментор, студент, родитель)
│ └── utils.ts # Вспомогательные функции │ └── utils.ts # Вспомогательные функции
├── styles/ # CSS стили ├── styles/ # CSS стили

View File

@ -44,6 +44,7 @@ export interface User {
language?: string; language?: string;
city?: string; city?: string;
country?: string; country?: string;
onboarding_tours_seen?: Record<string, boolean>;
} }
/** /**

View File

@ -17,6 +17,9 @@ export interface MentorHomeworkAISettings {
ai_trust_publish?: boolean; ai_trust_publish?: boolean;
} }
/** Прогресс онбординга: страница → просмотрено */
export type OnboardingToursSeen = Record<string, boolean>;
export interface ProfileSettings { export interface ProfileSettings {
preferences: { preferences: {
timezone?: string; timezone?: string;
@ -30,6 +33,8 @@ export interface ProfileSettings {
}; };
/** Только для ментора: доверие AI при проверке ДЗ */ /** Только для ментора: доверие AI при проверке ДЗ */
mentor_homework_ai?: MentorHomeworkAISettings; mentor_homework_ai?: MentorHomeworkAISettings;
/** Просмотренные туры онбординга по страницам */
onboarding_tours_seen?: OnboardingToursSeen;
} }
export async function getProfileSettings(): Promise<ProfileSettings> { export async function getProfileSettings(): Promise<ProfileSettings> {

View File

@ -174,7 +174,7 @@ export default function AnalyticsPage() {
); );
return ( return (
<DashboardLayout className="ios26-dashboard-analytics"> <DashboardLayout className="ios26-dashboard-analytics" data-tour="analytics-root">
<div className="ios26-analytics-swiper-wrap"> <div className="ios26-analytics-swiper-wrap">
<Swiper <Swiper
onSwiper={setSwiperInstance} onSwiper={setSwiperInstance}

View File

@ -189,7 +189,7 @@ export default function ChatPage() {
}, [mobileShowChat]); }, [mobileShowChat]);
return ( return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}> <div className="ios26-dashboard ios26-chat-page" data-tour="chat-root" style={{ padding: isMobile ? '8px' : '16px' }}>
<Box <Box
className="ios26-chat-layout" className="ios26-chat-layout"
sx={{ sx={{

View File

@ -201,7 +201,7 @@ export default function FeedbackPage() {
}; };
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
{error && ( {error && (
<div <div
style={{ style={{

View File

@ -10,6 +10,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { NavBadgesProvider } from '@/contexts/NavBadgesContext'; import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
import { SelectedChildProvider } from '@/contexts/SelectedChildContext'; import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
import { OnboardingProvider } from '@/contexts/OnboardingContext';
import { getNavBadges } from '@/api/navBadges'; import { getNavBadges } from '@/api/navBadges';
import { getActiveSubscription } from '@/api/subscriptions'; import { getActiveSubscription } from '@/api/subscriptions';
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals'; import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
@ -148,6 +149,7 @@ export default function ProtectedLayout({
return ( return (
<NavBadgesProvider refreshNavBadges={refreshNavBadges}> <NavBadgesProvider refreshNavBadges={refreshNavBadges}>
<SelectedChildProvider> <SelectedChildProvider>
<OnboardingProvider>
<div className="protected-layout-root"> <div className="protected-layout-root">
{!isFullWidthPage && <TopNavigationBar user={user} />} {!isFullWidthPage && <TopNavigationBar user={user} />}
<main <main
@ -176,6 +178,7 @@ export default function ProtectedLayout({
<NotificationBell /> <NotificationBell />
)} )}
</div> </div>
</OnboardingProvider>
</SelectedChildProvider> </SelectedChildProvider>
</NavBadgesProvider> </NavBadgesProvider>
); );

View File

@ -573,7 +573,7 @@ export default function MaterialsPage() {
} }
return ( return (
<div style={{ padding: '24px' }}> <div style={{ padding: '24px' }} data-tour="materials-root">
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -587,6 +587,7 @@ export default function MaterialsPage() {
{!isClient && ( {!isClient && (
<button <button
type="button" type="button"
data-tour="materials-add"
onClick={() => setAddPanelOpen(true)} onClick={() => setAddPanelOpen(true)}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',

View File

@ -288,7 +288,7 @@ export default function MyProgressPage() {
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<DashboardLayout className="ios26-dashboard-grid"> <DashboardLayout className="ios26-dashboard-grid" data-tour="my-progress-root">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */} {/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md"> <Panel padding="md">
<SectionHeader <SectionHeader

View File

@ -10,7 +10,7 @@ const ProfilePaymentTab = dynamic(
export default function PaymentPage() { export default function PaymentPage() {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }} data-tour="payment-root">
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}> <h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}>
Подписки и оплата Подписки и оплата
</h1> </h1>

View File

@ -24,6 +24,7 @@ import { ProfilePaymentTab } from '@/components/profile/ProfilePaymentTab';
import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection'; import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection';
import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings'; import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings';
import { TelegramSection } from '@/components/profile/TelegramSection'; import { TelegramSection } from '@/components/profile/TelegramSection';
import { OnboardingTipsSection } from '@/components/profile/OnboardingTipsSection';
import { Switch } from '@/components/common/Switch'; import { Switch } from '@/components/common/Switch';
function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null { function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null {
@ -382,6 +383,7 @@ function ProfilePage() {
return ( return (
<div <div
className="page-profile" className="page-profile"
data-tour="profile-root"
style={{ style={{
padding: 24, padding: 24,
position: 'relative', position: 'relative',
@ -893,6 +895,11 @@ function ProfilePage() {
{saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'} {saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'}
</button> </button>
</div> </div>
{(user?.role === 'mentor' || user?.role === 'client') && (
<div style={{ marginBottom: 24 }}>
<OnboardingTipsSection />
</div>
)}
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 16px 0', color: '#282C32' }}> <h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 16px 0', color: '#282C32' }}>
Настройки уведомлений Настройки уведомлений
</h2> </h2>

View File

@ -6,6 +6,7 @@ export default function ReferralsPage() {
return ( return (
<div <div
className="page-referrals" className="page-referrals"
data-tour="referrals-root"
style={{ style={{
padding: 24, padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)', background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',

View File

@ -122,9 +122,11 @@ export default function RequestMentorPage() {
style={{ style={{
padding: '24px', padding: '24px',
}} }}
data-tour="request-mentor-root"
> >
{/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */} {/* Табы всегда видны — Менторы | Ожидают ответа (ваши запросы) | Входящие приглашения (от менторов) */}
<div <div
data-tour="request-mentor-tabs"
style={{ style={{
display: 'flex', display: 'flex',
gap: 4, gap: 4,

View File

@ -113,42 +113,62 @@ export default function SchedulePage() {
})(); })();
}, [isFormVisible]); }, [isFormVisible]);
const loadLessons = useCallback(async () => { const loadLessons = useCallback(
const start = startOfMonth(subMonths(visibleMonth, 1)); async (merge?: boolean) => {
const end = endOfMonth(addMonths(visibleMonth, 1)); const start = startOfMonth(subMonths(visibleMonth, 1));
const isInitial = !hasLoadedLessonsOnceRef.current; const end = endOfMonth(addMonths(visibleMonth, 1));
try { const doMerge = merge ?? hasLoadedLessonsOnceRef.current;
if (isInitial) setLessonsLoading(true); const isInitial = !hasLoadedLessonsOnceRef.current && !doMerge;
setError(null); try {
const { lessons: lessonsData } = await getLessonsCalendar({ setLessonsLoading(true);
start_date: format(start, 'yyyy-MM-dd'), setError(null);
end_date: format(end, 'yyyy-MM-dd'), const { lessons: lessonsData } = await getLessonsCalendar({
...(selectedChild?.id && { child_id: selectedChild.id }), start_date: format(start, 'yyyy-MM-dd'),
}); end_date: format(end, 'yyyy-MM-dd'),
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({ ...(selectedChild?.id && { child_id: selectedChild.id }),
id: lesson.id, });
title: lesson.title, const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
start_time: lesson.start_time, id: lesson.id,
end_time: lesson.end_time, title: lesson.title,
status: lesson.status, start_time: lesson.start_time,
client: lesson.client?.id, end_time: lesson.end_time,
client_name: lesson.client_name ?? (lesson.client?.user status: lesson.status,
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim() client: lesson.client?.id,
: undefined), client_name: lesson.client_name ?? (lesson.client?.user
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name ? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim() : undefined),
: undefined), mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
subject: lesson.subject ?? lesson.subject_name ?? '', ? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
})); : undefined),
setLessons(mappedLessons); subject: lesson.subject ?? lesson.subject_name ?? '',
hasLoadedLessonsOnceRef.current = true; }));
} catch (err: any) { if (doMerge) {
console.error('Error loading lessons:', err); setLessons((prev) => {
setError(err?.message || 'Ошибка загрузки занятий'); const startStr = format(start, 'yyyy-MM-dd');
} finally { const endStr = format(end, 'yyyy-MM-dd');
if (isInitial) setLessonsLoading(false); const byId = new Map<string, CalendarLesson>();
} prev.forEach((l) => {
}, [visibleMonth, selectedChild?.id]); const lessonDateStr = l.start_time?.slice(0, 10) ?? '';
if (lessonDateStr < startStr || lessonDateStr > endStr) {
byId.set(String(l.id), l);
}
});
mappedLessons.forEach((l) => byId.set(String(l.id), l));
return Array.from(byId.values());
});
} else {
setLessons(mappedLessons);
}
hasLoadedLessonsOnceRef.current = true;
} catch (err: any) {
console.error('Error loading lessons:', err);
setError(err?.message || 'Ошибка загрузки занятий');
} finally {
setLessonsLoading(false);
}
},
[visibleMonth, selectedChild?.id]
);
useEffect(() => { useEffect(() => {
loadLessons(); loadLessons();
@ -442,7 +462,7 @@ export default function SchedulePage() {
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента // чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'min(calc(100vh - 160px), 600px)', minHeight: 'min(calc(100vh - 160px), 600px)',
}}> }}>
<div className="ios26-schedule-calendar-wrap"> <div className="ios26-schedule-calendar-wrap" data-tour="schedule-calendar">
<Calendar <Calendar
lessons={lessons} lessons={lessons}
lessonsLoading={lessonsLoading} lessonsLoading={lessonsLoading}
@ -454,7 +474,7 @@ export default function SchedulePage() {
userTimezone={user?.timezone} userTimezone={user?.timezone}
/> />
</div> </div>
<div className="ios26-schedule-right-wrap"> <div className="ios26-schedule-right-wrap" data-tour="schedule-form">
<CheckLesson <CheckLesson
selectedDate={selectedDate} selectedDate={selectedDate}
displayDate={displayDate} displayDate={displayDate}

View File

@ -414,6 +414,7 @@ export default function StudentsPage() {
return ( return (
<div <div
className="page-students" className="page-students"
data-tour="students-list"
style={{ style={{
padding: '24px', padding: '24px',
}} }}

View File

@ -96,13 +96,14 @@ export const Calendar: React.FC<CalendarProps> = ({
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{lessonsLoading ? ( {lessonsLoading && lessons.length === 0 ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />
) : ( ) : (
<LessonsCalendar <LessonsCalendar
lessons={mappedLessons} lessons={mappedLessons}
selectedDate={selectedDate} selectedDate={selectedDate}
userTimezone={userTimezone} userTimezone={userTimezone}
loading={lessonsLoading}
onSelectSlot={(date) => { onSelectSlot={(date) => {
try { try {
const d = startOfDay(date); const d = startOfDay(date);

View File

@ -60,6 +60,7 @@ export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMo
return ( return (
<Box <Box
className="ios-glass-panel" className="ios-glass-panel"
data-tour="chat-list"
sx={{ sx={{
borderRadius: '20px', borderRadius: '20px',
p: 2, p: 2,

View File

@ -388,6 +388,7 @@ export function ChatWindow({
return ( return (
<Box <Box
className="ios-glass-panel" className="ios-glass-panel"
data-tour="chat-window"
sx={{ sx={{
flex: 1, flex: 1,
borderRadius: '20px', borderRadius: '20px',

View File

@ -74,13 +74,18 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
} }
return ( return (
<div style={{ <div
width: '100%', data-tour="client-lessons"
maxWidth: '100%', style={{
padding: '16px', width: '100%',
}}> maxWidth: '100%',
padding: '16px',
}}
>
{/* Статистика студента */} {/* Статистика студента */}
<div style={{ <div
data-tour="client-stats"
style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px', gap: '16px',
@ -140,7 +145,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
{/* Следующее занятие */} {/* Следующее занятие */}
{stats?.next_lesson && ( {stats?.next_lesson && (
<div style={{ <div
data-tour="client-next-lesson"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',
@ -168,7 +175,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
marginBottom: '24px' marginBottom: '24px'
}}> }}>
{/* Домашние задания */} {/* Домашние задания */}
<div style={{ <div
data-tour="client-homework"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',
@ -206,7 +215,9 @@ export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) =>
</div> </div>
{/* Ближайшие занятия */} {/* Ближайшие занятия */}
<div style={{ <div
data-tour="client-upcoming"
style={{
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
borderRadius: '20px', borderRadius: '20px',
padding: '24px', padding: '24px',

View File

@ -26,6 +26,7 @@ import { ru } from 'date-fns/locale';
import { Box, IconButton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import { ChevronLeft, ChevronRight } from '@mui/icons-material'; import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import { parseISOToUserTimezone } from '@/utils/timezone'; import { parseISOToUserTimezone } from '@/utils/timezone';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
interface Lesson { interface Lesson {
id: string; id: string;
@ -48,6 +49,8 @@ interface LessonsCalendarProps {
onMonthChange?: (start: Date, end: Date) => void; onMonthChange?: (start: Date, end: Date) => void;
selectedDate?: Date; selectedDate?: Date;
userTimezone?: string; userTimezone?: string;
/** Идёт загрузка данных (запрос нового месяца) — блокирует навигацию */
loading?: boolean;
} }
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
@ -57,6 +60,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
onMonthChange, onMonthChange,
selectedDate, selectedDate,
userTimezone, userTimezone,
loading = false,
}) => { }) => {
const safeSelectedDate = useMemo(() => { const safeSelectedDate = useMemo(() => {
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate); if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
@ -176,24 +180,30 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<IconButton <IconButton
onClick={goPrevMonth} onClick={loading ? undefined : goPrevMonth}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<ChevronLeft fontSize="small" /> <ChevronLeft fontSize="small" />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={goToday} onClick={loading ? undefined : goToday}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
px: 1.25, px: 1.25,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}> <Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
@ -201,16 +211,24 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
</Typography> </Typography>
</IconButton> </IconButton>
<IconButton <IconButton
onClick={goNextMonth} onClick={loading ? undefined : goNextMonth}
size="small" size="small"
disabled={loading}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)', border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)', backgroundColor: 'var(--md-sys-color-surface)',
opacity: loading ? 0.6 : 1,
pointerEvents: loading ? 'none' : 'auto',
}} }}
> >
<ChevronRight fontSize="small" /> <ChevronRight fontSize="small" />
</IconButton> </IconButton>
{loading && (
<Box sx={{ ml: 0.5, display: 'flex', alignItems: 'center' }}>
<LoadingSpinner size="small" inline />
</Box>
)}
</Box> </Box>
</Box> </Box>

View File

@ -140,7 +140,7 @@ export const ExtraStatsSection: React.FC<ExtraStatsSectionProps> = ({ stats, loa
const rows = buildRows(stats, loading).slice(0, 9); const rows = buildRows(stats, loading).slice(0, 9);
return ( return (
<Panel padding="md"> <Panel padding="md" data-tour="mentor-extrastats">
<SectionHeader title="Статистика" /> <SectionHeader title="Статистика" />
<div className="ios26-stat-grid"> <div className="ios26-stat-grid">
{rows.map((row, index) => { {rows.map((row, index) => {

View File

@ -37,7 +37,7 @@ export const IncomeSection: React.FC<IncomeSectionProps> = ({
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0); const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
return ( return (
<Panel padding="md"> <Panel padding="md" data-tour="mentor-income">
<SectionHeader <SectionHeader
title="Динамика доходов" title="Динамика доходов"
trailing={ trailing={

View File

@ -125,7 +125,7 @@ export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> =
flipped={flipped} flipped={flipped}
onFlippedChange={setFlipped} onFlippedChange={setFlipped}
front={ front={
<Panel padding="md"> <Panel padding="md" data-tour="mentor-submissions">
<SectionHeader title="Последние сданные ДЗ" /> <SectionHeader title="Последние сданные ДЗ" />
{loading && !data ? ( {loading && !data ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />

View File

@ -107,7 +107,7 @@ export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
setFlipped(v); setFlipped(v);
}} }}
front={ front={
<Panel padding="md"> <Panel padding="md" data-tour="mentor-upcoming">
<SectionHeader title="Ближайшие занятия" /> <SectionHeader title="Ближайшие занятия" />
{loading && !data ? ( {loading && !data ? (
<LoadingSpinner size="medium" /> <LoadingSpinner size="medium" />

View File

@ -11,11 +11,13 @@ export interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
/** Дополнительный класс для контейнера */ /** Дополнительный класс для контейнера */
className?: string; className?: string;
/** data-tour для онбординга */
'data-tour'?: string;
} }
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '' }) => { export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => {
return ( return (
<div className={`ios26-dashboard ${className}`.trim()}> <div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
{children} {children}
</div> </div>
); );

View File

@ -16,6 +16,8 @@ export interface PanelProps {
/** Внутренние отступы. По умолчанию 24px */ /** Внутренние отступы. По умолчанию 24px */
padding?: 'none' | 'sm' | 'md' | 'lg'; padding?: 'none' | 'sm' | 'md' | 'lg';
style?: React.CSSProperties; style?: React.CSSProperties;
/** Атрибут для онбординга (data-tour) */
'data-tour'?: string;
} }
const paddingMap = { const paddingMap = {
@ -31,10 +33,12 @@ export const Panel: React.FC<PanelProps> = ({
interactive = false, interactive = false,
padding = 'md', padding = 'md',
style, style,
'data-tour': dataTour,
}) => { }) => {
const p = paddingMap[padding]; const p = paddingMap[padding];
return ( return (
<div <div
data-tour={dataTour}
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()} className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
style={{ style={{
padding: p ? `${p}px` : 0, padding: p ? `${p}px` : 0,

View File

@ -219,7 +219,7 @@ export function HomeworkPageContent() {
); );
return ( return (
<DashboardLayout className="ios26-dashboard ios26-feedback-page"> <DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
{error && ( {error && (
<div <div
style={{ style={{

View File

@ -91,6 +91,7 @@ import { getOrCreateLessonChat } from '@/api/chat';
import type { Chat } from '@/api/chat'; import type { Chat } from '@/api/chat';
import { ChatWindow } from '@/components/chat/ChatWindow'; import { ChatWindow } from '@/components/chat/ChatWindow';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useOnboarding } from '@/contexts/OnboardingContext';
import { getAvatarUrl } from '@/api/profile'; import { getAvatarUrl } from '@/api/profile';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
import { getNavBadges } from '@/api/navBadges'; import { getNavBadges } from '@/api/navBadges';
@ -817,6 +818,7 @@ export default function LiveKitRoomContent() {
const accessToken = searchParams.get('token'); const accessToken = searchParams.get('token');
const lessonIdParam = searchParams.get('lesson_id'); const lessonIdParam = searchParams.get('lesson_id');
const { user } = useAuth(); const { user } = useAuth();
const onboarding = useOnboarding();
const [serverUrl, setServerUrl] = useState<string>(''); const [serverUrl, setServerUrl] = useState<string>('');
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams)); const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
@ -923,6 +925,15 @@ export default function LiveKitRoomContent() {
} }
}, []); }, []);
// Подсказка по видеозвонку для студента (один раз, после входа в комнату)
useEffect(() => {
if (user?.role !== 'client' || !onboarding || showPreJoin) return;
const t = setTimeout(() => {
onboarding.runTourManually('livekit');
}, 3500);
return () => clearTimeout(t);
}, [user?.role, onboarding, showPreJoin]);
useEffect(() => { useEffect(() => {
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null; const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
if (id && !isNaN(id)) { if (id && !isNaN(id)) {

View File

@ -45,7 +45,7 @@ export function ChildSelectorCompact() {
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
return ( return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}> <div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}

View File

@ -164,6 +164,7 @@ export function NotificationBell({ embedded }: { embedded?: boolean }) {
<div <div
data-notification-bell data-notification-bell
data-tour="notifications-bell"
style={ style={
embedded embedded
? { ? {

View File

@ -0,0 +1,149 @@
'use client';
import { useState, useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useOnboarding } from '@/contexts/OnboardingContext';
import { useAuth } from '@/contexts/AuthContext';
import { getOnboardingKey } from '@/lib/onboarding-steps';
const PAGE_LABELS: Record<string, string> = {
dashboard: 'Главная',
schedule: 'Расписание',
students: 'Студенты',
materials: 'Материалы',
homework: 'Домашние задания',
feedback: 'Обратная связь',
analytics: 'Аналитика',
payment: 'Тарифы',
referrals: 'Рефералы',
profile: 'Профиль',
chat: 'Чат',
'my-progress': 'Прогресс',
'request-mentor': 'Мои менторы',
};
const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile'];
const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile'];
export function OnboardingTipsSection() {
const onboarding = useOnboarding();
const { user } = useAuth();
const pathname = usePathname();
const router = useRouter();
const [progress, setProgress] = useState({ seen: 0, total: 0 });
const [expanded, setExpanded] = useState(false);
const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null;
const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES;
useEffect(() => {
if (!onboarding) return;
onboarding.refreshProgress().then(() => {
setProgress(onboarding.getProgress());
});
}, [onboarding, pathname]);
if (!onboarding || !role) return null;
const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent');
const handleShowAgain = () => {
if (currentKey) onboarding.runTourManually(currentKey, { force: true });
};
const handleShowOnPage = (pageKey: string) => {
setExpanded(false);
const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`;
router.push(path);
setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800);
};
if (progress.total === 0) return null;
return (
<div
className="ios26-panel"
style={{
padding: 20,
borderRadius: 16,
background: 'var(--md-sys-color-surface-container-low)',
border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
}}
>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}>
Подсказки по платформе
</h3>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}>
Пройдено {progress.seen} из {progress.total} страниц
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{currentKey && (
<button
type="button"
onClick={handleShowAgain}
style={{
padding: '10px 16px',
fontSize: 14,
fontWeight: 600,
color: 'var(--md-sys-color-on-primary)',
background: 'var(--md-sys-color-primary)',
border: 'none',
borderRadius: 12,
cursor: 'pointer',
}}
>
Показать подсказки снова
</button>
)}
<button
type="button"
onClick={() => setExpanded(!expanded)}
style={{
padding: '10px 16px',
fontSize: 14,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: '1px solid var(--md-sys-color-primary)',
borderRadius: 12,
cursor: 'pointer',
}}
>
{expanded ? 'Свернуть' : 'Подсказки на другой странице'}
</button>
</div>
{expanded && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
display: 'flex',
flexWrap: 'wrap',
gap: 8,
}}
>
{pages.map((key) => (
<button
key={key}
type="button"
onClick={() => handleShowOnPage(key)}
style={{
padding: '8px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
background: 'var(--md-sys-color-surface-container-high)',
border: 'none',
borderRadius: 10,
cursor: 'pointer',
}}
>
{PAGE_LABELS[key] || key}
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,189 @@
'use client';
import { createContext, useContext, useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { driver, type DriveStep, type Driver } from 'driver.js';
import 'driver.js/dist/driver.css';
import '@/styles/driver-onboarding.css';
import {
MENTOR_ONBOARDING,
CLIENT_ONBOARDING,
PARENT_ONBOARDING,
getOnboardingKey,
getOnboardingProgress,
} from '@/lib/onboarding-steps';
import { getProfileSettings, updateProfileSettings } from '@/api/profile';
import { useAuth } from '@/contexts/AuthContext';
type Role = 'mentor' | 'client' | 'parent';
interface OnboardingContextType {
markTourSeen: (pageId: string) => Promise<void>;
runTourManually: (pageKey: string, options?: { force?: boolean }) => void;
getProgress: () => { seen: number; total: number };
refreshProgress: () => Promise<void>;
}
const OnboardingContext = createContext<OnboardingContextType | null>(null);
export function useOnboarding() {
return useContext(OnboardingContext);
}
export function OnboardingProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuth();
const toursSeenRef = useRef<Record<string, boolean>>({});
const driverRef = useRef<Driver | null>(null);
const markTourSeen = useCallback(async (pageId: string) => {
if (!pageId) return;
toursSeenRef.current[pageId] = true;
try {
await updateProfileSettings({
onboarding_tours_seen: { [pageId]: true },
});
} catch {
// ignore
}
}, []);
const runTour = useCallback(
(
config: { pageId: string; steps: { element?: string; popover: { title: string; description: string; side?: string; align?: string } }[] },
skipSeenCheck?: boolean
) => {
if (!skipSeenCheck && toursSeenRef.current[config.pageId]) return;
if (!config.steps.length) return;
// Преобразуем шаги в формат driver.js
const steps: DriveStep[] = config.steps
.map((s) => {
// driver.js: если элемент не найден, step показывается как overlay по центру
const element = s.element && document.querySelector(s.element) ? s.element : undefined;
return {
element: element || undefined,
popover: {
title: s.popover.title,
description: s.popover.description,
side: (s.popover.side as 'top' | 'right' | 'bottom' | 'left') || 'bottom',
align: (s.popover.align as 'start' | 'center' | 'end') || 'center',
},
};
})
.filter((s) => s.element || s.popover);
if (steps.length === 0) return;
if (driverRef.current) {
driverRef.current.destroy();
driverRef.current = null;
}
const driverObj = driver({
showProgress: true,
steps,
nextBtnText: 'Далее',
prevBtnText: 'Назад',
doneBtnText: 'Понятно',
progressText: '{{current}} из {{total}}',
popoverClass: 'driver-onboarding-friendly',
onDestroyStarted: () => {
markTourSeen(config.pageId);
driverObj.destroy();
driverRef.current = null;
},
});
driverRef.current = driverObj;
driverObj.drive();
},
[markTourSeen]
);
const runTourManually = useCallback(
(pageKey: string, options?: { force?: boolean }) => {
const role = user?.role as Role;
if (!role || !['mentor', 'client', 'parent'].includes(role)) return;
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
const config = configs[pageKey];
if (config) runTour(config, options?.force);
},
[user?.role, runTour]
);
const getProgress = useCallback(() => {
const role = user?.role as Role;
if (!role || !['mentor', 'client', 'parent'].includes(role)) return { seen: 0, total: 0 };
return getOnboardingProgress(toursSeenRef.current, role);
}, [user?.role]);
const refreshProgress = useCallback(async () => {
try {
const settings = await getProfileSettings();
const seen = settings?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
} catch {
// ignore
}
}, []);
const runTourRef = useRef(runTour);
runTourRef.current = runTour;
useEffect(() => {
if (!user) return;
getProfileSettings()
.then((s) => {
const seen = s?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
})
.catch(() => {});
}, [user?.id]);
useEffect(() => {
if (!user || !pathname) return;
const role = user.role as Role;
if (!['mentor', 'client', 'parent'].includes(role)) return;
if (pathname.startsWith('/login') || pathname.startsWith('/register') || pathname.startsWith('/livekit')) return;
const key = getOnboardingKey(pathname, role);
if (!key) return;
const configs = role === 'mentor' ? MENTOR_ONBOARDING : role === 'client' ? CLIENT_ONBOARDING : PARENT_ONBOARDING;
const config = configs[key];
if (!config) return;
let cancelled = false;
const loadAndRun = async () => {
try {
const settings = await getProfileSettings();
if (cancelled) return;
const seen = settings?.onboarding_tours_seen ?? {};
toursSeenRef.current = { ...toursSeenRef.current, ...seen };
if (seen[config.pageId]) return;
setTimeout(() => {
if (!cancelled) runTourRef.current(config);
}, 600);
} catch {
// при ошибке не показываем тур
}
};
loadAndRun();
return () => { cancelled = true; };
}, [pathname, user]);
const value: OnboardingContextType = {
markTourSeen,
runTourManually,
getProgress,
refreshProgress,
};
return (
<OnboardingContext.Provider value={value}>
{children}
</OnboardingContext.Provider>
);
}

View File

@ -0,0 +1,49 @@
# Онбординг-туры для платформы
Контекстные подсказки при первом посещении страниц для менторов, студентов и родителей. Используется библиотека **Driver.js**.
## Архитектура
### Backend
- **Поле User.onboarding_tours_seen** (JSONField): `{"mentor-dashboard": true, "mentor-schedule": false, ...}` — какие туры уже просмотрены.
- **API**:
- `GET /profile/settings/` — возвращает `onboarding_tours_seen` в ответе.
- `PATCH /profile/update_settings/` — принимает `onboarding_tours_seen` и сливает с текущим состоянием.
### Frontend
- **OnboardingProvider** (`contexts/OnboardingContext.tsx`): при смене страницы проверяет, нужен ли тур для текущей роли. Если тур ещё не просмотрен — запускает Driver.js.
- **Шаги** (`lib/onboarding-steps.ts`): определения шагов по страницам и ролям (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING).
- **data-tour** атрибуты: элементы с `data-tour="..."` используются как цели для подсветки (например, `mentor-income`, `schedule-calendar`, `client-lessons`).
## Страницы и шаги
### Ментор
- dashboard — Динамика доходов, Ближайшие занятия, Недавние сдачи, Навигация
- schedule — Календарь, форма создания занятия
- students, materials, homework, feedback, analytics, payment, profile
### Студент (client)
- dashboard — Ближайшие занятия, Навигация
- schedule, materials, homework, my-progress, request-mentor, profile
### Родитель
- dashboard — Выбор ребёнка, Занятия ребёнка, Навигация
- homework, my-progress, profile
## Добавление новых шагов
1. Добавить шаги в `lib/onboarding-steps.ts` в нужный объект (MENTOR_ONBOARDING, CLIENT_ONBOARDING, PARENT_ONBOARDING).
2. Добавить `data-tour="уникальный-id"` на целевой элемент в компоненте.
3. Шаги без `element` или с несуществующим селектором отображаются как overlay по центру.
## Ручной запуск тура
```ts
const { runTourManually } = useOnboarding();
runTourManually('dashboard'); // для текущей роли
```

View File

@ -25,6 +25,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"driver.js": "^1.3.1",
"livekit-client": "^2.16.0", "livekit-client": "^2.16.0",
"next": "^16.1.4", "next": "^16.1.4",
"react": "^19", "react": "^19",

View File

@ -0,0 +1,85 @@
/**
* Симпатичная стилизация онбординг-туров (Driver.js).
* Мягкие тени, скругления, дружелюбная палитра.
*/
.driver-popover.driver-onboarding-friendly {
background: var(--md-sys-color-surface-container-high, #fff);
border-radius: 20px;
box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(103, 80, 164, 0.12);
padding: 20px 24px;
max-width: 360px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-title {
font-size: 18px;
font-weight: 600;
color: var(--md-sys-color-on-surface, #1c1b1f);
margin-bottom: 10px;
line-height: 1.35;
letter-spacing: -0.01em;
}
.driver-popover.driver-onboarding-friendly .driver-popover-description {
font-size: 15px;
line-height: 1.5;
color: var(--md-sys-color-on-surface-variant, #49454f);
margin-bottom: 20px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
padding: 10px 20px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover,
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover {
transform: translateY(-1px);
}
.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn {
background: var(--md-sys-color-surface-variant, #e7e0ec);
color: var(--md-sys-color-on-surface-variant, #49454f);
}
.driver-popover.driver-onboarding-friendly .driver-popover-next-btn {
background: var(--md-sys-color-primary, #6750a4);
color: var(--md-sys-color-on-primary, #fff);
}
.driver-popover.driver-onboarding-friendly .driver-popover-progress-text {
font-size: 13px;
color: var(--md-sys-color-outline, #79747e);
font-weight: 500;
}
.driver-popover.driver-onboarding-friendly .driver-popover-arrow {
border-color: var(--md-sys-color-surface-container-high, #fff);
}
.driver-overlay {
background: rgba(0, 0, 0, 0) !important;
}
.driver-active-element {
border-radius: 16px !important;
box-shadow:
0 0 0 4px rgba(103, 80, 164, 0.9),
0 0 0 8px rgba(103, 80, 164, 0.25),
0 0 32px rgba(103, 80, 164, 0.35) !important;
}