From ff7a4b6bba18628293d38dee3f7877004ca025e8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:09:22 +0500 Subject: [PATCH 01/12] docs: avatar and display fixes design spec --- .../2026-05-21-avatar-display-fixes-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-avatar-display-fixes-design.md 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 товаров From 37be5eef0864de3a018e169f93b5fabcb63aaf8c Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:18:32 +0500 Subject: [PATCH 02/12] 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) From 0dfa428931486c0201da0da7f6ad001c3df41d1a Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:28:35 +0500 Subject: [PATCH 03/12] feat: add admin settings page for display name and avatar editing --- .../pages/admin-layout/ui/AdminLayoutPage.tsx | 5 +- client/src/pages/admin-settings/index.ts | 1 + .../admin-settings/ui/AdminSettingsPage.tsx | 247 ++++++++++++++++++ server/src/routes/api.js | 2 + server/src/routes/api/admin-profile.js | 64 +++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/admin-settings/index.ts create mode 100644 client/src/pages/admin-settings/ui/AdminSettingsPage.tsx create mode 100644 server/src/routes/api/admin-profile.js 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..fff9454 --- /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, UpdateProfileParams, updateProfileFx } 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: () => { + 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 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/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-profile.js b/server/src/routes/api/admin-profile.js new file mode 100644 index 0000000..ab7f5c2 --- /dev/null +++ b/server/src/routes/api/admin-profile.js @@ -0,0 +1,64 @@ +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 ? undefined : 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, + } + }) +} From 52290e162e063e5bb4f57c7b919515c851a852a6 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:42:59 +0500 Subject: [PATCH 04/12] fix: use mutation variables in onSuccess, fix null displayName handling --- .../admin-settings/ui/AdminSettingsPage.tsx | 13 ++++++------- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/api/admin-profile.js | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index fff9454..40908a8 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -72,13 +72,12 @@ export function AdminSettingsPage() { 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 + 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'] }) diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 10de6af9e72246757d55f061e46b93998eae7136..1802321d0f448b0133ea0c2bc2151f83944c02ae 100644 GIT binary patch delta 12225 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 N(abNX=MU){{~JJw&ZhtX delta 7505 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 diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js index ab7f5c2..64aaeff 100644 --- a/server/src/routes/api/admin-profile.js +++ b/server/src/routes/api/admin-profile.js @@ -18,7 +18,8 @@ export async function registerAdminProfileRoutes(fastify) { 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 ? undefined : String(nameRaw).trim() + 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 From d1e4cc67aa5644d58f070ffc7a188b0cf1622124 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:45:23 +0500 Subject: [PATCH 05/12] feat: admin avatar in header with settings link --- client/src/app/layout/AppHeader.tsx | 6 ++---- client/src/features/user/user-menu/ui/UserMenu.tsx | 10 +++++++--- 2 files changed, 9 insertions(+), 7 deletions(-) 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/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index fe72a6a..f2dbf7b 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -9,11 +9,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) @@ -63,8 +64,11 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { > {user ? ( <> - go('/me')}> - + go(isAdmin ? '/admin/settings' : '/me')}> + Выход From 2751332356181bd85d4dd0e526474abf222faa63 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:52:43 +0500 Subject: [PATCH 06/12] feat: avatar column in admin users table --- client/src/entities/user/model/types.ts | 3 +++ client/src/pages/admin-users/ui/AdminUsersPage.tsx | 13 ++++++++++++- server/src/routes/api/admin-users.js | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index 15dc992..66b4871 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -2,6 +2,9 @@ export type AdminUser = { id: string email: string name: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null createdAt: string updatedAt: string } diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 6a18d72..e5f1eb6 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -21,6 +21,7 @@ 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 @@ -171,6 +172,7 @@ export function AdminUsersPage() { {users.length === 0 && !usersQuery.isLoading ? ( - + Пользователей пока нет. ) : ( users.map((u) => ( + + + {u.email} {u.name ?? '—'} {formatDt(u.createdAt)} 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, })) From 7a9e44bc5ca85babfad0a9ead8e402e184d0f7c8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 20:58:50 +0500 Subject: [PATCH 07/12] fix: rename name to displayName in AdminUser type and page --- client/src/entities/user/api/user-api.ts | 4 ++-- client/src/entities/user/model/types.ts | 2 +- .../pages/admin-users/ui/AdminUsersPage.tsx | 14 +++++++------- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 4 files changed, 10 insertions(+), 10 deletions(-) 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 66b4871..3d00022 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -1,7 +1,7 @@ export type AdminUser = { id: string email: string - name: string | null + displayName: string | null avatar?: string | null avatarType?: string | null avatarStyle?: string | null diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index e5f1eb6..6e5d85b 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -25,10 +25,10 @@ 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 { @@ -78,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: () => { @@ -92,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: () => { @@ -119,7 +119,7 @@ export function AdminUsersPage() { openEditDialog(u) userForm.reset({ email: u.email, - name: u.name ?? '', + displayName: u.displayName ?? '', }) } @@ -201,7 +201,7 @@ export function AdminUsersPage() { /> {u.email} - {u.name ?? '—'} + {u.displayName ?? '—'} {formatDt(u.createdAt)} {formatDt(u.updatedAt)} @@ -264,7 +264,7 @@ export function AdminUsersPage() { /> } /> diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 1802321d0f448b0133ea0c2bc2151f83944c02ae..eda88e12626c812198dc9721c0f994d09ad00901 100644 GIT binary patch delta 758 zcmZozAl9%zY=Sgn#zYxs#*B>#S@YQ}ERBtfEhb->F9&9tS#16?|IB=cd`m;4QUe16 zV?%@dlH}~(twJL3xJBuK#J0eii=W=ip;=@Zf$d5 ze8evYQZ31%&B__z>gVF==N=#680qWk7Xnoi!1%geG~d#oJP~L}Vp@@DN?LhAN=a36 zLE8n!Z5Nob=htI(M_ztvZenJRuA#ZPrLnmM#0w53sl_FF$@#fp!?#1okYGk$dw&Kn zU}a@s!0GDr)Dqo-#7bl%%`6;J#VQhW3vyE7#$E**dv#j@^I3jltZrkHVYbH|INKVS z4M1KtG^zr6*%TCRMTx2A*(HV9(|7!5R`Ugh7}$#4P?usxLLsAoy*8&TgCqwBvoHe# zE2}-sQ=s^=02!HCnOap@RZsyoQpw)%7TCyJFm(k$btqA`g$3#x3`e2(2E+Vq0xY-W z)qoLLQ3*6M4HSXdx#Mz)O!S@YQp%}gu|jV521F9&9t7;XMC|IB=cyplA75(5JR zW0Mq%vaGV~l%g#2EXH}>CL%#h$}IM*oXNQbX+TAWc|b*GAVsEG7R3e@8OC5mh1(n$ zAMwk9R74u*@q$B1YH^8Pa(*t@a9;=+63obJ@6P}R ztgH+SI9;8dTB2K!Scz<;iIGF9SVdxPK~5^%*c`C2Iok@D&+;2%bsLimvpw#>+19{p z0P=F0K?Tsurl4?3wy3De$Vn@lzT-c$nlCWKz*Yo7U5XhAg^U9B+MKcsk{le&!VC2OdUp)ZDE1>2E$P(zQHhmn*hr#c{N}J zmS+L2P6J0^c}A6?fl->Fk+F#>$Xeme5OUiB#@GCy$i`H=&4J~Zy%i`OWCD#z1{sr; jY-nIomTL}iGL{4aHna~yZfjt9Z4XXI82XnTV7UMQUo!0L From d69647ffe3ca2df456462822dbbb7bb2efa049bc Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 21:00:26 +0500 Subject: [PATCH 08/12] fix: out of stock chip z-index, PersonIcon for unauthenticated users --- client/src/entities/product/ui/ProductCard.tsx | 1 + client/src/features/user/user-menu/ui/UserMenu.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index f2dbf7b..d714df7 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -4,6 +4,7 @@ import IconButton from '@mui/material/IconButton' import ListItemText from '@mui/material/ListItemText' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' +import PersonIcon from '@mui/icons-material/Person' import type { AuthUser } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' @@ -50,7 +51,7 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) size={28} /> ) : ( - + )} From 7e7bade80ccd8de1c0640a28cbd006035906ef83 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 21:05:22 +0500 Subject: [PATCH 09/12] feat: avatars in order messages --- .../src/entities/order/api/admin-order-api.ts | 9 +++- .../src/features/order-chat/ui/OrderChat.tsx | 34 +++++++++--- .../order-detail/ui/OrderDetailContent.tsx | 42 ++++++++++++--- .../features/user/user-menu/ui/UserMenu.tsx | 2 +- client/src/shared/ui/ChatMessageBubble.tsx | 49 ++++++++++++------ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/api/admin-orders.js | 2 +- 7 files changed, 103 insertions(+), 35 deletions(-) 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/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/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index d714df7..ab661c3 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -1,10 +1,10 @@ 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' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' -import PersonIcon from '@mui/icons-material/Person' import type { AuthUser } from '@/shared/model/auth' import { UserAvatar } from '@/shared/ui/UserAvatar' 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/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index eda88e12626c812198dc9721c0f994d09ad00901..f8989a0952fa99a7b414714fe94fdfa10532c26e 100644 GIT binary patch delta 733 zcmZozAl9%zY=Si7%!xA2j59YTWX@-^G&V6XG@N{Xz8pK4X=<|h%lxzR?K5-AEes3{ zj7>9h5)I1pOtZ7nOBm;QyD`TxDYMwKawg{%q~&MkRG0%5nSm4)rR7=XW@HqA6*X;h zV0^?c2U0D`qRq+~;Ogh%>E|9F;27!a>K6i46TtYUUN|$SpaN({VsW{7eo;oEWpbif z`~UNd+y9?u%AH@2%^7+5skw=nIl6}C=9b1l1oD7|LrH3JiC%JkF4*h{2pJO0$ZPM< z00ykA3=B9Oot|2vTaZ|ZY^14)Lu!R+Vs1fBD%{u_u(37U3YgFF8)0)AlMJ&x?x5M$ zz-(AA0@RlR^sp%?&~mfW($e!Ylcw+Z&#a~d3@)$*aZq<+#=!L6|IFe*6DmL^Sb$7O zuFA8>ttd=`na~6_p$Vr?x3EB*j^P9pr(+nuO@QULylQ4n5h%XXKyhVRW@K!hVNh&n zWNcyzvXp5tgxqGp@`fMiMogvK99WLqTW01I8UqbU1{soJm{w$JR#gafE=EcKrzWOq Vu))>a8d%=g0}aO1zU%#ne*8!EG&(Uj3=L;FUJmMnptfAGXLy+drL#3QUe16 zV?%@dlH}~(()}0jSGN^%s`6Lii(R;jEc;_if(Ol zV0^?c2U0D`qRq+~;Ogh%>E|9F;27!a>K6i46TtYUUf9y0JP~L{Vp@@DN?LhAN=a36 z`~UNd+y9?u%AH@2%^7+5skw=nIl6}C=9b3h5D!>5l%y7y=q2apg3aCzAwz;0dF}lf zz<`yNfdQwZ(^E@y3lb}ljWn}xNUab}%q_@Cg&TVnZ0yx-1qPDcDjr2)WIGeV7UMQ73S># 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' } }, }, From 57da755ea15024f627549c92ecf1736eff63efa3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 21:10:49 +0500 Subject: [PATCH 10/12] feat: real user avatars in reviews, conditional product link --- client/src/entities/review/api/reviews-api.ts | 14 ++++++- .../product-review/ui/ProductReviewsList.tsx | 8 +++- .../widgets/reviews-block/ui/ReviewsBlock.tsx | 40 +++++++++++-------- server/src/routes/api/public-reviews.js | 20 +++++++--- 4 files changed, 57 insertions(+), 25 deletions(-) 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/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/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/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 } From e09fe7211a32f2cb8310b80baf56d43b18bb4a4b Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 21:12:29 +0500 Subject: [PATCH 11/12] fix: type-only import for UpdateProfileParams --- .../admin-settings/ui/AdminSettingsPage.tsx | 3 ++- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx index 40908a8..41adb80 100644 --- a/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx +++ b/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx @@ -16,7 +16,8 @@ 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, UpdateProfileParams, updateProfileFx } from '@/shared/model/auth' +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 { diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index f8989a0952fa99a7b414714fe94fdfa10532c26e..ddf27de7444d49bfc07c7b3838e05cdac88fde23 100644 GIT binary patch delta 731 zcmZozAl9%zY=Si7sfjYqjHfmxWX)%@u(Y%=GM;>4z8sioYO(pt{Im1zOLK~{3=9m6 z%d$!h4N9wuQ>rWs8RvQX_3mU+X0d1GOwKJx%P-9-&IBql11U1ht4cH}&PW0)y1vbU z@e#iqNVO!3HY;aDLOLPkoE0K*fwQxwS5KYW2$Vr78dkJjprELYw=lG4VIgLq%*&cVuY-?aP z1o^k980cS9P?+Ur86@YJlqOBz@t;{u2^d;n3wA=?i5UUYd;c?w15GH-1)5+1G9jll zyD}@M*Z^k2b+8H7ar$%%3&iOdPC#)whVk13SZ>R!mgeME0xeDhMU`caWr0PeX_cXo zv56_zQZ5L&&4A?%KhTYsO1C+%9JjXw#y7~2WRM}*S%t+_rO8=P=VB%VkdZylA>_6O QmN)jGc;=l}o! delta 731 zcmZozAl9%zY=Si7%!xA2j59YTWX)$YHZd?Xw3vKhz8sioYO?vu{Im1zGjqx<3=9m6 zO*3;64a)OOv$N7m80UGrF~>0}v)Hq8Cg&EUgVF==N=#680qWk7Xnoi!1$(KBr~U=0%%5Jak+VZQAVO=a-vy5 z+XcpL7npM9*JE=?@*FjT%wnpp9?lR0z!raGxFN|Gk^gr zD+2>gH>anT=oTbaA{%LH;*eS)nwVRVlL|Ms25fB2wgTpJ{6^TE#w5dRk2_?xH82~3 z{9B#^^sgx>%yP5S($e!Ylcw+Z&#a~d3@xw)aZq<+M!@vm|IFe*6DmL^Sb$7OuFA8> zttd=`na~6_p$Vr?x3EB*j^P9pr(+nuO@QULylQ4n5h%LTKv88`W@K!hVNh&nWNcyz zvXp5tgxqGp@`fMiMogvK99WLqTW01I8UqbU1{soJm{w$JR#gafE@nai8(9q@w>7Z5 Ou?HHAseRc2mJ0xSP40;R From c5775c7f5ddf30bc123ac9188008bf36b5769098 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 21:17:06 +0500 Subject: [PATCH 12/12] test commit --- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index ddf27de7444d49bfc07c7b3838e05cdac88fde23..80a0e79dae519d88c744faffb4a58f1f673eb560 100644 GIT binary patch delta 1586 zcmah}TWB0*6rS0csk3R4e;1oXO==eEK=ZI+ZhKR3W7BA5!KBTDC|-7U=AYf!%wD<| zvl}Q$UzCDvlqiD;Z4fU|A0({VC_-!{L42?YlotA+gd`x?KpzC3gm`9WvsSX=!XAeI zobNl|`Tujy++y$CV()wX_H#P6bXK0~z%{SCb31lX( zOoaoyFj7^r8m5iyy zv8#_^7(&hD^;{((^YD|>RXKx|s#L%!@!wp-YA&NlqAJOfo(P6RQZOWk1U0B)S=K~V z)g_4!1{E<9k+pz`bu}ag!g4@Q2tic{U_n<^*~}%4EP2%vd*zcTc{Mh1?}}|_=4z9x zOJpaFE)i%4t)nl{CD2(#)|;=<>=SLZ(Z{JMxe#A{ytcM60wto$1lmPEqU-1~Krf($ z>kkG<8tkj!yF;Kq(S5Xq?$o>!>u6zX{|IACple(EPu*Ziv}5Pp0q3_)PV46la`4!6 zN-H-mMOVUtnl*)Nb?WU+vcBn}eMd-?Cr}=8>B!3OyF5L4UVp0s;Su`^L*s3XG}so~K@bFq z_6W4M>?OC+%HBBjn5VmjkalbMTgtQ`Z7*Uxdoo*L>}yl4^t_UeKBj4 z48<&3^E$<`rwtXK!b-vVg=b#&C5=+5oUmJt6Q9?>l>pa6pH^C63f5YZdcn($iCmcH zB4aJS%@o!D^#3softExSObg#qup}~m>!%d;YhOdM2tg@qBvU0$I?aiqz+0;ZWt}Qu zwFEBRFipq7iBd>SNHPFy%(S7EQU?$yy$#O;i8(O~WpUCNCykbH#U`vcm;gWm4ytGGl0(i5*g{nO6u6hwXP2p@h+wpa`hO23j{afM{!;VTWSl zyDsRVvnEdBEG{S|tQpOxw>#H`)|*og3+|)l{Czb`Jt?*AOxFMvb^mAY)#oUB5oYri zfo{P#e}|rlkVm5d_`7-Au!P&~W*O#yUm{@BCVExBh@tFp&uLL2ZX|^VivA+x%eq zvU$=M>km-}L7E6^p+0C?lITl~P!$T2Q1C@TVk7pUm4b*bD)F3ivq>ezfqUlObI=*VeI}5k(v$<2(yXL;R{xI;@Gwe@x zm8~~R{g+sCeY0n!ja=Md8^HR4fZEdUR*if1P9@2tZQIeMcqQq7E~?`&qDC>;9!$Roz-Cx3JPNIy7e z?SHqD%`fNe?60h}OJS*KTHVW*oy`@EYl|i0GF!gZXWm}adHjSuXTCc3O!vLZ-cggc zr4J36o$2c`*Ps5Lm_O|c1G`tsUTF_kM}~$cd(~rb41@Ga%}mq7J-NHm#Z~LJ^!|UW zVk7WCPAV^UeU2(ZxJUyeZV1%%B5C7BQ$V(H30S_uO=!m)PY%(5JIIs?7hFwqr6Ng) znzpoUsI%nK_8K586`Is^M?m^y##MlhjwzoDg!TiDE(?Lw(>?V&0?L_AN$XDGQDJs; zMp4_LBbkuB4G0K@w=#&p9v2gMi`cllgc7Lin`aUvd{R@f)KvoxRZgOz5KT(T0c?;% zLb%7923kNaLLdW@0n~vUk(-eu?={7HTmlm3b5F+GtwK3KBe-!9ljL+=q@s!`hm$Op$&a85O-iKHu` zC(x9p+g{skS zXxv(MVdeAt^6=zxTXEE8U{DO=TnUq~9*nprg;b791`C=lEf`l>2gJNvcBx`nww_vx zY!pV&i!2>&STBSyun;&#;A5l5Cr(bqqgxhtPck&~+7(TgX15U`${Xk4mln;zU9cao HSZn_Rumbd~