Merge branch 'final_fixes'
This commit is contained in:
Generated
+1528
-1134
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { AppProviders } from '@/app/providers/AppProviders'
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
import { AppRoutes } from '@/app/routes'
|
import { AppRoutes } from '@/app/routes'
|
||||||
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
||||||
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||||
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<CartSnackbar />
|
<NotificationStack />
|
||||||
<NoiseOverlay />
|
<NoiseOverlay />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AppHeader } from '@/app/layout/AppHeader'
|
|||||||
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
||||||
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
|
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
|
||||||
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
|
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
|
||||||
|
import { DemoBanner } from '@/shared/ui/DemoBanner'
|
||||||
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
<ScrollOnNavigate />
|
<ScrollOnNavigate />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
<DemoBanner />
|
||||||
|
|
||||||
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
|
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
|
||||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
<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 } }}>
|
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||||
<Grid container spacing={5}>
|
<Grid container spacing={5}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<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}
|
{STORE_NAME}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
||||||
@@ -50,7 +57,12 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<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>
|
</Typography>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
@@ -66,7 +78,12 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<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>
|
</Typography>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
@@ -95,7 +112,12 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<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>
|
</Typography>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type PropsWithChildren, useMemo } from 'react'
|
import { type PropsWithChildren, useMemo } from 'react'
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
|
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
|
||||||
import { SseProvider } from './SseProvider'
|
import { SseProvider } from './SseProvider'
|
||||||
@@ -223,89 +223,89 @@ function AppThemeInner({ children }: PropsWithChildren) {
|
|||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
colorSuccess: {
|
colorSuccess: ({ theme }) => {
|
||||||
bgcolor: isDark ? 'rgba(102,187,106,0.08)' : '#EDF3EC',
|
const isDark = theme.palette.mode === 'dark'
|
||||||
borderColor: isDark ? 'rgba(102,187,106,0.2)' : '#C5DFC2',
|
const p = theme.palette.success
|
||||||
color: isDark ? '#A5D6A7' : '#346538',
|
return {
|
||||||
'& .MuiAlert-icon': {
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
color: isDark ? '#A5D6A7' : '#346538',
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
},
|
color: isDark ? p.light : p.dark,
|
||||||
'&.MuiAlert-outlined': {
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
bgcolor: 'transparent',
|
'&.MuiAlert-outlined': {
|
||||||
borderColor: isDark ? 'rgba(102,187,106,0.3)' : '#C5DFC2',
|
bgcolor: 'transparent',
|
||||||
color: isDark ? '#A5D6A7' : '#346538',
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
'& .MuiAlert-icon': {
|
color: isDark ? p.light : p.dark,
|
||||||
color: isDark ? '#A5D6A7' : '#346538',
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
},
|
},
|
||||||
},
|
'&.MuiAlert-filled': {
|
||||||
'&.MuiAlert-filled': {
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
bgcolor: isDark ? 'rgba(102,187,106,0.15)' : '#346538',
|
borderColor: 'transparent',
|
||||||
borderColor: 'transparent',
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
color: isDark ? '#E8F5E9' : '#FFFFFF',
|
},
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
colorError: {
|
colorError: ({ theme }) => {
|
||||||
bgcolor: isDark ? 'rgba(239,83,80,0.08)' : '#FDEBEC',
|
const isDark = theme.palette.mode === 'dark'
|
||||||
borderColor: isDark ? 'rgba(239,83,80,0.2)' : '#F5C6C7',
|
const p = theme.palette.error
|
||||||
color: isDark ? '#EF9A9A' : '#9F2F2D',
|
return {
|
||||||
'& .MuiAlert-icon': {
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
color: isDark ? '#EF9A9A' : '#9F2F2D',
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
},
|
color: isDark ? p.light : p.dark,
|
||||||
'&.MuiAlert-outlined': {
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
bgcolor: 'transparent',
|
'&.MuiAlert-outlined': {
|
||||||
borderColor: isDark ? 'rgba(239,83,80,0.3)' : '#F5C6C7',
|
bgcolor: 'transparent',
|
||||||
color: isDark ? '#EF9A9A' : '#9F2F2D',
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
'& .MuiAlert-icon': {
|
color: isDark ? p.light : p.dark,
|
||||||
color: isDark ? '#EF9A9A' : '#9F2F2D',
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
},
|
},
|
||||||
},
|
'&.MuiAlert-filled': {
|
||||||
'&.MuiAlert-filled': {
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
bgcolor: isDark ? 'rgba(239,83,80,0.15)' : '#9F2F2D',
|
borderColor: 'transparent',
|
||||||
borderColor: 'transparent',
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
color: isDark ? '#FFEBEE' : '#FFFFFF',
|
},
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
colorWarning: {
|
colorWarning: ({ theme }) => {
|
||||||
bgcolor: isDark ? 'rgba(255,183,77,0.08)' : '#FBF3DB',
|
const isDark = theme.palette.mode === 'dark'
|
||||||
borderColor: isDark ? 'rgba(255,183,77,0.2)' : '#F0DCA0',
|
const p = theme.palette.warning
|
||||||
color: isDark ? '#FFD54F' : '#956400',
|
return {
|
||||||
'& .MuiAlert-icon': {
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
color: isDark ? '#FFD54F' : '#956400',
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
},
|
color: isDark ? p.light : p.dark,
|
||||||
'&.MuiAlert-outlined': {
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
bgcolor: 'transparent',
|
'&.MuiAlert-outlined': {
|
||||||
borderColor: isDark ? 'rgba(255,183,77,0.3)' : '#F0DCA0',
|
bgcolor: 'transparent',
|
||||||
color: isDark ? '#FFD54F' : '#956400',
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
'& .MuiAlert-icon': {
|
color: isDark ? p.light : p.dark,
|
||||||
color: isDark ? '#FFD54F' : '#956400',
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
},
|
},
|
||||||
},
|
'&.MuiAlert-filled': {
|
||||||
'&.MuiAlert-filled': {
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
bgcolor: isDark ? 'rgba(255,183,77,0.15)' : '#956400',
|
borderColor: 'transparent',
|
||||||
borderColor: 'transparent',
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
color: isDark ? '#FFF8E1' : '#FFFFFF',
|
},
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
colorInfo: {
|
colorInfo: ({ theme }) => {
|
||||||
bgcolor: isDark ? 'rgba(121,134,203,0.08)' : '#E1F3FE',
|
const isDark = theme.palette.mode === 'dark'
|
||||||
borderColor: isDark ? 'rgba(121,134,203,0.2)' : '#B8D8F0',
|
const p = theme.palette.info
|
||||||
color: isDark ? '#9FA8DA' : '#1F6C9F',
|
return {
|
||||||
'& .MuiAlert-icon': {
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
color: isDark ? '#9FA8DA' : '#1F6C9F',
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
},
|
color: isDark ? p.light : p.dark,
|
||||||
'&.MuiAlert-outlined': {
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
bgcolor: 'transparent',
|
'&.MuiAlert-outlined': {
|
||||||
borderColor: isDark ? 'rgba(121,134,203,0.3)' : '#B8D8F0',
|
bgcolor: 'transparent',
|
||||||
color: isDark ? '#9FA8DA' : '#1F6C9F',
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
'& .MuiAlert-icon': {
|
color: isDark ? p.light : p.dark,
|
||||||
color: isDark ? '#9FA8DA' : '#1F6C9F',
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
},
|
},
|
||||||
},
|
'&.MuiAlert-filled': {
|
||||||
'&.MuiAlert-filled': {
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
bgcolor: isDark ? 'rgba(121,134,203,0.15)' : '#1F6C9F',
|
borderColor: 'transparent',
|
||||||
borderColor: 'transparent',
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
color: isDark ? '#E8EAF6' : '#FFFFFF',
|
},
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export function SseProvider() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore parse errors (e.g. heartbit comments)
|
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'
|
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
|
||||||
if (!modeOk || !schemeOk) return null
|
if (!modeOk || !schemeOk) return null
|
||||||
return { mode, scheme }
|
return { mode, scheme }
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[theme] Failed to read stored theme', err)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +81,8 @@ export function ThemeControllerProvider({ children }: PropsWithChildren) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
|
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.warn('[theme] Failed to persist theme setting', err)
|
||||||
}
|
}
|
||||||
}, [settings])
|
}, [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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import type { Swiper as SwiperType } from 'swiper/types'
|
|||||||
|
|
||||||
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
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 navigate = useNavigate()
|
||||||
const isMobile = useMediaQuery('(max-width:600px)')
|
const isMobile = useMediaQuery('(max-width:600px)')
|
||||||
const swiperRef = useRef<SwiperType | null>(null)
|
const swiperRef = useRef<SwiperType | null>(null)
|
||||||
@@ -78,7 +78,10 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
>
|
>
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
{imageUrls.length ? (
|
{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
|
<Swiper
|
||||||
slidesPerView={1}
|
slidesPerView={1}
|
||||||
spaceBetween={16}
|
spaceBetween={16}
|
||||||
@@ -86,7 +89,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
onSwiper={(s) => {
|
onSwiper={(s) => {
|
||||||
swiperRef.current = s
|
swiperRef.current = s
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', height: mediaHeight, overflow: 'hidden' }}
|
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
{imageUrls.map((url) => (
|
{imageUrls.map((url) => (
|
||||||
<SwiperSlide key={url}>
|
<SwiperSlide key={url}>
|
||||||
@@ -94,7 +97,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
className="product-card__media"
|
className="product-card__media"
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: mediaHeight,
|
height: '100%',
|
||||||
transition: 'transform 320ms ease',
|
transition: 'transform 320ms ease',
|
||||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
@@ -104,7 +107,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
<OptimizedImage
|
<OptimizedImage
|
||||||
src={url}
|
src={url}
|
||||||
alt={product.title}
|
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={{
|
sx={{
|
||||||
width: '101%',
|
width: '101%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -120,7 +123,9 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
<CardMedia
|
<CardMedia
|
||||||
component="div"
|
component="div"
|
||||||
sx={{
|
sx={{
|
||||||
height: mediaHeight,
|
width: '100%',
|
||||||
|
aspectRatio: '3/4',
|
||||||
|
maxHeight: mediaHeight,
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'grey.50',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export function AddressMapPicker(props: {
|
|||||||
setHint(addr)
|
setHint(addr)
|
||||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.warn('[address-map-picker] Failed to reverse geocode', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
|
|||||||
if (addr) {
|
if (addr) {
|
||||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
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 { useUnit } from 'effector-react'
|
||||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { cartAdded } from '@/shared/model/cart-notifications'
|
import { addNotification } from '@/shared/model/notification'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -21,7 +21,12 @@ export function AddToCartButton(props: Props) {
|
|||||||
mutationFn: () => addToCart({ productId, qty }),
|
mutationFn: () => addToCart({ productId, qty }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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'
|
import { AddToCartButton } from '../AddToCartButton'
|
||||||
|
|
||||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||||
@@ -21,8 +21,8 @@ describe('AddToCartButton', () => {
|
|||||||
qc.clear()
|
qc.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls cartAdded after successful add', async () => {
|
it('calls addNotification after successful add', async () => {
|
||||||
const spy = vi.spyOn(notifications, 'cartAdded')
|
const spy = vi.spyOn(notifications, 'addNotification')
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<AddToCartButton productId="test-product" />
|
<AddToCartButton productId="test-product" />
|
||||||
@@ -32,7 +32,12 @@ describe('AddToCartButton', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
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 IconButton from '@mui/material/IconButton'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
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 { useUnit } from 'effector-react'
|
||||||
import { ShoppingCart } from 'lucide-react'
|
import { ShoppingCart } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { $user } from '@/shared/model/auth'
|
||||||
import { cartAdded } from '@/shared/model/cart-notifications'
|
import { addNotification } from '@/shared/model/notification'
|
||||||
|
|
||||||
export function ToggleCartIcon(props: {
|
export function ToggleCartIcon(props: {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -18,11 +19,7 @@ export function ToggleCartIcon(props: {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const cartQuery = useQuery({
|
const cartQuery = useCartQuery()
|
||||||
queryKey: ['me', 'cart'],
|
|
||||||
queryFn: fetchMyCart,
|
|
||||||
enabled: Boolean(user),
|
|
||||||
})
|
|
||||||
|
|
||||||
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
||||||
const inCart = Boolean(existing)
|
const inCart = Boolean(existing)
|
||||||
@@ -31,7 +28,12 @@ export function ToggleCartIcon(props: {
|
|||||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
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 { MemoryRouter } from 'react-router-dom'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import * as api from '@/entities/cart/api/cart-api'
|
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'
|
import { ToggleCartIcon } from '../ToggleCartIcon'
|
||||||
|
|
||||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||||
@@ -25,8 +25,8 @@ describe('ToggleCartIcon', () => {
|
|||||||
qc.clear()
|
qc.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls cartAdded after successful add', async () => {
|
it('calls addNotification after successful add', async () => {
|
||||||
const spy = vi.spyOn(notifications, 'cartAdded')
|
const spy = vi.spyOn(notifications, 'addNotification')
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -38,15 +38,20 @@ describe('ToggleCartIcon', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
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({
|
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
|
||||||
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
|
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(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { useMemo, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import FormControl from '@mui/material/FormControl'
|
import Link from '@mui/material/Link'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
|
||||||
import Select from '@mui/material/Select'
|
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
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 { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
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 { $user } from '@/shared/model/auth'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||||
@@ -64,7 +62,7 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<Typography sx={{ fontWeight: 700 }}>
|
<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)}
|
{formatPriceRub(detail.totalCents)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -127,6 +125,32 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
</Box>
|
</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 && (
|
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||||||
@@ -137,31 +161,34 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
<Box>
|
||||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
Быстрый переход статуса
|
||||||
<Select
|
</Typography>
|
||||||
labelId="next-status-label"
|
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
|
||||||
label="Сменить статус"
|
{nextStatuses.length === 0 ? (
|
||||||
value=""
|
<Typography variant="body2" color="text.secondary">
|
||||||
onChange={(e) => {
|
Статус финальный, смена недоступна
|
||||||
const next = String(e.target.value)
|
</Typography>
|
||||||
if (!next) return
|
) : (
|
||||||
statusMut.mutate(next)
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||||
}}
|
{nextStatuses.map((nextStatus) => {
|
||||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
const isCancelled = nextStatus === 'CANCELLED'
|
||||||
>
|
return (
|
||||||
<MenuItem value="">
|
<Button
|
||||||
<em>Выберите…</em>
|
key={nextStatus}
|
||||||
</MenuItem>
|
variant={isCancelled ? 'outlined' : 'contained'}
|
||||||
{nextStatuses.map((s) => (
|
color={isCancelled ? 'error' : 'primary'}
|
||||||
<MenuItem key={s} value={s}>
|
disabled={statusMut.isPending}
|
||||||
{orderStatusLabelRu(s)}
|
onClick={() => statusMut.mutate(nextStatus)}
|
||||||
</MenuItem>
|
>
|
||||||
))}
|
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||||||
</Select>
|
</Button>
|
||||||
</FormControl>
|
)
|
||||||
</Stack>
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
<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 Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Typography from '@mui/material/Typography'
|
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 = {
|
type Props = {
|
||||||
status: string
|
status: string
|
||||||
@@ -43,7 +43,7 @@ export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod,
|
|||||||
<>
|
<>
|
||||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
|
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
|
||||||
{orderStatusLabelRu('PAID')}».
|
{ORDER_STATUS_MAP['PAID'] ?? 'PAID'}».
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
|
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
|
||||||
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
|
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ function getApiErrorMessage(error: unknown): string | null {
|
|||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
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 { ORDER_STATUSES } from '@/shared/constants/order'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
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'
|
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||||
|
|
||||||
export function AdminOrdersPage() {
|
export function AdminOrdersPage() {
|
||||||
@@ -87,7 +89,7 @@ export function AdminOrdersPage() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
{ORDER_STATUSES.map((s) => (
|
{ORDER_STATUSES.map((s) => (
|
||||||
<MenuItem key={s} value={s}>
|
<MenuItem key={s} value={s}>
|
||||||
{orderStatusLabelRu(s)}
|
{ORDER_STATUS_MAP[s] ?? s}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -130,23 +132,42 @@ export function AdminOrdersPage() {
|
|||||||
<Fragment key={`group:${group.statusCode}`}>
|
<Fragment key={`group:${group.statusCode}`}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{group.items.map((o) => (
|
{group.items.map((o) => {
|
||||||
<TableRow key={o.id} hover>
|
const knownStatus = ORDER_STATUSES.includes(o.status as (typeof ORDER_STATUSES)[number])
|
||||||
<TableCell>{o.id.slice(-8)}</TableCell>
|
const deliveryFeeLocked = (o as typeof o & { deliveryFeeLocked?: boolean }).deliveryFeeLocked ?? true
|
||||||
<TableCell>{o.user.email}</TableCell>
|
const showPriceApprovalChip =
|
||||||
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
|
knownStatus &&
|
||||||
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
|
orderRequiresPriceApproval({
|
||||||
<TableCell>{o.itemsCount}</TableCell>
|
status: o.status as (typeof ORDER_STATUSES)[number],
|
||||||
<TableCell align="right">
|
deliveryType: o.deliveryType,
|
||||||
<Button size="small" onClick={() => open(o.id)}>
|
deliveryFeeLocked,
|
||||||
Открыть
|
})
|
||||||
</Button>
|
|
||||||
</TableCell>
|
return (
|
||||||
</TableRow>
|
<TableRow key={o.id} hover>
|
||||||
))}
|
<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>
|
||||||
|
<TableCell>{o.itemsCount}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" onClick={() => open(o.id)}>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{ordersQuery.isSuccess && items.length === 0 && (
|
{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 ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
|
||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||||
@@ -218,14 +218,13 @@ export function AdminSliderPage() {
|
|||||||
|
|
||||||
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
||||||
|
|
||||||
const initialSlides = useMemo<SlideDraft[]>(() => {
|
const initialSlides: SlideDraft[] = sliderQuery.isSuccess
|
||||||
if (!sliderQuery.isSuccess) return []
|
? sliderQuery.data.slides.map((s) => ({
|
||||||
return sliderQuery.data.slides.map((s) => ({
|
galleryImageId: s.galleryImageId,
|
||||||
galleryImageId: s.galleryImageId,
|
caption: s.caption,
|
||||||
caption: s.caption,
|
textColor: s.textColor || '#ffffff',
|
||||||
textColor: s.textColor || '#ffffff',
|
}))
|
||||||
}))
|
: []
|
||||||
}, [sliderQuery.isSuccess, sliderQuery.data?.slides])
|
|
||||||
|
|
||||||
if (sliderQuery.isLoading || galleryQuery.isLoading) {
|
if (sliderQuery.isLoading || galleryQuery.isLoading) {
|
||||||
return <Typography color="text.secondary">Загрузка…</Typography>
|
return <Typography color="text.secondary">Загрузка…</Typography>
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ function formatDt(v: string) {
|
|||||||
const d = new Date(v)
|
const d = new Date(v)
|
||||||
if (Number.isNaN(d.getTime())) return '—'
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
return d.toLocaleString()
|
return d.toLocaleString()
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[admin-users] Failed to format date', err)
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ function readStoredScheme(): ColorScheme {
|
|||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
const scheme = parsed?.scheme
|
const scheme = parsed?.scheme
|
||||||
return scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' ? scheme : 'craft'
|
return scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' ? scheme : 'craft'
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[auth] Failed to read stored theme scheme', err)
|
||||||
return 'craft'
|
return 'craft'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import IconButton from '@mui/material/IconButton'
|
|||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
import Typography from '@mui/material/Typography'
|
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 { useUnit } from 'effector-react'
|
||||||
import { Minus, Plus, Trash2 } from 'lucide-react'
|
import { Minus, Plus, Trash2 } from 'lucide-react'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
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 { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { usePageTitle } from '@/shared/lib/use-page-title'
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
@@ -20,11 +21,7 @@ export function CartPage() {
|
|||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const cartQuery = useQuery({
|
const cartQuery = useCartQuery()
|
||||||
queryKey: ['me', 'cart'],
|
|
||||||
queryFn: fetchMyCart,
|
|
||||||
enabled: Boolean(user),
|
|
||||||
})
|
|
||||||
|
|
||||||
const qtyMut = useMutation({
|
const qtyMut = useMutation({
|
||||||
mutationFn: (params: { id: string; qty: number }) => setCartQty(params.id, params.qty),
|
mutationFn: (params: { id: string; qty: number }) => setCartQty(params.id, params.qty),
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
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 { 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 { fetchMyAddresses } from '@/entities/user/api/address-api'
|
||||||
import { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
import { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { IS_DEMO_MODE } from '@/shared/config'
|
||||||
|
|
||||||
export function CheckoutPage() {
|
export function CheckoutPage() {
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
@@ -33,11 +35,7 @@ export function CheckoutPage() {
|
|||||||
const [addressId, setAddressId] = useState('')
|
const [addressId, setAddressId] = useState('')
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
|
|
||||||
const cartQuery = useQuery({
|
const cartQuery = useCartQuery()
|
||||||
queryKey: ['me', 'cart'],
|
|
||||||
queryFn: fetchMyCart,
|
|
||||||
enabled: Boolean(user),
|
|
||||||
})
|
|
||||||
|
|
||||||
const addressesQuery = useQuery({
|
const addressesQuery = useQuery({
|
||||||
queryKey: ['me', 'addresses'],
|
queryKey: ['me', 'addresses'],
|
||||||
@@ -84,6 +82,11 @@ export function CheckoutPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{IS_DEMO_MODE && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
Оформление заказа недоступно в демо-режиме. Заказ не будет создан.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Оформление заказа
|
Оформление заказа
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -263,7 +266,7 @@ export function CheckoutPage() {
|
|||||||
Создать заказ
|
Создать заказ
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{createMut.isError && <Alert severity="error">{(createMut.error as Error).message}</Alert>}
|
{createMut.isError && <Alert severity="error">{getApiErrorMessage(createMut.error)}</Alert>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function useProductFilters() {
|
|||||||
const [pageSize, setPageSize] = useState(12)
|
const [pageSize, setPageSize] = useState(12)
|
||||||
const [priceMinRub, setPriceMinRub] = useState('')
|
const [priceMinRub, setPriceMinRub] = useState('')
|
||||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = window.setTimeout(() => {
|
const t = window.setTimeout(() => {
|
||||||
@@ -54,10 +53,6 @@ export function useProductFilters() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCardScaleChange = (v: number) => {
|
|
||||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v as 70 | 90 | 110 | 130)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setCategorySlug('')
|
setCategorySlug('')
|
||||||
setQInput('')
|
setQInput('')
|
||||||
@@ -65,7 +60,6 @@ export function useProductFilters() {
|
|||||||
setPriceMinRub('')
|
setPriceMinRub('')
|
||||||
setPriceMaxRub('')
|
setPriceMaxRub('')
|
||||||
setPageSize(12)
|
setPageSize(12)
|
||||||
setCardScale(90)
|
|
||||||
setMoreOpen(false)
|
setMoreOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +80,6 @@ export function useProductFilters() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
priceMinRub,
|
priceMinRub,
|
||||||
priceMaxRub,
|
priceMaxRub,
|
||||||
cardScale,
|
|
||||||
setPage,
|
setPage,
|
||||||
setQInput,
|
setQInput,
|
||||||
setMoreOpen,
|
setMoreOpen,
|
||||||
@@ -95,7 +88,6 @@ export function useProductFilters() {
|
|||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
handlePriceMinChange,
|
handlePriceMinChange,
|
||||||
handlePriceMaxChange,
|
handlePriceMaxChange,
|
||||||
handleCardScaleChange,
|
|
||||||
resetFilters,
|
resetFilters,
|
||||||
toCents,
|
toCents,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function HomePage() {
|
|||||||
const products = productsQuery.data?.items ?? []
|
const products = productsQuery.data?.items ?? []
|
||||||
const total = productsQuery.data?.total ?? 0
|
const total = productsQuery.data?.total ?? 0
|
||||||
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
||||||
const mediaHeight = Math.round(200 * (filters.cardScale / 100))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -73,7 +72,7 @@ export function HomePage() {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
mb: 3,
|
mb: 3,
|
||||||
aspectRatio: { xs: '4/3', sm: '21/9' },
|
aspectRatio: { xs: '4/3', sm: '21/9' },
|
||||||
maxHeight: { xs: 320, sm: 400 },
|
maxHeight: { xs: 400, sm: 500 },
|
||||||
bgcolor: 'action.hover',
|
bgcolor: 'action.hover',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -100,8 +99,8 @@ export function HomePage() {
|
|||||||
{productsQuery.isLoading && (
|
{productsQuery.isLoading && (
|
||||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={i}>
|
||||||
<Skeleton variant="rectangular" height={360} />
|
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '3/4' }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -128,10 +127,9 @@ export function HomePage() {
|
|||||||
<>
|
<>
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
{products.map((p) => (
|
{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
|
<ProductCard
|
||||||
product={p}
|
product={p}
|
||||||
mediaHeight={mediaHeight}
|
|
||||||
actions={!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined}
|
actions={!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Box from '@mui/material/Box'
|
|||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
import Collapse from '@mui/material/Collapse'
|
import Collapse from '@mui/material/Collapse'
|
||||||
import Divider from '@mui/material/Divider'
|
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
import InputAdornment from '@mui/material/InputAdornment'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
@@ -12,9 +11,6 @@ import Paper from '@mui/material/Paper'
|
|||||||
import Select from '@mui/material/Select'
|
import Select from '@mui/material/Select'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
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 { Search, SlidersHorizontal } from 'lucide-react'
|
||||||
import type { Category } from '@/entities/product/model/types'
|
import type { Category } from '@/entities/product/model/types'
|
||||||
import type { UseProductFiltersResult } from '../lib/use-product-filters'
|
import type { UseProductFiltersResult } from '../lib/use-product-filters'
|
||||||
@@ -32,7 +28,6 @@ export function ProductFilters({
|
|||||||
pageSize,
|
pageSize,
|
||||||
priceMinRub,
|
priceMinRub,
|
||||||
priceMaxRub,
|
priceMaxRub,
|
||||||
cardScale,
|
|
||||||
categories,
|
categories,
|
||||||
categoriesLoading,
|
categoriesLoading,
|
||||||
setQInput,
|
setQInput,
|
||||||
@@ -42,7 +37,6 @@ export function ProductFilters({
|
|||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
handlePriceMinChange,
|
handlePriceMinChange,
|
||||||
handlePriceMaxChange,
|
handlePriceMaxChange,
|
||||||
handleCardScaleChange,
|
|
||||||
resetFilters,
|
resetFilters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const categoriesForFilter = useMemo(() => {
|
const categoriesForFilter = useMemo(() => {
|
||||||
@@ -187,40 +181,6 @@ export function ProductFilters({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</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>
|
</Paper>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Link as RouterLink } from 'react-router-dom'
|
|||||||
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
||||||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||||
import { fetchAdminAvatar } from '@/entities/user/api/user-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 { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
@@ -125,7 +125,7 @@ export function MessagesPage() {
|
|||||||
№{c.orderId.slice(-6)}
|
№{c.orderId.slice(-6)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="span" variant="caption" color="text.secondary">
|
<Typography component="span" variant="caption" color="text.secondary">
|
||||||
· {orderStatusLabelRu(c.status)}
|
· {ORDER_STATUS_MAP[c.status] ?? c.status}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ export function MessagesPage() {
|
|||||||
<Typography variant="h6">
|
<Typography variant="h6">
|
||||||
Чат заказа №{order.id.slice(-6)}{' '}
|
Чат заказа №{order.id.slice(-6)}{' '}
|
||||||
<Typography component="span" variant="body2" color="text.secondary">
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
({orderStatusLabelRu(order.status)})
|
({ORDER_STATUS_MAP[order.status] ?? order.status})
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
|
<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 { ORDER_STATUSES } from '@/shared/constants/order'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
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'
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
|
|
||||||
export function OrdersPage() {
|
export function OrdersPage() {
|
||||||
@@ -40,7 +40,7 @@ export function OrdersPage() {
|
|||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<Box key={group.status}>
|
<Box key={group.status}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
{orderStatusLabelRu(group.status)} ({group.items.length})
|
{ORDER_STATUS_MAP[group.status] ?? group.status} ({group.items.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{group.items.map((o) => (
|
{group.items.map((o) => (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function ProductPage() {
|
|||||||
if (productQuery.isLoading) {
|
if (productQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<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="60%" />
|
||||||
<Skeleton variant="text" width="40%" />
|
<Skeleton variant="text" width="40%" />
|
||||||
<Skeleton variant="text" />
|
<Skeleton variant="text" />
|
||||||
@@ -72,121 +72,128 @@ export function ProductPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={{ xs: 3, md: 4 }}>
|
||||||
{imageUrls.length > 0 ? (
|
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0 }}>
|
||||||
<Box
|
{imageUrls.length > 0 ? (
|
||||||
sx={{
|
<Box
|
||||||
borderRadius: '20px 20px 12px 12px',
|
sx={{
|
||||||
overflow: 'hidden',
|
borderRadius: '20px 20px 12px 12px',
|
||||||
border: 'none',
|
overflow: 'hidden',
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
border: 'none',
|
||||||
bgcolor: 'background.paper',
|
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
||||||
}}
|
bgcolor: 'background.paper',
|
||||||
>
|
width: '100%',
|
||||||
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: 420 }}>
|
aspectRatio: '3/4',
|
||||||
{imageUrls.map((url, idx) => (
|
}}
|
||||||
<SwiperSlide key={url}>
|
>
|
||||||
<Box
|
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: '100%' }}>
|
||||||
onClick={() => {
|
{imageUrls.map((url, idx) => (
|
||||||
setViewerIndex(idx)
|
<SwiperSlide key={url}>
|
||||||
setViewerOpen(true)
|
<Box
|
||||||
}}
|
onClick={() => {
|
||||||
sx={{
|
setViewerIndex(idx)
|
||||||
width: '100%',
|
setViewerOpen(true)
|
||||||
height: 420,
|
}}
|
||||||
cursor: 'zoom-in',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OptimizedImage
|
|
||||||
src={url}
|
|
||||||
alt={p.title}
|
|
||||||
sizes="(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px"
|
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
cursor: 'zoom-in',
|
||||||
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Box>
|
<OptimizedImage
|
||||||
</SwiperSlide>
|
src={url}
|
||||||
))}
|
alt={p.title}
|
||||||
</Swiper>
|
sizes="(max-width: 900px) 100vw, 50vw"
|
||||||
</Box>
|
sx={{
|
||||||
) : (
|
width: '100%',
|
||||||
<Box
|
height: '100%',
|
||||||
sx={{
|
objectFit: 'cover',
|
||||||
height: 420,
|
}}
|
||||||
borderRadius: 2,
|
/>
|
||||||
overflow: 'hidden',
|
</Box>
|
||||||
border: 1,
|
</SwiperSlide>
|
||||||
borderColor: 'divider',
|
))}
|
||||||
bgcolor: 'grey.100',
|
</Swiper>
|
||||||
display: 'flex',
|
</Box>
|
||||||
alignItems: 'center',
|
) : (
|
||||||
justifyContent: 'center',
|
<Box
|
||||||
}}
|
sx={{
|
||||||
>
|
width: '100%',
|
||||||
<Typography color="text.secondary">Нет фото</Typography>
|
aspectRatio: '3/4',
|
||||||
</Box>
|
borderRadius: 2,
|
||||||
)}
|
overflow: 'hidden',
|
||||||
|
border: 1,
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
borderColor: 'divider',
|
||||||
{p.category?.name && <Chip label={p.category.name} />}
|
bgcolor: 'grey.100',
|
||||||
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
|
display: 'flex',
|
||||||
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">Нет фото</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{(p.materials?.length ?? 0) > 0 && (
|
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Box>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
{p.category?.name && <Chip label={p.category.name} />}
|
||||||
Материалы
|
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
|
||||||
</Typography>
|
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
{(p.materials ?? []).map((m) => (
|
|
||||||
<Chip key={m} label={m} variant="outlined" />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
|
{(p.materials?.length ?? 0) > 0 && (
|
||||||
{p.title}
|
<Box>
|
||||||
</Typography>
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
Материалы
|
||||||
{formatPriceRub(p.priceCents)}
|
</Typography>
|
||||||
</Typography>
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{(p.materials ?? []).map((m) => (
|
||||||
|
<Chip key={m} label={m} variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{formatPriceRub(p.priceCents)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{p.description || p.shortDescription ? (
|
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
{p.description || p.shortDescription ? (
|
||||||
|
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Divider sx={{ my: { xs: 3, md: 4 } }} />
|
||||||
Отзывы
|
|
||||||
</Typography>
|
|
||||||
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
|
|
||||||
<Rating
|
|
||||||
value={p.reviewsSummary.avgRating ?? 0}
|
|
||||||
readOnly
|
|
||||||
precision={0.25}
|
|
||||||
icon={<Star fontSize="inherit" />}
|
|
||||||
emptyIcon={<Star fontSize="inherit" />}
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProductReviewsList productId={id} />
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
</Box>
|
Отзывы
|
||||||
|
</Typography>
|
||||||
|
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
|
||||||
|
<Rating
|
||||||
|
value={p.reviewsSummary.avgRating ?? 0}
|
||||||
|
readOnly
|
||||||
|
precision={0.25}
|
||||||
|
icon={<Star fontSize="inherit" />}
|
||||||
|
emptyIcon={<Star fontSize="inherit" />}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductReviewsList productId={id} />
|
||||||
|
|
||||||
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
||||||
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
|
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
config.headers.delete('content-type')
|
config.headers.delete('content-type')
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[api-client] Failed to set auth token', err)
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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_NAME = 'Комарова Лариса Николаевна'
|
||||||
export const STORE_OP_TYPE = 'Самозанятый'
|
export const STORE_OP_TYPE = 'Самозанятый'
|
||||||
export const STORE_OP_INN = '591878584346'
|
export const STORE_OP_INN = '591878584346'
|
||||||
export const STORE_OP_ADDR =
|
export const STORE_OP_ADDR = '618900, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
|
||||||
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34, кв. 24'
|
|
||||||
|
/** Демо-режим: баннеры «скоро открытие», предупреждения в чекауте. Включается через VITE_DEMO_MODE=true. */
|
||||||
|
export const IS_DEMO_MODE = import.meta.env.VITE_DEMO_MODE === 'true'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const PICKUP_COORDINATES = { lat: 58.09898000206914, lng: 57.813169680997
|
|||||||
|
|
||||||
/** Полная строка адреса для текстовых блоков. */
|
/** Полная строка адреса для текстовых блоков. */
|
||||||
export const PICKUP_ADDRESS_FULL =
|
export const PICKUP_ADDRESS_FULL =
|
||||||
'618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
|
'618900, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34'
|
||||||
|
|
||||||
/** Короткий адрес для компактных блоков. */
|
/** Короткий адрес для компактных блоков. */
|
||||||
export const PICKUP_ADDRESS_SHORT = 'Лысьва, ул. Мира, 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
export function getApiErrorMessage(err: unknown): string | null {
|
import { isAxiosError } from 'axios'
|
||||||
if (!err || typeof err !== 'object') return null
|
|
||||||
const anyErr = err as Record<string, unknown>
|
export function getApiErrorMessage(error: unknown): string | null {
|
||||||
const response = anyErr.response as Record<string, unknown> | undefined
|
if (!error) return null
|
||||||
const data = response?.data as Record<string, unknown> | undefined
|
|
||||||
const msg = data?.error
|
if (isAxiosError(error)) {
|
||||||
return typeof msg === 'string' ? msg : null
|
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
|
if (!json) return null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(json) as OrderAddressSnapshot
|
return JSON.parse(json) as OrderAddressSnapshot
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[order-address-snapshot] Failed to parse address snapshot', err)
|
||||||
return null
|
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 {
|
export function getOrderStatusData(code: string): OrderStatusData | undefined {
|
||||||
return ORDER_STATUS_DATA.find((s) => s.code === code)
|
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
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,8 @@ const TOKEN_KEY = 'craftshop_auth_token'
|
|||||||
export function readStoredToken(): string | null {
|
export function readStoredToken(): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[persist-token] Failed to read from localStorage', err)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,15 +13,15 @@ export function persistToken(token: string | null): void {
|
|||||||
try {
|
try {
|
||||||
if (!token) localStorage.removeItem(TOKEN_KEY)
|
if (!token) localStorage.removeItem(TOKEN_KEY)
|
||||||
else localStorage.setItem(TOKEN_KEY, token)
|
else localStorage.setItem(TOKEN_KEY, token)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.warn('[persist-token] Failed to write to localStorage', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeStoredToken(): void {
|
export function removeStoredToken(): void {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ import { useLocation } from 'react-router-dom'
|
|||||||
|
|
||||||
const BASE_TITLE = 'Любимый Креатив — Изделия ручной работы'
|
const BASE_TITLE = 'Любимый Креатив — Изделия ручной работы'
|
||||||
|
|
||||||
let currentTitle: string = BASE_TITLE
|
let didPageTitleSet = false
|
||||||
|
|
||||||
export function usePageTitle(title: string | null) {
|
export function usePageTitle(title: string | null) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentTitle = title ? `${title} — Любимый Креатив` : BASE_TITLE
|
didPageTitleSet = true
|
||||||
document.title = currentTitle
|
document.title = title ? `${title} — Любимый Креатив` : BASE_TITLE
|
||||||
}, [title])
|
}, [title])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ export function usePageTitleReset() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = BASE_TITLE
|
if (!didPageTitleSet) {
|
||||||
currentTitle = BASE_TITLE
|
document.title = BASE_TITLE
|
||||||
|
}
|
||||||
|
didPageTitleSet = false
|
||||||
}, [location.pathname])
|
}, [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)
|
|
||||||
@@ -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)
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,8 @@ const STORAGE_KEY = 'cookie-consent-accepted'
|
|||||||
function wasAccepted(): boolean {
|
function wasAccepted(): boolean {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(STORAGE_KEY) === '1'
|
return localStorage.getItem(STORAGE_KEY) === '1'
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn('[cookie-consent] Failed to read cookie consent', err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +19,8 @@ function wasAccepted(): boolean {
|
|||||||
function markAccepted() {
|
function markAccepted() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, '1')
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.warn('[cookie-consent] Failed to persist cookie consent', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor || editor.isDestroyed) return
|
||||||
const normalizedValue = value.trim() ? value : '<p></p>'
|
const normalizedValue = value.trim() ? value : '<p></p>'
|
||||||
if (editor.getHTML() === normalizedValue) return
|
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.commands.setContent(normalizedValue, { emitUpdate: false })
|
||||||
}, [editor, value])
|
}, [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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Vendored
+1
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_STORE_EMAIL?: string
|
readonly VITE_STORE_EMAIL?: string
|
||||||
readonly VITE_STORE_PHONE?: string
|
readonly VITE_STORE_PHONE?: string
|
||||||
readonly VITE_STORE_SOCIAL_NOTE?: string
|
readonly VITE_STORE_SOCIAL_NOTE?: string
|
||||||
|
readonly VITE_DEMO_MODE?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: { xs: '4/3', sm: '21/9' },
|
aspectRatio: { xs: '4/3', sm: '21/9' },
|
||||||
maxHeight: { xs: 320, sm: 400 },
|
maxHeight: { xs: 400, sm: 500 },
|
||||||
bgcolor: 'action.hover',
|
bgcolor: 'action.hover',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export function ReviewsBlock() {
|
|||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
|
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
|
||||||
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
<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 variant="body2" color="text.secondary">
|
||||||
Последние отзывы о товарах
|
Последние отзывы о товарах
|
||||||
</Typography>
|
</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
@@ -51,11 +51,12 @@ await fastify.register(cors, {
|
|||||||
|
|
||||||
await registerSecurityHeaders(fastify)
|
await registerSecurityHeaders(fastify)
|
||||||
|
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async (request) => {
|
||||||
try {
|
try {
|
||||||
await prisma.$queryRaw`SELECT 1`
|
await prisma.$queryRaw`SELECT 1`
|
||||||
return { status: 'ok', database: 'connected', uptime: process.uptime() }
|
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() }
|
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}`
|
request.headers.authorization = `Bearer ${request.query.token}`
|
||||||
}
|
}
|
||||||
await request.jwtVerify()
|
await request.jwtVerify()
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'JWT verification failed')
|
||||||
return reply.code(401).send({ error: 'Не авторизован' })
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ export function registerAuth(fastify) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify()
|
await request.jwtVerify()
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[auth] verifyAdmin failed')
|
||||||
return reply.code(401).send({ error: 'Не авторизован' })
|
return reply.code(401).send({ error: 'Не авторизован' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { asyncHandler } from '../../lib/async-handler.js'
|
||||||
import {
|
import {
|
||||||
getOrCreateUnspecifiedCategory,
|
getOrCreateUnspecifiedCategory,
|
||||||
isUnspecifiedCategorySlug,
|
isUnspecifiedCategorySlug,
|
||||||
@@ -6,20 +7,21 @@ import {
|
|||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerAdminCategoryRoutes(fastify) {
|
export async function registerAdminCategoryRoutes(fastify) {
|
||||||
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
fastify.get(
|
||||||
try {
|
'/api/admin/categories',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const items = await prisma.category.findMany({
|
const items = await prisma.category.findMany({
|
||||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||||
})
|
})
|
||||||
return { items }
|
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) => {
|
fastify.post(
|
||||||
try {
|
'/api/admin/categories',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const name = String(body.name ?? '').trim()
|
const name = String(body.name ?? '').trim()
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -45,14 +47,13 @@ export async function registerAdminCategoryRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
reply.code(201).send(category)
|
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) => {
|
fastify.patch(
|
||||||
try {
|
'/api/admin/categories/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
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 })
|
const updated = await prisma.category.update({ where: { id }, data })
|
||||||
return updated
|
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) => {
|
fastify.delete(
|
||||||
try {
|
'/api/admin/categories/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
const existing = await prisma.category.findUnique({ where: { id } })
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -133,9 +133,6 @@ export async function registerAdminCategoryRoutes(fastify) {
|
|||||||
prisma.category.delete({ where: { id } }),
|
prisma.category.delete({ where: { id } }),
|
||||||
])
|
])
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
} catch (err) {
|
}),
|
||||||
request.log.error(err)
|
)
|
||||||
return reply.code(500).send({ error: 'Не удалось удалить категорию' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { asyncHandler } from '../../lib/async-handler.js'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||||
import {
|
import {
|
||||||
@@ -72,21 +73,23 @@ export async function registerAdminGalleryRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.post('/api/admin/gallery/:id/resize', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
fastify.post(
|
||||||
const { id } = request.params
|
'/api/admin/gallery/:id/resize',
|
||||||
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
if (!row) {
|
asyncHandler(async (request, reply) => {
|
||||||
return reply.code(404).send({ error: 'Изображение не найдено' })
|
const { id } = request.params
|
||||||
}
|
const row = await prisma.galleryImage.findUnique({ where: { id } })
|
||||||
if (row.isResized) {
|
if (!row) {
|
||||||
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
return reply.code(404).send({ error: 'Изображение не найдено' })
|
||||||
}
|
}
|
||||||
|
if (row.isResized) {
|
||||||
|
return reply.code(409).send({ error: 'Изображение уже обработано' })
|
||||||
|
}
|
||||||
|
|
||||||
const urlParts = row.url.replace(/^\//, '').split('/')
|
const urlParts = row.url.replace(/^\//, '').split('/')
|
||||||
const fileName = urlParts[urlParts.length - 1]
|
const fileName = urlParts[urlParts.length - 1]
|
||||||
const uuid = path.parse(fileName).name
|
const uuid = path.parse(fileName).name
|
||||||
|
|
||||||
try {
|
|
||||||
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
|
||||||
|
|
||||||
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
|
||||||
@@ -99,11 +102,8 @@ export async function registerAdminGalleryRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { url: newUrl }
|
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) => {
|
fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { validateGalleryImages } from '../../lib/validate-gallery-images.js'
|
||||||
|
|
||||||
const CREATE_PRODUCT_SCHEMA = {
|
const CREATE_PRODUCT_SCHEMA = {
|
||||||
body: {
|
body: {
|
||||||
@@ -87,20 +88,10 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
|
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
|
||||||
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const galleryImages = await prisma.galleryImage.findMany({
|
try {
|
||||||
where: { url: { in: urls } },
|
await validateGalleryImages(prisma, urls)
|
||||||
select: { url: true, isResized: true },
|
} catch (err) {
|
||||||
})
|
return reply.code(err.statusCode || 400).send({ error: err.message })
|
||||||
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) перед прикреплением к товару' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,20 +205,10 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
|
if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
|
||||||
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const galleryImages = await prisma.galleryImage.findMany({
|
try {
|
||||||
where: { url: { in: urls } },
|
await validateGalleryImages(prisma, urls)
|
||||||
select: { url: true, isResized: true },
|
} catch (err) {
|
||||||
})
|
return reply.code(err.statusCode || 400).send({ error: err.message })
|
||||||
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) перед прикреплением к товару' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +241,8 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
try {
|
try {
|
||||||
await prisma.product.delete({ where: { id } })
|
await prisma.product.delete({ where: { id } })
|
||||||
reply.code(204).send()
|
reply.code(204).send()
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[admin-products] Operation failed')
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
try {
|
try {
|
||||||
await prisma.user.delete({ where: { id } })
|
await prisma.user.delete({ where: { id } })
|
||||||
reply.code(204).send()
|
reply.code(204).send()
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[admin-users] Operation failed')
|
||||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { asyncHandler } from '../../lib/async-handler.js'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
const MAX_SLIDES = 20
|
const MAX_SLIDES = 20
|
||||||
|
|
||||||
export async function registerCatalogSliderRoutes(fastify) {
|
export async function registerCatalogSliderRoutes(fastify) {
|
||||||
fastify.get('/api/catalog-slider', async (request, reply) => {
|
fastify.get(
|
||||||
try {
|
'/api/catalog-slider',
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: { galleryImage: true },
|
include: { galleryImage: true },
|
||||||
@@ -17,14 +19,13 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
textColor: s.textColor,
|
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) => {
|
fastify.get(
|
||||||
try {
|
'/api/admin/catalog-slider',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: { galleryImage: true },
|
include: { galleryImage: true },
|
||||||
@@ -38,14 +39,13 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
textColor: s.textColor,
|
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) => {
|
fastify.put(
|
||||||
try {
|
'/api/admin/catalog-slider',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const rawSlides = body.slides
|
const rawSlides = body.slides
|
||||||
if (!Array.isArray(rawSlides)) {
|
if (!Array.isArray(rawSlides)) {
|
||||||
@@ -103,9 +103,6 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
textColor: s.textColor,
|
textColor: s.textColor,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}),
|
||||||
request.log.error(err)
|
)
|
||||||
return reply.code(500).send({ error: 'Не удалось обновить слайдер' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ item: created })
|
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: 'Вы уже оставляли отзыв на этот товар' })
|
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ export async function registerSseRoutes(fastify) {
|
|||||||
if (closed) return
|
if (closed) return
|
||||||
try {
|
try {
|
||||||
reply.raw.write(chunk)
|
reply.raw.write(chunk)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[sse] safeWrite failed')
|
||||||
closed = true
|
closed = true
|
||||||
cleanUp()
|
cleanUp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { asyncHandler } from '../lib/async-handler.js'
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
function normalizePhoneLite(input) {
|
function normalizePhoneLite(input) {
|
||||||
@@ -45,22 +46,23 @@ function validateAddressPayload(body, reply) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUserAddressRoutes(fastify) {
|
export async function registerUserAddressRoutes(fastify) {
|
||||||
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get(
|
||||||
try {
|
'/api/me/addresses',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const items = await prisma.shippingAddress.findMany({
|
const items = await prisma.shippingAddress.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||||
})
|
})
|
||||||
return { items }
|
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) => {
|
fastify.post(
|
||||||
try {
|
'/api/me/addresses',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const validated = validateAddressPayload(request.body, reply)
|
const validated = validateAddressPayload(request.body, reply)
|
||||||
if (!validated) return
|
if (!validated) return
|
||||||
@@ -79,14 +81,13 @@ export async function registerUserAddressRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ item: created })
|
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) => {
|
fastify.patch(
|
||||||
try {
|
'/api/me/addresses/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
@@ -161,14 +162,13 @@ export async function registerUserAddressRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { item: updated }
|
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) => {
|
fastify.delete(
|
||||||
try {
|
'/api/me/addresses/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
@@ -176,14 +176,13 @@ export async function registerUserAddressRoutes(fastify) {
|
|||||||
|
|
||||||
await prisma.shippingAddress.delete({ where: { id } })
|
await prisma.shippingAddress.delete({ where: { id } })
|
||||||
return reply.code(204).send()
|
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) => {
|
fastify.post(
|
||||||
try {
|
'/api/me/addresses/:id/default',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
@@ -195,9 +194,6 @@ export async function registerUserAddressRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { item: updated }
|
return { item: updated }
|
||||||
} catch (err) {
|
}),
|
||||||
request.log.error(err)
|
)
|
||||||
return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { asyncHandler } from '../lib/async-handler.js'
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerUserCartRoutes(fastify) {
|
export async function registerUserCartRoutes(fastify) {
|
||||||
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get(
|
||||||
try {
|
'/api/me/cart',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const items = await prisma.cartItem.findMany({
|
const items = await prisma.cartItem.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -16,14 +19,13 @@ export async function registerUserCartRoutes(fastify) {
|
|||||||
product: x.product,
|
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) => {
|
fastify.post(
|
||||||
try {
|
'/api/me/cart/items',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const productId = String(request.body?.productId || '').trim()
|
const productId = String(request.body?.productId || '').trim()
|
||||||
const qtyRaw = request.body?.qty
|
const qtyRaw = request.body?.qty
|
||||||
@@ -46,14 +48,13 @@ export async function registerUserCartRoutes(fastify) {
|
|||||||
create: { userId, productId, qty: nextQty },
|
create: { userId, productId, qty: nextQty },
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ item })
|
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) => {
|
fastify.patch(
|
||||||
try {
|
'/api/me/cart/items/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const qtyRaw = request.body?.qty
|
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 } })
|
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||||
return { item: updated }
|
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) => {
|
fastify.delete(
|
||||||
try {
|
'/api/me/cart/items/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
} catch (err) {
|
}),
|
||||||
request.log.error(err)
|
)
|
||||||
return reply.code(500).send({ error: 'Не удалось удалить из корзины' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
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 { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerUserMessageRoutes(fastify) {
|
export async function registerUserMessageRoutes(fastify) {
|
||||||
fastify.get('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get(
|
||||||
const userId = request.user.sub
|
'/api/me/orders/:id/messages',
|
||||||
const { id } = request.params
|
{ preHandler: [fastify.authenticate] },
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
asyncHandler(async (request, reply) => {
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
const userId = request.user.sub
|
||||||
const items = await prisma.orderMessage.findMany({
|
const { id } = request.params
|
||||||
where: { orderId: id },
|
await findUserOrder(prisma, id, userId)
|
||||||
orderBy: { createdAt: 'asc' },
|
const items = await prisma.orderMessage.findMany({
|
||||||
})
|
where: { orderId: id },
|
||||||
return { items }
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
})
|
||||||
|
return { items }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
fastify.post('/api/me/orders/:id/messages', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user.sub
|
'/api/me/orders/:id/messages',
|
||||||
const { id } = request.params
|
{ preHandler: [fastify.authenticate] },
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
asyncHandler(async (request, reply) => {
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
const userId = request.user.sub
|
||||||
const text = String(request.body?.text || '').trim()
|
const { id } = request.params
|
||||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
await findUserOrder(prisma, id, userId)
|
||||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
const text = String(request.body?.text || '').trim()
|
||||||
const msg = await prisma.orderMessage.create({
|
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||||
data: { orderId: id, authorType: 'user', text },
|
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||||
})
|
const msg = await prisma.orderMessage.create({
|
||||||
|
data: { orderId: id, authorType: 'user', text },
|
||||||
|
})
|
||||||
|
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
|
||||||
orderId: id,
|
orderId: id,
|
||||||
authorType: 'user',
|
authorType: 'user',
|
||||||
messageId: msg.id,
|
messageId: msg.id,
|
||||||
preview: text,
|
preview: text,
|
||||||
})
|
})
|
||||||
|
|
||||||
return reply.code(201).send({ item: msg })
|
return reply.code(201).send({ item: msg })
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
@@ -116,18 +124,21 @@ export async function registerUserMessageRoutes(fastify) {
|
|||||||
return { items }
|
return { items }
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.post('/api/me/orders/:id/messages/read', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user.sub
|
'/api/me/orders/:id/messages/read',
|
||||||
const { id } = request.params
|
{ preHandler: [fastify.authenticate] },
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
asyncHandler(async (request, reply) => {
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
await findUserOrder(prisma, id, userId)
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
await prisma.userOrderMessageReadState.upsert({
|
await prisma.userOrderMessageReadState.upsert({
|
||||||
where: { userId_orderId: { userId, orderId: id } },
|
where: { userId_orderId: { userId, orderId: id } },
|
||||||
create: { userId, orderId: id, lastReadAt: now },
|
create: { userId, orderId: id, lastReadAt: now },
|
||||||
update: { lastReadAt: now },
|
update: { lastReadAt: now },
|
||||||
})
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
||||||
|
import { asyncHandler } from '../lib/async-handler.js'
|
||||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||||
|
import { findUserOrder } from '../lib/find-user-order.js'
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerUserOrderRoutes(fastify) {
|
export async function registerUserOrderRoutes(fastify) {
|
||||||
@@ -176,8 +178,10 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
return reply.code(201).send({ orderId: created.id })
|
return reply.code(201).send({ orderId: created.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.get(
|
||||||
try {
|
'/api/me/orders',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const orders = await prisma.order.findMany({
|
const orders = await prisma.order.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -195,39 +199,30 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
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) => {
|
fastify.get(
|
||||||
try {
|
'/api/me/orders/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const order = await prisma.order.findFirst({
|
const order = await findUserOrder(prisma, id, userId, {
|
||||||
where: { id, userId },
|
items: true,
|
||||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
messages: { orderBy: { createdAt: 'asc' } },
|
||||||
})
|
})
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
return { item: order }
|
return { item: order }
|
||||||
} catch (err) {
|
}),
|
||||||
request.log.error(err)
|
)
|
||||||
return reply.code(500).send({ error: 'Не удалось загрузить заказ' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/api/me/orders/:id/review-eligibility',
|
'/api/me/orders/:id/review-eligibility',
|
||||||
{ preHandler: [fastify.authenticate] },
|
{ preHandler: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
asyncHandler(async (request, reply) => {
|
||||||
const userId = request.user.sub
|
const userId = request.user.sub
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const order = await prisma.order.findFirst({
|
const order = await findUserOrder(prisma, id, userId, { items: true })
|
||||||
where: { id, userId },
|
|
||||||
include: { items: true },
|
|
||||||
})
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
if (order.status !== 'DONE') {
|
if (order.status !== 'DONE') {
|
||||||
return { canReview: false, items: [] }
|
return { canReview: false, items: [] }
|
||||||
}
|
}
|
||||||
@@ -254,31 +249,25 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
hasReview: reviewed.has(x.productId),
|
hasReview: reviewed.has(x.productId),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/api/me/orders/:id/confirm-received',
|
'/api/me/orders/:id/confirm-received',
|
||||||
{ preHandler: [fastify.authenticate] },
|
{ preHandler: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
asyncHandler(async (request, reply) => {
|
||||||
try {
|
const userId = request.user.sub
|
||||||
const userId = request.user.sub
|
const { id } = request.params
|
||||||
const { id } = request.params
|
const order = await findUserOrder(prisma, id, userId)
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||||
if (!okDelivery && !okPickup) {
|
if (!okDelivery && !okPickup) {
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||||
}
|
|
||||||
|
|
||||||
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: 'Не удалось подтвердить получение' })
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||||
|
return { ok: true, status: 'DONE' }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-139
@@ -1,148 +1,154 @@
|
|||||||
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
|
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 { prisma } from '../lib/prisma.js'
|
||||||
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
|
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
|
||||||
|
|
||||||
export async function registerUserPaymentRoutes(fastify) {
|
export async function registerUserPaymentRoutes(fastify) {
|
||||||
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user.sub
|
'/api/me/orders/:id/pay',
|
||||||
const userEmail = request.user.email
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
asyncHandler(async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const userEmail = request.user.email
|
||||||
|
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
|
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = request.params
|
|
||||||
|
|
||||||
const order = await prisma.order.findFirst({
|
|
||||||
where: { id, userId },
|
|
||||||
include: { items: true },
|
|
||||||
})
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
if (order.paymentMethod === 'on_pickup') {
|
|
||||||
return reply.code(409).send({
|
|
||||||
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status !== 'PENDING_PAYMENT') {
|
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order.deliveryFeeLocked) {
|
|
||||||
return reply.code(409).send({
|
|
||||||
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingPayment = await prisma.payment.findFirst({
|
|
||||||
where: {
|
|
||||||
orderId: id,
|
|
||||||
status: { in: ['pending', 'waiting_for_capture'] },
|
|
||||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingPayment && existingPayment.confirmationUrl) {
|
|
||||||
return { confirmationUrl: existingPayment.confirmationUrl }
|
|
||||||
}
|
|
||||||
|
|
||||||
const idempotencyKey = `${id}-${Date.now()}`
|
|
||||||
const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
|
||||||
const returnUrl = `${clientUrl}/me/orders/${id}?paid=1`
|
|
||||||
const clientIp = request.ip
|
|
||||||
|
|
||||||
const amount = {
|
|
||||||
value: (order.totalCents / 100).toFixed(2),
|
|
||||||
currency: order.currency,
|
|
||||||
}
|
|
||||||
|
|
||||||
const receipt = buildReceipt({
|
|
||||||
orderItems: order.items,
|
|
||||||
deliveryFeeCents: order.deliveryFeeCents,
|
|
||||||
userEmail: userEmail,
|
|
||||||
})
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await createPayment({
|
|
||||||
amount,
|
|
||||||
description: `Оплата заказа №${order.id.slice(-6)}`,
|
|
||||||
receipt,
|
|
||||||
confirmation: { type: 'redirect', return_url: returnUrl },
|
|
||||||
metadata: { orderId: order.id },
|
|
||||||
idempotencyKey,
|
|
||||||
clientIp,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
|
|
||||||
return reply.code(502).send({
|
|
||||||
error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.payment.create({
|
|
||||||
data: {
|
|
||||||
orderId: order.id,
|
|
||||||
yookassaPaymentId: result.paymentId,
|
|
||||||
status: result.status,
|
|
||||||
amountCents: order.totalCents,
|
|
||||||
currency: order.currency,
|
|
||||||
confirmationUrl: result.confirmationUrl,
|
|
||||||
expiresAt: result.expiresAt ? new Date(result.expiresAt) : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return { confirmationUrl: result.confirmationUrl }
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get('/api/me/orders/:orderId/payment', { preHandler: [fastify.authenticate] }, 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 payment = await prisma.payment.findFirst({
|
|
||||||
where: { orderId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
if (!payment) {
|
|
||||||
return { status: null, paid: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.status === 'succeeded' || payment.status === 'canceled') {
|
|
||||||
return { status: payment.status, paid: payment.status === 'succeeded' }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ykPayment = await getPayment(payment.yookassaPaymentId)
|
|
||||||
|
|
||||||
if (ykPayment.status !== payment.status) {
|
|
||||||
await prisma.payment.update({
|
|
||||||
where: { id: payment.id },
|
|
||||||
data: { status: ykPayment.status },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') {
|
|
||||||
const updated = await prisma.order.updateMany({
|
|
||||||
where: { id: orderId, status: 'PENDING_PAYMENT' },
|
|
||||||
data: { status: 'PAID' },
|
|
||||||
})
|
|
||||||
if (updated.count > 0) {
|
|
||||||
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
|
||||||
orderId,
|
|
||||||
userId: order.userId,
|
|
||||||
paymentStatus: 'paid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: ykPayment.status, paid: ykPayment.paid }
|
const { id } = request.params
|
||||||
} catch {
|
|
||||||
return { status: payment.status, paid: payment.status === 'succeeded' }
|
const order = await findUserOrder(prisma, id, userId, { items: true })
|
||||||
}
|
|
||||||
})
|
if (order.paymentMethod === 'on_pickup') {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'PENDING_PAYMENT') {
|
||||||
|
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order.deliveryFeeLocked) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPayment = await prisma.payment.findFirst({
|
||||||
|
where: {
|
||||||
|
orderId: id,
|
||||||
|
status: { in: ['pending', 'waiting_for_capture'] },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingPayment && existingPayment.confirmationUrl) {
|
||||||
|
return { confirmationUrl: existingPayment.confirmationUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = `${id}-${Date.now()}`
|
||||||
|
const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
||||||
|
const returnUrl = `${clientUrl}/me/orders/${id}?paid=1`
|
||||||
|
const clientIp = request.ip
|
||||||
|
|
||||||
|
const amount = {
|
||||||
|
value: (order.totalCents / 100).toFixed(2),
|
||||||
|
currency: order.currency,
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = buildReceipt({
|
||||||
|
orderItems: order.items,
|
||||||
|
deliveryFeeCents: order.deliveryFeeCents,
|
||||||
|
userEmail: userEmail,
|
||||||
|
})
|
||||||
|
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await createPayment({
|
||||||
|
amount,
|
||||||
|
description: `Оплата заказа №${order.id.slice(-6)}`,
|
||||||
|
receipt,
|
||||||
|
confirmation: { type: 'redirect', return_url: returnUrl },
|
||||||
|
metadata: { orderId: order.id },
|
||||||
|
idempotencyKey,
|
||||||
|
clientIp,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
yookassaPaymentId: result.paymentId,
|
||||||
|
status: result.status,
|
||||||
|
amountCents: order.totalCents,
|
||||||
|
currency: order.currency,
|
||||||
|
confirmationUrl: result.confirmationUrl,
|
||||||
|
expiresAt: result.expiresAt ? new Date(result.expiresAt) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { confirmationUrl: result.confirmationUrl }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 findUserOrder(prisma, orderId, userId)
|
||||||
|
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: { orderId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
if (!payment) {
|
||||||
|
return { status: null, paid: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.status === 'succeeded' || payment.status === 'canceled') {
|
||||||
|
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ykPayment = await getPayment(payment.yookassaPaymentId)
|
||||||
|
|
||||||
|
if (ykPayment.status !== payment.status) {
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: { id: payment.id },
|
||||||
|
data: { status: ykPayment.status },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') {
|
||||||
|
const updated = await prisma.order.updateMany({
|
||||||
|
where: { id: orderId, status: 'PENDING_PAYMENT' },
|
||||||
|
data: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
if (updated.count > 0) {
|
||||||
|
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
|
||||||
|
orderId,
|
||||||
|
userId: order.userId,
|
||||||
|
paymentStatus: 'paid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: ykPayment.status, paid: ykPayment.paid }
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[user-payments] Operation failed')
|
||||||
|
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export async function registerYookassaWebhookRoute(fastify) {
|
|||||||
let body
|
let body
|
||||||
try {
|
try {
|
||||||
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
|
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' })
|
return reply.code(400).send({ error: 'Invalid JSON body' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user