From 37be5eef0864de3a018e169f93b5fabcb63aaf8c Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:18:32 +0500 Subject: [PATCH] docs: avatar and display fixes implementation plan --- .../plans/2026-05-21-avatar-display-fixes.md | 1313 +++++++++++++++++ 1 file changed, 1313 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-avatar-display-fixes.md diff --git a/docs/superpowers/plans/2026-05-21-avatar-display-fixes.md b/docs/superpowers/plans/2026-05-21-avatar-display-fixes.md new file mode 100644 index 0000000..b91e2a5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-avatar-display-fixes.md @@ -0,0 +1,1313 @@ +# 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(null) + const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) + + const hasUnsavedPreview = previewSrc !== null + + const profileErrorMsg = getApiErrorMessage(profileSaveMut.error) + + if (isLoading) return Загрузка настроек... + if (isError) return Не удалось загрузить настройки. + if (!user) return Нужно войти. + + return ( + + + Настройки + + + Текущая почта: {user.email} + + + {profileErrorMsg && ( + + {profileErrorMsg} + + )} + + + + + Профиль + + + + + + + + + + + + Аватар + + + + + + + {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + + + {hasUnsavedPreview && ( + + + + Текущий + + + )} + + + + + Стиль + + + + + + {hasUnsavedPreview && ( + + + + + )} + + {hasOAuthAvatar && !hasUnsavedPreview && ( + + )} + + + + ) +} +``` + +- [ ] **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: }, +``` + +Add route after line 194 (``): +```tsx + } /> +``` + +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 && ( + + )} +``` + +Replace with: +```tsx + {isAdmin && user && !isMobile && ( + + )} +``` + +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 ? ( + <> + go('/me')}> + + + Выход + + ) : ( + go('/auth')}>Войти / регистрация + )} +``` + +With: +```tsx + {user ? ( + <> + go(isAdmin ? '/admin/settings' : '/me')}> + + + Выход + + ) : ( + go('/auth')}>Войти / регистрация + )} +``` + +- [ ] **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 && } +``` + +To: +```tsx + {!isAdmin && } +``` + +And add the admin version replacing lines 151-155: +```tsx + {isAdmin && user && !isMobile && ( + + )} +``` + +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 && }`. 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 + +``` + +In the users.map, add an avatar cell BEFORE the email cell (line 192): +```tsx + + + +``` + +- [ ] **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 ( + + {isAdmin && avatar && {avatar}} + + 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} + + {!isAdmin && avatar && {avatar}} + + ) +} +``` + +- [ ] **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 ? ( + + ) : currentUser ? ( + + ) : null + return ( + + + {isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} + + + + ) + })} +``` + +- [ ] **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 ? ( + + ) : currentUser ? ( + + ) : null + return ( + + + {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()} + + + + ) + })} +``` + +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 + +``` + +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 && ( + + ) + ) : ( + + ) + return ( + + ... + + ) + })} +``` + +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 + +``` + +With: +```tsx + +``` + +- [ ] **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 + +``` + +With: +```tsx + +``` + +Lines 125-138 (product link), replace: +```tsx + + {r.productTitle} + +``` + +With: +```tsx + {r.product.published ? ( + + {r.product.title} + + ) : ( + + {r.product.title} + + )} +``` + +- [ ] **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 `` (line 77). The image container at line 79 (``) 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 ? ( + + ) : ( + + )} +``` + +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 ``. 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)