initial: client

This commit is contained in:
Shop Deploy
2026-06-11 13:48:08 +05:00
commit a36f96c290
271 changed files with 28009 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { BrowserRouter } from 'react-router-dom'
import { AppProviders } from '@/app/providers/AppProviders'
import { AppRoutes } from '@/app/routes'
import { NotificationStack } from '@/shared/ui/NotificationStack'
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
import { DemoOverlay } from '@/shared/ui/DemoOverlay'
export function App() {
return (
<AppProviders>
<BrowserRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<NotificationStack />
<NoiseOverlay />
<DemoOverlay />
</BrowserRouter>
</AppProviders>
)
}
+202
View File
@@ -0,0 +1,202 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import { alpha, useTheme } from '@mui/material/styles'
import Toolbar from '@mui/material/Toolbar'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Menu, Package } from 'lucide-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useThemeController } from '@/app/providers/theme-controller'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { CartBadge } from '@/features/cart/cart-badge'
import { UserMenu } from '@/features/user/user-menu'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
import type { ColorScheme } from '@/shared/model/theme'
import { BearLogo } from '@/shared/ui/BearLogo'
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
import { NavigationDrawer } from '@/widgets/navigation-drawer'
type NavItem = { label: string; to: string }
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
export const AppHeader = React.memo(function AppHeader() {
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const isAdmin = Boolean(user?.isAdmin)
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user) && !isAdmin,
})
const cartCount = cartQuery.data?.items?.length ?? 0
const ordersQuery = useQuery({
queryKey: ['me', 'orders'],
queryFn: fetchMyOrders,
enabled: Boolean(user) && !isAdmin,
})
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
).length
const [mobileOpen, setMobileOpen] = useState(false)
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const handler = () => setScrolled(window.scrollY > 0)
handler()
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
const go = (to: string) => {
setMobileOpen(false)
navigate(to)
}
const onLogout = () => {
tokenSet(null)
logout()
setMobileOpen(false)
navigate('/')
}
return (
<>
<AppBar
position="sticky"
color="primary"
elevation={scrolled ? 2 : 0}
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: alpha(theme.palette.primary.main, 0.95),
backdropFilter: 'blur(8px)',
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
}}
>
<Toolbar
sx={{
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
}}
>
{isMobile && (
<IconButton
color="inherit"
onClick={() => setMobileOpen(true)}
aria-label="Открыть меню"
edge="start"
sx={{ mr: 1 }}
>
<Menu />
</IconButton>
)}
<Box
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
textDecoration: 'none',
color: 'inherit',
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{STORE_NAME}
</Typography>
</Box>
{!isMobile &&
headerNavItems.map((i) => (
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
{i.label}
</Button>
))}
{!isAdmin && (
<>
{user && (
<Tooltip title="Заказы">
<IconButton
color="inherit"
sx={{ ml: 1 }}
onClick={() => navigate('/me/orders')}
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
>
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
<Package />
</Badge>
</IconButton>
</Tooltip>
)}
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
</>
)}
{!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
{isAdmin && user && !isMobile && (
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
)}
{!isMobile && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: 'rgba(255, 255, 255, 0.25)',
borderRadius: 3,
px: 0.5,
py: 0.5,
}}
>
<SchemeSwitcher value={scheme} onChange={(s: ColorScheme) => setScheme(s)} />
</Box>
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={cycleMode} />
</Box>
)}
</Toolbar>
</AppBar>
<NavigationDrawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
user={user}
isAdmin={isAdmin}
navItems={headerNavItems}
scheme={scheme}
mode={mode}
resolvedMode={resolvedMode}
onSchemeChange={(s: ColorScheme) => setScheme(s)}
onCycleMode={cycleMode}
onNavigate={go}
onLogout={onLogout}
/>
</>
)
})
+144
View File
@@ -0,0 +1,144 @@
import { type PropsWithChildren } from 'react'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
import { DemoBanner } from '@/shared/ui/DemoBanner'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: 0, overflowX: 'hidden' }}>
<ScrollOnNavigate />
<ScrollToTop />
<AppHeader />
<DemoBanner />
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
{children}
</Container>
</Box>
<Box
component="footer"
sx={{
mt: 'auto',
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
py: { xs: 5, md: 7 },
}}
>
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
<Grid container spacing={5}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}
>
{STORE_NAME}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
Изделия ручной работы: вещи с характером и вниманием к деталям.
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Покупателям
</Typography>
<Stack spacing={1.5}>
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
Личный кабинет
</Link>
<Link component={RouterLink} to="/info" color="inherit" underline="hover" variant="body2">
О покупке
</Link>
<Link component={RouterLink} to="/about" color="inherit" underline="hover" variant="body2">
О нас
</Link>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Контакты
</Typography>
<Stack spacing={1}>
<Typography variant="body2">
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
{STORE_EMAIL}
</Link>
</Typography>
<Typography variant="body2">
Телефон:{' '}
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
{STORE_PHONE}
</Link>
</Typography>
<Link
href={VK_URL}
target="_blank"
rel="noopener noreferrer"
color="text.secondary"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
>
<Box component="img" src={vkLogoSrc} alt="" sx={{ width: 20, height: 20 }} />
VK
</Link>
</Stack>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography
variant="subtitle1"
component="h4"
gutterBottom
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
>
Юридическая информация
</Typography>
<Stack spacing={1.5}>
<Link component={RouterLink} to="/privacy" color="inherit" underline="hover" variant="body2">
Политика конфиденциальности
</Link>
<Link component={RouterLink} to="/terms" color="inherit" underline="hover" variant="body2">
Пользовательское соглашение
</Link>
</Stack>
</Grid>
</Grid>
<Divider sx={{ my: 4 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
© {year} {STORE_NAME}
</Typography>
</Box>
</Container>
</Box>
<CookieConsentBanner />
</Box>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { MainLayout } from '../MainLayout'
vi.mock('@/app/layout/AppHeader', () => ({
AppHeader: () => <header>Шапка</header>,
}))
vi.mock('@/shared/ui/CookieConsentBanner', () => ({
CookieConsentBanner: () => null,
}))
vi.mock('@/shared/ui/DemoBanner', () => ({
DemoBanner: () => null,
}))
vi.mock('@/shared/ui/ScrollOnNavigate', () => ({
ScrollOnNavigate: () => null,
}))
vi.mock('@/shared/ui/ScrollToTop', () => ({
ScrollToTop: () => null,
}))
describe('MainLayout', () => {
it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => {
const { container } = render(
<MemoryRouter>
<MainLayout>Контент</MainLayout>
</MemoryRouter>,
)
expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' })
})
})
+361
View File
@@ -0,0 +1,361 @@
import { type PropsWithChildren, useMemo } from 'react'
import CssBaseline from '@mui/material/CssBaseline'
import { alpha, ThemeProvider, createTheme } from '@mui/material/styles'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
import { SseProvider } from './SseProvider'
function AppThemeInner({ children }: PropsWithChildren) {
const controller = useThemeController()
const isDark = controller.resolvedMode === 'dark'
const theme = useMemo(
() =>
createTheme({
palette: (() => {
const common = { mode: controller.resolvedMode }
const text = isDark
? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' }
: { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' }
const chip = isDark ? { default: '#0E1510', paper: '#121B14' } : { default: '#F6FAF6', paper: '#FFFFFF' }
switch (controller.scheme) {
case 'forest':
return {
...common,
primary: { main: isDark ? '#8FBC8F' : '#2E8B57' },
secondary: { main: isDark ? '#CD853F' : '#8B4513' },
info: { main: isDark ? '#4682B4' : '#1E90FF' },
success: { main: isDark ? '#90EE90' : '#32CD32' },
warning: { main: isDark ? '#FFD700' : '#FFA500' },
error: { main: isDark ? '#F08080' : '#CD5C5C' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#0F1720', paper: '#1A242E' }
: { default: '#F8F6F3', paper: '#FFFFFF' },
}
case 'ocean':
return {
...common,
primary: { main: isDark ? '#5F9EA0' : '#20B2AA' },
secondary: { main: isDark ? '#7B68EE' : '#6A5ACD' },
info: { main: isDark ? '#87CEEB' : '#00BFFF' },
success: { main: isDark ? '#98FB98' : '#00FA9A' },
warning: { main: isDark ? '#FFE4B5' : '#FFDAB9' },
error: { main: isDark ? '#FF6347' : '#FF4500' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#0A1A2A', paper: '#0F1D35' }
: { default: '#F0F8FF', paper: '#FFFFFF' },
}
case 'berry':
return {
...common,
primary: { main: isDark ? '#9370DB' : '#8A2BE2' },
secondary: { main: isDark ? '#FF69B4' : '#FF1493' },
info: { main: isDark ? '#00CED1' : '#00BFFF' },
success: { main: isDark ? '#00FF7F' : '#7CFC00' },
warning: { main: isDark ? '#FFD700' : '#FFA500' },
error: { main: isDark ? '#FF4500' : '#FF6347' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#1A0A1A', paper: '#250E25' }
: { default: '#FFF0F5', paper: '#FFFFFF' },
}
case 'craft':
default:
return {
...common,
primary: { main: isDark ? '#90A4AE' : '#546E7A' },
secondary: { main: isDark ? '#78909C' : '#78909C' },
info: { main: isDark ? '#7986CB' : '#3F51B5' },
success: { main: isDark ? '#66BB6A' : '#43A047' },
warning: { main: isDark ? '#FFB74D' : '#F57C00' },
error: { main: isDark ? '#EF5350' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
text,
chip,
background: isDark
? { default: '#121212', paper: '#1E1E1E' }
: { default: '#F5F5F5', paper: '#FFFFFF' },
}
}
})(),
shape: { borderRadius: 12 },
typography: {
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
h3: { fontWeight: 700, letterSpacing: '-0.5px', lineHeight: 1.2, textWrap: 'balance' },
h4: { fontWeight: 700, letterSpacing: '-0.5px', textWrap: 'balance' },
h5: { fontWeight: 600, letterSpacing: '-0.25px', textWrap: 'balance' },
h6: { fontWeight: 600, textWrap: 'balance' },
subtitle1: { fontWeight: 600 },
subtitle2: { fontWeight: 500 },
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
body2: { fontSize: '0.75rem', lineHeight: 1.5 },
button: { textTransform: 'none', fontWeight: 600 },
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 12,
fontWeight: 600,
transition: 'all 0.2s ease-in-out',
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
contained: {
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
'&:hover': {
boxShadow: '0 6px 20px 0 rgba(0,0,0,0.18)',
transform: 'translateY(-2px)',
},
'&:active': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
transform: 'translateY(0) scale(0.98)',
},
},
outlined: {
border: '1px solid',
'&:hover': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
},
'&:active': {
boxShadow: 'none',
transform: 'scale(0.98)',
},
},
text: {
'&:hover': {
backgroundColor: 'action.hover',
},
'&:active': {
backgroundColor: 'action.selected',
},
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
transition: 'all 0.2s ease-in-out',
'&:hover': {
backgroundColor: 'action.hover',
transform: 'scale(1.08)',
},
'&:active': {
backgroundColor: 'action.selected',
transform: 'scale(0.95)',
},
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiLink: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
borderRadius: 2,
},
},
},
},
MuiInputBase: {
styleOverrides: {
root: {
'&.Mui-focused': {
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: 2,
},
},
},
},
},
MuiAlert: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
boxShadow: 'none',
fontWeight: 500,
alignItems: 'center',
padding: '8px 12px',
'& .MuiAlert-icon': {
padding: 0,
marginRight: 12,
display: 'flex',
alignItems: 'center',
},
'& .MuiAlert-message': {
padding: 0,
},
'& .MuiAlert-action': {
padding: 0,
marginRight: 0,
marginLeft: 8,
},
},
colorSuccess: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.success
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorError: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.error
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorWarning: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.warning
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
colorInfo: ({ theme }) => {
const isDark = theme.palette.mode === 'dark'
const p = theme.palette.info
return {
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
color: isDark ? p.light : p.dark,
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
},
'&.MuiAlert-filled': {
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
borderColor: 'transparent',
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
},
}
},
},
},
MuiSnackbarContent: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
bgcolor: isDark ? '#1E1E1E' : '#FFFFFF',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
color: isDark ? '#F2F2F2' : '#1F1B16',
fontWeight: 500,
},
},
},
},
}),
[controller.resolvedMode, controller.scheme],
)
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}
export function AppProviders({ children }: PropsWithChildren) {
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
[],
)
return (
<QueryClientProvider client={queryClient}>
<SseProvider />
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { createEventStream } from '@/shared/lib/sse'
import { $token } from '@/shared/model/auth'
export function SseProvider() {
const token = useUnit($token)
const queryClient = useQueryClient()
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (!token) {
if (sourceRef.current) {
sourceRef.current.close()
sourceRef.current = null
}
return
}
const es = createEventStream(token)
sourceRef.current = es
function invalidateOrderQueries(orderId: unknown) {
if (!orderId) return
queryClient.invalidateQueries({ queryKey: ['me', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
}
function handleEvent(eventName: string) {
return function (event: MessageEvent) {
try {
const data = JSON.parse(event.data)
const orderId = data.orderId
switch (eventName) {
case 'message:new':
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
invalidateOrderQueries(orderId)
break
case 'order:statusChanged':
invalidateOrderQueries(orderId)
break
case 'order:updated':
invalidateOrderQueries(orderId)
break
case 'order:new':
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
break
}
} catch (err) {
console.warn('[sse] Failed to parse event data', err)
}
}
}
const messageNewHandler = handleEvent('message:new')
const orderStatusHandler = handleEvent('order:statusChanged')
const orderUpdatedHandler = handleEvent('order:updated')
const orderNewHandler = handleEvent('order:new')
es.addEventListener('message:new', messageNewHandler)
es.addEventListener('order:statusChanged', orderStatusHandler)
es.addEventListener('order:updated', orderUpdatedHandler)
es.addEventListener('order:new', orderNewHandler)
return () => {
es.removeEventListener('message:new', messageNewHandler)
es.removeEventListener('order:statusChanged', orderStatusHandler)
es.removeEventListener('order:updated', orderUpdatedHandler)
es.removeEventListener('order:new', orderNewHandler)
es.close()
sourceRef.current = null
}
}, [token, queryClient])
return null
}
+159
View File
@@ -0,0 +1,159 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SseProvider } from '../SseProvider'
const mockInvalidateQueries = vi.fn()
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
})
vi.mock('@/shared/model/auth', () => ({
$token: {
defaultState: null,
subscribe: () => () => {},
getState: () => null,
watch: () => () => {},
on: () => {},
reset: () => {},
},
}))
let mockToken: string | null = null
let mockEventHandlers: Record<string, (event: MessageEvent) => void> = {}
let mockCloseCalls = 0
class MockEventSource {
url: string
constructor(url: string) {
this.url = url
mockCloseCalls = 0
mockEventHandlers = {}
}
addEventListener(type: string, handler: (event: MessageEvent) => void) {
mockEventHandlers[type] = handler
}
removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
delete mockEventHandlers[type]
}
close() {
mockCloseCalls++
}
}
vi.mock('@/shared/lib/sse', () => ({
createEventStream: (token: string) => {
mockToken = token
return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
},
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => mockToken }
})
function renderSse() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<SseProvider />
</QueryClientProvider>,
)
}
describe('SseProvider', () => {
afterEach(() => {
mockToken = null
mockInvalidateQueries.mockReset()
mockCloseCalls = 0
mockEventHandlers = {}
})
it('renders nothing (returns null)', () => {
mockToken = null
const { container } = renderSse()
expect(container.innerHTML).toBe('')
})
it('does not create EventSource when token is null', () => {
mockToken = null
renderSse()
expect(mockToken).toBeNull()
})
it('creates EventSource when token is set', () => {
mockToken = 'test-jwt'
renderSse()
expect(mockToken).toBe('test-jwt')
})
it('closes EventSource on unmount', () => {
mockToken = 'test-jwt'
const { unmount } = renderSse()
expect(mockCloseCalls).toBe(0)
unmount()
expect(mockCloseCalls).toBe(1)
})
it('invalidates unread-count and conversations on message:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(handler).toBeDefined()
handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates order queries on order:statusChanged', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:statusChanged']
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates order queries on order:updated', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:updated']
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
})
it('invalidates admin queries on order:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:new']
handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
})
it('handles invalid JSON gracefully', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(() => {
handler(new MessageEvent('message:new', { data: ':heartbit' }))
}).not.toThrow()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
})
+113
View File
@@ -0,0 +1,113 @@
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import type { PaletteMode } from '@mui/material'
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
export type ThemeSettings = {
mode: ThemeModePreference
scheme: ColorScheme
}
export type ThemeController = ThemeSettings & {
/** Итоговый режим, учитывая system. */
resolvedMode: PaletteMode
setMode: (mode: ThemeModePreference) => void
toggleMode: () => void
cycleMode: () => void
setScheme: (scheme: ColorScheme) => void
}
const THEME_STORAGE_KEY = 'craftshop_theme'
function readStoredTheme(): ThemeSettings | null {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
const mode: unknown = parsed?.mode
const scheme: unknown = parsed?.scheme
const modeOk = mode === 'light' || mode === 'dark' || mode === 'system'
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
if (!modeOk || !schemeOk) return null
return { mode, scheme }
} catch (err) {
console.warn('[theme] Failed to read stored theme', err)
return null
}
}
function getSystemMode(): PaletteMode {
if (typeof window === 'undefined') return 'light'
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function resolveMode(pref: ThemeModePreference): PaletteMode {
return pref === 'system' ? getSystemMode() : pref
}
const ThemeControllerContext = createContext<ThemeController | null>(null)
export function useThemeController(): ThemeController {
const ctx = useContext(ThemeControllerContext)
if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider')
return ctx
}
export function ThemeControllerProvider({ children }: PropsWithChildren) {
const [settings, setSettings] = useState<ThemeSettings>(
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
)
const [systemMode, setSystemMode] = useState<PaletteMode>(() => getSystemMode())
useEffect(() => {
const mql = window.matchMedia?.('(prefers-color-scheme: dark)')
if (!mql) return
const handler = () => setSystemMode(mql.matches ? 'dark' : 'light')
// начальное значение
handler()
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}
// Safari старых версий
mql.addListener(handler)
return () => mql.removeListener(handler)
}, [])
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
} catch (err) {
console.warn('[theme] Failed to persist theme setting', err)
}
}, [settings])
const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode
const controller = useMemo<ThemeController>(
() => ({
mode: settings.mode,
resolvedMode,
scheme: settings.scheme,
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
toggleMode: () =>
setSettings((s) => ({
...s,
mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light',
})),
cycleMode: () =>
setSettings((s) => ({
...s,
mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system',
})),
setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })),
}),
[resolvedMode, settings.mode, settings.scheme],
)
return <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
}
+135
View File
@@ -0,0 +1,135 @@
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { usePageTitleReset } from '@/shared/lib/use-page-title'
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage })))
const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage })))
const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage })))
const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage })))
const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage })))
const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage })))
const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage })))
const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage })))
const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage })))
const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage })))
export function AppRoutes() {
usePageTitleReset()
return (
<MainLayout>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<SkeletonPage />}>
<HomePage />
</Suspense>
}
/>
<Route
path="/admin/*"
element={
<Suspense fallback={<SkeletonPage />}>
<AdminLayoutPage />
</Suspense>
}
/>
<Route
path="/auth"
element={
<Suspense fallback={<SkeletonPage />}>
<AuthPage />
</Suspense>
}
/>
<Route
path="/auth/callback"
element={
<Suspense fallback={<SkeletonPage />}>
<AuthCallbackPage />
</Suspense>
}
/>
<Route
path="/cart"
element={
<Suspense fallback={<SkeletonPage />}>
<CartPage />
</Suspense>
}
/>
<Route
path="/checkout"
element={
<Suspense fallback={<SkeletonPage />}>
<CheckoutPage />
</Suspense>
}
/>
<Route
path="/about"
element={
<Suspense fallback={<SkeletonPage />}>
<AboutPage />
</Suspense>
}
/>
<Route
path="/info"
element={
<Suspense fallback={<SkeletonPage />}>
<InfoPage />
</Suspense>
}
/>
<Route
path="/privacy"
element={
<Suspense fallback={<SkeletonPage />}>
<PrivacyPolicyPage />
</Suspense>
}
/>
<Route
path="/terms"
element={
<Suspense fallback={<SkeletonPage />}>
<TermsPage />
</Suspense>
}
/>
<Route
path="/me/*"
element={
<Suspense fallback={<SkeletonPage />}>
<MeLayoutPage />
</Suspense>
}
/>
<Route
path="/products/:id"
element={
<Suspense fallback={<SkeletonPage />}>
<ProductPage />
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense fallback={<SkeletonPage />}>
<NotFoundPage />
</Suspense>
}
/>
</Routes>
</MainLayout>
)
}
+45
View File
@@ -0,0 +1,45 @@
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 500;
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 600;
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
font-display: swap;
}
:root {
color-scheme: light;
}
html {
scroll-behavior: smooth;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+21
View File
@@ -0,0 +1,21 @@
import type { CartItem } from '@/entities/cart/model/types'
import { apiClient } from '@/shared/api/client'
export type CartResponse = { items: CartItem[] }
export async function fetchMyCart(): Promise<CartResponse> {
const { data } = await apiClient.get<CartResponse>('me/cart')
return data
}
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
await apiClient.post('me/cart/items', body)
}
export async function setCartQty(id: string, qty: number): Promise<void> {
await apiClient.patch(`me/cart/items/${id}`, { qty })
}
export async function removeCartItem(id: string): Promise<void> {
await apiClient.delete(`me/cart/items/${id}`)
}
+3
View File
@@ -0,0 +1,3 @@
export type { CartItem } from './model/types'
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
export type { CartResponse } from './api/cart-api'
+14
View File
@@ -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),
})
}
+7
View File
@@ -0,0 +1,7 @@
import type { Product } from '@/entities/product/model/types'
export type CartItem = {
id: string
qty: number
product: Product
}
@@ -0,0 +1,19 @@
import { apiClient } from '@/shared/api/client'
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
return data
}
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
return data
}
export async function putAdminCatalogSlider(body: {
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
return data
}
+2
View File
@@ -0,0 +1,2 @@
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
+10
View File
@@ -0,0 +1,10 @@
export type CatalogSliderSlide = {
id: string
url: string
caption: string
textColor?: string
}
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
galleryImageId: string
}
+52
View File
@@ -0,0 +1,52 @@
import type { GalleryImageItem } from '@/entities/gallery/model/types'
import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
return data
}
export async function deleteGalleryImage(id: string): Promise<void> {
await apiClient.delete(`admin/gallery/${id}`)
}
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
for (const f of files) {
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
throw new Error(
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
)
}
}
const fd = new FormData()
for (const f of files) {
fd.append('files', f, f.name)
}
const token = localStorage.getItem('craftshop_auth_token')
const base = apiBaseURL.replace(/\/$/, '')
const res = await fetch(`${base}/admin/gallery/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
})
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
if (!res.ok) {
if (res.status === 413) {
throw new Error(
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
)
}
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
}
if (!Array.isArray(payload.urls)) {
throw new Error('Некорректный ответ сервера')
}
return payload.urls
}
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
return data
}
+3
View File
@@ -0,0 +1,3 @@
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid'
+7
View File
@@ -0,0 +1,7 @@
export type GalleryImageItem = {
id: string
url: string
isResized: boolean
createdAt: string
inUse?: boolean
}
+100
View File
@@ -0,0 +1,100 @@
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import type { GalleryImageItem } from '../model/types'
type Props = {
items: GalleryImageItem[]
deleting?: boolean
resizing?: string | null
onDelete: (id: string) => void
onResize: (id: string) => void
}
export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 2,
}}
>
{items.map((item) => (
<Box
key={item.id}
sx={{
position: 'relative',
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
aspectRatio: '1',
}}
>
<OptimizedImage
src={item.url}
alt=""
sizes="140px"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
{item.isResized ? (
<Chip
label="Готово"
size="small"
color="success"
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
/>
) : (
<Chip
label="Не обработано"
size="small"
color="warning"
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
/>
)}
</Box>
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
{!item.isResized && (
<Tooltip title="Обработать (resize)">
<IconButton
size="small"
color="primary"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
}}
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
<AutoFixHighOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Удалить из галереи">
<IconButton
size="small"
color="error"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
}}
disabled={deleting}
onClick={() => onDelete(item.id)}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
))}
</Box>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { apiClient } from '@/shared/api/client'
export interface UserNotificationSettings {
id: string
userId: string
globalEnabled: boolean
orderCreated: boolean
orderStatusChanged: boolean
orderMessageReceived: boolean
paymentStatusChanged: boolean
deliveryFeeAdjusted: boolean
createdAt: string
updatedAt: string
}
export interface AdminNotificationSettings {
id: string
emailEnabled: boolean
telegramEnabled: boolean
telegramChatId: string | null
newOrder: boolean
newOrderMessage: boolean
newReview: boolean
authCodeDuplicate: boolean
createdAt: string
updatedAt: string
}
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
return data
}
export async function updateUserNotificationSettings(
settings: Partial<UserNotificationSettings>,
): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
return data
}
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
return data
}
export async function updateAdminNotificationSettings(
settings: Partial<AdminNotificationSettings>,
): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
'admin/notifications/settings',
settings,
)
return data
}
+7
View File
@@ -0,0 +1,7 @@
export {
fetchUserNotificationSettings,
updateUserNotificationSettings,
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from './api/notifications-api'
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
+96
View File
@@ -0,0 +1,96 @@
import { apiClient } from '@/shared/api/client'
export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryFeeLocked: boolean
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
totalCents: number
currency: string
createdAt: string
updatedAt: string
user: { id: string; email: string }
itemsCount: number
}
export type AdminOrdersListResponse = {
items: AdminOrderListItem[]
total: number
page: number
pageSize: number
}
export type AdminOrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
deliveryFeeLocked: boolean
totalCents: number
currency: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
user: {
id: string
email: string
displayName: string | null
avatar?: string | null
avatarStyle?: string | null
}
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: string
text: string
attachmentUrl?: string | null
createdAt: string
}>
}
}
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
return data
}
export async function fetchAdminOrders(params?: {
status?: string
deliveryType?: 'delivery' | 'pickup'
q?: string
page?: number
pageSize?: number
}): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
return data
}
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
return data
}
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
await apiClient.patch(`admin/orders/${id}/status`, { status })
}
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
}
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text })
}
+103
View File
@@ -0,0 +1,103 @@
import { apiClient } from '@/shared/api/client'
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
export type OrderListItem = {
id: string
status: string
totalCents: number
currency: string
createdAt: string
updatedAt: string
itemsCount: number
}
export type OrderListResponse = { items: OrderListItem[] }
export type OrderPaymentMethod = 'online' | 'on_pickup'
export type OrderDetailResponse = {
item: {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number
deliveryFeeCents: number
deliveryFeeLocked: boolean
totalCents: number
currency: string
addressSnapshotJson: string | null
comment: string | null
createdAt: string
updatedAt: string
items: Array<{
id: string
productId: string
qty: number
titleSnapshot: string
priceCentsSnapshot: number
}>
messages: Array<{
id: string
authorType: 'user' | 'admin'
text: string
attachmentUrl?: string | null
createdAt: string
}>
}
}
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
addressId?: string | null
comment?: string | null
}): Promise<{ orderId: string }> {
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
return data
}
export async function fetchMyOrders(): Promise<OrderListResponse> {
const { data } = await apiClient.get<OrderListResponse>('me/orders')
return data
}
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
return data
}
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
return data
}
/** Получить статус платежа для заказа. */
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
return data
}
export async function postOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`me/orders/${id}/messages`, { text })
}
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
return data
}
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
canReview: boolean
items: ReviewEligibilityItem[]
}> {
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
`me/orders/${orderId}/review-eligibility`,
)
return data
}
+9
View File
@@ -0,0 +1,9 @@
export {
fetchMyOrders,
createOrder,
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
} from './api/order-api'
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
+108
View File
@@ -0,0 +1,108 @@
import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products')
return data
}
export async function createProduct(body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
quantity: number
materials?: string[]
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
categoryId: string
}): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body)
return data
}
export async function updateProduct(
id: string,
body: Partial<{
title: string
slug: string
shortDescription: string | null
description: string | null
quantity: number
materials: string[]
priceCents: number
imageUrl: string | null
imageUrls: string[]
published: boolean
categoryId: string
}>,
): Promise<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
export async function fetchAdminCategories(): Promise<Category[]> {
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
return data.items
}
export async function updateAdminCategory(
id: string,
body: Partial<{ name: string; slug: string; sort: number }>,
): Promise<Category> {
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
return data
}
export async function deleteAdminCategory(id: string): Promise<void> {
await apiClient.delete(`admin/categories/${id}`)
}
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
const list = Array.from(files)
for (const f of list) {
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
throw new Error(
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
)
}
}
const fd = new FormData()
for (const f of list) {
fd.append('files', f, f.name)
}
const token = localStorage.getItem('craftshop_auth_token')
const base = apiBaseURL.replace(/\/$/, '')
const res = await fetch(`${base}/admin/uploads`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
})
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
if (!res.ok) {
if (res.status === 413) {
throw new Error(
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
)
}
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
}
if (!Array.isArray(payload.urls)) {
throw new Error('Некорректный ответ сервера')
}
return payload.urls
}
+42
View File
@@ -0,0 +1,42 @@
import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
export type PublicProductsResponse = {
items: Product[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProducts(params?: {
categorySlug?: string
q?: string
sort?: 'price_asc' | 'price_desc' | ''
page?: number
pageSize?: number
priceMinCents?: number
priceMaxCents?: number
}): Promise<PublicProductsResponse> {
const { data } = await apiClient.get<PublicProductsResponse>('products', {
params: {
categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined,
sort: params?.sort || undefined,
page: params?.page || undefined,
pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? undefined,
priceMax: params?.priceMaxCents ?? undefined,
},
})
return data
}
export async function fetchPublicProduct(id: string): Promise<Product> {
const { data } = await apiClient.get<Product>(`products/${id}`)
return data
}
export async function fetchCategories(): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>('categories')
return data
}
+2
View File
@@ -0,0 +1,2 @@
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
export type { PublicProductsResponse } from './api/product-api'
+33
View File
@@ -0,0 +1,33 @@
export type Category = {
id: string
name: string
slug: string
sort: number
}
export type ProductReviewsSummary = {
approvedReviewCount: number
avgRating: number | null
latestApprovedText: string | null
}
export type Product = {
id: string
title: string
slug: string
shortDescription: string | null
description: string | null
quantity: number
materials?: string[]
priceCents: number
imageUrl: string | null
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
published: boolean
categoryId: string
createdAt: string
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
/** Для опубликованных товаров с публичного API. */
reviewsSummary?: ProductReviewsSummary | null
}
+264
View File
@@ -0,0 +1,264 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useMediaQuery } from '@mui/material'
import Box from '@mui/material/Box'
import Card from '@mui/material/Card'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useNavigate } from 'react-router-dom'
import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:600px)')
const swiperRef = useRef<SwiperType | null>(null)
const imageUrls = useMemo(() => {
const fromImages = (product.images ?? [])
.slice()
.sort((a, b) => a.sort - b.sort)
.map((x) => x.url)
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
return urls
}, [product.images, product.imageUrl])
const materials = (product.materials ?? []).slice(0, 3)
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
if (!swiperRef.current) return
if (imageUrls.length <= 1) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
swiperRef.current.slideTo(idx, 0)
}
const goToProduct = useCallback(() => {
navigate(`/products/${product.id}`)
}, [navigate, product.id])
const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const }
return (
<Card
onClick={goToProduct}
sx={{
cursor: 'pointer',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: '16px 16px 12px 12px',
border: 'none',
bgcolor: 'background.paper',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'transform 250ms ease, box-shadow 300ms ease',
'&:hover': {
transform: 'translateY(-6px)',
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
},
'&:hover .product-card__media': { transform: 'scale(1.06)' },
'&:hover .product-card__title': { color: 'primary.main' },
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:hover': { transform: 'none' },
'&:hover .product-card__media': { transform: 'none' },
},
}}
>
<Box sx={{ position: 'relative' }}>
{imageUrls.length ? (
<Box
onMouseMove={!isMobile ? onMouseMove : undefined}
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
>
<Swiper
slidesPerView={1}
spaceBetween={16}
allowTouchMove={!isMobile}
onSwiper={(s) => {
swiperRef.current = s
}}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
<Box
className="product-card__media"
sx={{
width: '100%',
height: '100%',
transition: 'transform 320ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
userSelect: 'none',
bgcolor: 'grey.50',
}}
>
<OptimizedImage
src={url}
alt={product.title}
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
sx={{
width: '101%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
</SwiperSlide>
))}
</Swiper>
</Box>
) : (
<CardMedia
component="div"
sx={{
width: '100%',
aspectRatio: '3/4',
maxHeight: mediaHeight,
bgcolor: 'grey.50',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography color="text.disabled" variant="body2">
Нет фото
</Typography>
</CardMedia>
)}
{stockLabel && (
<Chip
label={stockLabel.label}
size="small"
color={stockLabel.color}
variant="filled"
sx={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 2,
fontWeight: 600,
fontSize: '0.7rem',
backdropFilter: 'blur(4px)',
bgcolor: 'rgba(0,0,0,0.55)',
color: 'common.white',
}}
/>
)}
</Box>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
{product.category && (
<Chip
label={product.category.name}
size="small"
color="primary"
sx={{
alignSelf: 'flex-start',
fontWeight: 600,
fontSize: '0.65rem',
height: 22,
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
/>
)}
<Typography
variant="subtitle1"
component="h2"
className="product-card__title"
sx={{
textDecoration: 'none',
color: 'text.primary',
fontWeight: 600,
lineHeight: 1.3,
transition: 'color 150ms ease',
}}
>
{product.title}
</Typography>
{(product.materials?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
{materials.map((m) => (
<Chip
key={m}
label={m}
size="small"
variant="outlined"
sx={{
bgcolor: 'chip.default',
color: 'text.secondary',
fontSize: '0.7rem',
height: 22,
fontWeight: 500,
}}
/>
))}
{moreMaterials > 0 && (
<Chip
label={`+${moreMaterials}`}
size="small"
variant="outlined"
sx={{
bgcolor: 'chip.default',
color: 'text.secondary',
fontSize: '0.7rem',
height: 22,
fontWeight: 500,
}}
/>
)}
</Stack>
)}
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
WebkitLineClamp: 2,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
fontSize: '0.8125rem',
lineHeight: 1.45,
}}
>
{product.shortDescription ?? 'Описание появится позже.'}
</Typography>
</Stack>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
<Typography
variant="h6"
component="p"
color="primary"
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
>
{formatPriceRub(product.priceCents)}
</Typography>
{actions}
</Box>
</Box>
</Card>
)
}
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
})
+32
View File
@@ -0,0 +1,32 @@
import { apiClient } from '@/shared/api/client'
export type AdminReview = {
id: string
rating: number
text: string | null
status: string
createdAt: string
moderatedAt: string | null
user: { id: string; email: string; displayName: string | null }
product: { id: string; title: string }
}
export type AdminReviewsListResponse = {
items: AdminReview[]
total: number
page: number
pageSize: number
}
export async function fetchAdminReviews(params?: {
status?: string
page?: number
pageSize?: number
}): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
return data
}
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(`admin/reviews/${id}`, { action })
}
+78
View File
@@ -0,0 +1,78 @@
import { apiClient } from '@/shared/api/client'
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
export async function postProductReview(
productId: string,
body: { rating: number; text?: string | null; imageUrl?: string | null },
): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body)
}
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
throw new Error(`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`)
}
const fd = new FormData()
fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
return data
}
export type PublicReviewFeedItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
product: {
id: string
title: string
published: boolean
slug: string
}
}
export type PublicReviewsLatestResponse = {
items: PublicReviewFeedItem[]
}
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
params: { limit },
})
return data
}
export type PublicProductReviewItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
}
export type PublicProductReviewsResponse = {
items: PublicProductReviewItem[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProductReviews(
productId: string,
params?: { page?: number; pageSize?: number },
): Promise<PublicProductReviewsResponse> {
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
params: { page: params?.page, pageSize: params?.pageSize },
})
return data
}
+12
View File
@@ -0,0 +1,12 @@
export {
postProductReview,
uploadReviewImage,
fetchLatestApprovedReviews,
fetchPublicProductReviews,
} from './api/reviews-api'
export type {
PublicReviewFeedItem,
PublicReviewsLatestResponse,
PublicProductReviewItem,
PublicProductReviewsResponse,
} from './api/reviews-api'
@@ -0,0 +1,40 @@
import { apiClient } from '@/shared/api/client'
export type ChecklistResultDto = {
passed: boolean
comment: string | null
checkedAt: string
}
export type TestChecklistResponse = {
results: Record<string, ChecklistResultDto>
}
export type UpdateChecklistItemResponse = {
itemKey: string
passed: boolean
comment: string | null
checkedAt: string
}
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
return data
}
export async function updateTestChecklistItem(
itemKey: string,
passed: boolean,
comment?: string | null,
): Promise<UpdateChecklistItemResponse> {
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
itemKey,
passed,
comment: passed ? null : (comment ?? null),
})
return data.result
}
export async function resetTestChecklist(): Promise<void> {
await apiClient.post('admin/test-checklist/reset')
}
+49
View File
@@ -0,0 +1,49 @@
import type { ShippingAddress } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AddressesListResponse = { items: ShippingAddress[] }
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
return data
}
export async function createMyAddress(body: {
label?: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment?: string | null
lat: number
lng: number
isDefault?: boolean
}): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
return data
}
export async function updateMyAddress(
id: string,
body: Partial<{
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
}>,
): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
return data
}
export async function deleteMyAddress(id: string): Promise<void> {
await apiClient.delete(`me/addresses/${id}`)
}
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
return data
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from '@/shared/api/client'
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
return data
}
export async function markOrderMessagesRead(orderId: string): Promise<void> {
await apiClient.post(`me/orders/${orderId}/messages/read`)
}
export type ConversationSummary = {
orderId: string
status: string
deliveryType: 'delivery' | 'pickup'
lastMessageAt: string
preview: string
unreadCount: number
}
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
return data
}
+45
View File
@@ -0,0 +1,45 @@
import type { AdminUser } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AdminUsersListResponse = {
items: AdminUser[]
total: number
page: number
pageSize: number
}
export async function fetchAdminUsers(params?: {
q?: string
page?: number
pageSize?: number
}): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
return data
}
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body)
return data
}
export async function updateAdminUser(
id: string,
body: Partial<{ email: string; displayName: string | null }>,
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
return data
}
export type AdminAvatarResponse = {
avatar: string | null
avatarStyle: string | null
}
export async function fetchAdminAvatar(): Promise<AdminAvatarResponse> {
const { data } = await apiClient.get<AdminAvatarResponse>('admin/avatar')
return data
}
export async function deleteAdminUser(id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`)
}
+10
View File
@@ -0,0 +1,10 @@
export type { AdminUser, ShippingAddress } from './model/types'
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
export type { AdminUsersListResponse } from './api/user-api'
export {
fetchMyAddresses,
createMyAddress,
updateMyAddress,
deleteMyAddress,
setMyAddressDefault,
} from './api/address-api'
+23
View File
@@ -0,0 +1,23 @@
export type AdminUser = {
id: string
email: string
displayName: string | null
avatar?: string | null
avatarStyle?: string | null
createdAt: string
updatedAt: string
}
export type ShippingAddress = {
id: string
label: string | null
recipientName: string
recipientPhone: string
addressLine: string
comment: string | null
lat: number
lng: number
isDefault: boolean
createdAt: string
updatedAt: string
}
+2
View File
@@ -0,0 +1,2 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
+127
View File
@@ -0,0 +1,127 @@
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import { Controller, type UseFormReturn } from 'react-hook-form'
import { AddressMapPicker } from '@/features/address-map-picker'
export type AddressFormValues = {
label: string
recipientName: string
recipientPhone: string
addressLine: string
comment: string
lat: number | null
lng: number | null
isDefault: boolean
}
export function AddressFormDialog({
open,
onClose,
editing,
form,
onSubmit,
isPending,
}: {
open: boolean
onClose: () => void
editing: boolean
form: UseFormReturn<AddressFormValues>
onSubmit: () => void
isPending: boolean
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="label"
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Controller
control={form.control}
name="recipientName"
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="recipientPhone"
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
/>
</Stack>
<Controller
control={form.control}
name="addressLine"
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="comment"
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="lat"
render={({ field: latField }) => (
<Controller
control={form.control}
name="lng"
render={({ field: lngField }) => (
<AddressMapPicker
value={
latField.value !== null && lngField.value !== null
? { lat: latField.value, lng: lngField.value }
: null
}
onChange={(v) => {
latField.onChange(v.lat)
lngField.onChange(v.lng)
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
}}
/>
)}
/>
)}
/>
<Controller
control={form.control}
name="isDefault"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
label="Адрес по умолчанию"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={
isPending ||
!form.watch('recipientName').trim() ||
!form.watch('recipientPhone').trim() ||
!form.watch('addressLine').trim()
}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,28 @@
import type { LatLng, NominatimItem } from '../model/types'
export async function reverseGeocode(pos: LatLng): Promise<string | null> {
const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('lat', String(pos.lat))
url.searchParams.set('lon', String(pos.lng))
url.searchParams.set('accept-language', 'ru')
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
if (!res.ok) return null
const data = (await res.json()) as { display_name?: string }
return data.display_name ? String(data.display_name) : null
}
export async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
const url = new URL('https://nominatim.openstreetmap.org/search')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('q', q)
url.searchParams.set('accept-language', 'ru')
url.searchParams.set('limit', '5')
const res = await fetch(url.toString(), {
headers: { 'User-Agent': 'craftshop-demo' },
signal,
})
if (!res.ok) return []
const data = (await res.json()) as NominatimItem[]
return Array.isArray(data) ? data : []
}
+1
View File
@@ -0,0 +1 @@
export { AddressMapPicker } from './ui/AddressMapPicker'
+3
View File
@@ -0,0 +1,3 @@
export type NominatimItem = { display_name: string; lat: string; lon: string }
export type LatLng = { lat: number; lng: number }
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
import { MapPickerMap } from './MapPickerMap'
import type { LatLng, NominatimItem } from '../model/types'
export function AddressMapPicker(props: {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) {
const { value, onChange } = props
const [q, setQ] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<NominatimItem[]>([])
const [hint, setHint] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const lastQueryRef = useRef<string>('')
const lastRequestAtRef = useRef<number>(0)
const qTrimmed = q.trim()
const visibleResults = qTrimmed.length >= 3 ? results : []
const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 }
}, [value])
const pick = async (pos: LatLng) => {
setHint(null)
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
setHint(addr)
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch (err) {
console.warn('[address-map-picker] Failed to reverse geocode', err)
}
}
useEffect(() => {
const s = qTrimmed
if (s.length < 3) {
return
}
const t = window.setTimeout(async () => {
const now = Date.now()
if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return
lastQueryRef.current = s
lastRequestAtRef.current = now
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
setResults(await searchPlaces(s, ac.signal))
} catch (e) {
if ((e as { name?: string })?.name !== 'AbortError') {
setResults([])
}
} finally {
setSearching(false)
}
}, 450)
return () => {
window.clearTimeout(t)
}
}, [qTrimmed])
return (
<Stack spacing={1.5}>
<Typography variant="subtitle2">Выбор на карте</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
<Button
variant="outlined"
onClick={async () => {
const s = q.trim()
if (!s) return
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
lastQueryRef.current = s
lastRequestAtRef.current = Date.now()
setResults(await searchPlaces(s, ac.signal))
} finally {
setSearching(false)
}
}}
disabled={searching || !q.trim()}
sx={{ minWidth: 160 }}
>
{searching ? <CircularProgress size={18} /> : 'Найти'}
</Button>
</Stack>
{visibleResults.length > 0 && (
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
{visibleResults.map((r) => (
<ListItemButton
key={`${r.lat}:${r.lon}:${r.display_name}`}
onClick={() => {
const lat = Number(r.lat)
const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
void pick({ lat, lng })
}}
>
<ListItemText primary={r.display_name} />
</ListItemButton>
))}
</List>
)}
<MapPickerMap value={value} onChange={onChange} center={center} />
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{hint && (
<Typography variant="caption" color="text.secondary">
Подсказка адреса: {hint}
</Typography>
)}
</Box>
</Stack>
)
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from 'react'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import { reverseGeocode } from '../api/map-geocoding'
import type { LatLng } from '../model/types'
import type * as maplibregl from 'maplibre-gl'
let maplibreglPromise: Promise<typeof maplibregl> | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
([mod]) => mod,
)
}
return maplibreglPromise
}
type MapPickerMapProps = {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
center: { lat: number; lng: number }
}
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
const mapRef = useRef<MapRef | null>(null)
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
const [loading, setLoading] = useState(true)
const [locating, setLocating] = useState(false)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) {
setMaplibre(mod)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [])
const pick = async (pos: LatLng) => {
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch (err) {
console.warn('[map-picker] Failed to reverse geocode', err)
}
}
if (loading || !maplibre) {
return (
<Box
sx={{
height: 280,
borderRadius: 2,
border: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibre}
ref={mapRef}
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
style={{ width: '100%', height: 280 }}
mapStyle={{
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
}}
onClick={(e: MapMouseEvent) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
>
{value && (
<Marker
longitude={value.lng}
latitude={value.lat}
draggable
onDragEnd={(e) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
anchor="bottom"
>
<Box
sx={{
width: 18,
height: 18,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
)}
</Map>
<Tooltip title="Моё местоположение">
<span>
<IconButton
size="small"
disabled={locating || !('geolocation' in navigator)}
onClick={() => {
if (!('geolocation' in navigator)) return
setLocating(true)
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
setLocating(false)
},
() => {
setLocating(false)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthCodeForm } from './ui/AuthCodeForm'
+67
View File
@@ -0,0 +1,67 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { AuthCodeForm } from '../ui/AuthCodeForm'
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
function renderForm() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const onSuccess = vi.fn()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthCodeForm onSuccess={onSuccess} />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('AuthCodeForm', () => {
it('renders email field, code field, and buttons', () => {
renderForm()
expect(screen.getByLabelText(/Email/i)).toBeTruthy()
expect(screen.getByLabelText(/Код/i)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
})
it('disables send button when email is empty', () => {
renderForm()
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeDisabled()
})
it('disables login button when code.length !== 6', () => {
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123' } })
expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled()
})
it('enables login button when code is 6 digits', async () => {
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
})
})
it('calls onSuccess after successful verify', async () => {
const { apiClient } = await import('@/shared/api/client')
const { tokenSet } = await import('@/shared/model/auth')
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
renderForm()
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
await waitFor(() => {
expect(tokenSet).toHaveBeenCalledWith('test-token')
})
})
})
+115
View File
@@ -0,0 +1,115 @@
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
type AuthResponse = {
token: string
user: {
id: string
email: string
displayName?: string | null
avatar?: string | null
avatarStyle?: string | null
}
}
type FormValues = {
email: string
code: string
}
type Props = {
onSuccess: () => void
}
export function AuthCodeForm({ onSuccess }: Props) {
const { register, watch } = useForm<FormValues>({
defaultValues: { email: '', code: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const requestCodeMutation = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
},
})
const verifyCodeMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
tokenSet(data.token)
},
onSuccess,
})
return (
<Stack spacing={2}>
<TextField
label="Email"
{...register('email')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="outlined"
onClick={() => requestCodeMutation.mutate()}
disabled={!email || requestCodeMutation.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
<Button
variant="contained"
onClick={() => verifyCodeMutation.mutate()}
disabled={!email || code.length !== 6 || verifyCodeMutation.isPending}
sx={{ whiteSpace: 'nowrap' }}
>
Войти
</Button>
</Stack>
{(requestCodeMutation.error || verifyCodeMutation.error) && (
<TextField
error
helperText={getApiErrorMessage(requestCodeMutation.error) || getApiErrorMessage(verifyCodeMutation.error)}
sx={{ display: 'none' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
Нажимая «Войти», вы принимаете{' '}
<Link component={RouterLink} to="/terms" underline="hover">
пользовательское соглашение
</Link>{' '}
и{' '}
<Link component={RouterLink} to="/privacy" underline="hover">
политику конфиденциальности
</Link>
.
</Typography>
</Stack>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthForgotForm } from './ui/AuthForgotForm'
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { Lock, Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
type Step = 'request' | 'reset'
type FormValues = {
email: string
code: string
newPassword: string
passwordConfirm: string
}
type Props = {
onBack: () => void
}
export function AuthForgotForm({ onBack }: Props) {
const [step, setStep] = useState<Step>('request')
const { register, watch } = useForm<FormValues>({
defaultValues: { email: '', code: '', newPassword: '', passwordConfirm: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const newPassword = watch('newPassword')
const passwordConfirm = watch('passwordConfirm')
const forgotCodeMutation = useMutation({
mutationFn: async () => {
await apiClient.post('auth/forgot-password', { email })
},
onSuccess: () => setStep('reset'),
})
const resetPasswordMutation = useMutation({
mutationFn: async () => {
await apiClient.post('auth/reset-password', { email, code, newPassword })
},
})
const passwordError = newPassword && passwordConfirm && newPassword !== passwordConfirm ? 'Пароли не совпадают' : null
return (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
{step === 'request'
? 'Введите email, на который будет отправлен код для сброса пароля'
: 'Введите код и новый пароль'}
</Typography>
<TextField
label="Email"
{...register('email')}
disabled={step === 'reset'}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{step === 'reset' && (
<>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} fullWidth />
<TextField
label="Новый пароль"
type="password"
{...register('newPassword')}
fullWidth
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
<TextField
label="Подтверждение пароля"
type="password"
{...register('passwordConfirm')}
fullWidth
error={Boolean(passwordError)}
helperText={passwordError}
/>
</>
)}
{step === 'request' ? (
<Button
variant="contained"
disabled={!email || forgotCodeMutation.isPending}
onClick={() => forgotCodeMutation.mutate()}
>
Отправить код
</Button>
) : (
<Button
variant="contained"
disabled={
!code ||
code.length !== 6 ||
!newPassword ||
newPassword.length < 8 ||
Boolean(passwordError) ||
resetPasswordMutation.isPending
}
onClick={() => resetPasswordMutation.mutate()}
>
Сменить пароль
</Button>
)}
<Button variant="text" size="small" onClick={onBack}>
Назад к входу
</Button>
{(forgotCodeMutation.error || resetPasswordMutation.error) && (
<TextField
error
helperText={getApiErrorMessage(forgotCodeMutation.error) || getApiErrorMessage(resetPasswordMutation.error)}
sx={{ display: 'none' }}
/>
)}
</Stack>
)
}
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { OAuthButtons } from '../ui/OAuthButtons'
describe('OAuthButtons', () => {
it('renders Yandex and VK buttons', () => {
render(<OAuthButtons />)
expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
expect(screen.getByText('Войти через VK ID')).toBeDefined()
})
it('buttons have correct href', () => {
render(<OAuthButtons />)
const yaBtn = screen.getByText('Войти через Яндекс ID').closest('a')
const vkBtn = screen.getByText('Войти через VK ID').closest('a')
expect(yaBtn?.getAttribute('href')).toContain('/auth/oauth/yandex')
expect(vkBtn?.getAttribute('href')).toContain('/auth/oauth/vk')
})
})
+1
View File
@@ -0,0 +1 @@
export { OAuthButtons } from './ui/OAuthButtons'
+24
View File
@@ -0,0 +1,24 @@
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
export type OAuthProvider = {
id: 'yandex' | 'vk'
label: string
color: string
}
export const oauthProviders: OAuthProvider[] = [
{
id: 'yandex',
label: 'Яндекс ID',
color: '#FC3F1D',
},
{
id: 'vk',
label: 'VK ID',
color: '#0077FF',
},
]
export function getOAuthUrl(provider: 'yandex' | 'vk'): string {
return oauthAuthorizeUrl(provider)
}
+28
View File
@@ -0,0 +1,28 @@
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers'
export function OAuthButtons() {
return (
<Stack direction="row" spacing={1} sx={{ justifyContent: 'center' }}>
{oauthProviders.map((p) => (
<Button
key={p.id}
variant="outlined"
href={getOAuthUrl(p.id)}
sx={{
borderColor: p.color,
color: p.color,
'&:hover': {
borderColor: p.color,
bgcolor: `${p.color}14`,
borderWidth: '1px',
},
}}
>
Войти через {p.label}
</Button>
))}
</Stack>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthPasswordForm } from './ui/AuthPasswordForm'
@@ -0,0 +1,74 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { AuthPasswordForm } from '../ui/AuthPasswordForm'
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
function renderForm(isRegister: boolean) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const onSuccess = vi.fn()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AuthPasswordForm isRegister={isRegister} onRegisterChange={vi.fn()} onSuccess={onSuccess} />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('AuthPasswordForm', () => {
it('renders login button when isRegister=false', () => {
renderForm(false)
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
expect(screen.getByText('Вход')).toBeTruthy()
})
it('renders register button and passwordConfirm when isRegister=true', () => {
renderForm(true)
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeTruthy()
expect(screen.getByLabelText(/Подтверждение пароля/i)).toBeTruthy()
})
it('disables button when password < 8 chars', async () => {
const { apiClient } = await import('@/shared/api/client')
vi.mocked(apiClient.post).mockResolvedValue({} as never)
renderForm(true)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: '123' } })
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeDisabled()
})
})
it('shows error when passwords do not match', async () => {
renderForm(true)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
fireEvent.change(screen.getByLabelText(/Подтверждение пароля/i), { target: { value: 'different' } })
await waitFor(() => {
expect(screen.getByText('Пароли не совпадают')).toBeTruthy()
})
})
it('calls onSuccess after successful login', async () => {
const { apiClient } = await import('@/shared/api/client')
const { tokenSet } = await import('@/shared/model/auth')
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
renderForm(false)
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
await waitFor(() => {
expect(tokenSet).toHaveBeenCalledWith('test-token')
})
})
})
+234
View File
@@ -0,0 +1,234 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import InputAdornment from '@mui/material/InputAdornment'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { Lock, Mail } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { apiClient } from '@/shared/api/client'
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
import { tokenSet } from '@/shared/model/auth'
type AuthResponse = {
token: string
user: {
id: string
email: string
displayName?: string | null
avatar?: string | null
avatarStyle?: string | null
}
}
type FormValues = {
email: string
password: string
passwordConfirm: string
displayName: string
}
type Props = {
isRegister: boolean
onRegisterChange: (v: boolean) => void
onSuccess: () => void
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Props) {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
mode: 'onChange',
})
const password = watch('password')
const loginMutation = useMutation({
mutationFn: async (values: FormValues) => {
const { data } = await apiClient.post<AuthResponse>('auth/login', {
email: values.email,
password: values.password,
})
tokenSet(data.token)
},
onSuccess,
})
const registerMutation = useMutation({
mutationFn: async (values: FormValues) => {
const { data } = await apiClient.post<AuthResponse>('auth/register', {
email: values.email,
password: values.password,
displayName: values.displayName || undefined,
})
tokenSet(data.token)
},
onSuccess,
})
const apiError = loginMutation.error || registerMutation.error
const apiErrorMessage = apiError ? getApiErrorMessage(apiError) : null
const onSubmit = isRegister
? handleSubmit((values) => registerMutation.mutate(values))
: handleSubmit((values) => loginMutation.mutate(values))
const isPending = loginMutation.isPending || registerMutation.isPending
return (
<Box component="form" onSubmit={onSubmit} noValidate>
<Stack spacing={2}>
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
<Button
variant="text"
size="small"
sx={{
color: !isRegister ? 'primary.main' : 'text.secondary',
borderBottom: !isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => onRegisterChange(false)}
>
Вход
</Button>
<Button
variant="text"
size="small"
sx={{
color: isRegister ? 'primary.main' : 'text.secondary',
borderBottom: isRegister ? 2 : 0,
borderColor: 'primary.main',
borderRadius: 0,
pb: 0.5,
textTransform: 'none',
}}
onClick={() => onRegisterChange(true)}
>
Регистрация
</Button>
</Stack>
<TextField
label="Email"
{...register('email', {
required: 'Введите email',
pattern: {
value: EMAIL_PATTERN,
message: 'Некорректный email',
},
})}
fullWidth
autoComplete="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Mail size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Имя (необязательно)"
{...register('displayName')}
fullWidth
helperText="Если не указать, будет использована часть email до @"
/>
)}
<TextField
label="Пароль"
type="password"
autoComplete={isRegister ? 'new-password' : 'current-password'}
{...register('password', {
required: 'Введите пароль',
minLength: {
value: 8,
message: 'Пароль должен быть не менее 8 символов',
},
})}
fullWidth
error={Boolean(errors.password)}
helperText={errors.password?.message}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Lock size={18} />
</InputAdornment>
),
},
}}
/>
{isRegister && (
<TextField
label="Подтверждение пароля"
type="password"
autoComplete="new-password"
{...register('passwordConfirm', {
required: 'Подтвердите пароль',
validate: (value) => value === password || 'Пароли не совпадают',
})}
fullWidth
error={Boolean(errors.passwordConfirm)}
helperText={errors.passwordConfirm?.message}
/>
)}
{apiErrorMessage && (
<Alert
severity="error"
variant="outlined"
onClose={() => {
loginMutation.reset()
registerMutation.reset()
}}
>
{apiErrorMessage}
</Alert>
)}
{isRegister ? (
<Button variant="contained" size="large" type="submit" disabled={isPending}>
Зарегистрироваться
</Button>
) : (
<Button variant="contained" size="large" type="submit" disabled={isPending}>
Войти
</Button>
)}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
<Link component={RouterLink} to="/terms" underline="hover">
пользовательское соглашение
</Link>{' '}
и{' '}
<Link component={RouterLink} to="/privacy" underline="hover">
политику конфиденциальности
</Link>
.
</Typography>
</Stack>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AddToCartButton } from './ui/AddToCartButton'
@@ -0,0 +1,46 @@
import Button from '@mui/material/Button'
import type { ButtonProps } from '@mui/material/Button'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
import { addNotification } from '@/shared/model/notification'
type Props = {
productId: string
qty?: number
loggedOutLabel?: string
} & Omit<ButtonProps, 'onClick'>
export function AddToCartButton(props: Props) {
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
const qc = useQueryClient()
const user = useUnit($user)
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
addNotification({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
},
})
return (
<Button
{...rest}
disabled={Boolean(disabled) || !user || addMut.isPending}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
addMut.mutate()
}}
>
{user ? (children ?? 'В корзину') : loggedOutLabel}
</Button>
)
}
@@ -0,0 +1,43 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as notifications from '@/shared/model/notification'
import { AddToCartButton } from '../AddToCartButton'
vi.mock('@/entities/cart/api/cart-api', () => ({
addToCart: vi.fn(() => Promise.resolve()),
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
})
describe('AddToCartButton', () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
beforeEach(() => {
vi.clearAllMocks()
qc.clear()
})
it('calls addNotification after successful add', async () => {
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
<AddToCartButton productId="test-product" />
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalledWith({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
})
})
})
+1
View File
@@ -0,0 +1 @@
export { CartBadge } from './ui/CartBadge'
+31
View File
@@ -0,0 +1,31 @@
import Badge from '@mui/material/Badge'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { ShoppingCart } from 'lucide-react'
import type { AuthUser } from '@/shared/model/auth'
type Props = {
user: AuthUser | null
cartCount: number
onNavigate: (to: string) => void
}
export function CartBadge({ user, cartCount, onNavigate }: Props) {
return (
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<IconButton
color="inherit"
sx={{ ml: 1 }}
onClick={() => {
if (!user) onNavigate('/auth')
else onNavigate('/cart')
}}
aria-label={user ? `Корзина (${cartCount})` : 'Авторизуйтесь для совершения покупок'}
>
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
<ShoppingCart />
</Badge>
</IconButton>
</Tooltip>
)
}
+1
View File
@@ -0,0 +1 @@
export { ToggleCartIcon } from './ui/ToggleCartIcon'
@@ -0,0 +1,77 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { ShoppingCart } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { addToCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
import { $user } from '@/shared/model/auth'
import { addNotification } from '@/shared/model/notification'
export function ToggleCartIcon(props: {
productId: string
size?: 'small' | 'medium'
disabledReason?: string | null
}) {
const { productId, size = 'small', disabledReason = null } = props
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const cartQuery = useCartQuery()
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
const inCart = Boolean(existing)
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
addNotification({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
},
})
const removeMut = useMutation({
mutationFn: () => removeCartItem(existing!.id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const disabled = Boolean(disabledReason)
const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabledReason) return
if (!user) {
navigate('/auth')
return
}
if (inCart) removeMut.mutate()
else addMut.mutate()
}
const tooltip = disabledReason
? disabledReason
: !user
? 'Авторизуйтесь для совершения покупок'
: inCart
? 'Убрать из корзины'
: 'В корзину'
return (
<Tooltip title={tooltip}>
<span>
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
{user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
</IconButton>
</span>
</Tooltip>
)
}
@@ -0,0 +1,74 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as api from '@/entities/cart/api/cart-api'
import * as notifications from '@/shared/model/notification'
import { ToggleCartIcon } from '../ToggleCartIcon'
vi.mock('@/entities/cart/api/cart-api', () => ({
addToCart: vi.fn(() => Promise.resolve()),
fetchMyCart: vi.fn(() => Promise.resolve({ items: [] })),
removeCartItem: vi.fn(() => Promise.resolve()),
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
})
describe('ToggleCartIcon', () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
beforeEach(() => {
vi.clearAllMocks()
qc.clear()
})
it('calls addNotification after successful add', async () => {
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ToggleCartIcon productId="test-product" />
</MemoryRouter>
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalledWith({
type: 'success',
message: 'Товар добавлен в корзину',
actionLabel: 'Перейти в корзину',
actionPath: '/cart',
})
})
})
it('does not call addNotification on remove', async () => {
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
})
const spy = vi.spyOn(notifications, 'addNotification')
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ToggleCartIcon productId="test-product" />
</MemoryRouter>
</QueryClientProvider>,
)
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: /убрать из корзины/i })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /убрать из корзины/i }))
await vi.waitFor(() => {
expect(spy).not.toHaveBeenCalled()
})
})
})
+1
View File
@@ -0,0 +1 @@
export { OrderChat } from './ui/OrderChat'
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar'
type Message = {
id: string
authorType: 'user' | 'admin'
text: string
attachmentUrl?: string | null
createdAt: string
}
type Props = {
messages: Message[]
isPending: boolean
onSend: (text: string) => void
}
export function OrderChat({ messages, isPending, onSend }: Props) {
const [text, setText] = useState('')
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const currentUser = useUnit($user)
const adminAvatarQuery = useQuery({
queryKey: ['admin', 'avatar'],
queryFn: fetchAdminAvatar,
staleTime: 5 * 60 * 1000,
})
const handleSend = () => {
if (!canSend || isPending) return
onSend(text.trim())
setText('')
}
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Чат по заказу
</Typography>
<Stack spacing={1} sx={{ mb: 2 }}>
{messages.map((m) => {
const isAdminMsg = m.authorType === 'admin'
const adminAv = adminAvatarQuery.data
const avatarNode = isAdminMsg ? (
<UserAvatar userId="admin" avatarUrl={adminAv?.avatar} avatarStyle={adminAv?.avatarStyle} size={24} />
) : currentUser ? (
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
) : null
return (
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
<Typography variant="caption" color="text.secondary">
{isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
</ChatMessageBubble>
)
})}
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
</Box>
<Button variant="contained" onClick={handleSend} disabled={isPending || !canSend} sx={{ minWidth: 160 }}>
Отправить
</Button>
</Stack>
</Box>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm'
export { OrderDetailContent } from './ui/OrderDetailContent'
@@ -0,0 +1,55 @@
import { useState } from 'react'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
export function DeliveryFeeAdjustmentForm({
orderId,
deliveryFeeCents,
}: {
orderId: string
deliveryFeeCents: number
}) {
const qc = useQueryClient()
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
const feeMut = useMutation({
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
return (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Доставка, ₽"
type="number"
value={rub}
onChange={(e) => setRub(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
sx={{ width: { xs: '100%', sm: 200 } }}
/>
<Button
variant="contained"
disabled={
feeMut.isPending ||
!rub.trim() ||
!Number.isFinite(Number.parseFloat(rub)) ||
Number.parseFloat(rub) < 0 ||
!Number.isInteger(Number.parseFloat(rub))
}
onClick={() => feeMut.mutate()}
>
Утвердить доставку и открыть оплату
</Button>
</Stack>
)
}
+245
View File
@@ -0,0 +1,245 @@
import { useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Link as RouterLink } from 'react-router-dom'
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { getAdminNextOrderStatuses } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar'
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
const qc = useQueryClient()
const [msg, setMsg] = useState('')
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(orderId, next),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(orderId, msg.trim()),
onSuccess: async () => {
setMsg('')
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
},
})
const deliverySnapshot = useMemo(
() => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
[detail],
)
const nextStatuses = useMemo(
() => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'),
[detail],
)
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const currentUser = useUnit($user)
return (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {ORDER_STATUS_MAP[detail.status] ?? detail.status} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
)}
</Typography>
{detail.deliveryType === 'delivery' && (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'action.hover',
}}
>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
Адрес и получатель (на момент заказа)
</Typography>
{deliverySnapshot ? (
<Stack spacing={0.75}>
{deliverySnapshot.label?.trim() && (
<Typography variant="body2" color="text.secondary">
Метка: {deliverySnapshot.label}
</Typography>
)}
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Адрес:
</Box>{' '}
{deliverySnapshot.addressLine ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Получатель:
</Box>{' '}
{deliverySnapshot.recipientName ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Телефон:
</Box>{' '}
{deliverySnapshot.recipientPhone ?? '—'}
</Typography>
{deliverySnapshot.comment?.trim() && (
<Typography variant="body2" color="text.secondary">
Комментарий к адресу: {deliverySnapshot.comment}
</Typography>
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
Данные адреса в заказе отсутствуют или не распознаны.
</Typography>
)}
</Box>
)}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Товары в заказе
</Typography>
<Stack spacing={1}>
{detail.items.map((item) => (
<Stack
key={item.id}
direction="row"
spacing={2}
sx={{ alignItems: 'center', py: 0.5, px: 1, borderRadius: 1, bgcolor: 'action.hover' }}
>
<Box sx={{ flexGrow: 1 }}>
<Link component={RouterLink} to={`/products/${item.productId}`} underline="hover" color="primary">
{item.titleSnapshot}
</Link>
<Typography color="text.secondary" variant="body2">
{item.qty} × {formatPriceRub(item.priceCentsSnapshot)}
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(item.priceCentsSnapshot * item.qty)}</Typography>
</Stack>
))}
</Stack>
</Box>
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
</Alert>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
)}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
Быстрый переход статуса
</Typography>
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
{nextStatuses.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Статус финальный, смена недоступна
</Typography>
) : (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
{nextStatuses.map((nextStatus) => {
const isCancelled = nextStatus === 'CANCELLED'
return (
<Button
key={nextStatus}
variant={isCancelled ? 'outlined' : 'contained'}
color={isCancelled ? 'error' : 'primary'}
disabled={statusMut.isPending}
onClick={() => statusMut.mutate(nextStatus)}
>
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
</Button>
)
})}
</Stack>
)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => {
const isAdminMsg = m.authorType === 'admin'
const avatarNode = isAdminMsg ? (
currentUser && (
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
)
) : (
<UserAvatar
userId={detail.user.id}
avatarUrl={detail.user.avatar}
avatarStyle={detail.user.avatarStyle}
size={24}
/>
)
return (
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
<Typography variant="caption" color="text.secondary">
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
</ChatMessageBubble>
)
})}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)
}
@@ -0,0 +1,211 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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)} />,
}))
vi.mock('@/shared/ui/ChatMessageBubble', () => ({
ChatMessageBubble: ({ authorType, children }: { authorType: 'admin' | 'user'; children: ReactNode }) => (
<div data-testid={`chat-message-${authorType}`}>{children}</div>
),
}))
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
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()
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)
})
it('передает фактический authorType в пузырь сообщения', () => {
renderComponent(
createDetail({
messages: [
{
id: 'message-admin',
authorType: 'admin',
text: 'Ответ администратора',
attachmentUrl: null,
createdAt: '2026-05-28T10:00:00.000Z',
},
{
id: 'message-user',
authorType: 'user',
text: 'Сообщение покупателя',
attachmentUrl: null,
createdAt: '2026-05-28T10:01:00.000Z',
},
],
}),
)
expect(screen.getByTestId('chat-message-admin')).toHaveTextContent('Админ (вы)')
expect(screen.getByTestId('chat-message-user')).toHaveTextContent('Пользователь')
})
})
+1
View File
@@ -0,0 +1 @@
export { OrderPaymentSection } from './ui/OrderPaymentSection'
@@ -0,0 +1,65 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
type Props = {
status: string
deliveryFeeLocked: boolean
paymentMethod: string | null
totalCents: number
isPayPending: boolean
payError: unknown
onPay: () => void
}
export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod, isPayPending, onPay }: Props) {
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
if (payOnPickup) {
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Оплата
</Typography>
<Typography color="text.secondary" variant="body2">
Оплата при получении на точке самовывоза (наличные или карта по договорённости).
</Typography>
</Box>
)
}
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Оплата
</Typography>
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
</Typography>
)}
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
{ORDER_STATUS_MAP['PAID'] ?? 'PAID'}».
</Typography>
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
</Button>
</>
)}
{status === 'PAID' && (
<Typography color="success.main" variant="body1">
Оплачено. Спасибо!
</Typography>
)}
{status !== 'PENDING_PAYMENT' && status !== 'PAID' && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате не требуется.
</Typography>
)}
</Box>
)
}
+2
View File
@@ -0,0 +1,2 @@
export type { FormState } from './model/types'
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isValidProductPriceRub, isValidProductQuantity } from '../use-product-form-helpers'
describe('product form helpers', () => {
it('принимает корректную цену в рублях', () => {
expect(isValidProductPriceRub('1200')).toBe(true)
expect(isValidProductPriceRub('1200,50')).toBe(true)
})
it('отклоняет пустую или некорректную цену', () => {
expect(isValidProductPriceRub('')).toBe(false)
expect(isValidProductPriceRub('0')).toBe(false)
expect(isValidProductPriceRub('1200,555')).toBe(false)
expect(isValidProductPriceRub('1,2,3')).toBe(false)
})
it('принимает только целое количество от 0 до 10', () => {
expect(isValidProductQuantity('0')).toBe(true)
expect(isValidProductQuantity('10')).toBe(true)
expect(isValidProductQuantity('')).toBe(false)
expect(isValidProductQuantity('11')).toBe(false)
expect(isValidProductQuantity('1.5')).toBe(false)
})
})
@@ -0,0 +1,30 @@
import type { FormState } from '../model/types'
export const emptyForm = (): FormState => ({
title: '',
slug: '',
shortDescription: '',
description: '',
quantity: '0',
materials: '',
priceRub: '',
imageUrls: [],
published: true,
categoryId: '',
})
export function isValidProductPriceRub(value: string): boolean {
const trimmed = value.trim()
if (!/^\d+([,.]\d{1,2})?$/.test(trimmed)) return false
const priceRub = Number(trimmed.replace(',', '.'))
return Number.isFinite(priceRub) && priceRub > 0 && priceRub <= 10_000
}
export function isValidProductQuantity(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
const quantity = Number(trimmed)
return Number.isInteger(quantity) && quantity >= 0 && quantity <= 10
}
+12
View File
@@ -0,0 +1,12 @@
export type FormState = {
title: string
slug: string
shortDescription: string
description: string
quantity: string
materials: string
priceRub: string
imageUrls: string[]
published: boolean
categoryId: string
}
+142
View File
@@ -0,0 +1,142 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { fetchAdminGallery } from '@/entities/gallery'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
export function GalleryImagePicker({
open,
onClose,
onSelect,
currentUrls,
}: {
open: boolean
onClose: () => void
onSelect: (urls: string[]) => void
currentUrls: string[]
}) {
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
const [hideUsed, setHideUsed] = useState(false)
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
enabled: open,
})
const toggleUrl = (url: string) => {
setSelectedUrls((prev) => {
const next = new Set(prev)
if (next.has(url)) {
next.delete(url)
} else {
next.add(url)
}
return next
})
}
const handleApply = () => {
onSelect([...selectedUrls])
setSelectedUrls(new Set())
onClose()
}
const handleClose = () => {
setSelectedUrls(new Set())
onClose()
}
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Изображения из галереи</DialogTitle>
<DialogContent dividers>
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка</Typography>}
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)}
{galleryQuery.data &&
galleryQuery.data.items.length > 0 &&
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryQuery.isLoading && (
<Typography color="text.secondary">
В галерее пока нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<FormControlLabel
control={<Checkbox checked={hideUsed} onChange={(_, v) => setHideUsed(v)} />}
label="Скрыть уже прикреплённые"
sx={{ mb: 1 }}
/>
{galleryQuery.data &&
galleryQuery.data.items.length > 0 &&
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryQuery.isLoading && (
<Typography color="text.secondary">
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{(galleryQuery.data?.items ?? [])
.filter((item) => item.isResized)
.filter((item) => !hideUsed || !item.inUse)
.map((item) => {
const alreadyInCard = currentUrls.includes(item.url)
return (
<FormControlLabel
key={item.id}
sx={{ m: 0, alignItems: 'flex-start' }}
control={
<Checkbox
checked={alreadyInCard || selectedUrls.has(item.url)}
disabled={alreadyInCard}
onChange={() => toggleUrl(item.url)}
/>
}
label={
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
}
/>
)
})}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Отмена</Button>
<Button
variant="contained"
onClick={handleApply}
disabled={![...selectedUrls].some((u) => !currentUrls.includes(u))}
>
Добавить
</Button>
</DialogActions>
</Dialog>
)
}
+235
View File
@@ -0,0 +1,235 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import FormControl from '@mui/material/FormControl'
import FormControlLabel from '@mui/material/FormControlLabel'
import FormHelperText from '@mui/material/FormHelperText'
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 Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { Controller, type UseFormReturn } from 'react-hook-form'
import type { Category } from '@/entities/product/model/types'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
import type { FormState } from '../model/types'
export function ProductFormFields({
form,
categories,
onRemoveImage,
onPickFromGallery,
}: {
form: UseFormReturn<FormState>
categories: Category[]
onRemoveImage: (url: string) => void
onPickFromGallery: () => void
}) {
return (
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="title"
rules={{ required: 'Укажите название' }}
render={({ field, fieldState }) => (
<TextField
label="Название"
fullWidth
required
{...field}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug (URL)"
fullWidth
{...field}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
)}
/>
<Controller
control={form.control}
name="shortDescription"
render={({ field }) => (
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
)}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Описание
</Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>Стилизованный текст: жирный, курсив, список</FormHelperText>
<Controller
control={form.control}
name="description"
render={({ field }) => (
<RichTextMessageEditor value={field.value} onChange={field.onChange} placeholder="Описание товара" />
)}
/>
</Box>
<Controller
control={form.control}
name="materials"
render={({ field }) => (
<TextField
label="Материалы"
fullWidth
{...field}
helperText="Список через запятую (например: хлопок, дерево, акрил)"
/>
)}
/>
<Controller
control={form.control}
name="quantity"
rules={{
required: 'Укажите количество',
validate: (v) => isValidProductQuantity(v) || 'Целое число от 0 до 10',
}}
render={({ field, fieldState }) => (
<TextField
label="Количество"
fullWidth
{...field}
inputMode="numeric"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={form.control}
name="priceRub"
rules={{
required: 'Укажите цену',
validate: (v) => isValidProductPriceRub(v) || 'Цена должна быть от 0,01 до 10 000 ₽, максимум 2 знака',
}}
render={({ field, fieldState }) => (
<TextField
label="Цена, ₽"
fullWidth
{...field}
inputMode="decimal"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9.,]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (из галереи)
</Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся
на сервере и в галерее.
</FormHelperText>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
flexWrap: 'wrap',
}}
>
<Button variant="outlined" onClick={onPickFromGallery}>
Из галереи
</Button>
</Box>
{form.watch('imageUrls').length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{form.watch('imageUrls').map((url) => (
<Box
key={url}
sx={{
width: 92,
height: 92,
borderRadius: 1,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
}}
title={url}
>
<OptimizedImage
src={url}
alt="Фото товара"
widths={[320, 640]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<Button
size="small"
color="error"
variant="contained"
onClick={() => onRemoveImage(url)}
aria-label="Убрать из карточки"
title="Убрать из карточки"
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 0,
px: 0.75,
py: 0,
lineHeight: 1.2,
}}
>
×
</Button>
</Box>
))}
</Box>
)}
</Box>
<Controller
control={form.control}
name="categoryId"
rules={{ required: 'Выберите категорию' }}
render={({ field }) => (
<FormControl fullWidth error={!field.value}>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
{categories.map((c: Category) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
</FormControl>
)}
/>
<Controller
control={form.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать в каталоге"
/>
)}
/>
</Stack>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { ReviewSection } from './ui/ReviewSection'
export { ReviewDialog } from './ui/ReviewDialog'
export { ProductReviewsList } from './ui/ProductReviewsList'
@@ -0,0 +1,110 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { Star } from 'lucide-react'
import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api'
import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar'
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return (
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<UserAvatar userId={rv.authorId} avatarUrl={rv.authorAvatar} avatarStyle={rv.authorAvatarStyle} size={32} />
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<Star fontSize="inherit" />}
emptyIcon={<Star fontSize="inherit" />}
/>
{body ? (
<Box sx={{ color: 'text.secondary' }}>
<RichTextMessageContent value={body} tone="review" />
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
sx={{
width: 140,
height: 140,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={rv.imageUrl}
alt="Фото к отзыву"
widths={[320, 640]}
sizes="140px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
</Stack>
</Paper>
)
}
export function ProductReviewsList({ productId }: { productId: string }) {
const reviewsQuery = useQuery({
queryKey: ['products', 'public', productId, 'reviews', { page: 1, pageSize: 30 }],
queryFn: () => fetchPublicProductReviews(productId, { page: 1, pageSize: 30 }),
enabled: Boolean(productId),
})
if (reviewsQuery.isLoading) return <Typography color="text.secondary">Загрузка отзывов</Typography>
if (reviewsQuery.isError) return <Alert severity="warning">Не удалось загрузить отзывы.</Alert>
if (reviewsQuery.data && reviewsQuery.data.total === 0) {
return (
<Box sx={{ py: 3 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
Отзывов пока нет
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
</Typography>
</Box>
)
}
if (!reviewsQuery.data || reviewsQuery.data.items.length === 0) return null
return (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => (
<ReviewItem key={rv.id} rv={rv} />
))}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние {reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import axios from 'axios'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
type Props = {
productTitle: string | null
open: boolean
isPending: boolean
isUploadingImage: boolean
error: unknown
uploadError: unknown
onClose: () => void
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => Promise<void>
onUploadImage: (file: File) => Promise<{ url: string }>
}
function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const raw = err.response?.data
const apiMsg =
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
? (raw as { error: string }).error
: null
if (status === 409 || apiMsg?.toLowerCase().includes('уже')) {
return 'Вы уже оставляли отзыв на этот товар.'
}
return apiMsg || err.message || 'Не удалось отправить отзыв'
}
if (err instanceof Error) return err.message
return 'Не удалось отправить отзыв'
}
export function ReviewDialog({
productTitle,
open,
isPending,
isUploadingImage,
error,
uploadError,
onClose,
onSubmit,
onUploadImage,
}: Props) {
const [rating, setRating] = useState<number>(5)
const [text, setText] = useState('')
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [localUploadError, setLocalUploadError] = useState<string | null>(null)
const reset = () => {
setRating(5)
setText('')
setImageUrl(null)
setLocalUploadError(null)
}
const handleClose = () => {
if (isPending) return
reset()
onClose()
}
const handleSubmit = async () => {
if (isPending) return
await onSubmit({ rating, text: text.trim(), imageUrl })
}
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Отзыв: {productTitle}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Оценка
</Typography>
<Rating
value={rating}
onChange={(_, v) => {
if (v !== null) setRating(v)
}}
/>
<Box sx={{ mt: 2 }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Комментарий (необязательно)" />
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
<Button component="label" variant="outlined" disabled={isUploadingImage}>
{imageUrl ? 'Заменить фото' : 'Прикрепить фото'}
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
e.currentTarget.value = ''
setLocalUploadError(null)
try {
const result = await onUploadImage(file)
setImageUrl(result.url)
} catch (err) {
setLocalUploadError(
err instanceof Error ? err.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.',
)
}
}}
/>
</Button>
{imageUrl && (
<Button color="error" variant="text" onClick={() => setImageUrl(null)} disabled={isPending}>
Удалить фото
</Button>
)}
</Stack>
{imageUrl && (
<Box
component="img"
src={imageUrl}
alt="Фото к отзыву"
sx={{
mt: 1,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
{uploadError || localUploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{localUploadError
? localUploadError
: uploadError instanceof Error
? uploadError.message
: 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : null}
{error ? (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(error)}
</Alert>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={isPending}>
Отмена
</Button>
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
Отправить
</Button>
</DialogActions>
</Dialog>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { ReviewDialog } from './ReviewDialog'
type EligibileItem = {
productId: string
title: string
hasReview: boolean
}
type Props = {
items: EligibileItem[]
isSubmitPending: boolean
isUploadPending: boolean
submitError: unknown
uploadError: unknown
onSubmitReview: (params: {
productId: string
rating: number
text: string
imageUrl: string | null
}) => Promise<void>
onUploadImage: (file: File) => Promise<{ url: string }>
}
export function ReviewSection({
items,
isSubmitPending,
isUploadPending,
submitError,
uploadError,
onSubmitReview,
onUploadImage,
}: Props) {
const [target, setTarget] = useState<{ productId: string; title: string } | null>(null)
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null)
if (items.length === 0) return null
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Отзывы
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
</Typography>
<Stack spacing={1}>
{items.map((row) => (
<Stack
key={row.productId}
direction={{ xs: 'column', sm: 'row' }}
spacing={1}
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
>
<Typography sx={{ flexGrow: 1 }}>{row.title}</Typography>
<Button
size="small"
variant="outlined"
disabled={row.hasReview}
onClick={() => setTarget({ productId: row.productId, title: row.title })}
>
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
</Button>
</Stack>
))}
</Stack>
<ReviewDialog
productTitle={target?.title ?? null}
open={Boolean(target)}
isPending={isSubmitPending}
isUploadingImage={isUploadPending}
error={submitError}
uploadError={uploadError}
onClose={() => {
setTarget(null)
setUploadedImageUrl(null)
}}
onSubmit={async (params) => {
if (!target) return
await onSubmitReview({
productId: target.productId,
...params,
imageUrl: uploadedImageUrl,
})
setTarget(null)
setUploadedImageUrl(null)
}}
onUploadImage={async (file) => {
const result = await onUploadImage(file)
setUploadedImageUrl(result.url)
return result
}}
/>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { UserMenu } from './ui/UserMenu'
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react'
import PersonIcon from '@mui/icons-material/Person'
import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import type { AuthUser } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
type Props = {
user: AuthUser | null
isAdmin?: boolean
onNavigate: (to: string) => void
onLogout: () => void
}
export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const openMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
const closeMenu = () => setAnchorEl(null)
const go = (to: string) => {
closeMenu()
onNavigate(to)
}
const handleLogout = () => {
closeMenu()
onLogout()
}
return (
<>
<IconButton
color="inherit"
onClick={user ? openMenu : () => go('/auth')}
sx={{ ml: 1 }}
aria-label="Пользователь"
>
{user ? (
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
) : (
<PersonIcon sx={{ fontSize: 28 }} />
)}
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={closeMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{user ? (
<>
<MenuItem onClick={() => go(isAdmin ? '/admin/settings' : '/me')}>
<ListItemText
primary={(user.displayName && user.displayName.trim()) || user.email}
secondary={isAdmin ? 'Настройки' : 'Профиль'}
/>
</MenuItem>
<MenuItem onClick={handleLogout}>Выход</MenuItem>
</>
) : (
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
)}
</Menu>
</>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from '@/app/App'
import '@/app/styles/global.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken())
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
export { AboutPage } from './ui/AboutPage'
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Map, { Marker } from 'react-map-gl/maplibre'
import type * as maplibregl from 'maplibre-gl'
let maplibreglPromise: Promise<typeof maplibregl> | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
([mod]) => mod,
)
}
return maplibreglPromise
}
const rasterStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}
type AboutMapProps = {
lat: number
lng: number
}
export function AboutMap({ lat, lng }: AboutMapProps) {
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) setMaplibre(mod)
})
return () => {
cancelled = true
}
}, [])
if (!maplibre) {
return (
<Box
sx={{
height: 380,
borderRadius: 2,
border: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box
sx={{
height: 380,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibre}
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
style={{ width: '100%', height: 380 }}
mapStyle={rasterStyle}
scrollZoom={false}
dragRotate={false}
dragPan={false}
doubleClickZoom={false}
keyboard={false}
touchZoomRotate={false}
>
<Marker longitude={lng} latitude={lat} anchor="bottom">
<Box
sx={{
width: 20,
height: 20,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
</Map>
</Box>
)
}
+58
View File
@@ -0,0 +1,58 @@
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
import { usePageTitle } from '@/shared/lib/use-page-title'
import { AboutMap } from './AboutMap'
export function AboutPage() {
usePageTitle('О нас')
const { lat, lng } = PICKUP_COORDINATES
return (
<Box>
<Typography variant="h4" gutterBottom>
О нас
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Меня зовут Лариса. Я человек влюбленный в творчество и рукоделие. В моем магазине вы найдете оригинальные
изделия ручной работы (вязаные игрушки, аксессуары и многое другое). Каждое мое изделие сделано с любовью и
заботой.
</Typography>
<Stack spacing={3}>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>
Контакты
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Email:{' '}
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
{STORE_EMAIL}
</Link>
</Typography>
<Typography variant="body2">
Телефон:{' '}
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
{STORE_PHONE}
</Link>
</Typography>
<Typography variant="body2">
<Link href={VK_URL} target="_blank" rel="noopener noreferrer" underline="hover">
ВКонтакте
</Link>
</Typography>
<Typography sx={{ mb: 1, mt: 2 }}>Забрать заказ можно по адресу:</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Перед визитом согласуйте время чтобы заказ точно был готов к выдаче.
</Typography>
</Paper>
<AboutMap lat={lat} lng={lng} />
</Stack>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminCategoriesPage } from './ui/AdminCategoriesPage'
@@ -0,0 +1,295 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import {
createCategory,
deleteAdminCategory,
fetchAdminCategories,
updateAdminCategory,
} from '@/entities/product/api/admin-product-api'
import type { Category } from '@/entities/product/model/types'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
export function AdminCategoriesPage() {
const queryClient = useQueryClient()
const [catOpen, setCatOpen] = useState(false)
const [categoryEditOpen, setCategoryEditOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [categoryDeleteTarget, setCategoryDeleteTarget] = useState<Category | null>(null)
const categoryForm = useForm<{ name: string; slug: string }>({
defaultValues: { name: '', slug: '' },
mode: 'onChange',
})
const categoryEditForm = useForm<{ name: string; slug: string; sort: string }>({
defaultValues: { name: '', slug: '', sort: '0' },
mode: 'onChange',
})
const adminCategoriesQuery = useQuery({
queryKey: ['admin', 'categories'],
queryFn: fetchAdminCategories,
})
const createCategoryMut = useMutation({
mutationFn: () => {
const v = categoryForm.getValues()
return createCategory({
name: v.name.trim(),
slug: v.slug.trim() || undefined,
})
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories']])
setCatOpen(false)
categoryForm.reset({ name: '', slug: '' })
},
})
const updateCategoryMut = useMutation({
mutationFn: async () => {
if (!editingCategory) return
const v = categoryEditForm.getValues()
const payload: { name: string; slug?: string; sort: number } = {
name: v.name.trim(),
sort: Number(v.sort),
}
if (!Number.isFinite(payload.sort)) throw new Error('Некорректный порядок sort')
if (editingCategory.slug !== UNSPECIFIED_CATEGORY_SLUG) {
payload.slug = v.slug.trim()
}
return updateAdminCategory(editingCategory.id, payload)
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']])
setCategoryEditOpen(false)
setEditingCategory(null)
},
})
const deleteCategoryMut = useMutation({
mutationFn: (id: string) => deleteAdminCategory(id),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']])
setCategoryDeleteTarget(null)
},
})
const mutationError = createCategoryMut.error ?? updateCategoryMut.error ?? deleteCategoryMut.error
const openCategoryEdit = (c: Category) => {
setEditingCategory(c)
categoryEditForm.reset({
name: c.name,
slug: c.slug,
sort: String(c.sort),
})
setCategoryEditOpen(true)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Управление категориями
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
{adminCategoriesQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Не удалось загрузить категории.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(mutationError)}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Slug</TableCell>
<TableCell>Порядок</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(adminCategoriesQuery.data ?? []).map((c) => (
<TableRow key={c.id} hover>
<TableCell>{c.name}</TableCell>
<TableCell>{c.slug}</TableCell>
<TableCell>{c.sort}</TableCell>
<TableCell align="right">
<EntityRowActions
onEdit={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => openCategoryEdit(c)}
onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Новая категория</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={categoryForm.control}
name="name"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<Controller
control={categoryForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug"
fullWidth
{...field}
helperText="Необязательно — можно сгенерировать из названия на сервере"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
<Button
variant="contained"
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
onClick={() => createCategoryMut.mutate()}
>
Создать
</Button>
</DialogActions>
</Dialog>
<Dialog
open={categoryEditOpen}
onClose={() => {
setCategoryEditOpen(false)
setEditingCategory(null)
}}
fullWidth
maxWidth="xs"
>
<DialogTitle>Редактировать категорию</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={categoryEditForm.control}
name="name"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<Controller
control={categoryEditForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug"
fullWidth
{...field}
disabled={editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG}
helperText={
editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG
? 'Служебный slug нельзя изменить'
: 'Идентификатор в URL'
}
/>
)}
/>
<Controller
control={categoryEditForm.control}
name="sort"
render={({ field }) => (
<TextField
label="Порядок сортировки"
fullWidth
type="number"
{...field}
slotProps={{ htmlInput: { step: 1 } }}
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setCategoryEditOpen(false)
setEditingCategory(null)
}}
>
Отмена
</Button>
<Button
variant="contained"
disabled={!categoryEditForm.watch('name').trim() || updateCategoryMut.isPending || !editingCategory}
onClick={() => updateCategoryMut.mutate()}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
<Dialog
open={Boolean(categoryDeleteTarget)}
onClose={() => setCategoryDeleteTarget(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Удалить категорию?</DialogTitle>
<DialogContent>
<Typography variant="body2">
{categoryDeleteTarget && (
<>
Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано».
</>
)}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setCategoryDeleteTarget(null)}>Отмена</Button>
<Button
color="error"
variant="contained"
disabled={deleteCategoryMut.isPending}
onClick={() => {
if (categoryDeleteTarget) deleteCategoryMut.mutate(categoryDeleteTarget.id)
}}
>
Удалить
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminGalleryPage } from './ui/AdminGalleryPage'
+252
View File
@@ -0,0 +1,252 @@
import { useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Grid3x3, List } from 'lucide-react'
import {
deleteGalleryImage,
fetchAdminGallery,
GalleryGrid,
resizeGalleryImage,
uploadGalleryImages,
} from '@/entities/gallery'
import type { GalleryImageItem } from '@/entities/gallery'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import type { AxiosError } from 'axios'
function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }>
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch (err) {
console.warn('[gallery] Failed to format date', err)
return ''
}
}
function fileNameFromUrl(url: string): string {
const parts = url.split('/')
return parts[parts.length - 1] || url
}
function GalleryTable({
items,
deleting,
resizing,
onDelete,
onResize,
}: {
items: GalleryImageItem[]
deleting: boolean
resizing: string | null
onDelete: (id: string) => void
onResize: (id: string) => void
}) {
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Миниатюра</TableCell>
<TableCell>Имя файла</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Дата</TableCell>
<TableCell>Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1, display: 'block' }}
/>
</TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fileNameFromUrl(item.url)}
</TableCell>
<TableCell>
<Chip
label={item.isResized ? 'Готово' : 'Не обработано'}
size="small"
color={item.isResized ? 'success' : 'warning'}
/>
</TableCell>
<TableCell>{formatDate(item.createdAt)}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5}>
{!item.isResized && (
<Button
size="small"
variant="outlined"
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
Resize
</Button>
)}
<Button
size="small"
variant="outlined"
color="error"
disabled={deleting}
onClick={() => onDelete(item.id)}
>
Удалить
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
export function AdminGalleryPage() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [resizingId, setResizingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
})
const uploadMut = useMutation({
mutationFn: (files: File[]) => uploadGalleryImages(files),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGalleryImage(id),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const resizeMut = useMutation({
mutationFn: async (id: string) => {
setResizingId(id)
try {
return await resizeGalleryImage(id)
} finally {
setResizingId(null)
}
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const items = galleryQuery.data?.items ?? []
return (
<Box>
<Typography variant="h4" gutterBottom>
Галерея
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные
изображения доступны для добавления в карточку товара и слайдер.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Форматы: PNG, JPEG, WebP. На один файл до {formatAdminImageMaxSizeHint()}.
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
Загрузить файлы
<input
ref={fileInputRef}
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(e) => {
const files = e.target.files
if (!files?.length) return
uploadMut.mutate(Array.from(files))
}}
/>
</Button>
<ToggleButtonGroup value={viewMode} exclusive onChange={(_, v) => v && setViewMode(v)} size="small">
<ToggleButton value="grid" aria-label="Сетка">
<Grid3x3 size={16} />
</ToggleButton>
<ToggleButton value="table" aria-label="Таблица">
<List size={16} />
</ToggleButton>
</ToggleButtonGroup>
{uploadMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadMut.isError && (
<Typography color="error">
{uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'}
</Typography>
)}
{deleteMut.isError && (
<Typography color="error">{getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'}</Typography>
)}
{resizeMut.isError && (
<Typography color="error">{getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'}</Typography>
)}
</Stack>
{galleryQuery.isError && (
<Typography color="error" sx={{ mb: 2 }}>
Не удалось загрузить список.
</Typography>
)}
{viewMode === 'grid' ? (
<GalleryGrid
items={items}
deleting={deleteMut.isPending}
resizing={resizingId}
onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.mutate(id)}
/>
) : (
<GalleryTable
items={items}
deleting={deleteMut.isPending}
resizing={resizingId}
onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.mutate(id)}
/>
)}
{!galleryQuery.isLoading && items.length === 0 && (
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
)}
</Box>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { putAdminCatalogSlider } from '@/entities/catalog-slider'
import type { GalleryImageItem } from '@/entities/gallery'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
export type SlideDraft = { galleryImageId: string; caption: string }
type Props = {
initialSlides: SlideDraft[]
galleryItems: GalleryImageItem[]
}
export function GallerySliderSection({ initialSlides, galleryItems }: Props) {
const queryClient = useQueryClient()
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>(initialSlides)
const [pickOpen, setPickOpen] = useState(false)
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
const saveSliderMut = useMutation({
mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }),
onSuccess: async () => {
await invalidateQueryKeys(queryClient, [['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const moveSlide = (idx: number, dir: -1 | 1) => {
const next = idx + dir
if (next < 0 || next >= sliderDraft.length) return
setSliderDraft((prev) => {
const copy = [...prev]
const t = copy[idx]!
copy[idx] = copy[next]!
copy[next] = t
return copy
})
}
return (
<>
<Paper variant="outlined" sx={{ p: 2, mb: 3, borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>
Слайдер главной (каталог)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Сначала загрузите фото в галерею ниже, затем добавьте слайды, укажите подписи и сохраните. Порядок строк =
порядок показа на витрине.
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{sliderDraft.map((row, idx) => {
const img = galleryItems.find((g) => g.id === row.galleryImageId)
return (
<Stack
key={`${row.galleryImageId}-${idx}`}
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
sx={{ alignItems: { sm: 'flex-start' } }}
>
<Box
sx={{
width: 100,
height: 100,
flexShrink: 0,
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
}}
>
<Box
component="img"
src={img?.url ?? ''}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
<TextField
label="Подпись на слайде"
fullWidth
multiline
minRows={2}
value={row.caption}
onChange={(e) => {
const v = e.target.value
setSliderDraft((prev) => {
const copy = [...prev]
copy[idx] = { ...copy[idx]!, caption: v }
return copy
})
}}
/>
<Stack direction="row" spacing={0.5}>
<IconButton size="small" aria-label="Выше" onClick={() => moveSlide(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Ниже"
onClick={() => moveSlide(idx, 1)}
disabled={idx >= sliderDraft.length - 1}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="Убрать из слайдера"
onClick={() => setSliderDraft((prev) => prev.filter((_, i) => i !== idx))}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
)
})}
</Stack>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Button variant="outlined" disabled={pickCandidates.length === 0} onClick={() => setPickOpen(true)}>
Добавить слайд из галереи
</Button>
<Button variant="contained" disabled={saveSliderMut.isPending} onClick={() => saveSliderMut.mutate()}>
Сохранить слайдер
</Button>
{saveSliderMut.isError && (
<Typography color="error">
{saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'}
</Typography>
)}
</Stack>
</Paper>
<Dialog open={pickOpen} onClose={() => setPickOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Выберите изображение</DialogTitle>
<DialogContent dividers>
{pickCandidates.length === 0 ? (
<Typography color="text.secondary">Нет доступных файлов (все уже в слайдере или галерея пуста).</Typography>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{pickCandidates.map((item) => (
<Button
key={item.id}
sx={{ p: 0, minWidth: 0, display: 'block', borderRadius: 1, overflow: 'hidden' }}
onClick={() => {
setSliderDraft((prev) => [...prev, { galleryImageId: item.id, caption: '' }])
setPickOpen(false)
}}
>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }}
/>
</Button>
))}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setPickOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</>
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminLayoutPage } from './ui/AdminLayoutPage'

Some files were not shown because too many files have changed in this diff Show More