1034 lines
42 KiB
TypeScript
1034 lines
42 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback, Suspense, useRef } from 'react';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import {
|
||
getProfileSettings,
|
||
updateProfileSettings,
|
||
updateProfile,
|
||
searchCitiesFromCSV,
|
||
loadTelegramAvatar,
|
||
type ProfileSettings,
|
||
type CityOption,
|
||
} from '@/api/profile';
|
||
import {
|
||
getNotificationPreferences,
|
||
updateNotificationPreferences,
|
||
type NotificationPreference,
|
||
} from '@/api/notifications';
|
||
import { getReferralStats } from '@/api/referrals';
|
||
import { formatPhoneForDisplay, parsePhoneFromInput } from '@/lib/phone-utils';
|
||
import { ProfilePaymentTab } from '@/components/profile/ProfilePaymentTab';
|
||
import { NotificationSettingsSection } from '@/components/profile/NotificationSettingsSection';
|
||
import { ParentChildNotificationSettings } from '@/components/profile/ParentChildNotificationSettings';
|
||
import { TelegramSection } from '@/components/profile/TelegramSection';
|
||
import { Switch } from '@/components/common/Switch';
|
||
|
||
function getAvatarUrl(user: { avatar_url?: string | null; avatar?: string | null } | null): string | null {
|
||
if (!user) return null;
|
||
const url = user.avatar_url || user.avatar;
|
||
if (!url) return null;
|
||
if (url.startsWith('http')) return url;
|
||
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
||
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
|
||
}
|
||
|
||
const ROLE_LABELS: Record<string, string> = {
|
||
mentor: 'Ментор',
|
||
client: 'Студент',
|
||
parent: 'Родитель',
|
||
};
|
||
|
||
/** Имя/фамилия: только латиница ИЛИ только кириллица, без смешивания. Алфавит задаётся первым символом. */
|
||
function filterNameInput(value: string): string {
|
||
if (!value.trim()) return value.replace(/[^a-zA-ZА-Яа-яЁё]/g, '');
|
||
const first = value[0];
|
||
if (/[a-zA-Z]/.test(first)) return value.replace(/[^a-zA-Z]/g, '');
|
||
if (/[А-Яа-яЁё]/.test(first)) return value.replace(/[^А-Яа-яЁё]/g, '');
|
||
return value.replace(/[^a-zA-ZА-Яа-яЁё]/g, '');
|
||
}
|
||
|
||
type TabId = 'profile' | 'payment';
|
||
|
||
function ProfilePage() {
|
||
const { user, loading: authLoading, logout, refreshUser } = useAuth();
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const tabParam = searchParams?.get('tab');
|
||
const activeTab: TabId = tabParam === 'payment' ? 'payment' : 'profile';
|
||
const avatarUrl = getAvatarUrl(user);
|
||
|
||
const [settings, setSettings] = useState<ProfileSettings | null>(null);
|
||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPreference | null>(null);
|
||
const [formData, setFormData] = useState({ first_name: '', last_name: '', phone: '', email: '' });
|
||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||
const [citySearchQuery, setCitySearchQuery] = useState('');
|
||
const [citySearchResults, setCitySearchResults] = useState<CityOption[]>([]);
|
||
const [isCityInputFocused, setIsCityInputFocused] = useState(false);
|
||
const [isSearchingCities, setIsSearchingCities] = useState(false);
|
||
const [avatarHovered, setAvatarHovered] = useState(false);
|
||
const [loadingTelegramAvatar, setLoadingTelegramAvatar] = useState(false);
|
||
const [referralPoints, setReferralPoints] = useState<number | null>(null);
|
||
const cityInputRef = useRef<HTMLInputElement>(null);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const saveSettingsAndNotificationsRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||
const settingsSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const loadProfile = useCallback(async () => {
|
||
if (!user) return;
|
||
setFormData({
|
||
first_name: user.first_name || '',
|
||
last_name: user.last_name || '',
|
||
phone: user.phone || '',
|
||
email: user.email || '',
|
||
});
|
||
try {
|
||
const [settingsRes, notifRes, refStats] = await Promise.all([
|
||
getProfileSettings().catch(() => null),
|
||
getNotificationPreferences(),
|
||
getReferralStats().catch(() => null),
|
||
]);
|
||
if (refStats?.total_points != null) setReferralPoints(refStats.total_points);
|
||
setSettings(settingsRes || null);
|
||
const defaultPrefs = {
|
||
enabled: true,
|
||
email_enabled: true,
|
||
telegram_enabled: false,
|
||
in_app_enabled: true,
|
||
};
|
||
setNotificationPrefs((prev) => notifRes ?? prev ?? defaultPrefs);
|
||
if (settingsRes) {
|
||
if (!settingsRes.preferences?.timezone) {
|
||
settingsRes.preferences = {
|
||
...settingsRes.preferences,
|
||
timezone: typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow',
|
||
};
|
||
}
|
||
if (settingsRes.preferences?.city) {
|
||
setCitySearchQuery(settingsRes.preferences.city);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setLoadError('Не удалось загрузить настройки');
|
||
}
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
if (tabParam === 'analytics') router.replace('/analytics');
|
||
if (tabParam === 'referrals') router.replace('/referrals');
|
||
}, [tabParam, router]);
|
||
|
||
const handleChangePreference = useCallback((field: 'city' | 'timezone', value: string) => {
|
||
setSettings((prev) => {
|
||
if (!prev) return prev;
|
||
return {
|
||
...prev,
|
||
preferences: { ...prev.preferences, [field]: value },
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const handleCitySearch = useCallback(async (query: string) => {
|
||
if (query.trim().length < 2) {
|
||
setCitySearchResults([]);
|
||
return;
|
||
}
|
||
setIsSearchingCities(true);
|
||
try {
|
||
const results = await searchCitiesFromCSV(query.trim(), 20);
|
||
setCitySearchResults(results);
|
||
} catch {
|
||
setCitySearchResults([]);
|
||
} finally {
|
||
setIsSearchingCities(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => {
|
||
if (citySearchQuery.trim().length >= 2) handleCitySearch(citySearchQuery);
|
||
else setCitySearchResults([]);
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}, [citySearchQuery, handleCitySearch]);
|
||
|
||
useEffect(() => {
|
||
loadProfile();
|
||
}, [loadProfile]);
|
||
|
||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !user) return;
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
setLoadError('Размер файла не должен превышать 5 МБ');
|
||
return;
|
||
}
|
||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
setLoadError('Поддерживаются только форматы: JPEG, PNG, WebP, GIF');
|
||
return;
|
||
}
|
||
|
||
const objectUrl = URL.createObjectURL(file);
|
||
setAvatarPreview(objectUrl);
|
||
setAvatarFile(file);
|
||
setLoadError(null);
|
||
|
||
try {
|
||
setUploadingAvatar(true);
|
||
const updatedUser = await updateProfile({ avatar: file });
|
||
await refreshUser();
|
||
const newAvatarUrl = getAvatarUrl(updatedUser);
|
||
if (newAvatarUrl) {
|
||
URL.revokeObjectURL(objectUrl);
|
||
setAvatarPreview(newAvatarUrl);
|
||
setAvatarFile(null);
|
||
} else {
|
||
setAvatarPreview(objectUrl);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
setLoadError('Не удалось загрузить аватар. Нажмите «Сохранить» для повторной попытки.');
|
||
// objectUrl не отзываем — оставляем preview, avatarFile сохранён для кнопки «Сохранить»
|
||
} finally {
|
||
setUploadingAvatar(false);
|
||
e.target.value = '';
|
||
}
|
||
};
|
||
|
||
const handleAvatarDelete = async () => {
|
||
if (!user) return;
|
||
try {
|
||
await updateProfile({ avatar: null });
|
||
await refreshUser();
|
||
setAvatarFile(null);
|
||
setAvatarPreview(null);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const handleLoadTelegramAvatar = async () => {
|
||
if (!user?.telegram_id) return;
|
||
try {
|
||
setLoadingTelegramAvatar(true);
|
||
setLoadError(null);
|
||
const updatedUser = await loadTelegramAvatar();
|
||
await refreshUser();
|
||
const newUrl = getAvatarUrl(updatedUser);
|
||
if (newUrl) {
|
||
setAvatarPreview(newUrl);
|
||
setAvatarFile(null);
|
||
}
|
||
setSaveSuccess(true);
|
||
setTimeout(() => setSaveSuccess(false), 3000);
|
||
} catch (err: unknown) {
|
||
const msg =
|
||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||
(err instanceof Error ? err.message : 'Не удалось загрузить аватар из Telegram');
|
||
setLoadError(typeof msg === 'string' ? msg : 'Не удалось загрузить аватар из Telegram');
|
||
} finally {
|
||
setLoadingTelegramAvatar(false);
|
||
}
|
||
};
|
||
|
||
const saveProfileFields = useCallback(async () => {
|
||
if (!user) return;
|
||
try {
|
||
const updatedUser = await updateProfile({
|
||
first_name: formData.first_name,
|
||
last_name: formData.last_name,
|
||
phone: formData.phone,
|
||
email: formData.email,
|
||
});
|
||
if (updatedUser) {
|
||
setFormData({
|
||
first_name: updatedUser.first_name ?? formData.first_name,
|
||
last_name: updatedUser.last_name ?? formData.last_name,
|
||
phone: updatedUser.phone ?? formData.phone,
|
||
email: updatedUser.email ?? formData.email,
|
||
});
|
||
}
|
||
setSaveSuccess(true);
|
||
setTimeout(() => setSaveSuccess(false), 3000);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setLoadError('Не удалось сохранить имя или телефон.');
|
||
}
|
||
}, [user, formData.first_name, formData.last_name, formData.phone]);
|
||
|
||
const saveSettingsAndNotifications = useCallback(async () => {
|
||
if (!user || !settings) return;
|
||
try {
|
||
setSaving(true);
|
||
setLoadError(null);
|
||
const prefsToSave = {
|
||
...settings,
|
||
preferences: {
|
||
...settings?.preferences,
|
||
city: citySearchQuery.trim() || settings?.preferences?.city,
|
||
},
|
||
...(user.role === 'mentor' && settings?.mentor_homework_ai !== undefined && {
|
||
mentor_homework_ai: settings.mentor_homework_ai,
|
||
}),
|
||
};
|
||
await updateProfileSettings(prefsToSave);
|
||
if (notificationPrefs) {
|
||
const updated = await updateNotificationPreferences(notificationPrefs);
|
||
if (updated) setNotificationPrefs(updated);
|
||
}
|
||
setSaveSuccess(true);
|
||
setTimeout(() => setSaveSuccess(false), 3000);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setLoadError('Не удалось сохранить настройки.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [user, settings, citySearchQuery, notificationPrefs]);
|
||
|
||
saveSettingsAndNotificationsRef.current = saveSettingsAndNotifications;
|
||
|
||
const scheduleSettingsSave = useCallback(() => {
|
||
if (settingsSaveTimeoutRef.current) clearTimeout(settingsSaveTimeoutRef.current);
|
||
settingsSaveTimeoutRef.current = setTimeout(() => {
|
||
settingsSaveTimeoutRef.current = null;
|
||
saveSettingsAndNotificationsRef.current();
|
||
}, 800);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (settingsSaveTimeoutRef.current) clearTimeout(settingsSaveTimeoutRef.current);
|
||
};
|
||
}, []);
|
||
|
||
const handleProfileFieldBlur = useCallback(() => {
|
||
saveProfileFields();
|
||
}, [saveProfileFields]);
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!user) return;
|
||
setSaving(true);
|
||
setLoadError(null);
|
||
setSaveSuccess(false);
|
||
try {
|
||
const updatedUser = await updateProfile({
|
||
first_name: formData.first_name,
|
||
last_name: formData.last_name,
|
||
phone: formData.phone,
|
||
email: formData.email,
|
||
...(avatarFile instanceof File && { avatar: avatarFile }),
|
||
});
|
||
if (updatedUser) {
|
||
setFormData({
|
||
first_name: updatedUser.first_name ?? formData.first_name,
|
||
last_name: updatedUser.last_name ?? formData.last_name,
|
||
phone: updatedUser.phone ?? formData.phone,
|
||
email: updatedUser.email ?? formData.email,
|
||
});
|
||
}
|
||
const prefsToSave = {
|
||
...settings,
|
||
preferences: {
|
||
...settings?.preferences,
|
||
city: citySearchQuery.trim() || settings?.preferences?.city,
|
||
},
|
||
...(user.role === 'mentor' && settings?.mentor_homework_ai !== undefined && {
|
||
mentor_homework_ai: settings.mentor_homework_ai,
|
||
}),
|
||
};
|
||
if (prefsToSave) await updateProfileSettings(prefsToSave);
|
||
if (notificationPrefs) {
|
||
const updatedPrefs = await updateNotificationPreferences(notificationPrefs);
|
||
if (updatedPrefs) setNotificationPrefs(updatedPrefs);
|
||
}
|
||
setAvatarFile(null);
|
||
setAvatarPreview(null);
|
||
setSaveSuccess(true);
|
||
setTimeout(() => setSaveSuccess(false), 3000);
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (avatarFile instanceof File) {
|
||
setLoadError('Не удалось сохранить аватар. Проверьте формат и размер (до 5 МБ).');
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
router.push('/login');
|
||
};
|
||
|
||
if (authLoading || !user) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||
<LoadingSpinner size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: '100vh',
|
||
padding: 24,
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'minmax(0, 440px) 1fr',
|
||
gap: 24,
|
||
alignItems: 'start',
|
||
position: 'relative',
|
||
zIndex: 1,
|
||
}}
|
||
>
|
||
{/* Левая карточка — Мой профиль */}
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 20,
|
||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<form id="profile-form" onSubmit={handleSave}>
|
||
{/* Аватар — квадрат, заполняет пространство */}
|
||
<div
|
||
style={{
|
||
padding: 0,
|
||
aspectRatio: '1',
|
||
width: '100%',
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
<div
|
||
className="profile-avatar-container"
|
||
onMouseEnter={() => setAvatarHovered(true)}
|
||
onMouseLeave={() => setAvatarHovered(false)}
|
||
style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
aspectRatio: '1',
|
||
overflow: 'hidden',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: 'clamp(32px, 10vw, 64px)',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{avatarPreview || avatarUrl ? (
|
||
<>
|
||
<img
|
||
src={avatarPreview || avatarUrl || ''}
|
||
alt=""
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover', pointerEvents: 'none', opacity: uploadingAvatar ? 0.6 : 1 }}
|
||
/>
|
||
{uploadingAvatar && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'rgba(0,0,0,0.3)',
|
||
zIndex: 2,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
border: '3px solid rgba(255,255,255,0.3)',
|
||
borderTopColor: '#fff',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
user.first_name?.charAt(0) || user.email?.charAt(0) || 'У'
|
||
)}
|
||
<div
|
||
className="profile-avatar-overlay"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
zIndex: 1,
|
||
background: 'rgba(0,0,0,0.5)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 6,
|
||
padding: 8,
|
||
opacity: avatarHovered ? 1 : 0,
|
||
transition: 'opacity 0.2s',
|
||
pointerEvents: avatarHovered ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<label
|
||
className="profile-avatar-overlay-btn"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 4,
|
||
height: 50,
|
||
minHeight: 50,
|
||
padding: '0 10px',
|
||
borderRadius: 8,
|
||
background: 'rgba(255,255,255,0.95)',
|
||
color: '#282C32',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
}}
|
||
title="Обновить фото"
|
||
>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleAvatarChange}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>edit</span>
|
||
Обновить
|
||
</label>
|
||
{user?.telegram_id && (
|
||
<button
|
||
type="button"
|
||
onClick={handleLoadTelegramAvatar}
|
||
disabled={loadingTelegramAvatar || uploadingAvatar}
|
||
className="profile-avatar-overlay-btn"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 4,
|
||
height: 50,
|
||
minHeight: 50,
|
||
padding: '0 10px',
|
||
borderRadius: 8,
|
||
background: 'rgba(255,255,255,0.95)',
|
||
color: '#1976d2',
|
||
border: 'none',
|
||
cursor: loadingTelegramAvatar || uploadingAvatar ? 'not-allowed' : 'pointer',
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
opacity: loadingTelegramAvatar || uploadingAvatar ? 0.7 : 1,
|
||
}}
|
||
title="Подгрузить из Telegram"
|
||
>
|
||
{loadingTelegramAvatar ? (
|
||
<span
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
border: '2px solid rgba(25,118,210,0.3)',
|
||
borderTopColor: '#1976d2',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}}
|
||
/>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }} aria-hidden>
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z" />
|
||
</svg>
|
||
)}
|
||
Telegram
|
||
</button>
|
||
)}
|
||
{(avatarPreview || avatarUrl) && (
|
||
<button
|
||
type="button"
|
||
onClick={handleAvatarDelete}
|
||
className="profile-avatar-overlay-btn"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 4,
|
||
height: 50,
|
||
minHeight: 50,
|
||
padding: '0 10px',
|
||
borderRadius: 8,
|
||
background: 'rgba(255,255,255,0.95)',
|
||
color: '#BA1A1A',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
}}
|
||
title="Удалить фото"
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>delete</span>
|
||
Удалить
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: '16px 24px 0', display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
<p style={{ fontSize: 13, color: '#858585', margin: 0 }}>
|
||
{ROLE_LABELS[user.role] || user.role}
|
||
</p>
|
||
{referralPoints != null && (
|
||
<a
|
||
href="/referrals"
|
||
onClick={(e) => { e.preventDefault(); router.push('/referrals'); }}
|
||
style={{
|
||
fontSize: 13,
|
||
color: 'var(--md-sys-color-primary, #6750a4)',
|
||
textDecoration: 'none',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>military_tech</span>
|
||
{referralPoints} баллов
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
{/* Поля — 2 колонки */}
|
||
<div style={{ padding: '0 24px 24px 24px' }}>
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '12px 16px',
|
||
}}
|
||
>
|
||
<div>
|
||
<label htmlFor="profile-first-name" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Имя</label>
|
||
<input
|
||
id="profile-first-name"
|
||
type="text"
|
||
value={formData.first_name}
|
||
onChange={(e) => setFormData((p) => ({ ...p, first_name: filterNameInput(e.target.value) }))}
|
||
onBlur={handleProfileFieldBlur}
|
||
placeholder="Имя"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: '#FAFAFA',
|
||
fontSize: 15,
|
||
color: '#282C32',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="profile-last-name" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Фамилия</label>
|
||
<input
|
||
id="profile-last-name"
|
||
type="text"
|
||
value={formData.last_name}
|
||
onChange={(e) => setFormData((p) => ({ ...p, last_name: filterNameInput(e.target.value) }))}
|
||
onBlur={handleProfileFieldBlur}
|
||
placeholder="Фамилия"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: '#FAFAFA',
|
||
fontSize: 15,
|
||
color: '#282C32',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="profile-phone" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Телефон</label>
|
||
<input
|
||
id="profile-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
value={formatPhoneForDisplay(formData.phone)}
|
||
onChange={(e) => setFormData((p) => ({ ...p, phone: parsePhoneFromInput(e.target.value) }))}
|
||
onBlur={handleProfileFieldBlur}
|
||
placeholder="+7 (999) 123-45-67"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: '#FAFAFA',
|
||
fontSize: 15,
|
||
color: '#282C32',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="profile-email" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Email</label>
|
||
<input
|
||
id="profile-email"
|
||
type="email"
|
||
value={formData.email}
|
||
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value.toLowerCase().trim() }))}
|
||
onBlur={handleProfileFieldBlur}
|
||
placeholder="Email"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: formData.email?.endsWith('@platform.local') ? '#FFF9C4' : '#FAFAFA',
|
||
fontSize: 15,
|
||
color: '#282C32',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
{formData.email?.endsWith('@platform.local') && (
|
||
<p style={{ fontSize: 10, color: '#FBC02D', margin: '2px 0 0 0' }}>
|
||
Установите реальный Email для доступа к аккаунту
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Город */}
|
||
<div style={{ position: 'relative' }}>
|
||
<label htmlFor="profile-city" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Город</label>
|
||
<input
|
||
id="profile-city"
|
||
ref={cityInputRef}
|
||
type="text"
|
||
value={citySearchQuery}
|
||
onChange={(e) => setCitySearchQuery(e.target.value)}
|
||
onFocus={() => {
|
||
setIsCityInputFocused(true);
|
||
if (citySearchQuery.trim().length >= 2) handleCitySearch(citySearchQuery);
|
||
}}
|
||
onBlur={() => setTimeout(() => setIsCityInputFocused(false), 200)}
|
||
placeholder="Введите город"
|
||
autoComplete="off"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: '#FAFAFA',
|
||
fontSize: 15,
|
||
color: '#282C32',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
{isSearchingCities && (
|
||
<div style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)' }}>
|
||
<div
|
||
style={{
|
||
width: 18,
|
||
height: 18,
|
||
border: '2px solid #E6E6E6',
|
||
borderTopColor: 'var(--md-sys-color-primary)',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{isCityInputFocused && citySearchResults.length > 0 && (
|
||
<div
|
||
ref={dropdownRef}
|
||
style={{
|
||
position: 'absolute',
|
||
top: '100%',
|
||
left: 0,
|
||
right: 0,
|
||
marginTop: 4,
|
||
maxHeight: 200,
|
||
overflowY: 'auto',
|
||
background: '#fff',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{citySearchResults.map((city, idx) => (
|
||
<button
|
||
key={`${city.name}-${idx}`}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setCitySearchQuery(city.name);
|
||
if (city.timezone) handleChangePreference('timezone', city.timezone);
|
||
handleChangePreference('city', city.name);
|
||
setIsCityInputFocused(false);
|
||
scheduleSettingsSave();
|
||
}}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
textAlign: 'left',
|
||
border: 'none',
|
||
background: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
color: '#282C32',
|
||
}}
|
||
>
|
||
<div>{city.name}</div>
|
||
{city.timezone && (
|
||
<div style={{ fontSize: 12, color: '#858585', marginTop: 2 }}>
|
||
Часовой пояс: {city.timezone}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p style={{ fontSize: 10, color: '#858585', margin: '2px 0 0 0' }}>
|
||
Выберите город для определения часового пояса
|
||
</p>
|
||
</div>
|
||
|
||
{/* Часовой пояс */}
|
||
<div>
|
||
<label htmlFor="profile-timezone" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: '#858585', marginBottom: 4 }}>Часовой пояс</label>
|
||
<input
|
||
id="profile-timezone"
|
||
type="text"
|
||
value={settings?.preferences?.timezone ? `${settings.preferences.timezone}` : ''}
|
||
readOnly
|
||
disabled
|
||
placeholder="Определяется по городу"
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #E6E6E6',
|
||
background: '#F0F0F0',
|
||
fontSize: 15,
|
||
color: '#858585',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
<p style={{ fontSize: 10, color: '#858585', margin: '2px 0 0 0' }}>
|
||
Устанавливается автоматически по городу
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<TelegramSection onLinkedChange={() => refreshUser()} />
|
||
|
||
{loadError && (
|
||
<p style={{ color: 'var(--md-sys-color-error)', fontSize: 13, marginBottom: 16 }}>{loadError}</p>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleLogout}
|
||
style={{
|
||
width: '100%',
|
||
marginTop: 12,
|
||
padding: '12px 24px',
|
||
borderRadius: 14,
|
||
border: '1px solid var(--md-sys-color-error)',
|
||
background: 'transparent',
|
||
color: 'var(--md-sys-color-error)',
|
||
fontSize: 14,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Выйти из аккаунта
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{/* Правая часть — настройки уведомлений или контент таба */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||
{activeTab === 'profile' && (
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 20,
|
||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||
padding: '20px',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 24 }}>
|
||
<button
|
||
type="submit"
|
||
form="profile-form"
|
||
disabled={saving}
|
||
style={{
|
||
padding: '16px 32px',
|
||
borderRadius: 14,
|
||
border: 'none',
|
||
background: saveSuccess ? 'var(--md-sys-color-tertiary-container, #e8def8)' : 'var(--md-sys-color-primary)',
|
||
color: saveSuccess ? 'var(--md-sys-color-on-tertiary-container, #1d192b)' : 'var(--md-sys-color-on-primary)',
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
cursor: saving ? 'not-allowed' : 'pointer',
|
||
opacity: saving ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{saving ? 'Сохранение...' : saveSuccess ? 'Профиль успешно обновлён' : 'Сохранить'}
|
||
</button>
|
||
</div>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 16px 0', color: '#282C32' }}>
|
||
Настройки уведомлений
|
||
</h2>
|
||
<NotificationSettingsSection
|
||
preferences={notificationPrefs}
|
||
onChange={(p) => {
|
||
setNotificationPrefs(p);
|
||
scheduleSettingsSave();
|
||
}}
|
||
userRole={user?.role}
|
||
hasTelegram={!!user?.telegram_id}
|
||
disabled={saving}
|
||
/>
|
||
<ParentChildNotificationSettings />
|
||
|
||
{user?.role === 'mentor' && (
|
||
<div style={{ marginTop: 24 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
letterSpacing: '0.05em',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
ПРОВЕРКА ДЗ ЧЕРЕЗ ИИ
|
||
</div>
|
||
<div
|
||
style={{
|
||
border: '1px solid var(--ios26-list-divider)',
|
||
borderRadius: 10,
|
||
overflow: 'hidden',
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr auto',
|
||
background: 'var(--md-sys-color-surface-container-low)',
|
||
}}
|
||
>
|
||
<div style={{ padding: 8, fontWeight: 600, fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)' }}>Режим</div>
|
||
<div style={{ padding: 8, textAlign: 'center', fontWeight: 600, fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', minWidth: 64 }}>Вкл</div>
|
||
<div
|
||
style={{
|
||
padding: 8,
|
||
borderTop: '1px solid var(--ios26-list-divider)',
|
||
background: '#fff',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 500 }}>Доверять ИИ (черновик)</div>
|
||
<div style={{ fontSize: 10, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 1 }}>
|
||
ИИ выставит оценку и комментарий и сохранит как черновик. Вы сможете отредактировать и опубликовать.
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', borderTop: '1px solid var(--ios26-list-divider)', background: '#fff' }}>
|
||
<Switch
|
||
checked={!!settings?.mentor_homework_ai?.ai_trust_draft}
|
||
onChange={(v) => {
|
||
setSettings((prev) => ({
|
||
...prev!,
|
||
mentor_homework_ai: {
|
||
...prev?.mentor_homework_ai,
|
||
ai_trust_draft: v,
|
||
ai_trust_publish: v ? false : (prev?.mentor_homework_ai?.ai_trust_publish ?? false),
|
||
},
|
||
}));
|
||
scheduleSettingsSave();
|
||
}}
|
||
disabled={saving}
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
<div
|
||
style={{
|
||
padding: 8,
|
||
borderTop: '1px solid var(--ios26-list-divider)',
|
||
background: '#fff',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 500 }}>Полностью доверять ИИ</div>
|
||
<div style={{ fontSize: 10, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 1 }}>
|
||
ИИ выставит оценку и комментарий и опубликует результат студенту сразу после сдачи ДЗ.
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', borderTop: '1px solid var(--ios26-list-divider)', background: '#fff' }}>
|
||
<Switch
|
||
checked={!!settings?.mentor_homework_ai?.ai_trust_publish}
|
||
onChange={(v) => {
|
||
setSettings((prev) => ({
|
||
...prev!,
|
||
mentor_homework_ai: {
|
||
...prev?.mentor_homework_ai,
|
||
ai_trust_publish: v,
|
||
ai_trust_draft: v ? false : (prev?.mentor_homework_ai?.ai_trust_draft ?? false),
|
||
},
|
||
}));
|
||
scheduleSettingsSave();
|
||
}}
|
||
disabled={saving}
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{activeTab !== 'profile' && (
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 20,
|
||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||
padding: 24,
|
||
minHeight: 400,
|
||
}}
|
||
>
|
||
{activeTab === 'payment' && <ProfilePaymentTab />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ProfilePageWrapper() {
|
||
return (
|
||
<Suspense fallback={<LoadingSpinner size="large" />}>
|
||
<ProfilePage />
|
||
</Suspense>
|
||
);
|
||
}
|