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

```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):
```js
import { registerAdminProfileRoutes } from './api/admin-profile.js'
```

Add registration call (before last closing brace, after line 27):
```js
  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`:
```ts
export { AdminSettingsPage } from './ui/AdminSettingsPage'
```

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

```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):
```tsx
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 `]`):
```tsx
      { to: '/admin/settings', label: 'Настройки', icon: <Settings /> },
```

Add route after line 194 (`<Route path="notifications" .../>`):
```tsx
          <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**

```bash
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:
```tsx
          {isAdmin && user && !isMobile && (
            <Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
              Выход
            </Button>
          )}
```

Replace with:
```tsx
          {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:

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

Update the component destructuring:
```tsx
export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) {
```

Update the dropdown menu items (lines 64-73). Replace:
```tsx
        {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:
```tsx
        {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:
```tsx
          {!isAdmin && <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />}
```

To:
```tsx
          {!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
```

And add the admin version replacing lines 151-155:
```tsx
          {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**

```bash
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:
```js
    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):
```js
    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`:
```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):
```tsx
import { UserAvatar } from '@/shared/ui/UserAvatar'
```

Update columns (line 173) — add avatar column first:
```tsx
        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):
```tsx
            <TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
```

In the users.map, add an avatar cell BEFORE the email cell (line 192):
```tsx
              <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**

```bash
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:
```js
        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:

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

```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:
```tsx
import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
```

In the component, add:
```tsx
const currentUser = useUnit($user)
```

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

```tsx
        {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:
```tsx
import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
```

In the component, add:
```tsx
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']`:
```ts
    user: { id: string; email: string; displayName: string | null; avatar?: string | null; avatarType?: string | null; avatarStyle?: string | null }
```

In the messages map (line 167), update:

```tsx
          {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:
```tsx
<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:
```tsx
          {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**

```bash
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:
```js
        user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
```

Update the map (line 50):
```js
    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:

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

For `GET /api/products/:id/reviews` (line 83), update user include:
```js
      include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } },
```

Update the map (line 89):
```js
    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`:
```ts
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`:
```ts
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:
```tsx
          <UserAvatar userId={rv.authorDisplay} avatarUrl={null} avatarType={null} avatarStyle={null} size={32} />
```

With:
```tsx
          <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:
```tsx
                    <UserAvatar
                      userId={r.authorDisplay}
                      avatarUrl={null}
                      avatarType={null}
                      avatarStyle={null}
                      size={40}
                    />
```

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

Lines 125-138 (product link), replace:
```tsx
                      <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:
```tsx
                      {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**

```bash
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"
```

---

### Task 6: Product link in reviews only if published

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

```tsx
            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**

```bash
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:
```tsx
import PersonIcon from '@mui/icons-material/Person'
```

In the avatar/icon area (line 43-53), change the guest case:
```tsx
          {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**

```bash
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)
