tur
Deploy to Production / deploy-production (push) Successful in 29s
Details
Deploy to Production / deploy-production (push) Successful in 29s
Details
This commit is contained in:
parent
a167683bd9
commit
835bd76479
|
|
@ -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 4–8 GB на серверах с 8 GB RAM — для стабильности при пиковых нагрузках.
|
||||||
|
|
@ -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',)
|
||||||
|
|
|
||||||
|
|
@ -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="Просмотренные туры онбординга",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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': 'Настройки успешно обновлены'})
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 . .
|
||||||
|
|
|
||||||
|
|
@ -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 стили
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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%)',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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'); // для текущей роли
|
||||||
|
```
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue