324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
|
||
import { z as zod } from 'zod';
|
||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||
import { useForm, Controller } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
|
||
import Link from '@mui/material/Link';
|
||
import Alert from '@mui/material/Alert';
|
||
import Stack from '@mui/material/Stack';
|
||
import MenuItem from '@mui/material/MenuItem';
|
||
import Checkbox from '@mui/material/Checkbox';
|
||
import IconButton from '@mui/material/IconButton';
|
||
import TextField from '@mui/material/TextField';
|
||
import Typography from '@mui/material/Typography';
|
||
import Autocomplete from '@mui/material/Autocomplete';
|
||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
import LoadingButton from '@mui/lab/LoadingButton';
|
||
import InputAdornment from '@mui/material/InputAdornment';
|
||
|
||
import { paths } from 'src/routes/paths';
|
||
import { useRouter } from 'src/routes/hooks';
|
||
import { RouterLink } from 'src/routes/components';
|
||
|
||
import { useBoolean } from 'src/hooks/use-boolean';
|
||
|
||
import { Iconify } from 'src/components/iconify';
|
||
import { Form, Field } from 'src/components/hook-form';
|
||
|
||
import { signUp } from 'src/auth/context/jwt';
|
||
import { useAuthContext } from 'src/auth/hooks';
|
||
import { searchCities } from 'src/utils/profile-api';
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
export const SignUpSchema = zod
|
||
.object({
|
||
firstName: zod.string().min(1, { message: 'Введите имя!' }),
|
||
lastName: zod.string().min(1, { message: 'Введите фамилию!' }),
|
||
email: zod
|
||
.string()
|
||
.min(1, { message: 'Введите email!' })
|
||
.email({ message: 'Введите корректный email!' }),
|
||
role: zod.enum(['mentor', 'client', 'parent'], { message: 'Выберите роль!' }),
|
||
city: zod.string().min(1, { message: 'Введите город!' }),
|
||
password: zod
|
||
.string()
|
||
.min(1, { message: 'Введите пароль!' })
|
||
.min(8, { message: 'Пароль должен содержать минимум 8 символов!' }),
|
||
passwordConfirm: zod.string().min(1, { message: 'Подтвердите пароль!' }),
|
||
})
|
||
.refine((data) => data.password === data.passwordConfirm, {
|
||
message: 'Пароли не совпадают!',
|
||
path: ['passwordConfirm'],
|
||
});
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
function CityAutocomplete({ value, onChange, error, helperText }) {
|
||
const [inputValue, setInputValue] = useState(value || '');
|
||
const [options, setOptions] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const timerRef = useRef(null);
|
||
|
||
const fetch = useCallback(async (query) => {
|
||
if (!query || query.length < 2) { setOptions([]); return; }
|
||
setLoading(true);
|
||
try {
|
||
const res = await searchCities(query, 30);
|
||
setOptions(res.map((c) => (typeof c === 'string' ? c : c.name || c.city || String(c))));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
clearTimeout(timerRef.current);
|
||
timerRef.current = setTimeout(() => fetch(inputValue), 350);
|
||
return () => clearTimeout(timerRef.current);
|
||
}, [inputValue, fetch]);
|
||
|
||
return (
|
||
<Autocomplete
|
||
freeSolo
|
||
options={options}
|
||
loading={loading}
|
||
inputValue={inputValue}
|
||
onInputChange={(_, val) => {
|
||
setInputValue(val);
|
||
onChange(val);
|
||
}}
|
||
onChange={(_, val) => {
|
||
if (val) { setInputValue(val); onChange(val); }
|
||
}}
|
||
noOptionsText="Города не найдены"
|
||
loadingText="Поиск..."
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
label="Город (для часового пояса)"
|
||
error={error}
|
||
helperText={helperText}
|
||
InputLabelProps={{ shrink: true }}
|
||
InputProps={{
|
||
...params.InputProps,
|
||
endAdornment: (
|
||
<>
|
||
{loading ? <CircularProgress size={18} /> : null}
|
||
{params.InputProps.endAdornment}
|
||
</>
|
||
),
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
export function JwtSignUpView() {
|
||
const { checkUserSession } = useAuthContext();
|
||
const router = useRouter();
|
||
|
||
const password = useBoolean();
|
||
const passwordConfirm = useBoolean();
|
||
|
||
const [errorMsg, setErrorMsg] = useState('');
|
||
const [successMsg, setSuccessMsg] = useState('');
|
||
const [consent, setConsent] = useState(false);
|
||
|
||
const defaultValues = {
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
role: 'client',
|
||
city: '',
|
||
password: '',
|
||
passwordConfirm: '',
|
||
};
|
||
|
||
const methods = useForm({
|
||
resolver: zodResolver(SignUpSchema),
|
||
defaultValues,
|
||
});
|
||
|
||
const {
|
||
control,
|
||
handleSubmit,
|
||
formState: { isSubmitting },
|
||
} = methods;
|
||
|
||
const onSubmit = handleSubmit(async (data) => {
|
||
if (!consent) {
|
||
setErrorMsg('Необходимо согласиться с условиями использования и политикой конфиденциальности.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await signUp({
|
||
email: data.email,
|
||
password: data.password,
|
||
passwordConfirm: data.passwordConfirm,
|
||
firstName: data.firstName,
|
||
lastName: data.lastName,
|
||
role: data.role,
|
||
city: data.city,
|
||
});
|
||
|
||
if (result?.requiresVerification) {
|
||
setSuccessMsg(
|
||
`Письмо с подтверждением отправлено на ${data.email}. Пройдите по ссылке в письме для активации аккаунта.`
|
||
);
|
||
return;
|
||
}
|
||
|
||
await checkUserSession?.();
|
||
router.replace(paths.dashboard.root);
|
||
} catch (error) {
|
||
console.error(error);
|
||
const msg = error?.response?.data?.error?.message || error?.response?.data?.message || error?.response?.data?.detail || (error instanceof Error ? error.message : 'Ошибка регистрации. Проверьте введённые данные.');
|
||
setErrorMsg(msg);
|
||
}
|
||
});
|
||
|
||
const renderHead = (
|
||
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||
<Typography variant="h5">Создать аккаунт</Typography>
|
||
|
||
<Stack direction="row" spacing={0.5}>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
Уже есть аккаунт?
|
||
</Typography>
|
||
|
||
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||
Войти
|
||
</Link>
|
||
</Stack>
|
||
</Stack>
|
||
);
|
||
|
||
if (successMsg) {
|
||
return (
|
||
<>
|
||
{renderHead}
|
||
<Alert severity="success">{successMsg}</Alert>
|
||
<Stack sx={{ mt: 3 }}>
|
||
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||
Вернуться ко входу
|
||
</Link>
|
||
</Stack>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const renderForm = (
|
||
<Stack spacing={3}>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||
<Field.Text name="firstName" label="Имя" InputLabelProps={{ shrink: true }} />
|
||
<Field.Text name="lastName" label="Фамилия" InputLabelProps={{ shrink: true }} />
|
||
</Stack>
|
||
|
||
<Field.Text name="email" label="Email" InputLabelProps={{ shrink: true }} />
|
||
|
||
<Field.Select name="role" label="Роль" InputLabelProps={{ shrink: true }}>
|
||
<MenuItem value="client">Ученик</MenuItem>
|
||
<MenuItem value="mentor">Ментор</MenuItem>
|
||
<MenuItem value="parent">Родитель</MenuItem>
|
||
</Field.Select>
|
||
|
||
<Controller
|
||
name="city"
|
||
control={control}
|
||
render={({ field, fieldState }) => (
|
||
<CityAutocomplete
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
error={!!fieldState.error}
|
||
helperText={fieldState.error?.message}
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
<Field.Text
|
||
name="password"
|
||
label="Пароль"
|
||
placeholder="Минимум 8 символов"
|
||
type={password.value ? 'text' : 'password'}
|
||
InputLabelProps={{ shrink: true }}
|
||
InputProps={{
|
||
endAdornment: (
|
||
<InputAdornment position="end">
|
||
<IconButton onClick={password.onToggle} edge="end">
|
||
<Iconify icon={password.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||
</IconButton>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
<Field.Text
|
||
name="passwordConfirm"
|
||
label="Подтвердите пароль"
|
||
placeholder="Минимум 8 символов"
|
||
type={passwordConfirm.value ? 'text' : 'password'}
|
||
InputLabelProps={{ shrink: true }}
|
||
InputProps={{
|
||
endAdornment: (
|
||
<InputAdornment position="end">
|
||
<IconButton onClick={passwordConfirm.onToggle} edge="end">
|
||
<Iconify icon={passwordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||
</IconButton>
|
||
</InputAdornment>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
<FormControlLabel
|
||
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />}
|
||
label={
|
||
<Typography variant="body2">
|
||
Я соглашаюсь с{' '}
|
||
<Link underline="always" color="text.primary">
|
||
Условиями использования
|
||
</Link>{' '}
|
||
и{' '}
|
||
<Link underline="always" color="text.primary">
|
||
Политикой конфиденциальности
|
||
</Link>
|
||
</Typography>
|
||
}
|
||
/>
|
||
|
||
<LoadingButton
|
||
fullWidth
|
||
color="inherit"
|
||
size="large"
|
||
type="submit"
|
||
variant="contained"
|
||
loading={isSubmitting}
|
||
loadingIndicator="Создание аккаунта..."
|
||
>
|
||
Зарегистрироваться
|
||
</LoadingButton>
|
||
</Stack>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{renderHead}
|
||
|
||
{!!errorMsg && (
|
||
<Alert severity="error" sx={{ mb: 3 }}>
|
||
{errorMsg}
|
||
</Alert>
|
||
)}
|
||
|
||
<Form methods={methods} onSubmit={onSubmit}>
|
||
{renderForm}
|
||
</Form>
|
||
</>
|
||
);
|
||
}
|