Files
shop-server/docs/superpowers/plans/2026-05-22-auth-redesign.md
T
2026-05-22 13:18:21 +05:00

543 lines
17 KiB
Markdown
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.
# 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"
```