diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 7a0b734..d3c97de 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -146,12 +146,10 @@ export function AppHeader() { )} - {!isAdmin && } + {!isAdmin && } {isAdmin && user && !isMobile && ( - + )} {!isMobile && ( diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 2c38e16..6e20b8f 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -37,7 +37,14 @@ export type AdminOrderDetailResponse = { comment: string | null createdAt: string updatedAt: string - user: { id: string; email: string; displayName: string | null } + user: { + id: string + email: string + displayName: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null + } items: Array<{ id: string productId: string diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index b320251..3e1aa31 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -139,6 +139,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) { position: 'absolute', top: 8, left: 8, + zIndex: 2, fontWeight: 600, fontSize: '0.7rem', backdropFilter: 'blur(4px)', diff --git a/client/src/entities/review/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts index 13bcc6b..584198e 100644 --- a/client/src/entities/review/api/reviews-api.ts +++ b/client/src/entities/review/api/reviews-api.ts @@ -26,8 +26,15 @@ export type PublicReviewFeedItem = { imageUrl: string | null createdAt: string authorDisplay: string - productId: string - productTitle: string + authorAvatar?: string | null + authorAvatarType?: string | null + authorAvatarStyle?: string | null + product: { + id: string + title: string + published: boolean + slug: string + } } export type PublicReviewsLatestResponse = { @@ -48,6 +55,9 @@ export type PublicProductReviewItem = { imageUrl: string | null createdAt: string authorDisplay: string + authorAvatar?: string | null + authorAvatarType?: string | null + authorAvatarStyle?: string | null } export type PublicProductReviewsResponse = { diff --git a/client/src/entities/user/api/user-api.ts b/client/src/entities/user/api/user-api.ts index 814b6cc..20fdf20 100644 --- a/client/src/entities/user/api/user-api.ts +++ b/client/src/entities/user/api/user-api.ts @@ -17,14 +17,14 @@ export async function fetchAdminUsers(params?: { return data } -export async function createAdminUser(body: { email: string; name?: string | null }): Promise { +export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise { const { data } = await apiClient.post('admin/users', body) return data } export async function updateAdminUser( id: string, - body: Partial<{ email: string; name: string | null }>, + body: Partial<{ email: string; displayName: string | null }>, ): Promise { const { data } = await apiClient.patch(`admin/users/${id}`, body) return data diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index 15dc992..3d00022 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -1,7 +1,10 @@ export type AdminUser = { id: string email: string - name: string | null + displayName: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null createdAt: string updatedAt: string } diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx index a9c269d..978c28f 100644 --- a/client/src/features/order-chat/ui/OrderChat.tsx +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -3,9 +3,12 @@ import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' +import { useUnit } from 'effector-react' +import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { UserAvatar } from '@/shared/ui/UserAvatar' type Message = { id: string @@ -24,6 +27,7 @@ type Props = { export function OrderChat({ messages, isPending, onSend }: Props) { const [text, setText] = useState('') const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0 + const currentUser = useUnit($user) const handleSend = () => { if (!canSend || isPending) return @@ -37,14 +41,28 @@ export function OrderChat({ messages, isPending, onSend }: Props) { Чат по заказу - {messages.map((m) => ( - - - {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} - - - - ))} + {messages.map((m) => { + const isAdminMsg = m.authorType === 'admin' + const avatarNode = isAdminMsg ? ( + + ) : currentUser ? ( + + ) : null + return ( + + + {isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} + + + + ) + })} {messages.length === 0 && Пока сообщений нет.} diff --git a/client/src/features/order-detail/ui/OrderDetailContent.tsx b/client/src/features/order-detail/ui/OrderDetailContent.tsx index db2d20c..721c478 100644 --- a/client/src/features/order-detail/ui/OrderDetailContent.tsx +++ b/client/src/features/order-detail/ui/OrderDetailContent.tsx @@ -9,6 +9,7 @@ import Select from '@mui/material/Select' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api' import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api' import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier' @@ -17,9 +18,11 @@ import { formatPriceRub } from '@/shared/lib/format-price' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { $user } from '@/shared/model/auth' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' +import { UserAvatar } from '@/shared/ui/UserAvatar' import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm' export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) { @@ -56,6 +59,7 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta ) const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0 + const currentUser = useUnit($user) return ( @@ -164,14 +168,36 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta Сообщения - {detail.messages.map((m) => ( - - - {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()} - - - - ))} + {detail.messages.map((m) => { + const isAdminMsg = m.authorType === 'admin' + const avatarNode = isAdminMsg ? ( + currentUser && ( + + ) + ) : ( + + ) + return ( + + + {isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()} + + + + ) + })} {detail.messages.length === 0 && Нет сообщений.} diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index 8a0ac36..ed03e3d 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -19,7 +19,13 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { - + {rv.authorDisplay} diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index fe72a6a..ab661c3 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import PersonIcon from '@mui/icons-material/Person' import Badge from '@mui/material/Badge' import IconButton from '@mui/material/IconButton' import ListItemText from '@mui/material/ListItemText' @@ -9,11 +10,12 @@ import { UserAvatar } from '@/shared/ui/UserAvatar' type Props = { user: AuthUser | null + isAdmin?: boolean onNavigate: (to: string) => void onLogout: () => void } -export function UserMenu({ user, onNavigate, onLogout }: Props) { +export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) { const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) @@ -49,7 +51,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { size={28} /> ) : ( - + )} @@ -63,8 +65,11 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { > {user ? ( <> - go('/me')}> - + go(isAdmin ? '/admin/settings' : '/me')}> + Выход diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 29c1ff5..b7e0e73 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' import { useQuery } from '@tanstack/react-query' import { useUnit } from 'effector-react' -import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react' +import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { AdminCategoriesPage } from '@/pages/admin-categories' @@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery' import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminProductsPage } from '@/pages/admin-products' import { AdminReviewsPage } from '@/pages/admin-reviews' +import { AdminSettingsPage } from '@/pages/admin-settings' import { AdminUsersPage } from '@/pages/admin-users' import { $user } from '@/shared/model/auth' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' @@ -63,6 +64,7 @@ export function AdminLayoutPage() { { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, { to: '/admin/notifications', label: 'Уведомления', icon: }, + { to: '/admin/settings', label: 'Настройки', icon: }, ], [], ) @@ -192,6 +194,7 @@ export function AdminLayoutPage() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/admin-settings/index.ts b/client/src/pages/admin-settings/index.ts new file mode 100644 index 0000000..e5be1b6 --- /dev/null +++ b/client/src/pages/admin-settings/index.ts @@ -0,0 +1 @@ +export { AdminSettingsPage } from './ui/AdminSettingsPage' diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx new file mode 100644 index 0000000..41adb80 --- /dev/null +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -0,0 +1,247 @@ +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 { apiClient } from '@/shared/api/client' +import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' +import { $user, updateProfileFx } from '@/shared/model/auth' +import type { UpdateProfileParams } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' + +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 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 profileSaveMut = useMutation({ + mutationFn: (params: { + displayName: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null + }) => apiClient.patch('admin/profile', params), + onSuccess: (_data, variables) => { + const p: UpdateProfileParams = { displayName: variables.displayName ?? null } + if (variables.avatar !== undefined) { + p.avatar = variables.avatar + p.avatarType = variables.avatarType ?? null + p.avatarStyle = variables.avatarStyle ?? null + } + updateProfileFx(p) + void qc.invalidateQueries({ queryKey: ['admin', 'profile'] }) + }, + }) + + const profileErrorMsg = getApiErrorMessage(profileSaveMut.error) + + if (isLoading) return Загрузка настроек… + if (isError) return Не удалось загрузить настройки. + if (!user) return Нужно войти. + + return ( + + + Настройки + + + Текущая почта: {String(user.email)} + + + {profileErrorMsg && ( + + {profileErrorMsg} + + )} + + + + + Профиль + + + + + + + + + + + + Аватар + + + + + + + {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} + + + {hasUnsavedPreview && ( + + + + Текущий + + + )} + + + + + Стиль + + + + + + {hasUnsavedPreview && ( + + + + + )} + + {hasOAuthAvatar && !hasUnsavedPreview && ( + + )} + + + + ) +} diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 6a18d72..6e5d85b 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -21,13 +21,14 @@ import { $user } from '@/shared/model/auth' import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog' import { AdminTable } from '@/shared/ui/AdminTable' import { EntityRowActions } from '@/shared/ui/EntityRowActions' +import { UserAvatar } from '@/shared/ui/UserAvatar' type UserFormState = { email: string - name: string + displayName: string } -const emptyUserForm = (): UserFormState => ({ email: '', name: '' }) +const emptyUserForm = (): UserFormState => ({ email: '', displayName: '' }) function formatDt(v: string) { try { @@ -77,7 +78,7 @@ export function AdminUsersPage() { const v = userForm.getValues() await createAdminUser({ email: v.email.trim(), - name: v.name.trim() || null, + displayName: v.displayName.trim() || null, }) }, onSuccess: () => { @@ -91,7 +92,7 @@ export function AdminUsersPage() { const v = userForm.getValues() await updateAdminUser(editing!.id, { email: v.email.trim(), - name: v.name.trim() || null, + displayName: v.displayName.trim() || null, }) }, onSuccess: () => { @@ -118,7 +119,7 @@ export function AdminUsersPage() { openEditDialog(u) userForm.reset({ email: u.email, - name: u.name ?? '', + displayName: u.displayName ?? '', }) } @@ -171,6 +172,7 @@ export function AdminUsersPage() { {users.length === 0 && !usersQuery.isLoading ? ( - + Пользователей пока нет. ) : ( users.map((u) => ( + + + {u.email} - {u.name ?? '—'} + {u.displayName ?? '—'} {formatDt(u.createdAt)} {formatDt(u.updatedAt)} @@ -253,7 +264,7 @@ export function AdminUsersPage() { /> } /> diff --git a/client/src/shared/ui/ChatMessageBubble.tsx b/client/src/shared/ui/ChatMessageBubble.tsx index 0fa56e5..b6e193c 100644 --- a/client/src/shared/ui/ChatMessageBubble.tsx +++ b/client/src/shared/ui/ChatMessageBubble.tsx @@ -1,29 +1,46 @@ 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' -export function ChatMessageBubble(props: { authorType: Author; children: ReactNode }) { - const { authorType, children } = props +type Props = { + authorType: Author + avatar?: ReactNode + children: ReactNode +} + +export function ChatMessageBubble({ authorType, avatar, children }: Props) { + const isAdmin = authorType === 'admin' return ( - - authorType === 'admin' - ? 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), + alignItems: 'flex-end', }} > - {children} - + {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}} + ) } diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index f392849..71ff824 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -103,9 +103,9 @@ export function ReviewsBlock() { @@ -122,20 +122,26 @@ export function ReviewsBlock() { {formatReviewDate(r.createdAt)} - - {r.productTitle} - + {r.product.published ? ( + + {r.product.title} + + ) : ( + + {r.product.title} + + )} 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) diff --git a/docs/superpowers/specs/2026-05-21-avatar-display-fixes-design.md b/docs/superpowers/specs/2026-05-21-avatar-display-fixes-design.md new file mode 100644 index 0000000..204848d --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-avatar-display-fixes-design.md @@ -0,0 +1,174 @@ +# 2026-05-21 — Avatar & Display Name Fixes + +## Overview + +8 замечаний по отображению аватаров, имён, ссылок и stock-статусов в админке и на клиенте. + +## Approach + +Локальные изолированные правки (Подход 1). Каждый пункт правится в своём контексте без переиспользования общих компонентов с `/me` — минимизирует риск регрессии. + +--- + +## 1. Admin settings page (`/admin/settings`) + +**Проблема:** Админ не может настроить displayName/avatar. Страница `/me/settings` существует, но админ редиректится с `/me` на `/admin`. + +**Решение:** +- Новая FSD-страница `client/src/pages/admin-settings/` +- Пункт «Настройки» в сайдбаре `AdminLayoutPage` (после «Уведомления») +- Форма: редактирование `displayName`, выбор/генерация аватара (DiceBear, 16 стилей), загрузка своего аватара. Копирует UI с `/me/settings` (SettingsPage), но как отдельный компонент, не шаринг. +- API: `GET /api/admin/profile` и `PATCH /api/admin/profile` (новый роут в `server/src/routes/api/`) +- Роут защищён `verifyAdmin`, работает с полями: `displayName`, `avatar`, `avatarType`, `avatarStyle` +- После сохранения — инвалидация `$user` стора на клиенте, чтобы хедер подхватил новый аватар + +**Файлы:** +- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` (новый) +- `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` (добавить пункт меню) +- `server/src/routes/api/admin-profile.js` (новый) +- `server/src/index.js` (зарегистрировать роут) + +--- + +## 2. Admin avatar in header + +**Проблема:** В хедере админ видит только кнопку «Выход», без аватара. + +**Решение:** +- В `AppHeader` для админа: `IconButton` с `UserAvatar` + выпадающее меню с пунктами «Настройки» (`/admin/settings`) и «Выход» +- Аватар из `AuthUser.avatar/avatarType/avatarStyle`, при отсутствии — DiceBear fallback +- Компонент по аналогии с `UserMenu`, но упрощённый: только 2 пункта, без профиля покупателя. Можно сделать как `AdminUserMenu` в `features/user/user-menu/` или прямо в `AppHeader` + +**Файлы:** +- `client/src/app/layout/AppHeader.tsx` (заменить кнопку «Выход» на меню с аватаром) + +--- + +## 3. Avatar column in admin users table + +**Проблема:** В таблице пользователей (`/admin/users`) нет колонки с аватарами. + +**Решение:** +- `AdminUser` тип (`entities/user/model/types.ts`): добавить `avatar`, `avatarType`, `avatarStyle` (опциональные) +- Серверный `GET /api/admin/users`: добавить эти поля в SELECT +- `AdminUsersPage`: колонка «Аватар» первой (перед email), рендер через `` + +**Файлы:** +- `client/src/entities/user/model/types.ts` +- `client/src/pages/admin-users/ui/AdminUsersPage.tsx` +- `server/src/routes/api/admin-users.js` + +--- + +## 4. Avatars in order messages + +**Проблема:** `ChatMessageBubble` показывает только текст «Админ»/«Вы»/«Пользователь», без аватаров. + +**Решение:** +- `ChatMessageBubble`: добавить опциональный проп `avatar?: ReactNode` — рендерится слева от сообщения для `authorType='admin'`, справа для `'user'` +- `OrderChat` (пользователь): для админских сообщений — DiceBear по `'admin'` seed, для своих — аватар из `AuthUser` +- `OrderDetailContent` (админ): для пользователя — аватар из `order.user.avatar/avatarType/avatarStyle`, для админа — из `AuthUser` +- API `GET /api/orders/:id` и `GET /api/admin/orders/:id`: добавить `user { avatar, avatarType, avatarStyle }` в ответ +- Клиентский тип заказа: добавить эти поля в `user` + +**Файлы:** +- `client/src/shared/ui/ChatMessageBubble.tsx` +- `client/src/features/order-chat/ui/OrderChat.tsx` +- `client/src/features/order-detail/ui/OrderDetailContent.tsx` +- `server/src/routes/user-orders.js` (GET /:id) +- `server/src/routes/api/admin-orders.js` (GET /:id) +- Типы заказа на клиенте + +--- + +## 5. Actual user avatars in reviews + +**Проблема:** В отзывах всегда генерируется DiceBear по строке `authorDisplay`, а не используется реальный аватар пользователя. + +**Решение:** +- API `public-reviews`: добавить `authorAvatar`, `authorAvatarType`, `authorAvatarStyle` в ответ (из `user.avatar/avatarType/avatarStyle`) +- Тип `PublicProductReviewItem` и `PublicReviewFeedItem`: добавить эти поля +- `ReviewsBlock` и `ProductReviewsList`: передавать реальные значения в `UserAvatar` вместо `null` + +**Файлы:** +- `server/src/routes/api/public-reviews.js` +- `client/src/entities/review/api/reviews-api.ts` (типы) +- `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx` +- `client/src/features/product-review/ui/ProductReviewsList.tsx` +- `server/src/routes/api/admin-reviews.js` (тоже может использовать) + +--- + +## 6. Product link in reviews only if published + +**Проблема:** В `ReviewsBlock` ссылка на товар показывается всегда, даже если товар скрыт из каталога. + +**Решение:** +- API `public-reviews`: добавить объект `product: { id, title, published, slug }` в каждый элемент фида +- Тип `PublicReviewFeedItem`: обновить поле с `productId`/`productTitle` на `product: { id, title, published, slug }` +- `ReviewsBlock`: если `product.published === true` — ссылка ``, иначе — просто текст `` + +**Файлы:** +- `server/src/routes/api/public-reviews.js` +- `client/src/entities/review/api/reviews-api.ts` +- `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx` + +--- + +## 7. "Out of stock" chip visibility in catalog + +**Проблема:** Чип «Нет в наличии» существует в DOM, но визуально не виден в каталоге. + +**Решение:** +- Проверить `z-index` чипа в `ProductCard` — поднять выше (например `zIndex: 2`), чтобы не перекрывался `CardMedia` или другими элементами +- Предположительно проблема в том, что чип рендерится до изображения в DOM, и изображение перекрывает его по z-order + +**Файлы:** +- `client/src/entities/product/ui/ProductCard.tsx` + +--- + +## 8. Person icon for unauthenticated users + +**Проблема:** До авторизации в хедере нет иконки пользователя. + +**Решение:** +- В `AppHeader`: когда `user === null` и `!loading`, показывать `IconButton` с `PersonIcon`, ведущую на `/auth` +- Сейчас `UserMenu` не рендерится без `user` — добавить условие `user ? : ` + +**Файлы:** +- `client/src/app/layout/AppHeader.tsx` + +--- + +## Data flow summary + +``` +┌─ Admin settings ─────────────────────────────────────┐ +│ PATCH /api/admin/profile → DB → invalidate $user │ +│ → AppHeader reads $user.avatar → UserAvatar │ +└───────────────────────────────────────────────────────┘ + +┌─ Admin users table ───────────────────────────────────┐ +│ GET /api/admin/users → { ..., avatar, avatarType, │ +│ avatarStyle } → AdminUsersPage → │ +└───────────────────────────────────────────────────────┘ + +┌─ Order chat ──────────────────────────────────────────┐ +│ GET /api/orders/:id → { user: { avatar, ... } } │ +│ → OrderChat → ChatMessageBubble(avatar={})│ +│ Admin avatar: from AuthUser store │ +└───────────────────────────────────────────────────────┘ + +┌─ Reviews ─────────────────────────────────────────────┐ +│ GET /api/public-reviews → { authorAvatar, ..., │ +│ product: { published, ... } } │ +│ → ReviewsBlock/ProductReviewsList → UserAvatar + link│ +└───────────────────────────────────────────────────────┘ +``` + +## Testing + +- **Client unit tests:** Проверить рендер аватаров в `ProductReviewsList`, `ReviewsBlock`, `AdminUsersPage`, `ChatMessageBubble`, `AppHeader` для разных состояний (авторизован/неавторизован/админ) +- **Server tests:** Проверить новые поля в ответах API +- **Manual:** Проверить видимость чипа «Нет в наличии», отображение ссылки в отзывах для published/unpublished товаров diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 32612d6..66a71c3 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,5 +1,6 @@ import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js' import { registerAdminNotificationRoutes } from './api/admin/notifications.js' +import { registerAdminProfileRoutes } from './api/admin-profile.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' import { registerAdminGalleryRoutes } from './api/admin-gallery.js' import { registerAdminOrderRoutes } from './api/admin-orders.js' @@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) { await registerAdminReviewRoutes(fastify) await registerAdminUserRoutes(fastify) await registerAdminNotificationRoutes(fastify) + await registerAdminProfileRoutes(fastify) } diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 3092e76..eef31db 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, displayName: true } }, + user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js new file mode 100644 index 0000000..64aaeff --- /dev/null +++ b/server/src/routes/api/admin-profile.js @@ -0,0 +1,65 @@ +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 === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? 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 !== undefined && 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 !== undefined) { + data.displayName = 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, + } + }) +} diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index cc26341..e6fc7b4 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -33,6 +33,9 @@ export async function registerAdminUserRoutes(fastify) { id: true, email: true, displayName: true, + avatar: true, + avatarType: true, + avatarStyle: true, createdAt: true, updatedAt: true, }, @@ -44,6 +47,9 @@ export async function registerAdminUserRoutes(fastify) { id: u.id, email: u.email, displayName: u.displayName, + avatar: u.avatar, + avatarType: u.avatarType, + avatarStyle: u.avatarStyle, createdAt: u.createdAt, updatedAt: u.updatedAt, })) diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index ef535b2..a9d86fe 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -40,8 +40,8 @@ export async function registerPublicReviewRoutes(fastify) { const rows = await prisma.review.findMany({ where: { status: 'approved', product: { published: true } }, include: { - user: { select: { email: true, displayName: true } }, - product: { select: { id: true, title: true } }, + user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } }, + product: { select: { id: true, title: true, published: true, slug: true } }, }, orderBy: { createdAt: 'desc' }, take, @@ -54,8 +54,15 @@ export async function registerPublicReviewRoutes(fastify) { imageUrl: r.imageUrl, createdAt: r.createdAt, authorDisplay: publicReviewAuthorDisplay(r.user), - productId: r.productId, - productTitle: r.product?.title ?? '', + authorAvatar: r.user?.avatar ?? null, + authorAvatarType: r.user?.avatarType ?? null, + authorAvatarStyle: r.user?.avatarStyle ?? null, + product: { + id: r.product?.id ?? r.productId, + title: r.product?.title ?? '', + published: r.product?.published ?? false, + slug: r.product?.slug ?? '', + }, })) return { items } @@ -80,7 +87,7 @@ export async function registerPublicReviewRoutes(fastify) { const total = await prisma.review.count({ where }) const rawItems = await prisma.review.findMany({ where, - include: { user: { select: { email: true, displayName: true } } }, + include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, @@ -93,6 +100,9 @@ export async function registerPublicReviewRoutes(fastify) { 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, })) return { items, total, page, pageSize }