feat(client): redesign auth page with minimal style, BearLogo, pill buttons

This commit is contained in:
Kirill
2026-05-22 13:24:35 +05:00
parent eb30640b49
commit 9696a4dcc3
2 changed files with 228 additions and 106 deletions
@@ -24,9 +24,9 @@ function renderPage() {
describe('AuthPage', () => { describe('AuthPage', () => {
it('renders three tabs', () => { it('renders three tabs', () => {
renderPage() renderPage()
expect(screen.getByRole('tab', { name: 'Пароль' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Пароль' })).toBeTruthy()
expect(screen.getByRole('tab', { name: 'Код' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Код' })).toBeTruthy()
expect(screen.getByRole('tab', { name: 'Другой способ' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Другой способ' })).toBeTruthy()
}) })
it('shows login form by default on tab 0', () => { it('shows login form by default on tab 0', () => {
+225 -103
View File
@@ -1,19 +1,22 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { alpha, useTheme } from '@mui/material/styles'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { Lock, Mail } from 'lucide-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { OAuthButtons } from '@/features/auth-oauth' import { OAuthButtons } from '@/features/auth-oauth'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { $user, tokenSet } from '@/shared/model/auth' import { $user, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo'
type AuthResponse = { type AuthResponse = {
token: string token: string
@@ -36,6 +39,7 @@ function getApiErrorMessage(err: unknown): string | null {
} }
export function AuthPage() { export function AuthPage() {
const theme = useTheme()
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(null) const [oauthError, setOauthError] = useState<string | null>(null)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
@@ -112,121 +116,239 @@ export function AuthPage() {
getApiErrorMessage(requestCode.error) || getApiErrorMessage(requestCode.error) ||
getApiErrorMessage(verifyCode.error) getApiErrorMessage(verifyCode.error)
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null const passwordError =
isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
return ( return (
<Box> <Box
<Typography variant="h4" gutterBottom> sx={{
Вход / регистрация display: 'flex',
</Typography> alignItems: 'center',
justifyContent: 'center',
minHeight: 'calc(100vh - 64px)',
px: 2,
background: `radial-gradient(circle at 50% 30%, ${alpha(theme.palette.primary.main, 0.05)} 0%, transparent 70%)`,
}}
>
<Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ fontSize: 72 }} />
</Box>
{message && ( <Typography variant="h5" fontWeight={700} textAlign="center" gutterBottom>
<Alert severity="success" sx={{ mb: 2 }}> Добро пожаловать в Любимый Креатив
{message} </Typography>
</Alert>
)}
{oauthError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
{oauthError}
</Alert>
)}
{errMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{errMsg}
</Alert>
)}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" textAlign="center" sx={{ mb: 3 }}>
<Tab label="Пароль" /> Войдите или зарегистрируйтесь, чтобы продолжить
<Tab label="Код" /> </Typography>
<Tab label="Другой способ" />
</Tabs>
{tab === 0 && ( <Paper
<Stack spacing={2} sx={{ maxWidth: 520 }}> sx={{
<Stack direction="row" spacing={1}> p: 4,
<Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}> borderRadius: 3,
Вход border: `1px solid ${theme.palette.divider}`,
</Button> }}
<Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}> >
Регистрация <Stack direction="row" spacing={1} sx={{ mb: 3 }}>
</Button> {[
{ label: 'Пароль', idx: 0 },
{ label: 'Код', idx: 1 },
{ label: 'Другой способ', idx: 2 },
].map(({ label, idx }) => (
<Button
key={idx}
variant={tab === idx ? 'contained' : 'outlined'}
size="small"
sx={{ borderRadius: '24px', flex: 1, textTransform: 'none' }}
onClick={() => {
setTab(idx)
setMessage(null)
setOauthError(null)
}}
>
{label}
</Button>
))}
</Stack> </Stack>
<TextField label="Email" {...register('email')} fullWidth /> {(errMsg || oauthError) && (
<Alert
{isRegister && ( severity="error"
<TextField variant="outlined"
label="Имя (необязательно)" sx={{ mb: 2 }}
{...register('displayName')} onClose={() => {
fullWidth setOauthError(null)
helperText="Если не указать, будет использована часть email до @" }}
/> >
{errMsg || oauthError}
</Alert>
)}
{message && (
<Alert severity="success" variant="outlined" sx={{ mb: 2 }} onClose={() => setMessage(null)}>
{message}
</Alert>
)} )}
<TextField label="Пароль" type="password" {...register('password')} fullWidth /> {tab === 0 && (
<Stack spacing={2}>
<Stack direction="row" justifyContent="center" spacing={3}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(false)}
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => setIsRegister(true)}
>
Регистрация
</Button>
</Stack>
{isRegister && ( <TextField
<TextField label="Email"
label="Подтверждение пароля" {...register('email')}
type="password" fullWidth
{...register('passwordConfirm')} slotProps={{
fullWidth input: {
error={Boolean(passwordError)} startAdornment: (
helperText={passwordError} <InputAdornment position="start">
/> <Mail size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
)}
{isRegister ? (
<Button
variant="contained"
size="large"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться
</Button>
) : (
<Button
variant="contained"
size="large"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти
</Button>
)}
</Stack>
)} )}
{isRegister ? ( {tab === 1 && (
<Button <Stack spacing={2}>
variant="contained" <TextField
disabled={ label="Email"
!email || {...register('email')}
!password || fullWidth
password.length < 8 || slotProps={{
(isRegister && password !== passwordConfirm) || input: {
registerMutation.isPending startAdornment: (
} <InputAdornment position="start">
onClick={() => registerMutation.mutate()} <Mail size={18} />
> </InputAdornment>
Зарегистрироваться ),
</Button> },
) : ( }}
<Button />
variant="contained" <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
disabled={!email || !password || loginMutation.isPending} <Button
onClick={() => loginMutation.mutate()} variant="outlined"
> onClick={() => requestCode.mutate()}
Войти disabled={!email || requestCode.isPending}
</Button> sx={{ whiteSpace: 'nowrap' }}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Войти
</Button>
</Stack>
</Stack>
)} )}
</Stack>
)}
{tab === 1 && ( {tab === 2 && (
<Stack spacing={2} sx={{ maxWidth: 520 }}> <Stack spacing={2}>
<TextField label="Email" {...register('email')} fullWidth /> <OAuthButtons />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}> </Stack>
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}> )}
Отправить код </Paper>
</Button> </Box>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
>
Войти
</Button>
</Stack>
</Stack>
)}
{tab === 2 && (
<Stack sx={{ maxWidth: 520 }}>
<OAuthButtons />
</Stack>
)}
</Box> </Box>
) )
} }