543 lines
17 KiB
Markdown
543 lines
17 KiB
Markdown
# Auth Page Redesign — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Минималистичный редизайн страницы входа: Outfit локально, BearLogo, Paper-карточка, pill-кнопки, радиальный градиент.
|
||
|
||
**Architecture:** MUI sx prop only, замена `client/src/pages/auth/ui/AuthPage.tsx` полностью. Шрифт в `client/public/fonts/` + `@font-face` в `global.css`.
|
||
|
||
**Tech Stack:** React, MUI, lucide-react (иконки)
|
||
|
||
---
|
||
|
||
### Task 1: Download Outfit font and add @font-face
|
||
|
||
**Files:**
|
||
- Create: `client/public/fonts/Outfit-Regular.woff2`
|
||
- Create: `client/public/fonts/Outfit-Medium.woff2`
|
||
- Create: `client/public/fonts/Outfit-SemiBold.woff2`
|
||
- Create: `client/public/fonts/Outfit-Bold.woff2`
|
||
- Modify: `client/src/app/styles/global.css`
|
||
|
||
- [ ] **Step 1: Create fonts directory**
|
||
|
||
```bash
|
||
mkdir -p /mnt/d/my_projects/shop/client/public/fonts
|
||
```
|
||
|
||
- [ ] **Step 2: Download Outfit woff2 files**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/client/public/fonts
|
||
curl -sL 'https://fonts.google.com/download?family=Outfit' -o outfit.zip
|
||
# OR download individual woff2 files from a CDN:
|
||
curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-400-normal.woff2' -o Outfit-Regular.woff2
|
||
curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-500-normal.woff2' -o Outfit-Medium.woff2
|
||
curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-600-normal.woff2' -o Outfit-SemiBold.woff2
|
||
curl -sL 'https://cdn.jsdelivr.net/fontsource/fonts/outfit@latest/latin-700-normal.woff2' -o Outfit-Bold.woff2
|
||
```
|
||
|
||
Wait — the jsdelivr URLs may not be exact. Better approach: use `@fontsource/outfit` npm package or download from fontsource CDN:
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/client/public/fonts
|
||
# Outfit Regular (400)
|
||
curl -sLo Outfit-Regular.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-400-normal.woff2'
|
||
# Outfit Medium (500)
|
||
curl -sLo Outfit-Medium.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-500-normal.woff2'
|
||
# Outfit SemiBold (600)
|
||
curl -sLo Outfit-SemiBold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-600-normal.woff2'
|
||
# Outfit Bold (700)
|
||
curl -sLo Outfit-Bold.woff2 'https://cdn.jsdelivr.net/npm/@fontsource/outfit@5/files/outfit-latin-700-normal.woff2'
|
||
```
|
||
|
||
- [ ] **Step 3: Verify files downloaded**
|
||
|
||
```bash
|
||
ls -la /mnt/d/my_projects/shop/client/public/fonts/
|
||
```
|
||
|
||
Expected: 4 woff2 files, each > 10KB.
|
||
|
||
- [ ] **Step 4: Add @font-face to global.css**
|
||
|
||
Read `/mnt/d/my_projects/shop/client/src/app/styles/global.css`. It currently has:
|
||
|
||
```css
|
||
:root { color-scheme: light; }
|
||
html, body, #root { min-height: 100%; }
|
||
body { margin: 0; }
|
||
```
|
||
|
||
Replace entire file with:
|
||
|
||
```css
|
||
@font-face {
|
||
font-family: 'Outfit';
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
@font-face {
|
||
font-family: 'Outfit';
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
@font-face {
|
||
font-family: 'Outfit';
|
||
font-style: normal;
|
||
font-weight: 600;
|
||
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
@font-face {
|
||
font-family: 'Outfit';
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
|
||
font-display: swap;
|
||
}
|
||
|
||
:root { color-scheme: light; }
|
||
html, body, #root { min-height: 100%; }
|
||
body { margin: 0; }
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop
|
||
git add client/public/fonts/ client/src/app/styles/global.css
|
||
git commit -m "feat: load Outfit font from static files"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Rewrite AuthPage with new design
|
||
|
||
**Files:**
|
||
- Modify: `client/src/pages/auth/ui/AuthPage.tsx` (replace entirely)
|
||
|
||
- [ ] **Step 1: Read the current file for reference**
|
||
|
||
Read `/mnt/d/my_projects/shop/client/src/pages/auth/ui/AuthPage.tsx` — keep the imports, hooks and mutation logic. Only the render JSX changes.
|
||
|
||
- [ ] **Step 2: Replace AuthPage.tsx**
|
||
|
||
Write the entire file:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react'
|
||
import { alpha, useTheme } from '@mui/material/styles'
|
||
import Alert from '@mui/material/Alert'
|
||
import Box from '@mui/material/Box'
|
||
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 TextField from '@mui/material/TextField'
|
||
import Typography from '@mui/material/Typography'
|
||
import { useMutation } from '@tanstack/react-query'
|
||
import { useUnit } from 'effector-react'
|
||
import { Lock, Mail } from 'lucide-react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { OAuthButtons } from '@/features/auth-oauth'
|
||
import { apiClient } from '@/shared/api/client'
|
||
import { $user, tokenSet } from '@/shared/model/auth'
|
||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||
|
||
type AuthResponse = {
|
||
token: string
|
||
user: {
|
||
id: string
|
||
email: string
|
||
displayName?: string | null
|
||
avatar?: string | null
|
||
avatarStyle?: string | null
|
||
}
|
||
}
|
||
|
||
function getApiErrorMessage(err: unknown): string | null {
|
||
if (!err || typeof err !== 'object') return null
|
||
const anyErr = err as Record<string, unknown>
|
||
const response = anyErr.response as Record<string, unknown> | undefined
|
||
const data = response?.data as Record<string, unknown> | undefined
|
||
const msg = data?.error
|
||
return typeof msg === 'string' ? msg : null
|
||
}
|
||
|
||
export function AuthPage() {
|
||
const theme = useTheme()
|
||
const [message, setMessage] = useState<string | null>(null)
|
||
const [oauthError, setOauthError] = useState<string | null>(null)
|
||
const [tab, setTab] = useState(0)
|
||
const [isRegister, setIsRegister] = useState(false)
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const navigate = useNavigate()
|
||
const user = useUnit($user)
|
||
|
||
const { register, watch } = useForm<{
|
||
email: string
|
||
password: string
|
||
passwordConfirm: string
|
||
displayName: string
|
||
code: string
|
||
}>({
|
||
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' },
|
||
mode: 'onChange',
|
||
})
|
||
|
||
const email = watch('email')
|
||
const password = watch('password')
|
||
const passwordConfirm = watch('passwordConfirm')
|
||
const code = watch('code')
|
||
|
||
useEffect(() => {
|
||
if (user) navigate('/', { replace: true })
|
||
}, [navigate, user])
|
||
|
||
useEffect(() => {
|
||
const err = searchParams.get('oauthError')
|
||
if (!err) return
|
||
setOauthError(err)
|
||
setSearchParams({}, { replace: true })
|
||
}, [searchParams, setSearchParams])
|
||
|
||
const loginMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
||
tokenSet(data.token)
|
||
navigate('/', { replace: true })
|
||
},
|
||
})
|
||
|
||
const registerMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||
email,
|
||
password,
|
||
displayName: watch('displayName') || undefined,
|
||
})
|
||
tokenSet(data.token)
|
||
navigate('/', { replace: true })
|
||
},
|
||
})
|
||
|
||
const requestCode = useMutation({
|
||
mutationFn: async () => {
|
||
await apiClient.post('auth/request-code', { email })
|
||
},
|
||
onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'),
|
||
})
|
||
|
||
const verifyCode = useMutation({
|
||
mutationFn: async () => {
|
||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||
tokenSet(data.token)
|
||
navigate('/', { replace: true })
|
||
},
|
||
})
|
||
|
||
const errMsg =
|
||
getApiErrorMessage(loginMutation.error) ||
|
||
getApiErrorMessage(registerMutation.error) ||
|
||
getApiErrorMessage(requestCode.error) ||
|
||
getApiErrorMessage(verifyCode.error)
|
||
|
||
const passwordError =
|
||
isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
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>
|
||
|
||
<Typography variant="h5" fontWeight={700} textAlign="center" gutterBottom>
|
||
Добро пожаловать в Любимый Креатив
|
||
</Typography>
|
||
|
||
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ mb: 3 }}>
|
||
Войдите или зарегистрируйтесь, чтобы продолжить
|
||
</Typography>
|
||
|
||
<Paper
|
||
sx={{
|
||
p: 4,
|
||
borderRadius: 3,
|
||
border: `1px solid ${theme.palette.divider}`,
|
||
}}
|
||
>
|
||
<Stack direction="row" spacing={1} sx={{ mb: 3 }}>
|
||
{[
|
||
{ 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>
|
||
|
||
{(errMsg || oauthError) && (
|
||
<Alert
|
||
severity="error"
|
||
variant="outlined"
|
||
sx={{ mb: 2 }}
|
||
onClose={() => {
|
||
setOauthError(null)
|
||
}}
|
||
>
|
||
{errMsg || oauthError}
|
||
</Alert>
|
||
)}
|
||
{message && (
|
||
<Alert severity="success" variant="outlined" sx={{ mb: 2 }} onClose={() => setMessage(null)}>
|
||
{message}
|
||
</Alert>
|
||
)}
|
||
|
||
{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>
|
||
|
||
<TextField
|
||
label="Email"
|
||
{...register('email')}
|
||
fullWidth
|
||
slotProps={{
|
||
input: {
|
||
startAdornment: (
|
||
<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>
|
||
)}
|
||
|
||
{tab === 1 && (
|
||
<Stack spacing={2}>
|
||
<TextField
|
||
label="Email"
|
||
{...register('email')}
|
||
fullWidth
|
||
slotProps={{
|
||
input: {
|
||
startAdornment: (
|
||
<InputAdornment position="start">
|
||
<Mail size={18} />
|
||
</InputAdornment>
|
||
),
|
||
},
|
||
}}
|
||
/>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||
<Button
|
||
variant="outlined"
|
||
onClick={() => requestCode.mutate()}
|
||
disabled={!email || requestCode.isPending}
|
||
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>
|
||
)}
|
||
|
||
{tab === 2 && (
|
||
<Stack spacing={2}>
|
||
<OAuthButtons />
|
||
</Stack>
|
||
)}
|
||
</Paper>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run typecheck**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -20
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/client && npx vitest run
|
||
```
|
||
|
||
Expected: all tests pass (7 files, 29 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop
|
||
git add client/src/pages/auth/ui/AuthPage.tsx
|
||
git commit -m "feat(client): redesign auth page with minimal style, BearLogo, pill buttons"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Run full verification
|
||
|
||
- [ ] **Step 1: Client lint + format + build**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/client
|
||
npm run lint
|
||
npm run format:check
|
||
npm run build
|
||
```
|
||
|
||
Expected: 0 errors, format clean, build success.
|
||
|
||
- [ ] **Step 2: Server tests (regression check)**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop/server && npx vitest run
|
||
```
|
||
|
||
Expected: all pass (ignore pre-existing user-payments.test.js failures if any).
|
||
|
||
- [ ] **Step 3: Commit if anything changed**
|
||
|
||
```bash
|
||
cd /mnt/d/my_projects/shop
|
||
git add -A
|
||
git diff --cached --quiet || git commit -m "chore: post-redesign lint fixes"
|
||
```
|