initial: client
This commit is contained in:
Executable
+22
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+202
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
Executable
+144
@@ -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
@@ -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' })
|
||||
})
|
||||
})
|
||||
Executable
+361
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+83
@@ -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
@@ -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
@@ -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>
|
||||
}
|
||||
Executable
+135
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+45
@@ -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;
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Executable
+1
@@ -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 |
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
Executable
+21
@@ -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}`)
|
||||
}
|
||||
Executable
+3
@@ -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
@@ -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),
|
||||
})
|
||||
}
|
||||
Executable
+7
@@ -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
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
|
||||
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
galleryImageId: string
|
||||
}
|
||||
+52
@@ -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
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
|
||||
export type { GalleryImageItem } from './model/types'
|
||||
export { GalleryGrid } from './ui/GalleryGrid'
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
export type GalleryImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
isResized: boolean
|
||||
createdAt: string
|
||||
inUse?: boolean
|
||||
}
|
||||
+100
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fetchUserNotificationSettings,
|
||||
updateUserNotificationSettings,
|
||||
fetchAdminNotificationSettings,
|
||||
updateAdminNotificationSettings,
|
||||
} from './api/notifications-api'
|
||||
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
|
||||
+96
@@ -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 })
|
||||
}
|
||||
Executable
+103
@@ -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
|
||||
}
|
||||
Executable
+9
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
|
||||
export type { PublicProductsResponse } from './api/product-api'
|
||||
Executable
+33
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Executable
+12
@@ -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')
|
||||
}
|
||||
Executable
+49
@@ -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
|
||||
}
|
||||
Executable
+24
@@ -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
|
||||
}
|
||||
Executable
+45
@@ -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}`)
|
||||
}
|
||||
Executable
+10
@@ -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'
|
||||
Executable
+23
@@ -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
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { AddressFormDialog } from './ui/AddressFormDialog'
|
||||
export type { AddressFormValues } from './ui/AddressFormDialog'
|
||||
+127
@@ -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
@@ -0,0 +1 @@
|
||||
export { AddressMapPicker } from './ui/AddressMapPicker'
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthCodeForm } from './ui/AuthCodeForm'
|
||||
+67
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthForgotForm } from './ui/AuthForgotForm'
|
||||
+145
@@ -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')
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { OAuthButtons } from './ui/OAuthButtons'
|
||||
+24
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -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
@@ -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
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { CartBadge } from './ui/CartBadge'
|
||||
+31
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
+74
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { OrderChat } from './ui/OrderChat'
|
||||
+87
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+2
@@ -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
@@ -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('Пользователь')
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export type { FormState } from './model/types'
|
||||
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
|
||||
+24
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+3
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { UserMenu } from './ui/UserMenu'
|
||||
+72
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+13
@@ -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>,
|
||||
)
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AboutPage } from './ui/AboutPage'
|
||||
Executable
+106
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+58
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AdminGalleryPage } from './ui/AdminGalleryPage'
|
||||
+252
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AdminLayoutPage } from './ui/AdminLayoutPage'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user