Merge branch 'final_fixes'

This commit is contained in:
Kirill
2026-05-28 21:22:10 +05:00
89 changed files with 5482 additions and 2116 deletions
+1528 -1134
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
import { BrowserRouter } from 'react-router-dom'
import { AppProviders } from '@/app/providers/AppProviders'
import { AppRoutes } from '@/app/routes'
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
import { NotificationStack } from '@/shared/ui/NotificationStack'
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
@@ -12,7 +12,7 @@ export function App() {
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<CartSnackbar />
<NotificationStack />
<NoiseOverlay />
</BrowserRouter>
</AppProviders>
+26 -4
View File
@@ -11,6 +11,7 @@ import { AppHeader } from '@/app/layout/AppHeader'
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
import { DemoBanner } from '@/shared/ui/DemoBanner'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
@@ -22,6 +23,7 @@ export function MainLayout({ children }: PropsWithChildren) {
<ScrollOnNavigate />
<ScrollToTop />
<AppHeader />
<DemoBanner />
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
@@ -42,7 +44,12 @@ export function MainLayout({ children }: PropsWithChildren) {
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
<Grid container spacing={5}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}
>
{STORE_NAME}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
@@ -50,7 +57,12 @@ export function MainLayout({ children }: PropsWithChildren) {
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Покупателям
</Typography>
<Stack spacing={1.5}>
@@ -66,7 +78,12 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Контакты
</Typography>
<Stack spacing={1}>
@@ -95,7 +112,12 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Юридическая информация
</Typography>
<Stack spacing={1.5}>
+57 -57
View File
@@ -1,6 +1,6 @@
import { type PropsWithChildren, useMemo } from 'react'
import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { alpha, ThemeProvider, createTheme } from '@mui/material/styles'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
import { SseProvider } from './SseProvider'
@@ -223,89 +223,89 @@ function AppThemeInner({ children }: PropsWithChildren) {
marginLeft: 8,
},
},
colorSuccess: {
bgcolor: isDark ? 'rgba(102,187,106,0.08)' : '#EDF3EC',
borderColor: isDark ? 'rgba(102,187,106,0.2)' : '#C5DFC2',
color: isDark ? '#A5D6A7' : '#346538',
'& .MuiAlert-icon': {
color: isDark ? '#A5D6A7' : '#346538',
},
colorSuccess: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.success
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(102,187,106,0.3)' : '#C5DFC2',
color: isDark ? '#A5D6A7' : '#346538',
'& .MuiAlert-icon': {
color: isDark ? '#A5D6A7' : '#346538',
},
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(102,187,106,0.15)' : '#346538',
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? '#E8F5E9' : '#FFFFFF',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorError: {
bgcolor: isDark ? 'rgba(239,83,80,0.08)' : '#FDEBEC',
borderColor: isDark ? 'rgba(239,83,80,0.2)' : '#F5C6C7',
color: isDark ? '#EF9A9A' : '#9F2F2D',
'& .MuiAlert-icon': {
color: isDark ? '#EF9A9A' : '#9F2F2D',
},
colorError: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.error
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(239,83,80,0.3)' : '#F5C6C7',
color: isDark ? '#EF9A9A' : '#9F2F2D',
'& .MuiAlert-icon': {
color: isDark ? '#EF9A9A' : '#9F2F2D',
},
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(239,83,80,0.15)' : '#9F2F2D',
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? '#FFEBEE' : '#FFFFFF',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorWarning: {
bgcolor: isDark ? 'rgba(255,183,77,0.08)' : '#FBF3DB',
borderColor: isDark ? 'rgba(255,183,77,0.2)' : '#F0DCA0',
color: isDark ? '#FFD54F' : '#956400',
'& .MuiAlert-icon': {
color: isDark ? '#FFD54F' : '#956400',
},
colorWarning: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.warning
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(255,183,77,0.3)' : '#F0DCA0',
color: isDark ? '#FFD54F' : '#956400',
'& .MuiAlert-icon': {
color: isDark ? '#FFD54F' : '#956400',
},
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(255,183,77,0.15)' : '#956400',
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? '#FFF8E1' : '#FFFFFF',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorInfo: {
bgcolor: isDark ? 'rgba(121,134,203,0.08)' : '#E1F3FE',
borderColor: isDark ? 'rgba(121,134,203,0.2)' : '#B8D8F0',
color: isDark ? '#9FA8DA' : '#1F6C9F',
'& .MuiAlert-icon': {
color: isDark ? '#9FA8DA' : '#1F6C9F',
},
colorInfo: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.info
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(121,134,203,0.3)' : '#B8D8F0',
color: isDark ? '#9FA8DA' : '#1F6C9F',
'& .MuiAlert-icon': {
color: isDark ? '#9FA8DA' : '#1F6C9F',
},
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(121,134,203,0.15)' : '#1F6C9F',
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? '#E8EAF6' : '#FFFFFF',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
},
},
+2 -2
View File
@@ -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)
}
}
}
@@ -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])
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { fetchMyCart } from '../api/cart-api'
import { $user } from '@/shared/model/auth'
export function useCartQuery() {
const user = useUnit($user)
return useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
}
+11 -6
View File
@@ -18,7 +18,7 @@ import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:600px)')
const swiperRef = useRef<SwiperType | null>(null)
@@ -78,7 +78,10 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
>
<Box sx={{ position: 'relative' }}>
{imageUrls.length ? (
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
<Box
onMouseMove={!isMobile ? onMouseMove : undefined}
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
>
<Swiper
slidesPerView={1}
spaceBetween={16}
@@ -86,7 +89,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
onSwiper={(s) => {
swiperRef.current = s
}}
style={{ width: '100%', height: mediaHeight, overflow: 'hidden' }}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
@@ -94,7 +97,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
className="product-card__media"
sx={{
width: '100%',
height: mediaHeight,
height: '100%',
transition: 'transform 320ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
userSelect: 'none',
@@ -104,7 +107,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
<OptimizedImage
src={url}
alt={product.title}
sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
sx={{
width: '101%',
height: '100%',
@@ -120,7 +123,9 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
<CardMedia
component="div"
sx={{
height: mediaHeight,
width: '100%',
aspectRatio: '3/4',
maxHeight: mediaHeight,
bgcolor: 'grey.50',
display: 'flex',
alignItems: 'center',
@@ -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)
}
}
@@ -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)
}
}
@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
import { addNotification } from '@/shared/model/notification'
type Props = {
productId: string
@@ -21,7 +21,12 @@ export function AddToCartButton(props: Props) {
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
addNotification({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
},
})
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as notifications from '@/shared/model/cart-notifications'
import * as notifications from '@/shared/model/notification'
import { AddToCartButton } from '../AddToCartButton'
vi.mock('@/entities/cart/api/cart-api', () => ({
@@ -21,8 +21,8 @@ describe('AddToCartButton', () => {
qc.clear()
})
it('calls cartAdded after successful add', async () => {
const spy = vi.spyOn(notifications, 'cartAdded')
it('calls addNotification after successful add', async () => {
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
<AddToCartButton productId="test-product" />
@@ -32,7 +32,12 @@ describe('AddToCartButton', () => {
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledWith({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
})
})
})
@@ -1,12 +1,13 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { ShoppingCart } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { addToCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
import { addNotification } from '@/shared/model/notification'
export function ToggleCartIcon(props: {
productId: string
@@ -18,11 +19,7 @@ export function ToggleCartIcon(props: {
const qc = useQueryClient()
const navigate = useNavigate()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const cartQuery = useCartQuery()
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
const inCart = Boolean(existing)
@@ -31,7 +28,12 @@ export function ToggleCartIcon(props: {
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
addNotification({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
},
})
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as api from '@/entities/cart/api/cart-api'
import * as notifications from '@/shared/model/cart-notifications'
import * as notifications from '@/shared/model/notification'
import { ToggleCartIcon } from '../ToggleCartIcon'
vi.mock('@/entities/cart/api/cart-api', () => ({
@@ -25,8 +25,8 @@ describe('ToggleCartIcon', () => {
qc.clear()
})
it('calls cartAdded after successful add', async () => {
const spy = vi.spyOn(notifications, 'cartAdded')
it('calls addNotification after successful add', async () => {
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
@@ -38,15 +38,20 @@ describe('ToggleCartIcon', () => {
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledWith({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
})
})
it('does not call cartAdded on remove', async () => {
it('does not call addNotification on remove', async () => {
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
})
const spy = vi.spyOn(notifications, 'cartAdded')
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
@@ -2,14 +2,12 @@ import { useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
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 Link from '@mui/material/Link'
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 { Link as RouterLink } from 'react-router-dom'
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,7 +15,7 @@ import { getAdminNextOrderStatuses } from '@/shared/constants/order'
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 { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
@@ -64,7 +62,7 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
return (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
#{detail.id.slice(-8)} · {detail.user.email} · {ORDER_STATUS_MAP[detail.status] ?? detail.status} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Typography variant="body2" color="text.secondary">
@@ -127,6 +125,32 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
</Box>
)}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Товары в заказе
</Typography>
<Stack spacing={1}>
{detail.items.map((item) => (
<Stack
key={item.id}
direction="row"
spacing={2}
sx={{ alignItems: 'center', py: 0.5, px: 1, borderRadius: 1, bgcolor: 'action.hover' }}
>
<Box sx={{ flexGrow: 1 }}>
<Link component={RouterLink} to={`/products/${item.productId}`} underline="hover" color="primary">
{item.titleSnapshot}
</Link>
<Typography color="text.secondary" variant="body2">
{item.qty} × {formatPriceRub(item.priceCentsSnapshot)}
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(item.priceCentsSnapshot * item.qty)}</Typography>
</Stack>
))}
</Stack>
</Box>
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
@@ -137,31 +161,34 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Быстрый переход статуса
</Typography>
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
{nextStatuses.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Статус финальный, смена недоступна
</Typography>
) : (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
{nextStatuses.map((nextStatus) => {
const isCancelled = nextStatus === 'CANCELLED'
return (
<Button
key={nextStatus}
variant={isCancelled ? 'outlined' : 'contained'}
color={isCancelled ? 'error' : 'primary'}
disabled={statusMut.isPending}
onClick={() => statusMut.mutate(nextStatus)}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
</Button>
)
})}
</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
@@ -0,0 +1,181 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useUnit } from 'effector-react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { OrderDetailContent } from '../OrderDetailContent'
vi.mock('@/entities/order/api/admin-order-api', () => ({
setAdminOrderStatus: vi.fn(),
postAdminOrderMessage: vi.fn(),
}))
vi.mock('effector-react', () => ({
useUnit: vi.fn(),
}))
vi.mock('@/shared/ui/RichTextMessageEditor.lazy', () => ({
RichTextMessageEditor: ({
value,
onChange,
}: {
value: string
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
}))
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
const useUnitMock = vi.mocked(useUnit)
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
return {
id: 'order-12345678',
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryCarrier: null,
paymentMethod: 'online',
itemsSubtotalCents: 3000,
deliveryFeeCents: 300,
deliveryFeeLocked: true,
totalCents: 3300,
currency: 'RUB',
addressSnapshotJson: null,
comment: null,
createdAt: '2026-05-28T10:00:00.000Z',
updatedAt: '2026-05-28T10:00:00.000Z',
user: {
id: 'user-1',
email: 'buyer@example.com',
displayName: 'Покупатель',
avatar: null,
avatarStyle: null,
},
items: [
{
id: 'item-1',
productId: 'product-1',
qty: 1,
titleSnapshot: 'Тестовый товар',
priceCentsSnapshot: 3000,
},
],
messages: [],
...overrides,
}
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function renderComponent(detail: AdminOrderDetailResponse['item'], orderId = 'order-12345678') {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<OrderDetailContent detail={detail} orderId={orderId} />
</QueryClientProvider>
</MemoryRouter>,
)
}
describe('OrderDetailContent quick status transitions', () => {
beforeEach(() => {
vi.clearAllMocks()
useUnitMock.mockReturnValue(null)
setAdminOrderStatusMock.mockResolvedValue(undefined)
})
it('рендерит кнопки доступных переходов статуса', async () => {
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
expect(screen.getByText('Быстрый переход статуса')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeInTheDocument()
const cancelledButton = screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })
expect(cancelledButton).toBeInTheDocument()
expect(cancelledButton).toHaveClass('MuiButton-outlined')
expect(cancelledButton).toHaveClass('MuiButton-colorError')
})
it('по клику вызывает setAdminOrderStatus(orderId, статус)', async () => {
const user = userEvent.setup()
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }), 'order-click-test')
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('order-click-test', 'PAID')
})
it('в pending состоянии дизейблит кнопки перехода', async () => {
const user = userEvent.setup()
const deferred = createDeferred<void>()
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise)
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
await waitFor(() => {
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeDisabled()
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).toBeDisabled()
})
deferred.resolve(undefined)
await waitFor(() => {
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeDisabled()
})
})
it('для финального статуса показывает сообщение без кнопок перехода', () => {
renderComponent(createDetail({ status: 'CANCELLED' }))
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).not.toBeInTheDocument()
})
it('показывает ошибку мутации и после завершения запроса снова даёт кликнуть', async () => {
const user = userEvent.setup()
const deferred = createDeferred<void>()
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise).mockResolvedValueOnce(undefined)
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
const paidButton = await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })
await user.click(paidButton)
await waitFor(() => {
expect(paidButton).toBeDisabled()
})
deferred.reject(new Error('request failed'))
const errorAlert = await screen.findByText('Не удалось сменить статус')
expect(errorAlert).toBeInTheDocument()
await waitFor(() => {
expect(paidButton).not.toBeDisabled()
})
await user.click(paidButton)
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
})
})
@@ -1,7 +1,7 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
type Props = {
status: string
@@ -43,7 +43,7 @@ export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod,
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
{orderStatusLabelRu('PAID')}».
{ORDER_STATUS_MAP['PAID'] ?? 'PAID'}».
</Typography>
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
@@ -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 ''
}
}
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
@@ -20,7 +21,8 @@ import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailConten
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { orderRequiresPriceApproval } from '@/shared/lib/order-requires-price-approval'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
export function AdminOrdersPage() {
@@ -87,7 +89,7 @@ export function AdminOrdersPage() {
</MenuItem>
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
{ORDER_STATUS_MAP[s] ?? s}
</MenuItem>
))}
</Select>
@@ -130,12 +132,30 @@ export function AdminOrdersPage() {
<Fragment key={`group:${group.statusCode}`}>
<TableRow>
<TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
{orderStatusLabelRu(group.statusCode)} ({group.items.length})
{ORDER_STATUS_MAP[group.statusCode] ?? group.statusCode} ({group.items.length})
</TableCell>
</TableRow>
{group.items.map((o) => (
{group.items.map((o) => {
const knownStatus = ORDER_STATUSES.includes(o.status as (typeof ORDER_STATUSES)[number])
const deliveryFeeLocked = (o as typeof o & { deliveryFeeLocked?: boolean }).deliveryFeeLocked ?? true
const showPriceApprovalChip =
knownStatus &&
orderRequiresPriceApproval({
status: o.status as (typeof ORDER_STATUSES)[number],
deliveryType: o.deliveryType,
deliveryFeeLocked,
})
return (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>
<Stack direction="row" spacing={1} useFlexGap sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
<span>{o.id.slice(-8)}</span>
{showPriceApprovalChip && (
<Chip size="small" color="warning" variant="outlined" label="Цена не подтверждена" />
)}
</Stack>
</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
@@ -146,7 +166,8 @@ export function AdminOrdersPage() {
</Button>
</TableCell>
</TableRow>
))}
)
})}
</Fragment>
))}
{ordersQuery.isSuccess && items.length === 0 && (
@@ -0,0 +1,125 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchAdminOrders } from '@/entities/order/api/admin-order-api'
import type { AdminOrderListItem } from '@/entities/order/api/admin-order-api'
import { AdminOrdersPage } from '../AdminOrdersPage'
vi.mock('@/entities/order/api/admin-order-api', () => ({
fetchAdminOrders: vi.fn(),
fetchAdminOrder: vi.fn(),
}))
const fetchAdminOrdersMock = vi.mocked(fetchAdminOrders)
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<AdminOrdersPage />
</QueryClientProvider>,
)
}
type AdminOrderListItemWithApproval = AdminOrderListItem & { deliveryFeeLocked?: boolean }
function createOrder(overrides?: Partial<AdminOrderListItemWithApproval>): AdminOrderListItemWithApproval {
return {
id: 'order-12345678',
status: 'PENDING_PAYMENT',
deliveryType: 'delivery' as const,
deliveryCarrier: null,
paymentMethod: 'online' as const,
totalCents: 10000,
currency: 'RUB',
createdAt: '2026-05-28T10:00:00.000Z',
updatedAt: '2026-05-28T10:00:00.000Z',
user: { id: 'user-1', email: 'buyer@example.com' },
itemsCount: 1,
...overrides,
}
}
function mockOrdersResponse(order: AdminOrderListItemWithApproval) {
fetchAdminOrdersMock.mockResolvedValueOnce({
items: [order],
total: 1,
page: 1,
pageSize: 20,
})
}
describe('AdminOrdersPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('показывает бейдж для PENDING_PAYMENT + delivery + deliveryFeeLocked=false', async () => {
mockOrdersResponse(createOrder({ deliveryFeeLocked: false }))
renderPage()
expect(await screen.findByText('Цена не подтверждена')).toBeInTheDocument()
expect(screen.getByText('12345678')).toBeInTheDocument()
})
it('не показывает бейдж для PENDING_PAYMENT + pickup + deliveryFeeLocked=false', async () => {
mockOrdersResponse(
createOrder({
id: 'order-87654321',
deliveryType: 'pickup',
deliveryFeeLocked: false,
}),
)
renderPage()
expect(await screen.findByText('87654321')).toBeInTheDocument()
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
})
it('не показывает бейдж для PAID + delivery + deliveryFeeLocked=false', async () => {
mockOrdersResponse(
createOrder({
id: 'order-45671234',
status: 'PAID',
deliveryFeeLocked: false,
}),
)
renderPage()
expect(await screen.findByText('45671234')).toBeInTheDocument()
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
})
it('не показывает бейдж при отсутствии deliveryFeeLocked', async () => {
mockOrdersResponse(
createOrder({
id: 'order-11223344',
deliveryFeeLocked: undefined,
}),
)
renderPage()
expect(await screen.findByText('11223344')).toBeInTheDocument()
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
})
it('вызывает fetchAdminOrders на стартовом рендере', async () => {
mockOrdersResponse(createOrder({ id: 'order-99887766', deliveryFeeLocked: true }))
renderPage()
await screen.findByText('99887766')
expect(fetchAdminOrdersMock).toHaveBeenCalledTimes(1)
})
})
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
@@ -218,14 +218,13 @@ export function AdminSliderPage() {
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
const initialSlides = useMemo<SlideDraft[]>(() => {
if (!sliderQuery.isSuccess) return []
return sliderQuery.data.slides.map((s) => ({
const initialSlides: SlideDraft[] = sliderQuery.isSuccess
? sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
textColor: s.textColor || '#ffffff',
}))
}, [sliderQuery.isSuccess, sliderQuery.data?.slides])
: []
if (sliderQuery.isLoading || galleryQuery.isLoading) {
return <Typography color="text.secondary">Загрузка</Typography>
@@ -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 '—'
}
}
+2 -1
View File
@@ -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'
}
}
+4 -7
View File
@@ -6,11 +6,12 @@ import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Minus, Plus, Trash2 } from 'lucide-react'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
import { removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
import { formatPriceRub } from '@/shared/lib/format-price'
import { usePageTitle } from '@/shared/lib/use-page-title'
import { $user } from '@/shared/model/auth'
@@ -20,11 +21,7 @@ export function CartPage() {
const user = useUnit($user)
const qc = useQueryClient()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const cartQuery = useCartQuery()
const qtyMut = useMutation({
mutationFn: (params: { id: string; qty: number }) => setCartQty(params.id, params.qty),
+10 -7
View File
@@ -16,12 +16,14 @@ import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { createOrder } from '@/entities/order/api/order-api'
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
import { fetchMyAddresses } from '@/entities/user/api/address-api'
import { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
import { formatPriceRub } from '@/shared/lib/format-price'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { $user } from '@/shared/model/auth'
import { IS_DEMO_MODE } from '@/shared/config'
export function CheckoutPage() {
const user = useUnit($user)
@@ -33,11 +35,7 @@ export function CheckoutPage() {
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const cartQuery = useCartQuery()
const addressesQuery = useQuery({
queryKey: ['me', 'addresses'],
@@ -84,6 +82,11 @@ export function CheckoutPage() {
return (
<Box>
{IS_DEMO_MODE && (
<Alert severity="warning" sx={{ mb: 2 }}>
Оформление заказа недоступно в демо-режиме. Заказ не будет создан.
</Alert>
)}
<Typography variant="h4" gutterBottom>
Оформление заказа
</Typography>
@@ -263,7 +266,7 @@ export function CheckoutPage() {
Создать заказ
</Button>
{createMut.isError && <Alert severity="error">{(createMut.error as Error).message}</Alert>}
{createMut.isError && <Alert severity="error">{getApiErrorMessage(createMut.error)}</Alert>}
</Stack>
</Box>
)
@@ -13,7 +13,6 @@ export function useProductFilters() {
const [pageSize, setPageSize] = useState(12)
const [priceMinRub, setPriceMinRub] = useState('')
const [priceMaxRub, setPriceMaxRub] = useState('')
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
useEffect(() => {
const t = window.setTimeout(() => {
@@ -54,10 +53,6 @@ export function useProductFilters() {
setPage(1)
}
const handleCardScaleChange = (v: number) => {
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v as 70 | 90 | 110 | 130)
}
const resetFilters = () => {
setCategorySlug('')
setQInput('')
@@ -65,7 +60,6 @@ export function useProductFilters() {
setPriceMinRub('')
setPriceMaxRub('')
setPageSize(12)
setCardScale(90)
setMoreOpen(false)
}
@@ -86,7 +80,6 @@ export function useProductFilters() {
pageSize,
priceMinRub,
priceMaxRub,
cardScale,
setPage,
setQInput,
setMoreOpen,
@@ -95,7 +88,6 @@ export function useProductFilters() {
handlePageSizeChange,
handlePriceMinChange,
handlePriceMaxChange,
handleCardScaleChange,
resetFilters,
toCents,
}
+4 -6
View File
@@ -64,7 +64,6 @@ export function HomePage() {
const products = productsQuery.data?.items ?? []
const total = productsQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
const mediaHeight = Math.round(200 * (filters.cardScale / 100))
return (
<Box>
@@ -73,7 +72,7 @@ export function HomePage() {
width: '100%',
mb: 3,
aspectRatio: { xs: '4/3', sm: '21/9' },
maxHeight: { xs: 320, sm: 400 },
maxHeight: { xs: 400, sm: 500 },
bgcolor: 'action.hover',
borderRadius: 2,
overflow: 'hidden',
@@ -100,8 +99,8 @@ export function HomePage() {
{productsQuery.isLoading && (
<Grid container spacing={2} sx={{ mt: 2 }}>
{[1, 2, 3].map((i) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
<Skeleton variant="rectangular" height={360} />
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={i}>
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '3/4' }} />
</Grid>
))}
</Grid>
@@ -128,10 +127,9 @@ export function HomePage() {
<>
<Grid container spacing={2} sx={{ mt: 1 }}>
{products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={p.id}>
<ProductCard
product={p}
mediaHeight={mediaHeight}
actions={!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined}
/>
</Grid>
@@ -3,7 +3,6 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Collapse from '@mui/material/Collapse'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
import InputAdornment from '@mui/material/InputAdornment'
import InputLabel from '@mui/material/InputLabel'
@@ -12,9 +11,6 @@ import Paper from '@mui/material/Paper'
import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import Typography from '@mui/material/Typography'
import { Search, SlidersHorizontal } from 'lucide-react'
import type { Category } from '@/entities/product/model/types'
import type { UseProductFiltersResult } from '../lib/use-product-filters'
@@ -32,7 +28,6 @@ export function ProductFilters({
pageSize,
priceMinRub,
priceMaxRub,
cardScale,
categories,
categoriesLoading,
setQInput,
@@ -42,7 +37,6 @@ export function ProductFilters({
handlePageSizeChange,
handlePriceMinChange,
handlePriceMaxChange,
handleCardScaleChange,
resetFilters,
}: Props) {
const categoriesForFilter = useMemo(() => {
@@ -187,40 +181,6 @@ export function ProductFilters({
</Select>
</FormControl>
</Stack>
<Divider />
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Typography variant="subtitle2">Масштаб карточек</Typography>
<ToggleButtonGroup
exclusive
size="small"
value={cardScale}
onChange={(_, v) => handleCardScaleChange(v)}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': { px: 1.5, fontWeight: 600, textTransform: 'none' },
'& .MuiToggleButton-root.Mui-selected': {
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ToggleButton value={70}>S</ToggleButton>
<ToggleButton value={90}>M</ToggleButton>
<ToggleButton value={110}>L</ToggleButton>
<ToggleButton value={130}>XL</ToggleButton>
</ToggleButtonGroup>
</Box>
</Paper>
</Collapse>
</Stack>
@@ -15,7 +15,7 @@ import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { usePageTitle } from '@/shared/lib/use-page-title'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
@@ -125,7 +125,7 @@ export function MessagesPage() {
{c.orderId.slice(-6)}
</Typography>
<Typography component="span" variant="caption" color="text.secondary">
· {orderStatusLabelRu(c.status)}
· {ORDER_STATUS_MAP[c.status] ?? c.status}
</Typography>
</Stack>
}
@@ -168,7 +168,7 @@ export function MessagesPage() {
<Typography variant="h6">
Чат заказа {order.id.slice(-6)}{' '}
<Typography component="span" variant="body2" color="text.secondary">
({orderStatusLabelRu(order.status)})
({ORDER_STATUS_MAP[order.status] ?? order.status})
</Typography>
</Typography>
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
@@ -11,7 +11,7 @@ import { fetchMyOrders } from '@/entities/order/api/order-api'
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { usePageTitle } from '@/shared/lib/use-page-title'
export function OrdersPage() {
@@ -40,7 +40,7 @@ export function OrdersPage() {
{groups.map((group) => (
<Box key={group.status}>
<Typography variant="h6" sx={{ mb: 1 }}>
{orderStatusLabelRu(group.status)} ({group.items.length})
{ORDER_STATUS_MAP[group.status] ?? group.status} ({group.items.length})
</Typography>
<Stack spacing={2}>
{group.items.map((o) => (
+15 -8
View File
@@ -57,7 +57,7 @@ export function ProductPage() {
if (productQuery.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Skeleton variant="rectangular" height={420} />
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '3/4' }} />
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" />
@@ -72,7 +72,8 @@ export function ProductPage() {
return (
<Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={{ xs: 3, md: 4 }}>
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0 }}>
{imageUrls.length > 0 ? (
<Box
sx={{
@@ -81,9 +82,11 @@ export function ProductPage() {
border: 'none',
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
bgcolor: 'background.paper',
width: '100%',
aspectRatio: '3/4',
}}
>
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: 420 }}>
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: '100%' }}>
{imageUrls.map((url, idx) => (
<SwiperSlide key={url}>
<Box
@@ -93,7 +96,7 @@ export function ProductPage() {
}}
sx={{
width: '100%',
height: 420,
height: '100%',
cursor: 'zoom-in',
userSelect: 'none',
}}
@@ -101,7 +104,7 @@ export function ProductPage() {
<OptimizedImage
src={url}
alt={p.title}
sizes="(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px"
sizes="(max-width: 900px) 100vw, 50vw"
sx={{
width: '100%',
height: '100%',
@@ -116,7 +119,8 @@ export function ProductPage() {
) : (
<Box
sx={{
height: 420,
width: '100%',
aspectRatio: '3/4',
borderRadius: 2,
overflow: 'hidden',
border: 1,
@@ -130,7 +134,9 @@ export function ProductPage() {
<Typography color="text.secondary">Нет фото</Typography>
</Box>
)}
</Box>
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{p.category?.name && <Chip label={p.category.name} />}
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
@@ -164,8 +170,10 @@ export function ProductPage() {
) : (
<Typography color="text.secondary">Описание появится позже.</Typography>
)}
</Box>
</Stack>
<Divider sx={{ my: 2 }} />
<Divider sx={{ my: { xs: 3, md: 4 } }} />
<Typography variant="h6" sx={{ mb: 1 }}>
Отзывы
@@ -186,7 +194,6 @@ export function ProductPage() {
)}
<ProductReviewsList productId={id} />
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
+2 -1
View File
@@ -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
}
})
+4 -2
View File
@@ -21,5 +21,7 @@ export const VK_URL = import.meta.env.VITE_VK_URL ?? 'https://vk.com/club1583958
export const STORE_OP_NAME = 'Комарова Лариса Николаевна'
export const STORE_OP_TYPE = 'Самозанятый'
export const STORE_OP_INN = '591878584346'
export const STORE_OP_ADDR =
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34, кв. 24'
export const STORE_OP_ADDR = '618900, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
/** Демо-режим: баннеры «скоро открытие», предупреждения в чекауте. Включается через VITE_DEMO_MODE=true. */
export const IS_DEMO_MODE = import.meta.env.VITE_DEMO_MODE === 'true'
+1 -1
View File
@@ -3,7 +3,7 @@ export const PICKUP_COORDINATES = { lat: 58.09898000206914, lng: 57.813169680997
/** Полная строка адреса для текстовых блоков. */
export const PICKUP_ADDRESS_FULL =
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
'618900, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
/** Короткий адрес для компактных блоков. */
export const PICKUP_ADDRESS_SHORT = 'Лысьва, ул. Мира, 34'
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getApiErrorMessage } from '../get-api-error-message'
describe('getApiErrorMessage', () => {
it('returns server error message from response.data.error', () => {
const error = {
isAxiosError: true,
response: { data: { error: 'Товар не найден' }, status: 404 },
}
expect(getApiErrorMessage(error)).toBe('Товар не найден')
})
it('returns network error message when no response', () => {
const error = { isAxiosError: true, response: undefined }
expect(getApiErrorMessage(error)).toBe('Нет соединения с сервером. Проверьте подключение к интернету.')
})
it('returns server error message for 5xx status', () => {
const error = { isAxiosError: true, response: { data: {}, status: 500 } }
expect(getApiErrorMessage(error)).toBe('Произошла ошибка. Попробуйте повторить позже.')
})
it('returns error message for Error instance', () => {
expect(getApiErrorMessage(new Error('Something broke'))).toBe('Something broke')
})
it('returns null for falsy input (no error)', () => {
expect(getApiErrorMessage(null)).toBeNull()
expect(getApiErrorMessage(undefined)).toBeNull()
})
it('returns unknown error for random object', () => {
expect(getApiErrorMessage({ foo: 'bar' })).toBe('Произошла неизвестная ошибка')
})
})
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { orderRequiresPriceApproval } from '../order-requires-price-approval'
describe('orderRequiresPriceApproval', () => {
it('returns true when pending payment delivery fee is not locked for delivery', () => {
const result = orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: false,
})
expect(result).toBe(true)
})
it('returns false when status is not pending payment', () => {
const result = orderRequiresPriceApproval({
status: 'PAID',
deliveryType: 'delivery',
deliveryFeeLocked: false,
})
expect(result).toBe(false)
})
it('returns false when delivery type is pickup', () => {
const result = orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'pickup',
deliveryFeeLocked: false,
})
expect(result).toBe(false)
})
it('returns false when delivery fee is locked', () => {
const result = orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: true,
})
expect(result).toBe(false)
})
})
@@ -0,0 +1,75 @@
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({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
describe('useMutationWithToast', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows success notification on success with successMessage', async () => {
const mutationFn = (): Promise<{ ok: boolean }> => Promise.resolve({ ok: true })
const { result } = renderHook(() => useMutationWithToast({ mutationFn, successMessage: 'Done!' }), {
wrapper: createWrapper(),
})
;(result.current.mutate as () => void)()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(addNotification).toHaveBeenCalledWith({ type: 'success', message: 'Done!' })
})
it('does NOT show success notification without successMessage', async () => {
const mutationFn = (): Promise<{ ok: boolean }> => Promise.resolve({ ok: true })
const { result } = renderHook(() => useMutationWithToast({ mutationFn }), { wrapper: createWrapper() })
;(result.current.mutate as () => void)()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(addNotification).not.toHaveBeenCalled()
})
it('shows error notification on mutation error', async () => {
const err = new Error('Boom')
const mutationFn = (): Promise<never> => Promise.reject(err)
const { result } = renderHook(() => useMutationWithToast({ mutationFn }), { wrapper: createWrapper() })
;(result.current.mutate as () => void)()
await waitFor(() => expect(result.current.isError).toBe(true))
expect(addNotification).toHaveBeenCalledWith({ type: 'error', message: 'Boom' })
})
it('calls user-provided onSuccess callback', async () => {
const onSuccess: (
data: { ok: boolean },
variables: void,
onMutateResult: unknown,
mutationContext: unknown,
) => void = vi.fn()
const mutationFn = (): Promise<{ ok: boolean }> => Promise.resolve({ ok: true })
const { result } = renderHook(() => useMutationWithToast({ mutationFn, onSuccess, successMessage: 'OK' }), {
wrapper: createWrapper(),
})
;(result.current.mutate as () => void)()
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(onSuccess).toHaveBeenCalled()
})
it('calls user-provided onError callback', async () => {
const onError: (error: Error, variables: void, onMutateResult: unknown, mutationContext: unknown) => void = vi.fn()
const err = new Error('fail')
const mutationFn = (): Promise<never> => Promise.reject(err)
const { result } = renderHook(() => useMutationWithToast({ mutationFn, onError }), { wrapper: createWrapper() })
;(result.current.mutate as () => void)()
await waitFor(() => expect(result.current.isError).toBe(true))
expect(onError).toHaveBeenCalled()
})
})
+22 -7
View File
@@ -1,8 +1,23 @@
export function getApiErrorMessage(err: unknown): string | null {
if (!err || typeof err !== 'object') return null
const anyErr = err as Record<string, unknown>
const response = anyErr.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const msg = data?.error
return typeof msg === 'string' ? msg : null
import { isAxiosError } from 'axios'
export function getApiErrorMessage(error: unknown): string | null {
if (!error) return null
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 'Произошла неизвестная ошибка'
}
@@ -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
}
}
@@ -0,0 +1,11 @@
import type { OrderStatus } from '@/shared/constants/order'
type OrderPriceApprovalCandidate = {
status: OrderStatus
deliveryType: 'delivery' | 'pickup'
deliveryFeeLocked: boolean
}
export function orderRequiresPriceApproval(order: OrderPriceApprovalCandidate): boolean {
return order.status === 'PENDING_PAYMENT' && order.deliveryType === 'delivery' && order.deliveryFeeLocked === false
}
@@ -73,3 +73,7 @@ export const ORDER_STATUS_DATA: ReadonlyArray<OrderStatusData> = [
export function getOrderStatusData(code: string): OrderStatusData | undefined {
return ORDER_STATUS_DATA.find((s) => s.code === code)
}
export const ORDER_STATUS_MAP: Record<string, string> = Object.fromEntries(
ORDER_STATUS_DATA.map((s) => [s.code, s.label]),
)
@@ -1,14 +0,0 @@
/** Человекочитаемые подписи к кодам статуса заказа */
export function orderStatusLabelRu(code: string): string {
const map: Record<string, string> = {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
PAID: 'Оплачен',
IN_PROGRESS: 'Подготовка к отправке',
SHIPPED: 'Отправлен',
READY_FOR_PICKUP: 'Готово к получению',
DONE: 'Завершён',
CANCELLED: 'Отменён',
}
return map[code] ?? code
}
+6 -5
View File
@@ -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)
}
}
@@ -0,0 +1,42 @@
import { useMutation, type MutationFunctionContext, type UseMutationOptions } from '@tanstack/react-query'
import { addNotification } from '../model/notification'
import { getApiErrorMessage } from './get-api-error-message'
type MutationWithToastOptions<TData, TError, TVariables, TOnMutateResult> = UseMutationOptions<
TData,
TError,
TVariables,
TOnMutateResult
> & {
successMessage?: string
}
export function useMutationWithToast<TData = unknown, TError = unknown, TVariables = void, TOnMutateResult = unknown>(
options: MutationWithToastOptions<TData, TError, TVariables, TOnMutateResult>,
) {
const { successMessage, onSuccess, onError, ...mutationOptions } = options
return useMutation({
...mutationOptions,
onSuccess: (
data: TData,
variables: TVariables,
onMutateResult: TOnMutateResult,
mutationContext: MutationFunctionContext,
) => {
if (successMessage) {
addNotification({ type: 'success', message: successMessage })
}
onSuccess?.(data, variables, onMutateResult, mutationContext)
},
onError: (
error: TError,
variables: TVariables,
onMutateResult: TOnMutateResult | undefined,
mutationContext: MutationFunctionContext,
) => {
addNotification({ type: 'error', message: getApiErrorMessage(error) || 'Произошла неизвестная ошибка' })
onError?.(error, variables, onMutateResult, mutationContext)
},
})
}
+6 -4
View File
@@ -3,12 +3,12 @@ import { useLocation } from 'react-router-dom'
const BASE_TITLE = 'Любимый Креатив — Изделия ручной работы'
let currentTitle: string = BASE_TITLE
let didPageTitleSet = false
export function usePageTitle(title: string | null) {
useEffect(() => {
currentTitle = title ? `${title} — Любимый Креатив` : BASE_TITLE
document.title = currentTitle
didPageTitleSet = true
document.title = title ? `${title} — Любимый Креатив` : BASE_TITLE
}, [title])
}
@@ -16,7 +16,9 @@ export function usePageTitleReset() {
const location = useLocation()
useEffect(() => {
if (!didPageTitleSet) {
document.title = BASE_TITLE
currentTitle = BASE_TITLE
}
didPageTitleSet = false
}, [location.pathname])
}
@@ -1,23 +0,0 @@
import { allSettled, fork } from 'effector'
import { describe, it, expect } from 'vitest'
import { $cartSnackOpen, cartAdded, cartDismissed } from '../cart-notifications'
describe('cart-notifications store', () => {
it('opens on cartAdded', async () => {
const scope = fork()
await allSettled(cartAdded, { scope })
expect(scope.getState($cartSnackOpen)).toBe(true)
})
it('closes on cartDismissed', async () => {
const scope = fork()
await allSettled(cartAdded, { scope })
await allSettled(cartDismissed, { scope })
expect(scope.getState($cartSnackOpen)).toBe(false)
})
it('starts closed by default', () => {
const scope = fork()
expect(scope.getState($cartSnackOpen)).toBe(false)
})
})
@@ -0,0 +1,97 @@
import { allSettled, fork } from 'effector'
import { describe, it, expect } from 'vitest'
import { $notifications, addNotification, dismissNotification, dismissAll } from '../notification'
describe('notification store', () => {
it('starts empty', () => {
const scope = fork()
expect(scope.getState($notifications)).toEqual([])
})
it('adds a notification with generated id', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'info', message: 'test' },
})
const list = scope.getState($notifications)
expect(list).toHaveLength(1)
expect(list[0].id).toEqual(expect.any(String))
expect(list[0].message).toBe('test')
expect(list[0].type).toBe('info')
})
it('caps at 3 visible notifications', async () => {
const scope = fork()
for (let i = 0; i < 5; i++) {
await allSettled(addNotification, {
scope,
params: { type: 'info', message: `msg-${i}` },
})
}
const list = scope.getState($notifications)
expect(list).toHaveLength(3)
expect(list[0].message).toBe('msg-2')
expect(list[2].message).toBe('msg-4')
})
it('dismisses notification by id', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'info', message: 'test' },
})
const [{ id }] = scope.getState($notifications)
await allSettled(dismissNotification, {
scope,
params: id,
})
expect(scope.getState($notifications)).toEqual([])
})
it('clears all on dismissAll', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'info', message: 'test' },
})
await allSettled(dismissAll, { scope })
expect(scope.getState($notifications)).toEqual([])
})
it('defaults autoHideDuration to 4000 for info', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'info', message: 'test' },
})
expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000)
})
it('defaults autoHideDuration to 4000 for success', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'success', message: 'test' },
})
expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000)
})
it('defaults autoHideDuration to 4000 for warning', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'warning', message: 'test' },
})
expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000)
})
it('defaults autoHideDuration to 6000 for error', async () => {
const scope = fork()
await allSettled(addNotification, {
scope,
params: { type: 'error', message: 'test' },
})
expect(scope.getState($notifications)[0].autoHideDuration).toBe(6000)
})
})
@@ -1,8 +0,0 @@
import { createEvent, createStore } from 'effector'
export const cartAdded = createEvent()
export const cartDismissed = createEvent()
export const $cartSnackOpen = createStore(false)
.on(cartAdded, () => true)
.on(cartDismissed, () => false)
+41
View File
@@ -0,0 +1,41 @@
import { createEvent, createStore } from 'effector'
type NotificationType = 'success' | 'error' | 'info' | 'warning'
export interface Notification {
id: string
type: NotificationType
message: string
autoHideDuration?: number
actionLabel?: string
actionPath?: string
}
const MAX_VISIBLE = 3
let nextId = 1
export const addNotification = createEvent<{
type: NotificationType
message: string
autoHideDuration?: number
actionLabel?: string
actionPath?: string
}>()
export const dismissNotification = createEvent<string>()
export const dismissAll = createEvent()
export const $notifications = createStore<Notification[]>([])
.on(addNotification, (state, { type, message, autoHideDuration, actionLabel, actionPath }) => {
const notification: Notification = {
id: String(nextId++),
type,
message,
autoHideDuration: autoHideDuration ?? (type === 'error' ? 6000 : 4000),
actionLabel,
actionPath,
}
return [...state, notification].slice(-MAX_VISIBLE)
})
.on(dismissNotification, (state, id) => state.filter((n) => n.id !== id))
.reset(dismissAll)
-90
View File
@@ -1,90 +0,0 @@
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Snackbar from '@mui/material/Snackbar'
import { useUnit } from 'effector-react'
import { useNavigate } from 'react-router-dom'
import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications'
export function CartSnackbar() {
const open = useUnit($cartSnackOpen)
const navigate = useNavigate()
const handleClose = (_event: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return
cartDismissed()
}
const handleGoToCart = () => {
cartDismissed()
navigate('/cart')
}
return (
<Snackbar
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
autoHideDuration={4000}
sx={{
'& .MuiSnackbarContent-root': {
borderRadius: 12,
border: '1px solid',
borderColor: 'rgba(0,0,0,0.06)',
bgcolor: 'background.paper',
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
},
}}
>
<Alert
severity="success"
variant="standard"
onClose={handleClose}
sx={{
bgcolor: 'transparent',
border: 'none',
boxShadow: 'none',
p: 1.5,
alignItems: 'center',
'& .MuiAlert-icon': {
padding: 0,
mr: 1.5,
display: 'flex',
alignItems: 'center',
},
'& .MuiAlert-message': {
padding: 0,
fontSize: '0.875rem',
fontWeight: 600,
},
'& .MuiAlert-action': {
padding: 0,
mr: 0,
ml: 1,
},
}}
action={
<Button
size="small"
variant="text"
onClick={handleGoToCart}
sx={{
fontWeight: 600,
fontSize: '0.8125rem',
px: 1.5,
py: 0.5,
borderRadius: 8,
color: 'success.main',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
Перейти в корзину
</Button>
}
>
Товар добавлен в корзину
</Alert>
</Snackbar>
)
}
+4 -3
View File
@@ -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)
}
}
+23
View File
@@ -0,0 +1,23 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import { IS_DEMO_MODE } from '@/shared/config'
export function DemoBanner() {
if (!IS_DEMO_MODE) return null
return (
<Box>
<Alert
severity="warning"
variant="filled"
sx={{
borderRadius: 0,
justifyContent: 'center',
'& .MuiAlert-message': { textAlign: 'center' },
}}
>
Сайт работает в демо-режиме. Заказы не оформляются. Скоро открытие!
</Alert>
</Box>
)
}
@@ -0,0 +1,74 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { allSettled, fork } from 'effector'
import { Provider } from 'effector-react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, afterEach } from 'vitest'
import { addNotification, dismissAll, $notifications } from '../../model/notification'
import { NotificationStack } from './NotificationStack'
describe('NotificationStack', () => {
afterEach(() => {
dismissAll()
})
function createTestScope() {
return fork()
}
function renderWithScope(scope: ReturnType<typeof fork>) {
return render(
<MemoryRouter>
<Provider value={scope}>
<NotificationStack />
</Provider>
</MemoryRouter>,
)
}
it('renders nothing when empty', () => {
const scope = createTestScope()
const { container } = renderWithScope(scope)
expect(container.textContent).toBe('')
})
it('renders a success notification with check icon', async () => {
const scope = createTestScope()
await allSettled(addNotification, { scope, params: { type: 'success', message: 'Success!' } })
renderWithScope(scope)
expect(screen.getByText('Success!')).toBeDefined()
})
it('renders error alert severity', async () => {
const scope = createTestScope()
await allSettled(addNotification, { scope, params: { type: 'error', message: 'Error!' } })
renderWithScope(scope)
const alert = screen.getByRole('alert')
expect(alert).toBeDefined()
})
it('dismisses on close button click', async () => {
const scope = createTestScope()
await allSettled(addNotification, { scope, params: { type: 'info', message: 'Dismiss me' } })
renderWithScope(scope)
const closeBtn = screen.getByRole('button')
fireEvent.click(closeBtn)
await waitFor(() => {
const state = scope.getState($notifications)
expect(state).toHaveLength(0)
})
})
it('renders multiple notifications up to 3', async () => {
const scope = createTestScope()
await allSettled(addNotification, { scope, params: { type: 'info', message: 'A' } })
await allSettled(addNotification, { scope, params: { type: 'info', message: 'B' } })
await allSettled(addNotification, { scope, params: { type: 'info', message: 'C' } })
renderWithScope(scope)
expect(screen.getByText('A')).toBeDefined()
expect(screen.getByText('B')).toBeDefined()
expect(screen.getByText('C')).toBeDefined()
})
})
@@ -0,0 +1,106 @@
import CloseIcon from '@mui/icons-material/Close'
import { Snackbar, Alert, IconButton, Button } from '@mui/material'
import { useUnit } from 'effector-react'
import { useNavigate } from 'react-router-dom'
import { $notifications, dismissNotification as dismissNotificationEvent } from '../../model/notification'
const GAP = 76
export function NotificationStack() {
const notifications = useUnit($notifications)
const dismissNotification = useUnit(dismissNotificationEvent)
const navigate = useNavigate()
if (notifications.length === 0) return null
return (
<>
{notifications.map((n, index) => (
<Snackbar
key={n.id}
open
autoHideDuration={n.autoHideDuration}
onClose={() => dismissNotification(n.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{
bottom: `${88 + (notifications.length - 1 - index) * GAP}px !important`,
zIndex: 2000 + index,
}}
>
<Alert
severity={n.type}
variant="standard"
onClose={() => dismissNotification(n.id)}
sx={(theme) => ({
width: '100%',
minWidth: 360,
maxWidth: 440,
px: 2,
py: 1.5,
alignItems: 'center',
color: `${theme.palette.primary.main} !important`,
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
WebkitBackdropFilter: 'blur(8px)',
backdropFilter: 'blur(8px)',
backgroundColor: 'rgba(255,255,255,0.7)',
'& .MuiAlert-icon': {
py: 0.5,
mr: 1.5,
display: 'flex',
alignItems: 'center',
color: `${theme.palette.primary.main} !important`,
},
'& .MuiAlert-message': {
py: 0.5,
fontSize: '0.875rem',
fontWeight: 600,
lineHeight: 1.5,
},
'& .MuiAlert-action': {
py: 0.5,
pr: 0,
ml: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1,
},
})}
action={
<>
{n.actionLabel && n.actionPath && (
<Button
size="small"
variant="text"
onClick={() => {
dismissNotification(n.id)
navigate(n.actionPath!)
}}
sx={{
fontWeight: 700,
fontSize: '0.8125rem',
px: 1.5,
py: 0.5,
borderRadius: 8,
whiteSpace: 'nowrap',
color: 'primary.main',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{n.actionLabel}
</Button>
)}
<IconButton size="small" onClick={() => dismissNotification(n.id)} sx={{ color: 'primary.main' }}>
<CloseIcon fontSize="small" />
</IconButton>
</>
}
>
{n.message}
</Alert>
</Snackbar>
))}
</>
)
}
@@ -0,0 +1 @@
export { NotificationStack } from './NotificationStack'
@@ -18,9 +18,14 @@ export function RichTextMessageContent({ value, tone = 'default' }: RichTextMess
})
useEffect(() => {
if (!editor) return
if (!editor || editor.isDestroyed) return
const normalizedValue = value.trim() ? value : '<p></p>'
try {
if (editor.getHTML() === normalizedValue) return
} catch (err) {
console.warn('[tiptap] Failed to get editor HTML', err)
return
}
editor.commands.setContent(normalizedValue, { emitUpdate: false })
}, [editor, value])
@@ -1,69 +0,0 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { cartAdded, cartDismissed } from '@/shared/model/cart-notifications'
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const mod = await importOriginal<typeof import('react-router-dom')>()
return { ...mod, useNavigate: () => navigateMock }
})
beforeEach(() => {
navigateMock.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
function renderWithRouter() {
render(
<MemoryRouter initialEntries={['/']}>
<CartSnackbar />
</MemoryRouter>,
)
}
describe('CartSnackbar', () => {
it('is hidden when store is false', () => {
cartDismissed()
renderWithRouter()
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('shows snackbar when cartAdded is fired', () => {
renderWithRouter()
cartAdded()
expect(screen.getByText(/товар добавлен/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /перейти в корзину/i })).toBeInTheDocument()
})
it('closes on dismiss button click', () => {
renderWithRouter()
cartAdded()
const closeBtn = screen.getByLabelText(/закрыть/i)
fireEvent.click(closeBtn)
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('auto-closes after 4 seconds', () => {
vi.useFakeTimers()
renderWithRouter()
cartAdded()
act(() => {
vi.advanceTimersByTime(4000)
})
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('navigates to /cart and closes on "Перейти в корзину" click', () => {
renderWithRouter()
cartAdded()
fireEvent.click(screen.getByRole('button', { name: /перейти в корзину/i }))
expect(navigateMock).toHaveBeenCalledWith('/cart')
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
})
+1
View File
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
readonly VITE_STORE_EMAIL?: string
readonly VITE_STORE_PHONE?: string
readonly VITE_STORE_SOCIAL_NOTE?: string
readonly VITE_DEMO_MODE?: string
}
interface ImportMeta {
@@ -43,7 +43,7 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
position: 'relative',
width: '100%',
aspectRatio: { xs: '4/3', sm: '21/9' },
maxHeight: { xs: 320, sm: 400 },
maxHeight: { xs: 400, sm: 500 },
bgcolor: 'action.hover',
}}
>
@@ -32,7 +32,9 @@ export function ReviewsBlock() {
return (
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
<Stack spacing={0.75} sx={{ mb: 2 }}>
<Typography variant="h5" component="h3">Отзывы</Typography>
<Typography variant="h5" component="h3">
Отзывы
</Typography>
<Typography variant="body2" color="text.secondary">
Последние отзывы о товарах
</Typography>
@@ -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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
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<TData, TError, TVariables, TContext> =
UseMutationOptions<TData, TError, TVariables, TContext> & {
successMessage?: string
}
export function useMutationWithToast<TData = unknown, TError = unknown, TVariables = void, TContext = unknown>(
options: MutationWithToastOptions<TData, TError, TVariables, TContext>,
) {
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 && (
<Alert severity="error">
{(createMut.error as Error).message}
</Alert>
)}
```
After:
```tsx
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
{createMut.isError && (
<Alert severity="error">
{getApiErrorMessage(createMut.error)}
</Alert>
)}
```
- [ ] **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"
```
@@ -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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
)
}
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"
```
@@ -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
@@ -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"
```
@@ -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<string>()
export const dismissAll = createEvent()
export const $notifications = createStore<Notification[]>([])
.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(
<Provider value={scope}>
<NotificationStack />
</Provider>,
),
}
}
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(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
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(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
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 (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 80,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2000,
width: 'auto',
maxWidth: 400,
}}
>
{notifications.map((n) => (
<Snackbar
key={n.id}
open
autoHideDuration={n.autoHideDuration}
onClose={() => dismissNotification(n.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={n.type}
variant="filled"
action={
<IconButton size="small" color="inherit" onClick={() => dismissNotification(n.id)}>
<CloseIcon fontSize="small" />
</IconButton>
}
>
{n.message}
</Alert>
</Snackbar>
))}
</Stack>
)
}
```
```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 </Router>
<>
<ErrorBoundary>
<AppProviders>
<BrowserRouter>
<MainLayout>
<AppRoutes />
</MainLayout>
</BrowserRouter>
</AppProviders>
</ErrorBoundary>
<NotificationStack />
</>
```
- [ ] **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 `<CartSnackbar />` 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"
```
@@ -0,0 +1,446 @@
# Admin Orders UX Improvements 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:** Добавить в списке заказов маркер `Цена не подтверждена` и заменить смену статуса в деталке на быстрые кнопки допустимых переходов.
**Architecture:** Изменения ограничены фронтендом (`client`) и опираются на текущие поля заказа (`status`, `deliveryType`, `deliveryFeeLocked`) и текущую логику переходов `getAdminNextOrderStatuses`. Серверные API и контракты не меняются, синхронизация данных остается через существующую инвалидацию React Query.
**Tech Stack:** React, TypeScript, MUI, TanStack React Query, Vitest, Testing Library.
---
## File Structure
**Create:**
- `client/src/shared/lib/order-requires-price-approval.ts`
- `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
- `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
- `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
**Modify:**
- `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
- `client/src/features/order-detail/ui/OrderDetailContent.tsx`
---
### Task 1: Вычисление признака "цена не подтверждена"
**Files:**
- Create: `client/src/shared/lib/order-requires-price-approval.ts`
- Test: `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
- [ ] **Step 1: Write the failing unit test**
```ts
import { describe, expect, it } from 'vitest'
import { orderRequiresPriceApproval } from '../order-requires-price-approval'
describe('orderRequiresPriceApproval', () => {
it('returns true for delivery pending payment with unlocked delivery fee', () => {
expect(
orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: false,
}),
).toBe(true)
})
it('returns false when delivery fee is already locked', () => {
expect(
orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: true,
}),
).toBe(false)
})
it('returns false for pickup even if payment is pending', () => {
expect(
orderRequiresPriceApproval({
status: 'PENDING_PAYMENT',
deliveryType: 'pickup',
deliveryFeeLocked: false,
}),
).toBe(false)
})
it('returns false for non-pending statuses', () => {
expect(
orderRequiresPriceApproval({
status: 'PAID',
deliveryType: 'delivery',
deliveryFeeLocked: false,
}),
).toBe(false)
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts`
Expected: FAIL with module/function not found.
- [ ] **Step 3: Write minimal implementation**
```ts
type PriceApprovalOrder = {
status: string
deliveryType: 'delivery' | 'pickup'
deliveryFeeLocked: boolean
}
export function orderRequiresPriceApproval(order: PriceApprovalOrder): boolean {
return order.status === 'PENDING_PAYMENT' && order.deliveryType === 'delivery' && order.deliveryFeeLocked === false
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/lib/order-requires-price-approval.ts client/src/shared/lib/__tests__/order-requires-price-approval.test.ts
git commit -m "test: add price approval predicate for admin orders"
```
---
### Task 2: Маркер "Цена не подтверждена" в списке заказов
**Files:**
- Modify: `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
- Test: `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
- [ ] **Step 1: Write the failing component test for chip visibility**
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AdminOrdersPage } from '../AdminOrdersPage'
const fetchAdminOrdersMock = vi.fn()
vi.mock('@/entities/order/api/admin-order-api', () => ({
fetchAdminOrders: fetchAdminOrdersMock,
fetchAdminOrder: vi.fn(),
}))
describe('AdminOrdersPage price approval marker', () => {
it('shows "Цена не подтверждена" for eligible order', async () => {
fetchAdminOrdersMock.mockResolvedValueOnce({
items: [
{
id: 'order-1',
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: false,
totalCents: 10000,
currency: 'RUB',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
user: { id: 'u1', email: 'a@example.com' },
itemsCount: 1,
},
],
total: 1,
page: 1,
pageSize: 20,
})
const qc = new QueryClient()
render(
<QueryClientProvider client={qc}>
<AdminOrdersPage />
</QueryClientProvider>,
)
expect(await screen.findByText('Цена не подтверждена')).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npm test -- src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
Expected: FAIL because chip text is not rendered yet.
- [ ] **Step 3: Implement marker in `AdminOrdersPage`**
```tsx
import Chip from '@mui/material/Chip'
import { orderRequiresPriceApproval } from '@/shared/lib/order-requires-price-approval'
// ...
{group.items.map((o) => {
const needsPriceApproval = orderRequiresPriceApproval({
status: o.status,
deliveryType: o.deliveryType,
deliveryFeeLocked: o.deliveryFeeLocked,
})
return (
<TableRow key={o.id} hover>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<Box component="span">{o.id.slice(-8)}</Box>
{needsPriceApproval && <Chip size="small" color="warning" label="Цена не подтверждена" />}
</Stack>
</TableCell>
{/* ... */}
</TableRow>
)
})}
```
- [ ] **Step 4: Add negative case and rerun test**
```tsx
it('does not show marker for non-eligible order', async () => {
fetchAdminOrdersMock.mockResolvedValueOnce({
items: [
{
id: 'order-2',
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryFeeLocked: true,
totalCents: 10000,
currency: 'RUB',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
user: { id: 'u2', email: 'b@example.com' },
itemsCount: 2,
},
],
total: 1,
page: 1,
pageSize: 20,
})
const qc = new QueryClient()
render(
<QueryClientProvider client={qc}>
<AdminOrdersPage />
</QueryClientProvider>,
)
expect(await screen.findByText('order-2'.slice(-8))).toBeInTheDocument()
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
})
```
Run: `cd client && npm test -- src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add client/src/pages/admin-orders/ui/AdminOrdersPage.tsx client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx
git commit -m "feat: show price approval marker in admin orders list"
```
---
### Task 3: Быстрые кнопки смены статуса в деталке
**Files:**
- Modify: `client/src/features/order-detail/ui/OrderDetailContent.tsx`
- Test: `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
- [ ] **Step 1: Write failing tests for quick actions**
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OrderDetailContent } from '../OrderDetailContent'
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
const setAdminOrderStatusMock = vi.fn(async () => undefined)
vi.mock('@/entities/order/api/admin-order-api', async () => {
const actual = await vi.importActual<object>('@/entities/order/api/admin-order-api')
return {
...actual,
setAdminOrderStatus: setAdminOrderStatusMock,
postAdminOrderMessage: vi.fn(async () => undefined),
}
})
function buildDetail(patch: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
return {
id: 'o1',
status: 'PENDING_PAYMENT',
deliveryType: 'delivery',
deliveryCarrier: null,
paymentMethod: 'online',
itemsSubtotalCents: 10000,
deliveryFeeCents: 500,
deliveryFeeLocked: false,
totalCents: 10500,
currency: 'RUB',
addressSnapshotJson: null,
comment: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
user: {
id: 'u1',
email: 'a@example.com',
displayName: null,
avatar: null,
avatarStyle: null,
},
items: [],
messages: [],
...patch,
}
}
describe('OrderDetailContent quick status actions', () => {
it('renders quick action buttons for next statuses', () => {
const qc = new QueryClient()
render(
<QueryClientProvider client={qc}>
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
</QueryClientProvider>,
)
expect(screen.getByRole('button', { name: /Оплачен/i })).toBeInTheDocument()
})
it('calls setAdminOrderStatus on click', () => {
const qc = new QueryClient()
render(
<QueryClientProvider client={qc}>
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: /Оплачен/i }))
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('o1', 'PAID')
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
Expected: FAIL because old `Select` UI is still used.
- [ ] **Step 3: Replace select with quick action buttons**
```tsx
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Быстрый переход статуса
</Typography>
{nextStatuses.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Статус финальный, смена недоступна
</Typography>
) : (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
{nextStatuses.map((nextStatus) => {
const isCancel = nextStatus === 'CANCELLED'
return (
<Button
key={nextStatus}
variant={isCancel ? 'outlined' : 'contained'}
color={isCancel ? 'error' : 'primary'}
onClick={() => statusMut.mutate(nextStatus)}
disabled={statusMut.isPending}
>
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
</Button>
)
})}
</Stack>
)}
</Stack>
```
- [ ] **Step 4: Add pending/empty-state assertions and rerun tests**
```tsx
it('disables all quick action buttons while mutation is pending', () => {
const qc = new QueryClient()
setAdminOrderStatusMock.mockImplementationOnce(() => new Promise(() => {}))
render(
<QueryClientProvider client={qc}>
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
</QueryClientProvider>,
)
const paidButton = screen.getByRole('button', { name: /Оплачен/i })
fireEvent.click(paidButton)
expect(paidButton).toBeDisabled()
})
it('shows final state note when no transitions available', () => {
const qc = new QueryClient()
render(
<QueryClientProvider client={qc}>
<OrderDetailContent detail={buildDetail({ status: 'DONE', deliveryType: 'delivery' })} orderId="o1" />
</QueryClientProvider>,
)
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
})
```
Run: `cd client && npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
git commit -m "feat: replace admin order status select with quick actions"
```
---
### Task 4: Регрессия, линт и финальная проверка
**Files:**
- Modify (if needed): `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
- Modify (if needed): `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
- Modify (if needed): `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
- [ ] **Step 1: Run focused test suite for changed units**
Run:
`cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
Expected: PASS.
- [ ] **Step 2: Run frontend lint**
Run: `cd client && npm run lint`
Expected: PASS with no new lint errors.
- [ ] **Step 3: Run format check**
Run: `cd client && npm run format:check`
Expected: PASS, no formatting violations.
- [ ] **Step 4: Fix issues if any and re-run exact failed command**
Run (example): `cd client && npm run lint`
Expected: PASS after fixes.
- [ ] **Step 5: Final commit**
```bash
git add client/src/pages/admin-orders/ui/AdminOrdersPage.tsx client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/shared/lib/order-requires-price-approval.ts client/src/shared/lib/__tests__/order-requires-price-approval.test.ts client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
git commit -m "feat: improve admin orders flow for price approval and status updates"
```
@@ -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 `<Alert>` на страницах
- Нет централизованного показа ошибок от 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` (проверка типов)
@@ -0,0 +1,114 @@
# Дизайн: Доработки админки заказов (маркер цены + быстрые статусы)
**Дата:** 2026-05-28
**Статус:** Draft
## Контекст
Нужно улучшить UX в админке заказов по двум направлениям:
1. Явно показывать в списке заказов, что требуется подтверждение цены (итоговой стоимости для оплаты).
2. Сделать смену статуса заказа более удобной для администратора.
Ограничения, согласованные в обсуждении:
- Не вводим новый статус заказа для подтверждения цены.
- Используем короткий текст для индикатора.
- Для смены статуса выбираем формат быстрых кнопок доступных переходов (вместо выпадающего списка).
## Цели
- Сократить время на обнаружение заказов, требующих подтверждения цены.
- Упростить и ускорить смену статуса до 1 клика.
- Сохранить существующие API-контракты и логику допустимых переходов.
## Не в рамках этой итерации
- Изменение серверной статусной модели.
- Добавление новых серверных полей или эндпоинтов.
- Массовая смена статусов из таблицы без открытия деталки.
- Редизайн всей таблицы заказов.
## Решение (утвержденный вариант A)
### 1) Маркер в списке заказов
В `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` добавить короткий бейдж `Цена не подтверждена` для строк, где:
- `status === 'PENDING_PAYMENT'`
- `deliveryType === 'delivery'`
- `deliveryFeeLocked === false`
Размещение: в колонке `ID` рядом с коротким номером заказа (`o.id.slice(-8)`), чтобы не расширять таблицу и чтобы сигнал был виден до открытия деталки.
### 2) Быстрая смена статуса в деталке заказа
В `client/src/features/order-detail/ui/OrderDetailContent.tsx` заменить текущий `Select` "Сменить статус" на набор кнопок быстрых переходов:
- Источник переходов остается прежним: `getAdminNextOrderStatuses(detail.status, detail.deliveryType)`
- Для каждого допустимого статуса рендерится отдельная кнопка
- Клик вызывает текущую мутацию `setAdminOrderStatus` через `statusMut.mutate(next)`
Поведение кнопок:
- Пока мутация выполняется (`statusMut.isPending`) все кнопки отключены
- Если доступных переходов нет — показываем текст `Статус финальный, смена недоступна`
- Для `CANCELLED` использовать визуально менее акцентную кнопку (`outlined`), чтобы снизить риск случайного нажатия
## Архитектурные границы и переиспользование
- Используем существующие данные `deliveryFeeLocked`, `status`, `deliveryType`.
- Правила переходов не дублируем в UI, берем из `getAdminNextOrderStatuses`.
- API слой (`client/src/entities/order/api/admin-order-api.ts`) без изменений.
- React Query инвалидация сохраняется текущая:
- `['admin', 'orders']`
- `['admin', 'orders', 'detail']`
- `['admin', 'orders', 'summary']`
## Поток данных
1. Страница списка получает `items` из `fetchAdminOrders`.
2. Для каждой строки вычисляется `needsPriceApproval`.
3. Если `needsPriceApproval === true`, отображается бейдж `Цена не подтверждена`.
4. В деталке рассчитывается `nextStatuses`.
5. Клик по кнопке статуса вызывает `statusMut`.
6. После успеха инвалидация обновляет список/деталку/summary; маркер в списке исчезает автоматически, когда условие перестает выполняться.
## Обработка ошибок
- Ошибка смены статуса: показать локальный `Alert` в деталке с текстом `Не удалось сменить статус`, дать возможность повторить действие.
- Ошибка загрузки списка: сохранить текущее поведение (`Не удалось загрузить заказы.`).
- Пустой список переходов: показать явное сообщение о финальном статусе.
## Тестирование
Минимальный набор:
1. Unit: проверка условия `needsPriceApproval`.
2. Component (`OrderDetailContent`):
- рендерит кнопки только допустимых переходов
- вызывает `setAdminOrderStatus` при клике
- блокирует кнопки в pending-состоянии
3. Component/Smoke (`AdminOrdersPage`):
- показывает `Цена не подтверждена` для целевых заказов
- не показывает бейдж для остальных
## Критерии готовности
- В списке заказов видно короткий маркер `Цена не подтверждена` для релевантных заказов.
- В деталке смена статуса выполняется через быстрые кнопки допустимых переходов.
- Список и деталка синхронизируются после успешной смены статуса.
- Для `CANCELLED` используется менее акцентный стиль кнопки.
- Проходят `npm run lint` и `npm run format:check` в `client`.
## План изменений по файлам
- `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` — маркер `Цена не подтверждена` в строках списка.
- `client/src/features/order-detail/ui/OrderDetailContent.tsx` — замена select на быстрые кнопки.
- (опционально) `client/src/shared/lib/` — небольшой хелпер `needsPriceApproval`, если нужно переиспользование или unit-тест вне компонента.
## Риски и меры
- Риск перегруза UI в деталке при большом количестве кнопок: низкий, так как число допустимых переходов обычно 1-2.
- Риск случайного нажатия на отмену: снижается стилем `outlined` для `CANCELLED`.
- Риск расхождения логики переходов: отсутствует, т.к. используется единый источник `getAdminNextOrderStatuses`.
+5 -3
View File
@@ -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: 'Не авторизован' })
}
})
@@ -0,0 +1,33 @@
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() }
const result = await asyncHandler(handler)(request, reply)
expect(handler).toHaveBeenCalledWith(request, reply)
expect(result).toEqual({ hello: 'world' })
})
it('catches errors and sends 500 with generic 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 object when 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' })
})
})
@@ -0,0 +1,27 @@
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 order not found', async () => {
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
})
it('passes include option', async () => {
const mockOrder = { id: '1', userId: 'user1', items: [] }
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
const result = await findUserOrder(prisma, '1', 'user1', { items: true })
expect(result).toEqual(mockOrder)
expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({ include: { items: true } }))
})
})
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from 'vitest'
import { validateGalleryImages } from '../validate-gallery-images.js'
describe('validateGalleryImages', () => {
it('returns null when urls is empty', async () => {
const prisma = { galleryImage: { findMany: vi.fn() } }
const result = await validateGalleryImages(prisma, [])
expect(result).toBeNull()
})
it('throws 400 when image not found', async () => {
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
await expect(validateGalleryImages(prisma, ['/uploads/missing.jpg'])).rejects.toMatchObject({ statusCode: 400 })
})
it('throws 400 when image not yet resized', async () => {
const prisma = {
galleryImage: { findMany: vi.fn().mockResolvedValue([{ url: '/uploads/img.jpg', isResized: false }]) },
}
await expect(validateGalleryImages(prisma, ['/uploads/img.jpg'])).rejects.toMatchObject({ statusCode: 400 })
})
it('returns existing images when all valid and resized', async () => {
const images = [
{ url: '/uploads/img1.jpg', isResized: true },
{ url: '/uploads/img2.jpg', isResized: true },
]
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue(images) } }
const result = await validateGalleryImages(prisma, ['/uploads/img1.jpg', '/uploads/img2.jpg'])
expect(result).toEqual(images)
})
})
+12
View File
@@ -0,0 +1,12 @@
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 })
}
}
}
+12
View File
@@ -0,0 +1,12 @@
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
}
+23
View File
@@ -0,0 +1,23 @@
export async function validateGalleryImages(prisma, urls) {
if (!urls || urls.length === 0) return null
const existing = await prisma.galleryImage.findMany({
where: { url: { in: urls } },
select: { url: true, isResized: true },
})
const galleryMap = new Map(existing.map((g) => [g.url, g]))
const notFound = urls.filter((u) => !galleryMap.has(u))
if (notFound.length > 0) {
throw Object.assign(new Error(`Gallery images not found: ${notFound.join(', ')}`), { statusCode: 400 })
}
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
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
}
+2 -1
View File
@@ -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: 'Не авторизован' })
}
+25 -28
View File
@@ -1,3 +1,4 @@
import { asyncHandler } from '../../lib/async-handler.js'
import {
getOrCreateUnspecifiedCategory,
isUnspecifiedCategorySlug,
@@ -6,20 +7,21 @@ import {
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify) {
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.get(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить категории' })
}
})
}),
)
fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const body = request.body ?? {}
const name = String(body.name ?? '').trim()
if (!name) {
@@ -45,14 +47,13 @@ export async function registerAdminCategoryRoutes(fastify) {
},
})
reply.code(201).send(category)
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось создать категорию' })
}
})
}),
)
fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.patch(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
@@ -105,14 +106,13 @@ export async function registerAdminCategoryRoutes(fastify) {
const updated = await prisma.category.update({ where: { id }, data })
return updated
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить категорию' })
}
})
}),
)
fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.delete(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
@@ -133,9 +133,6 @@ export async function registerAdminCategoryRoutes(fastify) {
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить категорию' })
}
})
}),
)
}
+7 -7
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { asyncHandler } from '../../lib/async-handler.js'
import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
import {
@@ -72,7 +73,10 @@ export async function registerAdminGalleryRoutes(fastify) {
}
})
fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
fastify.post(
'/api/admin/gallery/:id/resize',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
@@ -86,7 +90,6 @@ export async function registerAdminGalleryRoutes(fastify) {
const fileName = urlParts[urlParts.length - 1]
const uuid = path.parse(fileName).name
try {
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
@@ -99,11 +102,8 @@ export async function registerAdminGalleryRoutes(fastify) {
})
return { url: newUrl }
} catch (error) {
request.log.error(error, 'Resize failed')
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
}
})
}),
)
fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
+11 -29
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma.js'
import { validateGalleryImages } from '../../lib/validate-gallery-images.js'
const CREATE_PRODUCT_SCHEMA = {
body: {
@@ -87,20 +88,10 @@ export async function registerAdminProductRoutes(fastify) {
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
if (urls.length > 0) {
const galleryImages = await prisma.galleryImage.findMany({
where: { url: { in: urls } },
select: { url: true, isResized: true },
})
const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
const notFound = urls.filter((u) => !galleryMap.has(u))
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
if (notFound.length > 0) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply
.code(400)
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
try {
await validateGalleryImages(prisma, urls)
} catch (err) {
return reply.code(err.statusCode || 400).send({ error: err.message })
}
}
}
@@ -214,20 +205,10 @@ export async function registerAdminProductRoutes(fastify) {
if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
if (urls.length > 0) {
const galleryImages = await prisma.galleryImage.findMany({
where: { url: { in: urls } },
select: { url: true, isResized: true },
})
const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
const notFound = urls.filter((u) => !galleryMap.has(u))
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
if (notFound.length > 0) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply
.code(400)
.send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
try {
await validateGalleryImages(prisma, urls)
} catch (err) {
return reply.code(err.statusCode || 400).send({ error: err.message })
}
}
}
@@ -260,7 +241,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: 'Товар не найден' })
}
})
+2 -1
View File
@@ -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: 'Пользователь не найден' })
}
})
+18 -21
View File
@@ -1,10 +1,12 @@
import { asyncHandler } from '../../lib/async-handler.js'
import { prisma } from '../../lib/prisma.js'
const MAX_SLIDES = 20
export async function registerCatalogSliderRoutes(fastify) {
fastify.get('/api/catalog-slider', async (request, reply) => {
try {
fastify.get(
'/api/catalog-slider',
asyncHandler(async (request, reply) => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
@@ -17,14 +19,13 @@ export async function registerCatalogSliderRoutes(fastify) {
textColor: s.textColor,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить слайдер' })
}
})
}),
)
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.get(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
@@ -38,14 +39,13 @@ export async function registerCatalogSliderRoutes(fastify) {
textColor: s.textColor,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить слайдер' })
}
})
}),
)
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
try {
fastify.put(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
asyncHandler(async (request, reply) => {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
@@ -103,9 +103,6 @@ export async function registerCatalogSliderRoutes(fastify) {
textColor: s.textColor,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить слайдер' })
}
})
}),
)
}
+2 -1
View File
@@ -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: 'Вы уже оставляли отзыв на этот товар' })
}
})
+2 -1
View File
@@ -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()
}
+31 -35
View File
@@ -1,3 +1,4 @@
import { asyncHandler } from '../lib/async-handler.js'
import { prisma } from '../lib/prisma.js'
function normalizePhoneLite(input) {
@@ -45,22 +46,23 @@ function validateAddressPayload(body, reply) {
}
export async function registerUserAddressRoutes(fastify) {
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.get(
'/api/me/addresses',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const items = await prisma.shippingAddress.findMany({
where: { userId },
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
})
return { items }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить адреса' })
}
})
}),
)
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.post(
'/api/me/addresses',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const validated = validateAddressPayload(request.body, reply)
if (!validated) return
@@ -79,14 +81,13 @@ export async function registerUserAddressRoutes(fastify) {
})
})
return reply.code(201).send({ item: created })
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось создать адрес' })
}
})
}),
)
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.patch(
'/api/me/addresses/:id',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -161,14 +162,13 @@ export async function registerUserAddressRoutes(fastify) {
})
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить адрес' })
}
})
}),
)
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.delete(
'/api/me/addresses/:id',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -176,14 +176,13 @@ export async function registerUserAddressRoutes(fastify) {
await prisma.shippingAddress.delete({ where: { id } })
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить адрес' })
}
})
}),
)
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.post(
'/api/me/addresses/:id/default',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
@@ -195,9 +194,6 @@ export async function registerUserAddressRoutes(fastify) {
})
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' })
}
})
}),
)
}
+25 -28
View File
@@ -1,8 +1,11 @@
import { asyncHandler } from '../lib/async-handler.js'
import { prisma } from '../lib/prisma.js'
export async function registerUserCartRoutes(fastify) {
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.get(
'/api/me/cart',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const items = await prisma.cartItem.findMany({
where: { userId },
@@ -16,14 +19,13 @@ export async function registerUserCartRoutes(fastify) {
product: x.product,
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить корзину' })
}
})
}),
)
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.post(
'/api/me/cart/items',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const productId = String(request.body?.productId || '').trim()
const qtyRaw = request.body?.qty
@@ -46,14 +48,13 @@ export async function registerUserCartRoutes(fastify) {
create: { userId, productId, qty: nextQty },
})
return reply.code(201).send({ item })
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось добавить в корзину' })
}
})
}),
)
fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.patch(
'/api/me/cart/items/:id',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const qtyRaw = request.body?.qty
@@ -74,23 +75,19 @@ export async function registerUserCartRoutes(fastify) {
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
return { item: updated }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось обновить количество' })
}
})
}),
)
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.delete(
'/api/me/cart/items/:id',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
await prisma.cartItem.delete({ where: { id } })
return reply.code(204).send()
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось удалить из корзины' })
}
})
}),
)
}
+23 -12
View File
@@ -1,24 +1,31 @@
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { asyncHandler } from '../lib/async-handler.js'
import { findUserOrder } from '../lib/find-user-order.js'
import { prisma } from '../lib/prisma.js'
export async function registerUserMessageRoutes(fastify) {
fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.get(
'/api/me/orders/:id/messages',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
await findUserOrder(prisma, id, userId)
const items = await prisma.orderMessage.findMany({
where: { orderId: id },
orderBy: { createdAt: 'asc' },
})
return { items }
})
}),
)
fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.post(
'/api/me/orders/:id/messages',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
await findUserOrder(prisma, id, userId)
const text = String(request.body?.text || '').trim()
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
@@ -34,7 +41,8 @@ export async function registerUserMessageRoutes(fastify) {
})
return reply.code(201).send({ item: msg })
})
}),
)
fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
const userId = request.user.sub
@@ -116,11 +124,13 @@ export async function registerUserMessageRoutes(fastify) {
return { items }
})
fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.post(
'/api/me/orders/:id/messages/read',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
await findUserOrder(prisma, id, userId)
const now = new Date()
await prisma.userOrderMessageReadState.upsert({
@@ -129,5 +139,6 @@ export async function registerUserMessageRoutes(fastify) {
update: { lastReadAt: now },
})
return { ok: true }
})
}),
)
}
+23 -34
View File
@@ -1,5 +1,7 @@
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { asyncHandler } from '../lib/async-handler.js'
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
import { findUserOrder } from '../lib/find-user-order.js'
import { prisma } from '../lib/prisma.js'
export async function registerUserOrderRoutes(fastify) {
@@ -176,8 +178,10 @@ export async function registerUserOrderRoutes(fastify) {
return reply.code(201).send({ orderId: created.id })
})
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.get(
'/api/me/orders',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const orders = await prisma.order.findMany({
where: { userId },
@@ -195,39 +199,30 @@ export async function registerUserOrderRoutes(fastify) {
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
})),
}
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить заказы' })
}
})
}),
)
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
try {
fastify.get(
'/api/me/orders/:id',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
const order = await findUserOrder(prisma, id, userId, {
items: true,
messages: { orderBy: { createdAt: 'asc' } },
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось загрузить заказ' })
}
})
}),
)
fastify.get(
'/api/me/orders/:id/review-eligibility',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true },
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const order = await findUserOrder(prisma, id, userId, { items: true })
if (order.status !== 'DONE') {
return { canReview: false, items: [] }
}
@@ -254,18 +249,16 @@ export async function registerUserOrderRoutes(fastify) {
hasReview: reviewed.has(x.productId),
})),
}
},
}),
)
fastify.post(
'/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
try {
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const order = await findUserOrder(prisma, id, userId)
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
@@ -275,10 +268,6 @@ export async function registerUserOrderRoutes(fastify) {
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: 'DONE' }
} catch (err) {
request.log.error(err)
return reply.code(500).send({ error: 'Не удалось подтвердить получение' })
}
},
}),
)
}
+18 -12
View File
@@ -1,9 +1,14 @@
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { asyncHandler } from '../lib/async-handler.js'
import { findUserOrder } from '../lib/find-user-order.js'
import { prisma } from '../lib/prisma.js'
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
export async function registerUserPaymentRoutes(fastify) {
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.post(
'/api/me/orders/:id/pay',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const userEmail = request.user.email
@@ -13,11 +18,7 @@ export async function registerUserPaymentRoutes(fastify) {
const { id } = request.params
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true },
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const order = await findUserOrder(prisma, id, userId, { items: true })
if (order.paymentMethod === 'on_pickup') {
return reply.code(409).send({
@@ -95,14 +96,17 @@ export async function registerUserPaymentRoutes(fastify) {
})
return { confirmationUrl: result.confirmationUrl }
})
}),
)
fastify.get('/api/me/orders/:orderId/payment', { preHandler: [fastify.authenticate] }, async (request, reply) => {
fastify.get(
'/api/me/orders/:orderId/payment',
{ preHandler: [fastify.authenticate] },
asyncHandler(async (request, reply) => {
const userId = request.user.sub
const { orderId } = request.params
const order = await prisma.order.findFirst({ where: { id: orderId, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const order = await findUserOrder(prisma, orderId, userId)
const payment = await prisma.payment.findFirst({
where: { orderId },
@@ -141,8 +145,10 @@ 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' }
}
})
}),
)
}
+2 -1
View File
@@ -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' })
}