uchill/front_material/app/(auth)/register/page.tsx

524 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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';
import { getErrorMessage } from '@/lib/error-utils';
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(getErrorMessage(err, 'Ошибка регистрации. Проверьте данные.'));
} 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>
);
}