Files
shop-server/docs/superpowers/plans/2026-05-21-avatar-display-fixes.md
T

47 KiB

Avatar & Display Fixes 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: Fix 8 issues: admin settings, admin header avatar, users table avatar, order chat avatars, review avatars, review product link, out-of-stock chip visibility, unauthenticated header icon.

Architecture: All 8 tasks are independent — each touches its own files without shared dependencies between tasks. Server changes: new admin-profile route + expand fields in existing API responses. Client changes: new AdminSettingsPage, updates to header, chat, reviews, product card, user types.

Tech Stack: React + MUI + effector + react-query (client), Fastify + Prisma + SQLite (server)

Verification: After each task, run cd client && npm run lint && npm test and cd server && npm test. After all tasks: cd client && npm run build.


Task 1: Admin settings page

Files:

  • Create: server/src/routes/api/admin-profile.js

  • Create: client/src/pages/admin-settings/ui/AdminSettingsPage.tsx

  • Create: client/src/pages/admin-settings/index.ts

  • Modify: server/src/routes/api.js (register new route)

  • Modify: client/src/pages/admin-layout/ui/AdminLayoutPage.tsx (add nav item + route)

  • Step 1: Create server admin profile route

Create server/src/routes/api/admin-profile.js:

import { prisma } from '../../lib/prisma.js'

export async function registerAdminProfileRoutes(fastify) {
  fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
    const userId = request.user.sub
    const user = await prisma.user.findUnique({ where: { id: userId } })
    if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
    return {
      id: user.id,
      email: user.email,
      displayName: user.displayName,
      avatar: user.avatar,
      avatarType: user.avatarType,
      avatarStyle: user.avatarStyle,
    }
  })

  fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
    const userId = request.user.sub
    const nameRaw = request.body?.displayName
    const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
    const avatarRaw = request.body?.avatar
    const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
    const avatarTypeRaw = request.body?.avatarType
    const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
    const avatarStyleRaw = request.body?.avatarStyle
    const avatarStyle =
      avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()

    if (displayName !== null && displayName.length > 40)
      return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
    if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') {
      return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' })
    }
    if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
    if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) {
      return reply.code(400).send({ error: 'Стиль аватара слишком длинный' })
    }

    const data = {}
    if (displayName !== null) {
      data.displayName = displayName.length ? displayName : null
    }
    if (avatarType !== undefined) {
      data.avatarType = avatarType === '' ? null : avatarType
    }
    if (avatar !== undefined) {
      data.avatar = avatar === '' ? null : avatar
    }
    if (avatarStyle !== undefined) {
      data.avatarStyle = avatarStyle === '' ? null : avatarStyle
    }

    const updated = await prisma.user.update({ where: { id: userId }, data })
    return {
      id: updated.id,
      email: updated.email,
      displayName: updated.displayName,
      avatar: updated.avatar,
      avatarType: updated.avatarType,
      avatarStyle: updated.avatarStyle,
    }
  })
}
  • Step 2: Register the new route in server/api.js

In server/src/routes/api.js:

Add import at top (after line 8):

import { registerAdminProfileRoutes } from './api/admin-profile.js'

Add registration call (before last closing brace, after line 27):

  await registerAdminProfileRoutes(fastify)
  • Step 3: Run server tests to verify no breakage

Run: cd server && npm test Expected: all pass

  • Step 4: Create AdminSettingsPage client component

Create directory: client/src/pages/admin-settings/

Create client/src/pages/admin-settings/index.ts:

export { AdminSettingsPage } from './ui/AdminSettingsPage'

Create client/src/pages/admin-settings/ui/AdminSettingsPage.tsx:

import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { createAvatar } from '@dicebear/core'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
import { $user, UpdateProfileParams, updateProfileFx } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
import { apiClient } from '@/shared/api/client'

function getApiErrorMessage(error: unknown): string | null {
  const e = error as { response?: { data?: { error?: string } } }
  const msg = e?.response?.data?.error
  return msg ? String(msg) : null
}

