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

17 KiB

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

mkdir -p /mnt/d/my_projects/shop/client/public/fonts
  • Step 2: Download Outfit woff2 files
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:

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
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:

:root { color-scheme: light; }
html, body, #root { min-height: 100%; }
body { margin: 0; }

Replace entire file with:

@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
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:

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
cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -20

Expected: no errors.

  • Step 4: Run tests
cd /mnt/d/my_projects/shop/client && npx vitest run

Expected: all tests pass (7 files, 29 tests).

  • Step 5: Commit
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
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)
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
cd /mnt/d/my_projects/shop
git add -A
git diff --cached --quiet || git commit -m "chore: post-redesign lint fixes"