530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { register } from '@/api/auth';
|
||
import { REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||
import { searchCitiesFromCSV, type CityOption } from '@/api/profile';
|
||
|
||
const loadMaterialComponents = async () => {
|
||
await Promise.all([
|
||
import('@material/web/textfield/filled-text-field.js'),
|
||
import('@material/web/button/filled-button.js'),
|
||
import('@material/web/button/text-button.js'),
|
||
import('@material/web/checkbox/checkbox.js'),
|
||
import('@material/web/select/filled-select.js'),
|
||
import('@material/web/select/select-option.js'),
|
||
]);
|
||
};
|
||
|
||
const ROLE_OPTIONS: { value: 'mentor' | 'client' | 'parent'; label: string }[] = [
|
||
{ value: 'mentor', label: 'Ментор' },
|
||
{ value: 'client', label: 'Студент' },
|
||
{ value: 'parent', label: 'Родитель' },
|
||
];
|
||
|
||
export default function RegisterPage() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const [firstName, setFirstName] = useState('');
|
||
const [lastName, setLastName] = useState('');
|
||
const [city, setCity] = useState('');
|
||
const [citySearchResults, setCitySearchResults] = useState<CityOption[]>([]);
|
||
const [isCityInputFocused, setIsCityInputFocused] = useState(false);
|
||
const [isSearchingCities, setIsSearchingCities] = useState(false);
|
||
const [timezoneOverride, setTimezoneOverride] = useState<string | null>(null);
|
||
const [role, setRole] = useState<'mentor' | 'client' | 'parent'>('client');
|
||
const [email, setEmail] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
const [referralCode, setReferralCode] = useState('');
|
||
const [showReferralField, setShowReferralField] = useState(false);
|
||
const [consent, setConsent] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||
const roleSelectRef = useRef<HTMLElement & { value: string } | null>(null);
|
||
|
||
// Реферальный код: при открытии — из URL ?ref= или из localStorage
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const refFromUrl = searchParams.get('ref')?.trim() || '';
|
||
if (refFromUrl) {
|
||
localStorage.setItem(REFERRAL_STORAGE_KEY, refFromUrl);
|
||
setReferralCode(refFromUrl);
|
||
setShowReferralField(true);
|
||
return;
|
||
}
|
||
const fromLs = localStorage.getItem(REFERRAL_STORAGE_KEY) || '';
|
||
if (fromLs) {
|
||
setReferralCode(fromLs);
|
||
setShowReferralField(true);
|
||
}
|
||
}, [searchParams]);
|
||
|
||
useEffect(() => {
|
||
const el = roleSelectRef.current;
|
||
if (el && role) el.value = role;
|
||
}, [role, componentsLoaded]);
|
||
|
||
useEffect(() => {
|
||
loadMaterialComponents()
|
||
.then(() => setComponentsLoaded(true))
|
||
.catch((err) => {
|
||
console.error('Error loading Material components:', err);
|
||
setComponentsLoaded(true);
|
||
});
|
||
}, []);
|
||
|
||
const getTimezoneForSubmit = () => {
|
||
if (timezoneOverride) return timezoneOverride;
|
||
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
return 'Europe/Moscow';
|
||
};
|
||
|
||
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 (city.trim().length >= 2) handleCitySearch(city);
|
||
else setCitySearchResults([]);
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}, [city, handleCitySearch]);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
if (!consent) {
|
||
setError('Необходимо согласие на обработку персональных данных');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (password !== confirmPassword) {
|
||
setError('Пароли не совпадают');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await register({
|
||
email,
|
||
password,
|
||
password_confirm: confirmPassword,
|
||
first_name: firstName,
|
||
last_name: lastName,
|
||
role,
|
||
city: city.trim(),
|
||
timezone: getTimezoneForSubmit(),
|
||
});
|
||
|
||
// Не авторизуем сразу — требуется подтверждение email
|
||
setRegisteredEmail(email);
|
||
setRegistrationSuccess(true);
|
||
return;
|
||
} catch (err: any) {
|
||
setError(
|
||
err.response?.data?.detail ||
|
||
(Array.isArray(err.response?.data?.email)
|
||
? err.response.data.email[0]
|
||
: err.response?.data?.email) ||
|
||
err.response?.data?.message ||
|
||
'Ошибка регистрации. Проверьте данные.'
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!componentsLoaded) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||
<div
|
||
style={{
|
||
width: '40px',
|
||
height: '40px',
|
||
border: '3px solid #e0e0e0',
|
||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (registrationSuccess) {
|
||
return (
|
||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||
Регистрация
|
||
</p>
|
||
<div
|
||
style={{
|
||
padding: '24px',
|
||
background: 'var(--md-sys-color-surface-container, #f5f5f5)',
|
||
borderRadius: '16px',
|
||
marginBottom: '24px',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<p
|
||
style={{
|
||
fontSize: '16px',
|
||
color: 'var(--md-sys-color-on-surface, #1a1a1a)',
|
||
lineHeight: 1.5,
|
||
marginBottom: '16px',
|
||
}}
|
||
>
|
||
На адрес <strong>{registeredEmail}</strong> отправлено письмо с ссылкой для подтверждения.
|
||
</p>
|
||
<p
|
||
style={{
|
||
fontSize: '14px',
|
||
color: 'var(--md-sys-color-on-surface-variant, #666)',
|
||
lineHeight: 1.5,
|
||
marginBottom: '24px',
|
||
}}
|
||
>
|
||
Перейдите по ссылке из письма, затем войдите в аккаунт.
|
||
</p>
|
||
<md-filled-button
|
||
onClick={() => router.push('/login')}
|
||
style={{
|
||
width: '100%',
|
||
height: '48px',
|
||
fontSize: '16px',
|
||
fontWeight: '500',
|
||
}}
|
||
>
|
||
Вернуться ко входу
|
||
</md-filled-button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||
|
||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||
Регистрация
|
||
</p>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div
|
||
className="auth-name-grid"
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '16px',
|
||
marginBottom: '20px',
|
||
}}
|
||
>
|
||
<md-filled-text-field
|
||
label="Имя"
|
||
type="text"
|
||
value={firstName}
|
||
onInput={(e: any) => setFirstName(e.target.value || '')}
|
||
required
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<md-filled-text-field
|
||
label="Фамилия"
|
||
type="text"
|
||
value={lastName}
|
||
onInput={(e: any) => setLastName(e.target.value || '')}
|
||
required
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px', position: 'relative' }}>
|
||
<label htmlFor="register-city" style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant, #666)', marginBottom: 4 }}>
|
||
Город (для часового пояса)
|
||
</label>
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
id="register-city"
|
||
type="text"
|
||
value={city}
|
||
onChange={(e) => setCity(e.target.value)}
|
||
onFocus={() => {
|
||
setIsCityInputFocused(true);
|
||
if (city.trim().length >= 2) handleCitySearch(city);
|
||
}}
|
||
onBlur={() => setTimeout(() => setIsCityInputFocused(false), 200)}
|
||
placeholder="Введите город"
|
||
autoComplete="off"
|
||
required
|
||
style={{
|
||
width: '100%',
|
||
padding: '14px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid var(--md-sys-color-outline, #E6E6E6)',
|
||
background: 'var(--md-sys-color-surface-container-highest, #f5f5f5)',
|
||
fontSize: 16,
|
||
color: 'var(--md-sys-color-on-surface, #1a1a1a)',
|
||
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, #6750a4)',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.8s linear infinite',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isCityInputFocused && citySearchResults.length > 0 && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: '100%',
|
||
left: 0,
|
||
right: 0,
|
||
marginTop: 4,
|
||
maxHeight: 200,
|
||
overflowY: 'auto',
|
||
background: '#fff',
|
||
borderRadius: 12,
|
||
border: '1px solid var(--md-sys-color-outline, #E6E6E6)',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{citySearchResults.map((cityOpt, idx) => (
|
||
<button
|
||
key={`${cityOpt.name}-${idx}`}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setCity(cityOpt.name);
|
||
if (cityOpt.timezone) setTimezoneOverride(cityOpt.timezone);
|
||
setIsCityInputFocused(false);
|
||
}}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
textAlign: 'left',
|
||
border: 'none',
|
||
background: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
color: '#282C32',
|
||
}}
|
||
>
|
||
<div>{cityOpt.name}</div>
|
||
{cityOpt.timezone && (
|
||
<div style={{ fontSize: 12, color: '#858585', marginTop: 2 }}>
|
||
Часовой пояс: {cityOpt.timezone}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<md-filled-select
|
||
ref={roleSelectRef as any}
|
||
label="Роль"
|
||
onChange={(e: any) => {
|
||
const v = (e.target?.value ?? '') as string;
|
||
if (v === 'mentor' || v === 'client' || v === 'parent') setRole(v);
|
||
}}
|
||
required
|
||
style={{ width: '100%' }}
|
||
>
|
||
{ROLE_OPTIONS.map((opt) => (
|
||
<md-select-option
|
||
key={opt.value}
|
||
value={opt.value}
|
||
selected={role === opt.value}
|
||
>
|
||
<span slot="headline">{opt.label}</span>
|
||
</md-select-option>
|
||
))}
|
||
</md-filled-select>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<md-filled-text-field
|
||
label="Email"
|
||
type="email"
|
||
value={email}
|
||
onInput={(e: any) => setEmail(e.target.value || '')}
|
||
required
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<md-filled-text-field
|
||
label="Пароль"
|
||
type="password"
|
||
value={password}
|
||
onInput={(e: any) => setPassword(e.target.value || '')}
|
||
required
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<md-filled-text-field
|
||
label="Подтвердите пароль"
|
||
type="password"
|
||
value={confirmPassword}
|
||
onInput={(e: any) => setConfirmPassword(e.target.value || '')}
|
||
required
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Реферальный код — сворачиваемый блок */}
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowReferralField((v) => !v)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
width: '100%',
|
||
padding: '12px 0',
|
||
border: 'none',
|
||
background: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: '14px',
|
||
color: '#333',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<span>Есть реферальный код?</span>
|
||
<span
|
||
style={{
|
||
transform: showReferralField ? 'rotate(180deg)' : 'rotate(0deg)',
|
||
transition: 'transform 0.2s ease',
|
||
}}
|
||
>
|
||
▼
|
||
</span>
|
||
</button>
|
||
<div
|
||
style={{
|
||
overflow: 'hidden',
|
||
maxHeight: showReferralField ? '88px' : '0',
|
||
opacity: showReferralField ? 1 : 0,
|
||
marginTop: showReferralField ? '8px' : 0,
|
||
transition: 'max-height 0.35s ease, opacity 0.3s ease, margin-top 0.3s ease',
|
||
}}
|
||
>
|
||
<md-filled-text-field
|
||
label="Реферальный код"
|
||
type="text"
|
||
value={referralCode}
|
||
onInput={(e: any) => {
|
||
const v = (e.target?.value ?? '') as string;
|
||
setReferralCode(v);
|
||
if (typeof window !== 'undefined') localStorage.setItem(REFERRAL_STORAGE_KEY, v);
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
marginBottom: '20px',
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '12px',
|
||
}}
|
||
>
|
||
<md-checkbox
|
||
checked={consent}
|
||
onChange={(e: any) => setConsent(!!e.target?.checked)}
|
||
style={{ flexShrink: 0, marginTop: '4px' }}
|
||
/>
|
||
<label
|
||
style={{
|
||
fontSize: '14px',
|
||
color: '#333',
|
||
lineHeight: '1.5',
|
||
cursor: 'pointer',
|
||
}}
|
||
onClick={() => setConsent((c) => !c)}
|
||
>
|
||
Я даю согласие на обработку моих персональных данных в соответствии с политикой конфиденциальности
|
||
</label>
|
||
</div>
|
||
|
||
{error && (
|
||
<div
|
||
style={{
|
||
padding: '12px 16px',
|
||
marginBottom: '20px',
|
||
background: '#ffebee',
|
||
color: '#c62828',
|
||
borderRadius: '12px',
|
||
fontSize: '14px',
|
||
lineHeight: '1.5',
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<md-filled-button
|
||
type="submit"
|
||
disabled={loading}
|
||
style={{
|
||
width: '100%',
|
||
height: '48px',
|
||
marginBottom: '16px',
|
||
fontSize: '16px',
|
||
fontWeight: '500',
|
||
}}
|
||
>
|
||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||
</md-filled-button>
|
||
|
||
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||
Уже есть аккаунт? Войти
|
||
</md-text-button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|