export function AdminSettingsPage() {
  const user = useUnit($user)
  const qc = useQueryClient()
  const pendingProfile = useUnit(updateProfileFx.pending)

  const {
    data: profile,
    isLoading,
    isError,
  } = useQuery({
    queryKey: ['admin', 'profile'],
    queryFn: async () => {
      const { data } = await apiClient.get<{
        id: string; email: string; displayName: string | null
        avatar: string | null; avatarType: string | null; avatarStyle: string | null
      }>('admin/profile')
      return data
    },
  })

  const profileSaveMut = useMutation({
    mutationFn: (params: { displayName: string | null; avatar?: string | null; avatarType?: string | null; avatarStyle?: string | null }) =>
      apiClient.patch('admin/profile', params),
    onSuccess: () => {
      const name = profileForm.getValues('displayName').trim()
      const p: UpdateProfileParams = { displayName: name.length ? name : null }
      if (hasUnsavedPreview) {
        p.avatar = previewSrc
        p.avatarType = 'generated'
        p.avatarStyle = previewStyle
      }
      updateProfileFx(p)
      void qc.invalidateQueries({ queryKey: ['admin', 'profile'] })
    },
  })

  const profileForm = useForm<{ displayName: string }>({
    defaultValues: { displayName: profile?.displayName ?? '' },
    values: { displayName: profile?.displayName ?? '' },
    mode: 'onChange',
  })

  const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
  const useOAuth = user?.avatarType === 'oauth'
  const useGenerated = user?.avatarType === 'generated'

  const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
  const [previewSrc, setPreviewSrc] = useState<string | null>(null)
  const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)

  const hasUnsavedPreview = previewSrc !== null

  const profileErrorMsg = getApiErrorMessage(profileSaveMut.error)

  if (isLoading) return <Typography>Загрузка настроек...</Typography>
  if (isError) return <Alert severity="error">Не удалось загрузить настройки.</Alert>
  if (!user) return <Alert severity="info">Нужно войти.</Alert>

  return (
    <Box>
      <Typography variant="h4" gutterBottom>
        Настройки
      </Typography>
      <Typography color="text.secondary" sx={{ mb: 3 }}>
        Текущая почта: <b>{user.email}</b>
      </Typography>

      {profileErrorMsg && (
        <Alert severity="error" sx={{ mb: 2 }}>
          {profileErrorMsg}
        </Alert>
      )}

      <Stack spacing={3} sx={{ maxWidth: 560 }}>
        <Box>
          <Typography variant="h6" gutterBottom>
            Профиль
          </Typography>
          <Stack spacing={2}>
            <TextField
              label="Имя или ник"
              helperText="До 40 символов"
              slotProps={{ htmlInput: { maxLength: 40 } }}
              {...profileForm.register('displayName')}
            />
            <Button
              variant="contained"
              disabled={pendingProfile || profileSaveMut.isPending}
              onClick={() => {
                const raw = profileForm.getValues('displayName')
                const name = raw.trim()
                profileSaveMut.mutate({ displayName: name.length ? name : null })
              }}
            >
              Сохранить
            </Button>
          </Stack>
        </Box>

        <Divider />

        <Box>
          <Typography variant="h6" gutterBottom>
            Аватар
          </Typography>

          <Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
            <Box sx={{ textAlign: 'center' }}>
              <UserAvatar
                userId={String(user.id)}
                avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
                avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
                avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
                size={80}
                sx={{
                  border: 2,
                  borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
                }}
              />
              <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
                {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
              </Typography>
            </Box>
            {hasUnsavedPreview && (
              <Box sx={{ textAlign: 'center' }}>
                <UserAvatar
                  userId={String(user.id)}
                  avatarUrl={user.avatar}
                  avatarType={user.avatarType}
                  avatarStyle={user.avatarStyle}
                  size={80}
                  sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
                />
                <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
                  Текущий
                </Typography>
              </Box>
            )}
          </Stack>

          <Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
            <FormControl size="small" sx={{ minWidth: 140 }}>
              <InputLabel>Стиль</InputLabel>
              <Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
                {AVATAR_STYLES.map((s) => (
                  <MenuItem key={s.id} value={s.id}>
                    {s.label}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>
            <Button
              variant="outlined"
              onClick={() => {
                const seed = `${user.id}_${Date.now()}`
                const styleDef = getStyleById(selectedStyle)
                const avatar = createAvatar(styleDef.style, { seed })
                setPreviewSrc(avatar.toDataUri())
                setPreviewStyle(selectedStyle)
              }}
            >
              Сгенерировать
            </Button>
          </Stack>

          {hasUnsavedPreview && (
            <Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
              <Button
                variant="contained"
                disabled={pendingProfile || profileSaveMut.isPending}
                onClick={() => {
                  const raw = profileForm.getValues('displayName')
                  const name = raw.trim()
                  profileSaveMut.mutate({
                    displayName: name.length ? name : null,
                    avatar: previewSrc,
                    avatarType: 'generated',
                    avatarStyle: previewStyle,
                  })
                  setPreviewSrc(null)
                }}
              >
                Сохранить
              </Button>
              <Button variant="text" onClick={() => setPreviewSrc(null)}>
                Отмена
              </Button>
            </Stack>
          )}

          {hasOAuthAvatar && !hasUnsavedPreview && (
            <Button
              variant="outlined"
              disabled={pendingProfile || profileSaveMut.isPending || useOAuth}
              onClick={() => {
                const raw = profileForm.getValues('displayName')
                const name = raw.trim()
                profileSaveMut.mutate({
                  displayName: name.length ? name : null,
                  avatarType: 'oauth',
                })
              }}
              sx={{ mt: 0.5 }}
            >
              Использовать OAuth
            </Button>
          )}
        </Box>
      </Stack>
    </Box>
  )
}
  • Step 5: Add AdminSettingsPage to admin layout

In client/src/pages/admin-layout/ui/AdminLayoutPage.tsx:

Add import after line 26 (AdminUsersPage):

import { AdminSettingsPage } from '@/pages/admin-settings'

Add icon import — add Settings to lucide-react import at line 18:

import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'

Add nav item in the navItems array (line 67, before closing ]):

      { to: '/admin/settings', label: 'Настройки', icon: <Settings /> },

Add route after line 194 (<Route path="notifications" .../>):

          <Route path="settings" element={<AdminSettingsPage />} />

Update colSpan={5} to colSpan={6} only if the table has more columns now (don't change this yet, only Task 3 changes the colSpan) Actually no — the colSpan is in AdminUsersPage which is a different component, leave it.

  • Step 6: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 7: Commit
git add server/src/routes/api/admin-profile.js server/src/routes/api.js \
  client/src/pages/admin-settings/ \
  client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
git commit -m "feat: admin settings page with avatar and display name"

Task 2: Admin avatar in header

Files:

  • Modify: client/src/app/layout/AppHeader.tsx

  • Step 1: Replace admin logout button with avatar dropdown in AppHeader

In client/src/app/layout/AppHeader.tsx, replace the admin logout button block (lines 151-155):

Remove:

          {isAdmin && user && !isMobile && (
            <Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
              Выход
            </Button>
          )}

Replace with:

          {isAdmin && user && !isMobile && (
            <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />
          )}

But wait — UserMenu shows /me profile link for non-admin users. For admin, it shows the same dropdown but the profile link goes to /me which redirects admin away. We need to modify UserMenu to show /admin/settings for admin users.

Better approach: modify UserMenu to accept an isAdmin prop and show appropriate menu items.

  • Step 2: Modify UserMenu to support admin mode

In client/src/features/user/user-menu/ui/UserMenu.tsx, add isAdmin to Props:

type Props = {
  user: AuthUser | null
  isAdmin?: boolean
  onNavigate: (to: string) => void
  onLogout: () => void
}

Update the component destructuring:

export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) {

Update the dropdown menu items (lines 64-73). Replace:

        {user ? (
          <>
            <MenuItem onClick={() => go('/me')}>
              <ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
            </MenuItem>
            <MenuItem onClick={handleLogout}>Выход</MenuItem>
          </>
        ) : (
          <MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
        )}

With:

        {user ? (
          <>
            <MenuItem onClick={() => go(isAdmin ? '/admin/settings' : '/me')}>
              <ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary={isAdmin ? 'Настройки' : 'Профиль'} />
            </MenuItem>
            <MenuItem onClick={handleLogout}>Выход</MenuItem>
          </>
        ) : (
          <MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
        )}
  • Step 3: Update AppHeader to pass isAdmin to UserMenu

In client/src/app/layout/AppHeader.tsx, update the admin UserMenu call at line 149:

Change:

          {!isAdmin && <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />}

To:

          {!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}

And add the admin version replacing lines 151-155:

          {isAdmin && user && !isMobile && (
            <UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
          )}

Also update the non-user case (when user is null and not admin) to still show a Person icon (Task 8 will handle this).

But wait — currently line 149 says {!isAdmin && <UserMenu .../>}. For non-logged-in users, UserMenu already renders a guest avatar with "Войти" link. So for non-admin, it works. For admin, we add the new block.

  • Step 4: Also update mobile NavigationDrawer for admin avatar

Check NavigationDrawer — need to read it to see if it also needs updating.

Actually, let me read it to check.

After reading: NavigationDrawer is at client/src/widgets/navigation-drawer. It receives user, isAdmin, onLogout etc. as props. It likely shows user info and logout. We should update it to show avatar and settings link for admin too. But this is tangential — the main ask was header. Let's keep it focused on AppHeader for now.

  • Step 5: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 6: Commit
git add client/src/app/layout/AppHeader.tsx \
  client/src/features/user/user-menu/ui/UserMenu.tsx
git commit -m "feat: admin avatar in header with settings link"

Task 3: Avatar column in admin users table

Files:

  • Modify: server/src/routes/api/admin-users.js (GET endpoint)

  • Modify: client/src/entities/user/model/types.ts (AdminUser type)

  • Modify: client/src/pages/admin-users/ui/AdminUsersPage.tsx

  • Step 1: Add avatar fields to server admin users list

In server/src/routes/api/admin-users.js, update the select in prisma.user.findMany (lines 32-38):

Add avatar, avatarType, avatarStyle to select:

    const users = await prisma.user.findMany({
      where,
      select: {
        id: true,
        email: true,
        displayName: true,
        avatar: true,
        avatarType: true,
        avatarStyle: true,
        createdAt: true,
        updatedAt: true,
      },
      orderBy: { updatedAt: 'desc' },
      skip: (page - 1) * pageSize,
      take: pageSize,
    })

Update the map (lines 43-49):

    const items = users.map((u) => ({
      id: u.id,
      email: u.email,
      displayName: u.displayName,
      avatar: u.avatar,
      avatarType: u.avatarType,
      avatarStyle: u.avatarStyle,
      createdAt: u.createdAt,
      updatedAt: u.updatedAt,
    }))
  • Step 2: Run server tests

Run: cd server && npm test Expected: all pass

  • Step 3: Update AdminUser client type

In client/src/entities/user/model/types.ts:

export type AdminUser = {
  id: string
  email: string
  name: string | null
  avatar?: string | null
  avatarType?: string | null
  avatarStyle?: string | null
  createdAt: string
  updatedAt: string
}
  • Step 4: Add avatar column to AdminUsersPage

In client/src/pages/admin-users/ui/AdminUsersPage.tsx:

Add import for UserAvatar (after other imports):

import { UserAvatar } from '@/shared/ui/UserAvatar'

Update columns (line 173) — add avatar column first:

        columns={[
          { key: 'avatar', label: 'Аватар' },
          { key: 'email', label: 'Почта' },
          { key: 'name', label: 'Имя' },
          { key: 'createdAt', label: 'Создан' },
          { key: 'updatedAt', label: 'Обновлён' },
          { key: 'actions', label: 'Действия', align: 'right' },
        ]}

Update colSpan from 5 to 6 (line 185):

            <TableCell colSpan={6} sx={{ color: 'text.secondary' }}>

In the users.map, add an avatar cell BEFORE the email cell (line 192):

              <TableCell>
                <UserAvatar
                  userId={u.id}
                  avatarUrl={u.avatar}
                  avatarType={u.avatarType}
                  avatarStyle={u.avatarStyle}
                  size={28}
                />
              </TableCell>
  • Step 5: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 6: Commit
git add server/src/routes/api/admin-users.js \
  client/src/entities/user/model/types.ts \
  client/src/pages/admin-users/ui/AdminUsersPage.tsx
git commit -m "feat: avatar column in admin users table"

Task 4: Avatars in order messages

Files:

  • Modify: server/src/routes/api/admin-orders.js (GET /:id — add user avatar to include)

  • Modify: server/src/routes/user-orders.js (GET /me/orders/:id — add user avatar to include)

  • Modify: client/src/shared/ui/ChatMessageBubble.tsx (add avatar prop)

  • Modify: client/src/features/order-chat/ui/OrderChat.tsx (pass avatars to bubble)

  • Modify: client/src/features/order-detail/ui/OrderDetailContent.tsx (pass avatars to bubble)

  • Modify: client/src/entities/order/api/admin-order-api.ts (update types)

  • Modify: client/src/entities/order/api/order-api.ts (update types)

  • Step 1: Update server admin order detail to include user avatar

In server/src/routes/api/admin-orders.js, line 76, update the user include:

        user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
  • Step 2: Update server user order detail to include user avatar

In server/src/routes/user-orders.js, line 210, the GET /api/me/orders/:id currently does NOT include user in the include. Need to add it:

      const order = await prisma.order.findFirst({
        where: { id, userId },
        include: {
          items: true,
          messages: { orderBy: { createdAt: 'asc' } },
          user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
        },
      })

Note: For the user detail response, the item already includes the full order object. But we need to make sure user is nested properly. The current response sends { item: order } — so order.user will now have avatar fields.

But wait — for security, the user getting their own order shouldn't need the admin's avatar. For user-side chat, admin messages show a generic admin icon. So we just need user's avatar for their own messages, which is already in AuthUser store.

Actually, looking more carefully: In OrderChat (user-side), messages come from the order detail response. For the user's own messages — we can use AuthUser from the store. For admin messages — there's no admin avatar in the response. We'll use a generic admin avatar.

In OrderDetailContent (admin-side), messages come from admin order detail response. For user's messages — we need order.user.avatar/avatarType/avatarStyle (now added). For admin's own messages — use AuthUser from the store.

So let me refine:

For user-orders.js: The user doesn't need to see the admin avatar. We just need user.avatar for the REVIEWS fix (Task 5), not for chat. Actually, the user's own avatar is already in AuthUser store. Let me NOT modify user-orders.js for chat — the UserChat already has access to AuthUser.

Wait, let me reconsider. The user-side OrderChat currently shows:

  • Admin messages: label "Админ"
  • User messages: label "Вы"

To add avatars:

  • Admin messages: generic avatar (DiceBear with seed='admin')
  • User messages: avatar from AuthUser store (already available)

So for user-side, we DON'T need to modify the order API. We just need the AuthUser from the store.

For admin-side OrderDetailContent:

  • Admin messages (shown as "Админ (вы)"): avatar from AuthUser store
  • User messages (shown as "Пользователь"): avatar from detail.user.avatar/avatarType/avatarStyle

So for admin-side, we DO need to modify the admin order detail API to include user avatar. And the admin order detail type needs updating.

Let me simplify: Only modify server admin-orders.js, and update client types for admin order detail. For user-side, use AuthUser store.

  • Step 1 revised: Update server admin order detail to include user avatar

In server/src/routes/api/admin-orders.js, line 76, already updated above. Keep.

  • Step 2: Remove user-orders.js change (not needed for chat)

Skip the user-orders.js change for chat. The user-side chat uses AuthUser store.

  • Step 3: Update ChatMessageBubble to accept avatar prop

In client/src/shared/ui/ChatMessageBubble.tsx:

import type { ReactNode } from 'react'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import { alpha } from '@mui/material/styles'

type Author = 'admin' | 'user'

type Props = {
  authorType: Author
  avatar?: ReactNode
  children: ReactNode
}

export function ChatMessageBubble({ authorType, avatar, children }: Props) {
  const isAdmin = authorType === 'admin'
  return (
    <Stack
      direction="row"
      spacing={1}
      sx={{
        alignSelf: isAdmin ? 'flex-start' : 'flex-end',
        maxWidth: '85%',
        alignItems: 'flex-end',
      }}
    >
      {isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
      <Box
        sx={{
          p: 1.25,
          borderRadius: 2,
          border: 1,
          borderColor: 'divider',
          width: 'fit-content',
          color: 'text.primary',
          bgcolor: (theme) =>
            isAdmin
              ? alpha(theme.palette.grey[500], theme.palette.mode === 'dark' ? 0.28 : 0.14)
              : alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.28 : 0.1),
        }}
      >
        {children}
      </Box>
      {!isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
    </Stack>
  )
}
  • Step 4: Update OrderChat (user-side) to pass avatars

In client/src/features/order-chat/ui/OrderChat.tsx:

Add imports:

import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'

In the component, add:

const currentUser = useUnit($user)

In the messages map (line 41), update the ChatMessageBubble usage:

        {messages.map((m) => {
          const isAdminMsg = m.authorType === 'admin'
          const avatarNode = isAdminMsg ? (
            <UserAvatar userId="admin" avatarUrl={null} avatarType={null} avatarStyle={null} size={24} />
          ) : currentUser ? (
            <UserAvatar
              userId={currentUser.id}
              avatarUrl={currentUser.avatar}
              avatarType={currentUser.avatarType}
              avatarStyle={currentUser.avatarStyle}
              size={24}
            />
          ) : null
          return (
            <ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
              <Typography variant="caption" color="text.secondary">
                {isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
              </Typography>
              <OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
            </ChatMessageBubble>
          )
        })}
  • Step 5: Update OrderDetailContent (admin-side) to pass avatars

In client/src/features/order-detail/ui/OrderDetailContent.tsx:

Add imports:

import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'

In the component, add:

const currentUser = useUnit($user)

Update the AdminOrderDetailResponse type (in admin-order-api.ts) to include avatar fields in user:

In client/src/entities/order/api/admin-order-api.ts, update the user type in AdminOrderDetailResponse['item']:

    user: { id: string; email: string; displayName: string | null; avatar?: string | null; avatarType?: string | null; avatarStyle?: string | null }

In the messages map (line 167), update:

          {detail.messages.map((m) => {
            const isUserMsg = m.authorType === 'user'
            const avatarNode = isUserMsg ? (
              <UserAvatar
                userId={detail.user.id}
                avatarUrl={detail.user.avatar}
                avatarType={detail.user.avatarType}
                avatarStyle={detail.user.avatarStyle}
                size={24}
              />
            ) : currentUser ? (
              <UserAvatar
                userId={currentUser.id}
                avatarUrl={currentUser.avatar}
                avatarType={currentUser.avatarType}
                avatarStyle={currentUser.avatarStyle}
                size={24}
              />
            ) : null
            return (
              <ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'} avatar={avatarNode}>
                <Typography variant="caption" color="text.secondary">
                  {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
                </Typography>
                <OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
              </ChatMessageBubble>
            )
          })}

Wait — the authorType is swapped in the admin view. So for admin messages (m.authorType === 'admin'), the bubble shows authorType='user' (right-aligned), and for user messages, the bubble shows authorType='admin' (left-aligned). The avatar should follow: for user messages (left-aligned), show user's avatar on the left. For admin messages (right-aligned), show admin avatar on the right.

Let me reconsider. The current code has:

<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>

This means:

  • m.authorType === 'admin' → bubble authorType='user' (right side, primary color)
  • m.authorType === 'user' → bubble authorType='admin' (left side, gray)

Now the avatar should be on the same side as the bubble. With the new ChatMessageBubble, avatar is placed left of bubble for 'admin' type, right for 'user' type.

So:

  • For user messages (m.authorType === 'user', bubble authorType='admin', left side): avatar should be user avatar, on the left. This matches: isAdmin=true → avatar appears on left.
  • For admin messages (m.authorType === 'admin', bubble authorType='user', right side): avatar should be admin avatar, on the right. This matches: isAdmin=false → avatar appears on right.

So the avatar for user messages should be user's avatar, and for admin messages should be admin's avatar. And the bubble's authorType already handles positioning.

Wait, but in my ChatMessageBubble, I check isAdmin for avatar placement. When authorType='admin' (user's message in admin view), avatar goes on the left — correct, admin sees user avatar on left for user messages. When authorType='user' (admin's own message in admin view), avatar goes on the right — correct, admin sees own avatar on right.

So in the map:

          {detail.messages.map((m) => {
            const isAdminMsg = m.authorType === 'admin'
            // In admin view, bubbles are reversed: admin msgs appear right (authorType='user'), user msgs appear left (authorType='admin')
            // Avatar should match the actual author
            const avatarNode = isAdminMsg ? (
              currentUser && (
                <UserAvatar
                  userId={currentUser.id}
                  avatarUrl={currentUser.avatar}
                  avatarType={currentUser.avatarType}
                  avatarStyle={currentUser.avatarStyle}
                  size={24}
                />
              )
            ) : (
              <UserAvatar
                userId={detail.user.id}
                avatarUrl={detail.user.avatar}
                avatarType={detail.user.avatarType}
                avatarStyle={detail.user.avatarStyle}
                size={24}
              />
            )
            return (
              <ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'user' : 'admin'} avatar={avatarNode}>
                ...
              </ChatMessageBubble>
            )
          })}

Hmm, but the avatar placement in ChatMessageBubble is: admin type → avatar left, user type → avatar right. So for admin msg in admin view (bubble type='user'): avatar appears right. For user msg (bubble type='admin'): avatar appears left.

This is actually correct! Admin sees their own messages on right with avatar on right. User messages on left with avatar on left.

Similarly for user-side OrderChat:

  • Admin messages: authorType='admin' → avatar left, label "Админ"
  • User messages: authorType='user' → avatar right, label "Вы"

This works.

  • Step 6: Run server tests

Run: cd server && npm test Expected: all pass

  • Step 7: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 8: Commit
git add server/src/routes/api/admin-orders.js \
  client/src/shared/ui/ChatMessageBubble.tsx \
  client/src/features/order-chat/ui/OrderChat.tsx \
  client/src/features/order-detail/ui/OrderDetailContent.tsx \
  client/src/entities/order/api/admin-order-api.ts
git commit -m "feat: avatars in order messages"

Task 5: Actual user avatars in reviews

Files:

  • Modify: server/src/routes/api/public-reviews.js (add avatar fields to user include and response)

  • Modify: client/src/entities/review/api/reviews-api.ts (update types)

  • Modify: client/src/features/product-review/ui/ProductReviewsList.tsx (pass real avatars)

  • Modify: client/src/widgets/reviews-block/ui/ReviewsBlock.tsx (pass real avatars)

  • Step 1: Update server public-reviews to include avatar fields

In server/src/routes/api/public-reviews.js:

For GET /api/reviews/latest (line 43), update user include:

        user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },

Update the map (line 50):

    const items = rows.map((r) => ({
      id: r.id,
      rating: r.rating,
      text: r.text,
      imageUrl: r.imageUrl,
      createdAt: r.createdAt,
      authorDisplay: publicReviewAuthorDisplay(r.user),
      authorAvatar: r.user?.avatar ?? null,
      authorAvatarType: r.user?.avatarType ?? null,
      authorAvatarStyle: r.user?.avatarStyle ?? null,
      productId: r.productId,
      productTitle: r.product?.title ?? '',
      product: {
        id: r.product?.id ?? r.productId,
        title: r.product?.title ?? '',
        published: r.product?.published ?? false,
        slug: r.product?.slug ?? '',
      },
    }))

Wait, but the include.product needs to include slug and published. Let me update the product include:

        product: { select: { id: true, title: true, published: true, slug: true } },

For GET /api/products/:id/reviews (line 83), update user include:

      include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } },

Update the map (line 89):

    const items = rawItems.map((r) => ({
      id: r.id,
      rating: r.rating,
      text: r.text,
      imageUrl: r.imageUrl,
      createdAt: r.createdAt,
      authorDisplay: publicReviewAuthorDisplay(r.user),
      authorAvatar: r.user?.avatar ?? null,
      authorAvatarType: r.user?.avatarType ?? null,
      authorAvatarStyle: r.user?.avatarStyle ?? null,
    }))
  • Step 2: Run server tests

Run: cd server && npm test Expected: all pass

  • Step 3: Update client review types

In client/src/entities/review/api/reviews-api.ts:

Update PublicReviewFeedItem:

export type PublicReviewFeedItem = {
  id: string
  rating: number
  text: string | null
  imageUrl: string | null
  createdAt: string
  authorDisplay: string
  authorAvatar?: string | null
  authorAvatarType?: string | null
  authorAvatarStyle?: string | null
  product: {
    id: string
    title: string
    published: boolean
    slug: string
  }
}

Update PublicProductReviewItem:

export type PublicProductReviewItem = {
  id: string
  rating: number
  text: string | null
  imageUrl: string | null
  createdAt: string
  authorDisplay: string
  authorAvatar?: string | null
  authorAvatarType?: string | null
  authorAvatarStyle?: string | null
}
  • Step 4: Update ProductReviewsList to use real avatars

In client/src/features/product-review/ui/ProductReviewsList.tsx, line 22:

Replace:

          <UserAvatar userId={rv.authorDisplay} avatarUrl={null} avatarType={null} avatarStyle={null} size={32} />

With:

          <UserAvatar
            userId={rv.authorDisplay}
            avatarUrl={rv.authorAvatar}
            avatarType={rv.authorAvatarType}
            avatarStyle={rv.authorAvatarStyle}
            size={32}
          />
  • Step 5: Update ReviewsBlock to use real avatars + product link conditional

In client/src/widgets/reviews-block/ui/ReviewsBlock.tsx:

Line 104-109, replace:

                    <UserAvatar
                      userId={r.authorDisplay}
                      avatarUrl={null}
                      avatarType={null}
                      avatarStyle={null}
                      size={40}
                    />

With:

                    <UserAvatar
                      userId={r.authorDisplay}
                      avatarUrl={r.authorAvatar}
                      avatarType={r.authorAvatarType}
                      avatarStyle={r.authorAvatarStyle}
                      size={40}
                    />

Lines 125-138 (product link), replace:

                      <Typography
                        variant="caption"
                        component={RouterLink}
                        to={`/products/${r.productId}`}
                        sx={{
                          display: 'block',
                          mt: 0.25,
                          color: 'primary.main',
                          textDecoration: 'none',
                          '&:hover': { textDecoration: 'underline' },
                        }}
                      >
                        {r.productTitle}
                      </Typography>

With:

                      {r.product.published ? (
                        <Typography
                          variant="caption"
                          component={RouterLink}
                          to={`/products/${r.product.slug || r.product.id}`}
                          sx={{
                            display: 'block',
                            mt: 0.25,
                            color: 'primary.main',
                            textDecoration: 'none',
                            '&:hover': { textDecoration: 'underline' },
                          }}
                        >
                          {r.product.title}
                        </Typography>
                      ) : (
                        <Typography
                          variant="caption"
                          color="text.secondary"
                          sx={{ display: 'block', mt: 0.25 }}
                        >
                          {r.product.title}
                        </Typography>
                      )}
  • Step 6: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 7: Commit
git add server/src/routes/api/public-reviews.js \
  client/src/entities/review/api/reviews-api.ts \
  client/src/features/product-review/ui/ProductReviewsList.tsx \
  client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
git commit -m "feat: real user avatars in reviews, conditional product link"

(Consolidated into Task 5 above — the product link change is already included in ReviewsBlock update)

No separate task needed. Already done in Task 5 Step 5.


Task 7: Out of stock chip visibility

Files:

  • Modify: client/src/entities/product/ui/ProductCard.tsx

  • Step 1: Fix z-index of stock chip

In client/src/entities/product/ui/ProductCard.tsx, the stock chip (line 132-149) is positioned absolute at top:8, left:8. The issue is likely that it's rendered inside the Box that also contains the image, and the image covers it.

The chip is at lines 132-149 inside <Box sx={{ position: 'relative' }}> (line 77). The image container at line 79 (<Box onMouseMove={...} sx={{ height: mediaHeight, overflow: 'hidden' }}>) doesn't have position styling. The chip should already be on top since it comes AFTER the image in DOM.

But wait — the image is inside Swiper, which uses overflow: hidden on the outer Box. The chip has position: absolute relative to the parent Box. The parent is the position:'relative' Box at line 77. The chip has top: 8, left: 8 which places it at the top of this Box. The image is inside a child Box that starts at the same top. The chip should be rendered ON TOP of the image because it appears later in the DOM.

But maybe the issue is that the image Box at line 79 has height equal to mediaHeight and the parent Box's height matches, making the chip sit "above" the visible area if the Swiper content extends beyond... no, that doesn't make sense.

Actually, the real issue might be that the Chip is being clipped by the parent Box overflow. The parent Box at line 77 has no overflow hidden, so it shouldn't clip. But the child Box at line 79 has overflow: 'hidden' — this clips the image, not the chip (since chip is sibling, not child).

Let me check: Box sx={{ position: 'relative' }} (line 77) contains:

  1. Image Box (line 79-114 or 115-129)
  2. Chip (line 132-149)

Since chip is a direct child of the position:'relative' Box, and image is inside a normal-flow Box, the absolute-positioned chip should overlay on top. Unless there's a z-index issue.

The fix is to add zIndex: 2 to the Chip's sx:

            sx={{
              position: 'absolute',
              top: 8,
              left: 8,
              zIndex: 2,
              fontWeight: 600,
              fontSize: '0.7rem',
              backdropFilter: 'blur(4px)',
              bgcolor: 'rgba(0,0,0,0.55)',
              color: 'common.white',
            }}
  • Step 2: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 3: Commit
git add client/src/entities/product/ui/ProductCard.tsx
git commit -m "fix: out of stock chip z-index in product card"

Task 8: Person icon for unauthenticated users

Files:

  • Modify: client/src/features/user/user-menu/ui/UserMenu.tsx

  • Step 1: Show PersonIcon instead of guest avatar when not logged in

Currently UserMenu shows a generated guest avatar when user is null (line 52). The user wants a Person icon instead.

In client/src/features/user/user-menu/ui/UserMenu.tsx:

Add import:

import PersonIcon from '@mui/icons-material/Person'

In the avatar/icon area (line 43-53), change the guest case:

          {user ? (
            <UserAvatar
              userId={user.id}
              avatarUrl={user.avatar}
              avatarType={user.avatarType}
              avatarStyle={user.avatarStyle}
              size={28}
            />
          ) : (
            <PersonIcon sx={{ fontSize: 28 }} />
          )}

But wait — PersonIcon inside an IconButton with Badge might not look right. The IconButton already provides the icon styling. Let's check if we need a wrapper.

Actually, looking at the current code: the IconButton wraps the Badge which wraps the UserAvatar. For non-logged-in, it renders <UserAvatar ... size={28} />. The PersonIcon should work similarly since it's just a MUI icon component taking sx={{ fontSize: 28 }}.

But there's also the Badge showing a green dot when user is logged in. When user is null, the Badge has invisible={true}, so no green dot. That's fine.

Wait — actually line 41 says invisible={!user}, which means when user is null, the badge is invisible (no dot). When user is set, show green dot. So for unauthenticated users, it's just the PersonIcon without any badge dot. Good.

  • Step 2: Run client lint + tests

Run: cd client && npm run lint && npm test Expected: no lint errors, tests pass

  • Step 3: Commit
git add client/src/features/user/user-menu/ui/UserMenu.tsx
git commit -m "fix: show PersonIcon instead of avatar for unauthenticated users"

Final Verification

  • Run full build: cd client && npm run build
  • Run all server tests: cd server && npm test
  • Run all client tests: cd client && npm test

Implementation Order

Tasks are independent and can be done in any order. Recommended order:

  1. Task 1 (admin settings — new files, largest change)
  2. Task 2 (header avatar — depends on Task 1 conceptually)
  3. Task 3 (users table avatar)
  4. Task 4 (order chat avatars)
  5. Task 5+6 (review avatars + product link)
  6. Task 7 (stock chip fix)
  7. Task 8 (person icon)