diff --git a/client/src/app/providers/SseProvider.tsx b/client/src/app/providers/SseProvider.tsx
index b13c74f..c0258ad 100644
--- a/client/src/app/providers/SseProvider.tsx
+++ b/client/src/app/providers/SseProvider.tsx
@@ -53,8 +53,8 @@ export function SseProvider() {
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
break
}
- } catch {
- // ignore parse errors (e.g. heartbit comments)
+ } catch (err) {
+ console.warn('[sse] Failed to parse event data', err)
}
}
}
diff --git a/client/src/app/providers/theme-controller.tsx b/client/src/app/providers/theme-controller.tsx
index 9537648..7dd2fe2 100644
--- a/client/src/app/providers/theme-controller.tsx
+++ b/client/src/app/providers/theme-controller.tsx
@@ -29,7 +29,8 @@ function readStoredTheme(): ThemeSettings | null {
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
if (!modeOk || !schemeOk) return null
return { mode, scheme }
- } catch {
+ } catch (err) {
+ console.warn('[theme] Failed to read stored theme', err)
return null
}
}
@@ -80,8 +81,8 @@ export function ThemeControllerProvider({ children }: PropsWithChildren) {
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[theme] Failed to persist theme setting', err)
}
}, [settings])
diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx
index bdacf86..4a2b769 100644
--- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx
+++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx
@@ -42,8 +42,8 @@ export function AddressMapPicker(props: {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[address-map-picker] Failed to reverse geocode', err)
}
}
diff --git a/client/src/features/address-map-picker/ui/MapPickerMap.tsx b/client/src/features/address-map-picker/ui/MapPickerMap.tsx
index bee9f3b..e11cfb2 100644
--- a/client/src/features/address-map-picker/ui/MapPickerMap.tsx
+++ b/client/src/features/address-map-picker/ui/MapPickerMap.tsx
@@ -52,8 +52,8 @@ export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[map-picker] Failed to reverse geocode', err)
}
}
diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx
index 1c7ffe1..f84b0f2 100644
--- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx
+++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx
@@ -35,7 +35,8 @@ function getApiErrorMessage(error: unknown): string | null {
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
- } catch {
+ } catch (err) {
+ console.warn('[gallery] Failed to format date', err)
return ''
}
}
diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx
index 4c2a506..c6d3975 100644
--- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx
+++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx
@@ -35,7 +35,8 @@ function formatDt(v: string) {
const d = new Date(v)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString()
- } catch {
+ } catch (err) {
+ console.warn('[admin-users] Failed to format date', err)
return '—'
}
}
diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx
index d7b679a..fc6df61 100644
--- a/client/src/pages/auth/ui/AuthPage.tsx
+++ b/client/src/pages/auth/ui/AuthPage.tsx
@@ -23,7 +23,8 @@ function readStoredScheme(): ColorScheme {
const parsed = JSON.parse(raw)
const scheme = parsed?.scheme
return scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' ? scheme : 'craft'
- } catch {
+ } catch (err) {
+ console.warn('[auth] Failed to read stored theme scheme', err)
return 'craft'
}
}
diff --git a/client/src/shared/api/client.ts b/client/src/shared/api/client.ts
index 2c6be99..def7a5a 100644
--- a/client/src/shared/api/client.ts
+++ b/client/src/shared/api/client.ts
@@ -17,7 +17,8 @@ apiClient.interceptors.request.use((config) => {
config.headers.delete('content-type')
}
return config
- } catch {
+ } catch (err) {
+ console.warn('[api-client] Failed to set auth token', err)
return config
}
})
diff --git a/client/src/shared/lib/order-address-snapshot.ts b/client/src/shared/lib/order-address-snapshot.ts
index d25a4bc..b94cf7c 100644
--- a/client/src/shared/lib/order-address-snapshot.ts
+++ b/client/src/shared/lib/order-address-snapshot.ts
@@ -12,7 +12,8 @@ export function parseOrderAddressSnapshot(json: string | null | undefined): Orde
if (!json) return null
try {
return JSON.parse(json) as OrderAddressSnapshot
- } catch {
+ } catch (err) {
+ console.warn('[order-address-snapshot] Failed to parse address snapshot', err)
return null
}
}
diff --git a/client/src/shared/lib/persist-token.ts b/client/src/shared/lib/persist-token.ts
index 62635b6..ed6dea3 100644
--- a/client/src/shared/lib/persist-token.ts
+++ b/client/src/shared/lib/persist-token.ts
@@ -3,7 +3,8 @@ const TOKEN_KEY = 'craftshop_auth_token'
export function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
- } catch {
+ } catch (err) {
+ console.warn('[persist-token] Failed to read from localStorage', err)
return null
}
}
@@ -12,15 +13,15 @@ export function persistToken(token: string | null): void {
try {
if (!token) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, token)
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[persist-token] Failed to write to localStorage', err)
}
}
export function removeStoredToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[persist-token] Failed to remove from localStorage', err)
}
}
diff --git a/client/src/shared/ui/CookieConsentBanner.tsx b/client/src/shared/ui/CookieConsentBanner.tsx
index 2e7196e..cef36d3 100644
--- a/client/src/shared/ui/CookieConsentBanner.tsx
+++ b/client/src/shared/ui/CookieConsentBanner.tsx
@@ -10,7 +10,8 @@ const STORAGE_KEY = 'cookie-consent-accepted'
function wasAccepted(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1'
- } catch {
+ } catch (err) {
+ console.warn('[cookie-consent] Failed to read cookie consent', err)
return false
}
}
@@ -18,8 +19,8 @@ function wasAccepted(): boolean {
function markAccepted() {
try {
localStorage.setItem(STORAGE_KEY, '1')
- } catch {
- // ignore
+ } catch (err) {
+ console.warn('[cookie-consent] Failed to persist cookie consent', err)
}
}
diff --git a/client/src/shared/ui/RichTextMessageContent.tsx b/client/src/shared/ui/RichTextMessageContent.tsx
index 34d6ad2..5cc6f8c 100644
--- a/client/src/shared/ui/RichTextMessageContent.tsx
+++ b/client/src/shared/ui/RichTextMessageContent.tsx
@@ -22,8 +22,8 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
const normalizedValue = value.trim() ? value : '
'
try {
if (editor.getHTML() === normalizedValue) return
- } catch {
- // editor schema not ready yet
+ } catch (err) {
+ console.warn('[tiptap] Failed to get editor HTML', err)
return
}
editor.commands.setContent(normalizedValue, { emitUpdate: false })
diff --git a/docs/superpowers/plans/2026-05-27-api-error-handling.md b/docs/superpowers/plans/2026-05-27-api-error-handling.md
new file mode 100644
index 0000000..6975986
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-api-error-handling.md
@@ -0,0 +1,289 @@
+# API Error Handling — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Создать `useMutationWithToast` обёртку, улучшить `getApiErrorMessage`, исправить CheckoutPage error display.
+
+**Architecture:** Обёртка над `useMutation` с автоматическим показом toast (через notification store из подпроекта 1), улучшенный парсинг ошибок API.
+
+**Tech Stack:** TypeScript, TanStack React Query, Vitest
+
+**Depends on:** Subproject 1 (toast notifications store)
+
+---
+
+### Task 1: Улучшить getApiErrorMessage
+
+**Files:**
+- Modify: `client/src/shared/lib/get-api-error-message.ts`
+- Test: modify existing or add new tests
+
+- [ ] **Step 1: Write/update tests**
+
+```ts
+// client/src/shared/lib/__tests__/get-api-error-message.test.ts
+import { describe, it, expect } from 'vitest'
+import { getApiErrorMessage } from '../get-api-error-message'
+
+describe('getApiErrorMessage', () => {
+ it('returns server error message when axios error has response.data.error', () => {
+ const err = { response: { data: { error: 'Email already taken' } } }
+ expect(getApiErrorMessage(err)).toBe('Email already taken')
+ })
+
+ it('returns network error message when no response', () => {
+ const err = { message: 'Network Error', code: 'ERR_NETWORK' }
+ expect(getApiErrorMessage(err)).toBe('Нет соединения с сервером. Проверьте подключение к интернету.')
+ })
+
+ it('returns generic 500 message', () => {
+ const err = { response: { status: 500, data: {} } }
+ expect(getApiErrorMessage(err)).toBe('Произошла ошибка. Попробуйте повторить позже.')
+ })
+
+ it('falls back to error message', () => {
+ const err = new Error('Something went wrong')
+ expect(getApiErrorMessage(err)).toBe('Something went wrong')
+ })
+
+ it('handles unknown error shape', () => {
+ expect(getApiErrorMessage({})).toBe('Произошла неизвестная ошибка')
+ })
+})
+```
+
+- [ ] **Step 2: Run to see failure**
+
+Run: `cd client && npx vitest run shared/lib/__tests__/get-api-error-message.test.ts`
+Expected: FAIL (existing tests may not cover new cases)
+
+- [ ] **Step 3: Implement improved getApiErrorMessage**
+
+```ts
+// client/src/shared/lib/get-api-error-message.ts
+import { isAxiosError } from 'axios'
+
+export function getApiErrorMessage(error: unknown): string {
+ if (!error) return 'Произошла неизвестная ошибка'
+
+ if (isAxiosError(error)) {
+ if (error.response?.data?.error && typeof error.response.data.error === 'string') {
+ return error.response.data.error
+ }
+ if (!error.response) {
+ return 'Нет соединения с сервером. Проверьте подключение к интернету.'
+ }
+ if (error.response.status >= 500) {
+ return 'Произошла ошибка. Попробуйте повторить позже.'
+ }
+ }
+
+ if (error instanceof Error) {
+ return error.message
+ }
+
+ return 'Произошла неизвестная ошибка'
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd client && npx vitest run shared/lib/__tests__/get-api-error-message.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/shared/lib/get-api-error-message.ts client/src/shared/lib/__tests__/get-api-error-message.test.ts
+git commit -m "feat: improve getApiErrorMessage with user-friendly messages"
+```
+
+---
+
+### Task 2: useMutationWithToast обёртка
+
+**Files:**
+- Create: `client/src/shared/lib/use-mutation-with-toast.ts`
+- Test: `client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx`
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useMutationWithToast } from '../use-mutation-with-toast'
+import { addNotification } from '../../model/notification'
+
+vi.mock('../../model/notification', () => ({
+ addNotification: vi.fn(),
+}))
+
+function createWrapper() {
+ const qc = new QueryClient()
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ )
+}
+
+describe('useMutationWithToast', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows success toast on success', async () => {
+ const mutationFn = vi.fn().mockResolvedValue({ ok: true })
+
+ const { result } = renderHook(
+ () => useMutationWithToast({ mutationFn, successMessage: 'Done!' }),
+ { wrapper: createWrapper() },
+ )
+
+ result.current.mutate()
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(addNotification).toHaveBeenCalledWith({ type: 'success', message: 'Done!' })
+ })
+
+ it('shows error toast on error', async () => {
+ const mutationFn = vi.fn().mockRejectedValue(new Error('Boom'))
+
+ const { result } = renderHook(
+ () => useMutationWithToast({ mutationFn }),
+ { wrapper: createWrapper() },
+ )
+
+ result.current.mutate()
+ await waitFor(() => expect(result.current.isError).toBe(true))
+
+ expect(addNotification).toHaveBeenCalledWith({ type: 'error', message: 'Boom' })
+ })
+
+ it('calls user-provided onSuccess', async () => {
+ const mutationFn = vi.fn().mockResolvedValue({ ok: true })
+ const onSuccess = vi.fn()
+
+ const { result } = renderHook(
+ () => useMutationWithToast({ mutationFn, onSuccess, successMessage: 'Done!' }),
+ { wrapper: createWrapper() },
+ )
+
+ result.current.mutate()
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(onSuccess).toHaveBeenCalled()
+ })
+
+ it('does not show success toast if no successMessage', async () => {
+ const mutationFn = vi.fn().mockResolvedValue({ ok: true })
+
+ const { result } = renderHook(
+ () => useMutationWithToast({ mutationFn }),
+ { wrapper: createWrapper() },
+ )
+
+ result.current.mutate()
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(addNotification).not.toHaveBeenCalled()
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd client && npx vitest run shared/lib/__tests__/use-mutation-with-toast.test.tsx`
+Expected: FAIL
+
+- [ ] **Step 3: Implement useMutationWithToast**
+
+```ts
+// client/src/shared/lib/use-mutation-with-toast.ts
+import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
+import { addNotification } from '../model/notification'
+import { getApiErrorMessage } from './get-api-error-message'
+
+type MutationWithToastOptions =
+ UseMutationOptions & {
+ successMessage?: string
+ }
+
+export function useMutationWithToast(
+ options: MutationWithToastOptions,
+) {
+ const { successMessage, onSuccess, onError, ...mutationOptions } = options
+
+ return useMutation({
+ ...mutationOptions,
+ onSuccess: (data, variables, context) => {
+ if (successMessage) {
+ addNotification({ type: 'success', message: successMessage })
+ }
+ onSuccess?.(data, variables, context)
+ },
+ onError: (error, variables, context) => {
+ addNotification({ type: 'error', message: getApiErrorMessage(error) })
+ onError?.(error, variables, context)
+ },
+ })
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd client && npx vitest run shared/lib/__tests__/use-mutation-with-toast.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/shared/lib/use-mutation-with-toast.ts client/src/shared/lib/__tests__/use-mutation-with-toast.test.tsx
+git commit -m "feat: add useMutationWithToast wrapper"
+```
+
+---
+
+### Task 3: Исправить CheckoutPage error display
+
+**Files:**
+- Modify: `client/src/pages/checkout/CheckoutPage.tsx`
+
+- [ ] **Step 1: Найти прямой вызов error.message**
+
+Search for: `(createMut.error as Error).message`
+
+- [ ] **Step 2: Заменить на getApiErrorMessage**
+
+Before:
+```tsx
+{createMut.isError && (
+
+ {(createMut.error as Error).message}
+
+)}
+```
+
+After:
+```tsx
+import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
+
+{createMut.isError && (
+
+ {getApiErrorMessage(createMut.error)}
+
+)}
+```
+
+- [ ] **Step 3: Run lint + test + build**
+
+```bash
+cd client && npm run lint && npm test && npm run build
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/pages/checkout/CheckoutPage.tsx
+git commit -m "fix: use getApiErrorMessage in CheckoutPage for user-friendly error display"
+```
diff --git a/docs/superpowers/plans/2026-05-27-client-duplication.md b/docs/superpowers/plans/2026-05-27-client-duplication.md
new file mode 100644
index 0000000..97209ad
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-client-duplication.md
@@ -0,0 +1,175 @@
+# Client Duplication — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Устранить дублирование `useQuery` для корзины (4 копии → 1 хук), устранить дублирование статусов заказа.
+
+**Architecture:** Кастомный хук `useCartQuery` в `entities/cart/lib/`, единый источник `ORDER_STATUS_DATA`.
+
+**Tech Stack:** TypeScript, TanStack React Query, Vitest
+
+**Depends on:** none
+
+---
+
+### Task 1: useCartQuery хук + тесты
+
+**Files:**
+- Create: `client/src/entities/cart/lib/use-cart-query.ts`
+- Test: `client/src/entities/cart/lib/use-cart-query.test.tsx`
+- Modify: `client/src/widgets/catalog-slider/ui/AppHeader.tsx`
+- Modify: `client/src/pages/cart/CartPage.tsx`
+- Modify: `client/src/pages/checkout/CheckoutPage.tsx`
+- Modify: `client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx`
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// client/src/entities/cart/lib/use-cart-query.test.tsx
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useCartQuery } from './use-cart-query'
+import { fetchMyCart } from '../../api'
+
+vi.mock('../../api', () => ({
+ fetchMyCart: vi.fn(),
+}))
+
+vi.mock('@/shared/model/auth', () => ({
+ useAuthUser: vi.fn(),
+}))
+
+import { useAuthUser } from '@/shared/model/auth'
+
+function createWrapper() {
+ const qc = new QueryClient()
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ )
+}
+
+describe('useCartQuery', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns query with correct key and enabled flag for authenticated user', async () => {
+ vi.mocked(useAuthUser).mockReturnValue({ id: '1', email: 'test@test.com' })
+ vi.mocked(fetchMyCart).mockResolvedValue({ items: [] })
+
+ const { result } = renderHook(() => useCartQuery(), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+ expect(fetchMyCart).toHaveBeenCalled()
+ expect(result.current.queryKey).toEqual(['me', 'cart'])
+ })
+
+ it('does not fetch when user is not authenticated', () => {
+ vi.mocked(useAuthUser).mockReturnValue(null)
+
+ const { result } = renderHook(() => useCartQuery(), { wrapper: createWrapper() })
+
+ expect(fetchMyCart).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd client && npx vitest run entities/cart/lib/use-cart-query.test.tsx`
+Expected: FAIL
+
+- [ ] **Step 3: Implement useCartQuery**
+
+```ts
+// client/src/entities/cart/lib/use-cart-query.ts
+import { useQuery } from '@tanstack/react-query'
+import { useAuthUser } from '@/shared/model/auth'
+import { fetchMyCart } from '../../api'
+
+export function useCartQuery() {
+ const user = useAuthUser()
+
+ return useQuery({
+ queryKey: ['me', 'cart'],
+ queryFn: fetchMyCart,
+ enabled: Boolean(user),
+ })
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd client && npx vitest run entities/cart/lib/use-cart-query.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Apply to AppHeader.tsx**
+
+Before:
+```tsx
+const { data: cart } = useQuery({
+ queryKey: ['me', 'cart'],
+ queryFn: fetchMyCart,
+ enabled: Boolean(user),
+})
+```
+
+After:
+```tsx
+import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
+const { data: cart } = useCartQuery()
+```
+
+- [ ] **Step 6: Apply to CartPage.tsx, CheckoutPage.tsx, ToggleCartIcon.tsx**
+
+Same replacement in each file.
+
+- [ ] **Step 7: Run lint + test + build**
+
+```bash
+cd client && npm run lint && npm test && npm run build
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add client/src/entities/cart/lib/use-cart-query.ts client/src/entities/cart/lib/use-cart-query.test.tsx client/src/widgets/catalog-slider/ui/AppHeader.tsx client/src/pages/cart/CartPage.tsx client/src/pages/checkout/CheckoutPage.tsx client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx
+git commit -m "refactor: extract useCartQuery hook"
+```
+
+---
+
+### Task 2: Устранить дублирование статусов заказа
+
+**Files:**
+- Read: `client/src/shared/lib/order-status-data.ts`
+- Read: `client/src/shared/lib/order-status-labels.ts`
+- Delete: `client/src/shared/lib/order-status-labels.ts`
+- Modify: all files importing from `order-status-labels`
+
+- [ ] **Step 1: Найти все импорты orderStatusLabelRu**
+
+Run: `rg 'orderStatusLabelRu' client/src/ --include '*.ts' --include '*.tsx'`
+
+- [ ] **Step 2: Заменить импорты на ORDER_STATUS_DATA**
+
+Each file importing `{ orderStatusLabelRu }` from `order-status-labels`:
+- Change to import `{ ORDER_STATUS_DATA }` from `order-status-data`
+- Replace `orderStatusLabelRu(status)` with `ORDER_STATUS_DATA[status].label`
+
+- [ ] **Step 3: Delete order-status-labels.ts**
+
+- [ ] **Step 4: Run lint + test + build**
+
+```bash
+cd client && npm run lint && npm test && npm run build
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/shared/lib/order-status-labels.ts client/src/shared/lib/order-status-data.ts
+git commit -m "refactor: remove duplicate order status labels, use ORDER_STATUS_DATA as single source"
+```
diff --git a/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md b/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md
new file mode 100644
index 0000000..cb7a57c
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-empty-catch-blocks.md
@@ -0,0 +1,178 @@
+# Empty Catch Blocks — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Добавить логирование во все пустые catch-блоки (17 мест), убрать `// ignore` комментарии.
+
+**Architecture:** В каждом catch добавить `console.warn` (клиент) или `request.log.error` (сервер) с контекстным сообщением. Минимальные, безопасные изменения.
+
+**Tech Stack:** TypeScript, JavaScript, ESLint (no-console разрешает warn/error/info)
+
+**Depends on:** none
+
+---
+
+### Task 1: Пустые catch на клиенте — persist-token.ts (3 места)
+
+**Files:**
+- Modify: `client/src/shared/model/persist-token.ts`
+
+- [ ] **Step 1: Read file to find empty catch blocks**
+
+Run: read `client/src/shared/model/persist-token.ts`
+
+- [ ] **Step 2: Add console.warn to each catch**
+
+Each `catch { /* ignore */ }` becomes:
+```ts
+catch (err) {
+ console.warn('[persist-token] Failed to ...', err)
+}
+```
+
+Specific messages based on context:
+- localStorage.getItem error → `'[persist-token] Failed to read from localStorage'`
+- localStorage.setItem error → `'[persist-token] Failed to write to localStorage'`
+- JSON.parse error → `'[persist-token] Failed to parse stored value'`
+
+- [ ] **Step 3: Run lint**
+
+Run: `cd client && npm run lint`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/shared/model/persist-token.ts
+git commit -m "fix: add error logging to persist-token catch blocks"
+```
+
+---
+
+### Task 2: Пустые catch на клиенте — theme-controller.tsx (2 места)
+
+**Files:**
+- Modify: `client/src/shared/model/theme-controller.tsx`
+
+- [ ] **Step 1: Read file**
+
+Run: read `client/src/shared/model/theme-controller.tsx`
+
+- [ ] **Step 2: Add console.warn to each catch**
+
+```ts
+catch (err) {
+ console.warn('[theme] Failed to ...', err)
+}
+```
+
+- [ ] **Step 3: Run lint**
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/shared/model/theme-controller.tsx
+git commit -m "fix: add error logging to theme-controller catch blocks"
+```
+
+---
+
+### Task 3: Пустые catch на клиенте — CookieConsentBanner (2 места)
+
+**Files:**
+- Modify: `client/src/widgets/navigation-drawer/ui/CookieConsentBanner.tsx`
+
+- [ ] **Step 1: Read file**
+
+- [ ] **Step 2: Add console.warn**
+
+```ts
+catch (err) {
+ console.warn('[cookie-consent] Failed to ...', err)
+}
+```
+
+- [ ] **Step 3: Run lint**
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/widgets/navigation-drawer/ui/CookieConsentBanner.tsx
+git commit -m "fix: add error logging to CookieConsentBanner catch blocks"
+```
+
+---
+
+### Task 4: Пустые catch на клиенте — SseProvider.tsx
+
+**Files:**
+- Modify: `client/src/app/SseProvider.tsx`
+
+- [ ] **Step 1: Read file, find empty catch**
+
+- [ ] **Step 2: Add console.warn**
+
+```ts
+catch (err) {
+ console.warn('[sse] Connection error:', err)
+}
+```
+
+- [ ] **Step 3: Run lint**
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add client/src/app/SseProvider.tsx
+git commit -m "fix: add error logging to SseProvider catch block"
+```
+
+---
+
+### Task 5: Пустые catch на сервере
+
+**Files:** (find exact paths from exploration, likely in admin routes)
+
+- [ ] **Step 1: Find all empty catch blocks in server/src/**
+
+Run: `rg 'catch\s*\{' server/src/ --include '*.js'`
+
+- [ ] **Step 2: Add request.log.error to each**
+
+```js
+catch (err) {
+ request.log.error({ err }, 'Failed to [operation description]')
+}
+```
+
+- [ ] **Step 3: Run lint**
+
+Run: `cd server && npm run lint`
+Expected: PASS
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd server && npm test`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/
+git commit -m "fix: add error logging to server catch blocks"
+```
+
+---
+
+### Task 6: Финальная проверка
+
+- [ ] **Step 1: Run all lints + tests**
+
+```bash
+cd client && npm run lint && npm test
+cd server && npm run lint && npm test
+```
+
+- [ ] **Step 2: Verify no new ESLint errors**
+
+Expected: PASS all
diff --git a/docs/superpowers/plans/2026-05-27-server-duplication.md b/docs/superpowers/plans/2026-05-27-server-duplication.md
new file mode 100644
index 0000000..5dba1a2
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-server-duplication.md
@@ -0,0 +1,318 @@
+# Server Duplication — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Устранить дублирование в серверных роутах: asyncHandler декоратор, validateGalleryImages, findUserOrder.
+
+**Architecture:** Вынос повторяющихся паттернов в shared-хелперы (`server/src/lib/`), замена ручного try/catch на декоратор.
+
+**Tech Stack:** JavaScript, Fastify, Prisma, Vitest
+
+**Depends on:** none
+
+---
+
+### Task 1: asyncHandler декоратор + тесты
+
+**Files:**
+- Create: `server/src/lib/async-handler.js`
+- Test: `server/src/lib/__tests__/async-handler.test.js`
+
+- [ ] **Step 1: Write failing tests**
+
+```js
+// server/src/lib/__tests__/async-handler.test.js
+import { describe, it, expect, vi } from 'vitest'
+import { asyncHandler } from '../async-handler.js'
+
+describe('asyncHandler', () => {
+ it('calls the handler and returns result on success', async () => {
+ const handler = vi.fn().mockResolvedValue({ hello: 'world' })
+ const request = {}
+ const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
+ await asyncHandler(handler)(request, reply)
+ expect(handler).toHaveBeenCalledWith(request, reply)
+ expect(reply.send).toHaveBeenCalledWith({ hello: 'world' })
+ })
+
+ it('catches errors and sends 500 with message', async () => {
+ const handler = vi.fn().mockRejectedValue(new Error('boom'))
+ const request = { log: { error: vi.fn() } }
+ const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
+ await asyncHandler(handler)(request, reply)
+ expect(reply.code).toHaveBeenCalledWith(500)
+ expect(reply.send).toHaveBeenCalledWith({ error: 'Internal server error' })
+ })
+
+ it('uses statusCode from error if present', async () => {
+ const err = new Error('Not found')
+ err.statusCode = 404
+ const handler = vi.fn().mockRejectedValue(err)
+ const request = { log: { error: vi.fn() } }
+ const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
+ await asyncHandler(handler)(request, reply)
+ expect(reply.code).toHaveBeenCalledWith(404)
+ expect(reply.send).toHaveBeenCalledWith({ error: 'Not found' })
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd server && npx vitest run lib/__tests__/async-handler.test.js`
+Expected: FAIL
+
+- [ ] **Step 3: Implement asyncHandler**
+
+```js
+// server/src/lib/async-handler.js
+export function asyncHandler(fn) {
+ return async (request, reply) => {
+ try {
+ return await fn(request, reply)
+ } catch (err) {
+ request.log.error(err)
+ const statusCode = err.statusCode || 500
+ const message = err.statusCode ? err.message : 'Internal server error'
+ return reply.code(statusCode).send({ error: message })
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd server && npx vitest run lib/__tests__/async-handler.test.js`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/lib/async-handler.js server/src/lib/__tests__/async-handler.test.js
+git commit -m "feat: add asyncHandler decorator for route error handling"
+```
+
+---
+
+### Task 2: Применить asyncHandler в роутах
+
+**Files:**
+- Modify: `server/src/routes/user-orders.js`
+- Modify: `server/src/routes/user-messages.js`
+- Modify: `server/src/routes/user-payments.js`
+- Modify: `server/src/routes/admin-gallery.js`
+- Modify: all other route files with manual try/catch
+
+- [ ] **Step 1: Найти все ручные try/catch в роутах**
+
+Run: `rg 'try\s*\{' server/src/routes/ --include '*.js'`
+
+- [ ] **Step 2: Заменить каждый try/catch на asyncHandler**
+
+Before:
+```js
+fastify.get('/orders', async (request, reply) => {
+ try {
+ const orders = await prisma.order.findMany(...)
+ return reply.send(orders)
+ } catch (err) {
+ request.log.error(err)
+ return reply.code(500).send({ error: 'Internal server error' })
+ }
+})
+```
+
+After:
+```js
+import { asyncHandler } from '../../lib/async-handler.js'
+
+fastify.get('/orders', asyncHandler(async (request, reply) => {
+ const orders = await prisma.order.findMany(...)
+ return reply.send(orders)
+}))
+```
+
+- [ ] **Step 3: Run lint + tests**
+
+```bash
+cd server && npm run lint && npm test
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/src/routes/
+git commit -m "refactor: apply asyncHandler to all route handlers"
+```
+
+---
+
+### Task 3: validateGalleryImages хелпер
+
+**Files:**
+- Create: `server/src/lib/validate-gallery-images.js`
+- Test: `server/src/lib/__tests__/validate-gallery-images.test.js`
+- Modify: `server/src/routes/api/admin-products.js`
+
+- [ ] **Step 1: Write tests**
+
+```js
+// server/src/lib/__tests__/validate-gallery-images.test.js
+import { describe, it, expect, vi } from 'vitest'
+import { validateGalleryImages } from '../validate-gallery-images.js'
+
+describe('validateGalleryImages', () => {
+ it('returns null when galleryImages is empty', async () => {
+ const prisma = { galleryImage: { findMany: vi.fn() } }
+ const result = await validateGalleryImages(prisma, [])
+ expect(result).toBeNull()
+ })
+
+ it('throws when image not found', async () => {
+ const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
+ await expect(validateGalleryImages(prisma, [1])).rejects.toThrow('not found')
+ })
+})
+```
+
+- [ ] **Step 2: Implement**
+
+```js
+// server/src/lib/validate-gallery-images.js
+export async function validateGalleryImages(prisma, galleryImages) {
+ if (!galleryImages || galleryImages.length === 0) return null
+
+ const existing = await prisma.galleryImage.findMany({
+ where: { id: { in: galleryImages } },
+ select: { id: true, resized: true },
+ })
+
+ const foundIds = new Set(existing.map((img) => img.id))
+ const missing = galleryImages.filter((id) => !foundIds.has(id))
+ if (missing.length > 0) {
+ throw Object.assign(new Error(`Gallery images not found: ${missing.join(', ')}`), { statusCode: 404 })
+ }
+
+ const notResized = existing.filter((img) => !img.resized)
+ if (notResized.length > 0) {
+ throw Object.assign(new Error('Some gallery images have not been processed yet. Please try again later.'), { statusCode: 400 })
+ }
+
+ return existing
+}
+```
+
+- [ ] **Step 3: Заменить дублирующийся код в admin-products.js**
+
+Before (in POST /admin/products):
+```js
+// validate gallery images
+if (galleryImages && galleryImages.length > 0) {
+ const existingImages = await prisma.galleryImage.findMany({ ... })
+ // ... duplicate validation logic
+}
+```
+
+After:
+```js
+import { validateGalleryImages } from '../../lib/validate-gallery-images.js'
+
+// single call
+await validateGalleryImages(prisma, galleryImages)
+```
+
+Same replacement for PATCH /admin/products/:id.
+
+- [ ] **Step 4: Run tests**
+
+```bash
+cd server && npm run lint && npm test
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/lib/validate-gallery-images.js server/src/lib/__tests__/validate-gallery-images.test.js server/src/routes/api/admin-products.js
+git commit -m "refactor: extract validateGalleryImages helper"
+```
+
+---
+
+### Task 4: findUserOrder хелпер
+
+**Files:**
+- Create: `server/src/lib/find-user-order.js`
+- Test: `server/src/lib/__tests__/find-user-order.test.js`
+- Modify: `server/src/routes/user-orders.js`
+- Modify: `server/src/routes/user-messages.js`
+- Modify: `server/src/routes/user-payments.js`
+
+- [ ] **Step 1: Write tests**
+
+```js
+// server/src/lib/__tests__/find-user-order.test.js
+import { describe, it, expect, vi } from 'vitest'
+import { findUserOrder } from '../find-user-order.js'
+
+describe('findUserOrder', () => {
+ it('returns order when found', async () => {
+ const mockOrder = { id: '1', userId: 'user1' }
+ const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
+ const result = await findUserOrder(prisma, '1', 'user1')
+ expect(result).toEqual(mockOrder)
+ expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: '1', userId: 'user1' },
+ }))
+ })
+
+ it('throws 404 when not found', async () => {
+ const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
+ await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
+ })
+})
+```
+
+- [ ] **Step 2: Implement**
+
+```js
+// server/src/lib/find-user-order.js
+export async function findUserOrder(prisma, orderId, userId, include = {}) {
+ const order = await prisma.order.findFirst({
+ where: { id: orderId, userId },
+ include,
+ })
+
+ if (!order) {
+ throw Object.assign(new Error('Order not found'), { statusCode: 404 })
+ }
+
+ return order
+}
+```
+
+- [ ] **Step 3: Заменить дублирующийся код в роутах**
+
+Each instance of:
+```js
+const order = await prisma.order.findFirst({ where: { id, userId } })
+if (!order) return reply.code(404).send({ error: 'Order not found' })
+```
+
+Becomes:
+```js
+import { findUserOrder } from '../../lib/find-user-order.js'
+const order = await findUserOrder(prisma, id, userId)
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+cd server && npm run lint && npm test
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/src/lib/find-user-order.js server/src/lib/__tests__/find-user-order.test.js server/src/routes/user-{orders,messages,payments}.js
+git commit -m "refactor: extract findUserOrder helper"
+```
diff --git a/docs/superpowers/plans/2026-05-27-toast-notifications.md b/docs/superpowers/plans/2026-05-27-toast-notifications.md
new file mode 100644
index 0000000..b9a78d7
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-toast-notifications.md
@@ -0,0 +1,378 @@
+# Toast Notifications System — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
+
+**Goal:** Создать общую систему toast-уведомлений на Effector + MUI, мигрировать CartSnackbar, удалить старый код.
+
+**Architecture:** Effector-стор `$notifications` с очередью (макс 3 видимых), компонент `NotificationStack` с MUI Snackbar + Alert, интеграция через события `addNotification`/`dismissNotification`.
+
+**Tech Stack:** Effector, MUI Snackbar + Alert, TypeScript, Vitest + @testing-library/react
+
+**Depends on:** none
+
+---
+
+### Task 1: Effector-стор уведомлений
+
+**Files:**
+- Create: `client/src/shared/model/notification.ts`
+- Test: `client/src/shared/model/notification.test.ts`
+
+- [ ] **Step 1: Write failing tests for notification store**
+
+```ts
+// client/src/shared/model/notification.test.ts
+import { describe, it, expect } from 'vitest'
+import {
+ $notifications,
+ addNotification,
+ dismissNotification,
+ dismissAll,
+} from './notification'
+
+describe('notification store', () => {
+ it('starts empty', () => {
+ expect($notifications.getState()).toEqual([])
+ })
+
+ it('adds a notification', () => {
+ addNotification({ type: 'success', message: 'OK' })
+ const state = $notifications.getState()
+ expect(state).toHaveLength(1)
+ expect(state[0]).toMatchObject({ type: 'success', message: 'OK' })
+ expect(state[0].id).toBeDefined()
+ })
+
+ it('caps at 3 visible notifications, queues extras', () => {
+ addNotification({ type: 'info', message: 'A' })
+ addNotification({ type: 'info', message: 'B' })
+ addNotification({ type: 'info', message: 'C' })
+ addNotification({ type: 'info', message: 'D' })
+ expect($notifications.getState()).toHaveLength(3)
+ // wait for idle and check — actually effector stores are sync
+ // but we need to verify only 3 are visible and 4th is queued
+ })
+
+ it('dismisses a notification by id', () => {
+ const id = 'test-id'
+ // manually set state
+ // add dismiss and check
+ })
+
+ it('dismisses all notifications', () => {
+ // add several, dismissAll, check empty
+ })
+
+ it('auto-dismisses after autoHideDuration', () => {
+ // use fake timers
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd client && npx vitest run shared/model/notification.test.ts`
+Expected: FAIL, module not found
+
+- [ ] **Step 3: Implement notification store**
+
+```ts
+// client/src/shared/model/notification.ts
+import { createEvent, createStore, sample } from 'effector'
+
+type NotificationType = 'success' | 'error' | 'info' | 'warning'
+
+interface Notification {
+ id: string
+ type: NotificationType
+ message: string
+ autoHideDuration?: number
+}
+
+const MAX_VISIBLE = 3
+
+let nextId = 1
+
+export const addNotification = createEvent<{
+ type: NotificationType
+ message: string
+ autoHideDuration?: number
+}>()
+
+export const dismissNotification = createEvent()
+export const dismissAll = createEvent()
+
+export const $notifications = createStore([])
+ .on(addNotification, (state, { type, message, autoHideDuration }) => {
+ const notification: Notification = {
+ id: String(nextId++),
+ type,
+ message,
+ autoHideDuration: autoHideDuration ?? (type === 'error' ? 6000 : 4000),
+ }
+ return [...state, notification].slice(-MAX_VISIBLE)
+ })
+ .on(dismissNotification, (state, id) =>
+ state.filter((n) => n.id !== id),
+ )
+ .reset(dismissAll)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd client && npx vitest run shared/model/notification.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/shared/model/notification.ts client/src/shared/model/notification.test.ts
+git commit -m "feat: add notification store (Effector)"
+```
+
+---
+
+### Task 2: NotificationStack component
+
+**Files:**
+- Create: `client/src/shared/ui/NotificationStack/NotificationStack.tsx`
+- Create: `client/src/shared/ui/NotificationStack/index.ts`
+- Test: `client/src/shared/ui/NotificationStack/NotificationStack.test.tsx`
+
+- [ ] **Step 1: Write failing tests for NotificationStack**
+
+```tsx
+// client/src/shared/ui/NotificationStack/NotificationStack.test.tsx
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render, screen, act, fireEvent } from '@testing-library/react'
+import { NotificationStack } from './NotificationStack'
+import { $notifications, addNotification, dismissNotification } from '../../model/notification'
+import { fork, allSettled } from 'effector'
+import { Provider } from 'effector-react'
+
+function renderStack() {
+ const scope = fork()
+ return {
+ scope,
+ ...render(
+
+
+ ,
+ ),
+ }
+}
+
+describe('NotificationStack', () => {
+ afterEach(() => {
+ $notifications.reset()
+ })
+
+ it('renders nothing when empty', () => {
+ const { container } = renderStack()
+ expect(container.textContent).toBe('')
+ })
+
+ it('renders a notification when added', async () => {
+ const scope = fork()
+ await allSettled(addNotification, { scope, params: { type: 'success', message: 'Test message' } })
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('Test message')).toBeDefined()
+ })
+
+ it('renders correct icon for each type', async () => {
+ const scope = fork()
+ // success — CheckCircle, error — Error, info — Info, warning — Warning
+ // Check MUI Alert has correct severity attribute
+ await allSettled(addNotification, { scope, params: { type: 'error', message: 'Error!' } })
+ render(
+
+
+ ,
+ )
+ const alert = screen.getByRole('alert')
+ expect(alert.getAttribute('severity')).toBe('error')
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx`
+Expected: FAIL
+
+- [ ] **Step 3: Implement NotificationStack**
+
+```tsx
+// client/src/shared/ui/NotificationStack/NotificationStack.tsx
+import { useUnit } from 'effector-react'
+import { Snackbar, Alert, Stack, IconButton } from '@mui/material'
+import CloseIcon from '@mui/icons-material/Close'
+import { $notifications, dismissNotification } from '../../model/notification'
+
+export function NotificationStack() {
+ const notifications = useUnit($notifications)
+
+ if (notifications.length === 0) return null
+
+ return (
+
+ {notifications.map((n) => (
+ dismissNotification(n.id)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ >
+ dismissNotification(n.id)}>
+
+
+ }
+ >
+ {n.message}
+
+
+ ))}
+
+ )
+}
+```
+
+```ts
+// client/src/shared/ui/NotificationStack/index.ts
+export { NotificationStack } from './NotificationStack'
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add client/src/shared/ui/NotificationStack/
+git commit -m "feat: add NotificationStack component"
+```
+
+---
+
+### Task 3: Add NotificationStack to App.tsx
+
+**Files:**
+- Modify: `client/src/app/App.tsx`
+
+- [ ] **Step 1: Add NotificationStack to App layout**
+
+```tsx
+// App.tsx — add import and component
+import { NotificationStack } from '@/shared/ui/NotificationStack'
+
+// Inside the return, after
+<>
+
+
+
+
+
+
+
+
+
+
+>
+```
+
+- [ ] **Step 2: Run lint + build**
+
+Run: `cd client && npm run lint && npm test`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/app/App.tsx
+git commit -m "feat: integrate NotificationStack into App"
+```
+
+---
+
+### Task 4: Migrate CartSnackbar to notification store
+
+**Files:**
+- Modify: `client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx`
+- Modify: `client/src/features/cart/ui/AddToCartButton/AddToCartButton.tsx`
+- Delete: `client/src/features/cart/ui/CartSnackbar/CartSnackbar.tsx`
+- Delete: `client/src/features/cart/ui/CartSnackbar/index.ts`
+- Delete: `client/src/features/cart/model/cart-notifications.ts`
+- Modify: `client/src/app/App.tsx` (remove CartSnackbar import + usage)
+
+- [ ] **Step 1: Replace cartAdded events with addNotification in ToggleCartIcon**
+
+```tsx
+// ToggleCartIcon.tsx — replace cartAdded with addNotification
+import { addNotification } from '@/shared/model/notification'
+
+// search for: cartAdded()
+// replace with:
+addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
+```
+
+- [ ] **Step 2: Same for AddToCartButton**
+
+```tsx
+// AddToCartButton.tsx — same replacement
+addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
+```
+
+- [ ] **Step 3: Remove CartSnackbar component files**
+
+Delete `CartSnackbar.tsx` and `CartSnackbar/index.ts`.
+
+- [ ] **Step 4: Remove cart-notifications model**
+
+Delete `features/cart/model/cart-notifications.ts`.
+
+- [ ] **Step 5: Remove CartSnackbar from App.tsx**
+
+Remove import and `` from render.
+
+- [ ] **Step 6: Remove cart-notifications import from features/cart/index.ts**
+
+Check `features/cart/index.ts` for re-exports of cart-notifications or CartSnackbar.
+
+- [ ] **Step 7: Remove CartSnackbar test file**
+
+Delete `CartSnackbar.test.tsx`.
+
+- [ ] **Step 8: Run lint + test + build**
+
+```bash
+cd client && npm run lint && npm test && npm run build
+```
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A
+git commit -m "feat: migrate CartSnackbar to global notification store"
+```
diff --git a/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md b/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md
new file mode 100644
index 0000000..d9a1361
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-27-refactoring-audit-design.md
@@ -0,0 +1,201 @@
+# Refactoring Audit — Code Quality Improvements
+
+Date: 2026-05-27
+
+## Overview
+
+Системный рефакторинг кодовой базы shop (craftshop monorepo) по 5 направлениям: toast-уведомления, пустые catch-блоки, серверное дублирование, клиентское дублирование, обработка ошибок API.
+
+## Подпроект 1: Система toast-уведомлений
+
+### Проблема
+- Единственный `CartSnackbar` (Effector) только для "товар добавлен в корзину"
+- Остальные операции без обратной связи — только inline `` на страницах
+- Нет централизованного показа ошибок от API
+
+### Решение
+
+**1.1 Effector-стор уведомлений** (`client/src/shared/model/notification.ts`)
+```ts
+type NotificationType = 'success' | 'error' | 'info' | 'warning'
+
+interface Notification {
+ id: string
+ type: NotificationType
+ message: string
+ autoHideDuration?: number
+}
+
+// События
+addNotification — добавить (генерирует id + пушит в очередь)
+dismissNotification — закрыть по id
+dismissAll — закрыть все
+
+// Стор
+$notifications: Notification[] — очередь, макс 3 видимых
+```
+
+**1.2 Компонент `NotificationStack`** (`client/src/shared/ui/NotificationStack/`)
+- Использует MUI `Snackbar` + `Alert` (уже стилизованы через тему)
+- Позиция: `bottom: 80px`
+- `Slide` transition, auto-hide по таймеру
+- Крестик для ручного закрытия
+- Не более 3 одновременных уведомлений (остальные в очереди)
+
+**1.3 Миграция CartSnackbar**
+- `cartAdded` событие пишет в `addNotification({ type: 'info', message: 'Товар добавлен в корзину' })`
+- Старый `CartSnackbar` удаляется (компонент, стор, тесты)
+- `ToggleCartIcon` и `AddToCartButton` — убрать прямой вызов `cartAdded`, заменить на `addNotification`
+
+**1.4 Интеграция с `useMutationWithToast`** (опционально, см. подпроект 5)
+
+### Файлы
+- Новые: `shared/model/notification.ts`, `shared/ui/NotificationStack/NotificationStack.tsx`, `shared/ui/NotificationStack/index.ts`
+- Изменённые: `app/App.tsx` (+ NotificationStack), удаление `features/cart/model/cart-notifications.ts`, `features/cart/ui/CartSnackbar/`
+- Тесты: `shared/model/notification.test.ts`, `shared/ui/NotificationStack/NotificationStack.test.tsx`
+
+---
+
+## Подпроект 2: Пустые catch-блоки
+
+### Проблема
+17 мест с `catch { /* ignore */ }` или пустым телом.
+
+### Решение
+Минимальное логирование с контекстом:
+
+| Файл | Уровень | Сообщение |
+|------|---------|-----------|
+| `persist-token.ts` (3 места) | `console.warn` | `'[persist-token] Failed to ...'` |
+| `theme-controller.tsx` (2 места) | `console.warn` | `'[theme] Failed to ...'` |
+| `CookieConsentBanner.tsx` (2 места) | `console.warn` | `'[cookie-consent] Failed to ...'` |
+| `SseProvider.tsx` | `console.warn` | `'[sse] Connection error:'` |
+| `admin-gallery` (сервер) | `request.log.error` | Контекст операции |
+| Остальные серверные | `request.log.error` | Контекст операции |
+
+### Файлы
+Только изменения в существующих файлах. Новых файлов нет.
+
+---
+
+## Подпроект 3: Сервер — дублирование в роутах
+
+### Проблема
+- ~25 мест с ручным try/catch-паттерном
+- Дублирование валидации галерейных изображений (POST/PATCH admin/products)
+- Дублирование `prisma.order.findFirst({ where: { id, userId } })` в 3 файлах
+
+### Решение
+
+**3.1 `asyncHandler`** (`server/src/lib/async-handler.js`)
+```js
+function asyncHandler(fn) {
+ return async (request, reply) => {
+ try {
+ return await fn(request, reply)
+ } catch (err) {
+ request.log.error(err)
+ const statusCode = err.statusCode || 500
+ const message = err.statusCode ? err.message : 'Internal server error'
+ return reply.code(statusCode).send({ error: message })
+ }
+ }
+}
+```
+
+**3.2 `validateGalleryImages`** (`server/src/lib/validate-gallery-images.js`)
+- Проверка существования `galleryImages` в БД
+- Проверка наличия resized-версий
+- Используется в POST и PATCH admin/products
+
+**3.3 `findUserOrder`** (`server/src/lib/find-user-order.js`)
+- `prisma.order.findFirst({ where: { id, userId }, ... })` с included relations
+
+### Файлы
+- Новые: `server/src/lib/async-handler.js`, `server/src/lib/validate-gallery-images.js`, `server/src/lib/find-user-order.js`
+- Изменённые: ~10 server route files
+
+---
+
+## Подпроект 4: Клиент — дублирование
+
+### Проблема
+- 4 копии `useQuery({ queryKey: ['me', 'cart'], ... })`
+- Дублирование `orderStatusLabelRu` и `ORDER_STATUS_DATA`
+
+### Решение
+
+**4.1 `useCartQuery`** (`client/src/entities/cart/lib/use-cart-query.ts`)
+```ts
+export function useCartQuery() {
+ const user = useAuthUser()
+ return useQuery({
+ queryKey: ['me', 'cart'],
+ queryFn: fetchMyCart,
+ enabled: Boolean(user),
+ })
+}
+```
+Замена в 4 компонентах.
+
+**4.2 Дубль статусов**
+- `ORDER_STATUS_DATA` — единственный источник
+- `orderStatusLabelRu` удалить, импорты переписать на `ORDER_STATUS_DATA`
+
+### Файлы
+- Новые: `entities/cart/lib/use-cart-query.ts`
+- Изменённые: `AppHeader.tsx`, `CartPage.tsx`, `CheckoutPage.tsx`, `ToggleCartIcon.tsx`, удаление `order-status-labels.ts`
+
+---
+
+## Подпроект 5: Обработка ошибок API (клиент)
+
+### Проблема
+- Нет централизованного показа ошибок API
+- `CheckoutPage` показывает сырое `(error as Error).message`
+
+### Решение
+
+**5.1 `useMutationWithToast`** (`client/src/shared/lib/use-mutation-with-toast.ts`)
+```ts
+function useMutationWithToast(options) {
+ return useMutation({
+ ...options,
+ onSuccess: (data, ...rest) => {
+ if (options.successMessage) addNotification({ type: 'success', message: options.successMessage })
+ options.onSuccess?.(data, ...rest)
+ },
+ onError: (error, ...rest) => {
+ addNotification({ type: 'error', message: getApiErrorMessage(error) })
+ options.onError?.(error, ...rest)
+ },
+ })
+}
+```
+
+**5.2 `getApiErrorMessage`** улучшение (`client/src/shared/lib`)
+- Если сервер вернул `{ error: string }` — показать его
+- Ошибка сети → "Нет соединения с сервером."
+- 500 → "Произошла ошибка. Попробуйте позже."
+- Остальное → стандартное сообщение
+
+### Файлы
+- Новые: `shared/lib/use-mutation-with-toast.ts`
+- Изменённые: `shared/lib/get-api-error-message.ts`, `CheckoutPage.tsx` (исправить отображение ошибки)
+
+---
+
+## Порядок реализации
+
+Подпроекты независимы и могут выполняться в любом порядке. Рекомендуемый порядок:
+
+1. **Подпроект 2** (пустые catch) — быстрые и безопасные изменения, хороший разогрев
+2. **Подпроект 1** (toast) — база для подпроекта 5, новая функциональность
+3. **Подпроект 5** (useMutationWithToast) — строится поверх подпроекта 1
+4. **Подпроект 3** (сервер) — standalone
+5. **Подпроект 4** (клиент) — standalone
+
+## Тестирование
+
+- Каждый подпроект: `npm run lint` (или `npm run lint:fix`) + `npm test` для своей директории
+- Финальная проверка: `cd client && npm run build` (проверка типов)
diff --git a/server/src/index.js b/server/src/index.js
index eb50e3f..175e33f 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -51,11 +51,12 @@ await fastify.register(cors, {
await registerSecurityHeaders(fastify)
-fastify.get('/health', async () => {
+fastify.get('/health', async (request) => {
try {
await prisma.$queryRaw`SELECT 1`
return { status: 'ok', database: 'connected', uptime: process.uptime() }
- } catch {
+ } catch (err) {
+ request.log.error({ err }, 'Health check database query failed')
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
}
})
@@ -119,7 +120,8 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
request.headers.authorization = `Bearer ${request.query.token}`
}
await request.jwtVerify()
- } catch {
+ } catch (err) {
+ request.log.error({ err }, 'JWT verification failed')
return reply.code(401).send({ error: 'Не авторизован' })
}
})
diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js
index 7c746d4..794e925 100644
--- a/server/src/plugins/auth.js
+++ b/server/src/plugins/auth.js
@@ -13,7 +13,8 @@ export function registerAuth(fastify) {
try {
await request.jwtVerify()
- } catch {
+ } catch (err) {
+ request.log.error({ err }, '[auth] verifyAdmin failed')
return reply.code(401).send({ error: 'Не авторизован' })
}
diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js
index 8ac1893..3992e43 100644
--- a/server/src/routes/api/admin-products.js
+++ b/server/src/routes/api/admin-products.js
@@ -260,7 +260,8 @@ export async function registerAdminProductRoutes(fastify) {
try {
await prisma.product.delete({ where: { id } })
reply.code(204).send()
- } catch {
+ } catch (err) {
+ request.log.error({ err }, '[admin-products] Operation failed')
reply.code(404).send({ error: 'Товар не найден' })
}
})
diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js
index 9dd717b..46f4abf 100644
--- a/server/src/routes/api/admin-users.js
+++ b/server/src/routes/api/admin-users.js
@@ -160,7 +160,8 @@ export async function registerAdminUserRoutes(fastify) {
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
- } catch {
+ } catch (err) {
+ request.log.error({ err }, '[admin-users] Operation failed')
reply.code(404).send({ error: 'Пользователь не найден' })
}
})
diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js
index ebf81c5..ddd9701 100644
--- a/server/src/routes/api/public-reviews.js
+++ b/server/src/routes/api/public-reviews.js
@@ -144,7 +144,8 @@ export async function registerPublicReviewRoutes(fastify) {
},
})
return reply.code(201).send({ item: created })
- } catch {
+ } catch (err) {
+ request.log.error({ err }, 'Failed to create review (possible duplicate)')
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
}
})
diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js
index ac6785d..33eb514 100644
--- a/server/src/routes/sse.js
+++ b/server/src/routes/sse.js
@@ -119,7 +119,8 @@ export async function registerSseRoutes(fastify) {
if (closed) return
try {
reply.raw.write(chunk)
- } catch {
+ } catch (err) {
+ request.log.error({ err }, '[sse] safeWrite failed')
closed = true
cleanUp()
}
diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js
index 643d661..3f3a59f 100644
--- a/server/src/routes/user-payments.js
+++ b/server/src/routes/user-payments.js
@@ -141,7 +141,8 @@ export async function registerUserPaymentRoutes(fastify) {
}
return { status: ykPayment.status, paid: ykPayment.paid }
- } catch {
+ } catch (err) {
+ request.log.error({ err }, '[user-payments] Operation failed')
return { status: payment.status, paid: payment.status === 'succeeded' }
}
})
diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js
index 6362de5..4d0a705 100644
--- a/server/src/routes/webhook-yookassa.js
+++ b/server/src/routes/webhook-yookassa.js
@@ -7,7 +7,8 @@ export async function registerYookassaWebhookRoute(fastify) {
let body
try {
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
- } catch {
+ } catch (err) {
+ request.log.error({ err }, 'Failed to parse webhook JSON body')
return reply.code(400).send({ error: 'Invalid JSON body' })
}