Merge branch 'refactor'
This commit is contained in:
+3
-28
@@ -1,37 +1,12 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { MainLayout } from '@/app/layout/MainLayout'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AppProviders } from '@/app/providers/AppProviders'
|
||||
import { AboutPage } from '@/pages/about'
|
||||
import { AdminLayoutPage } from '@/pages/admin-layout'
|
||||
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
|
||||
import { CartPage } from '@/pages/cart'
|
||||
import { CheckoutPage } from '@/pages/checkout'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { InfoPage } from '@/pages/info'
|
||||
import { MeLayoutPage } from '@/pages/me'
|
||||
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
||||
import { ProductPage } from '@/pages/product'
|
||||
import { AppRoutes } from '@/app/routes'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin/*" element={<AdminLayoutPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/info" element={<InfoPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/me/*" element={<MeLayoutPage />} />
|
||||
<Route path="/products/:id" element={<ProductPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
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 Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
@@ -31,9 +25,12 @@ import type { ColorScheme } from '@/app/providers/theme-controller'
|
||||
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 { BearLogo } from '@/shared/ui/BearLogo'
|
||||
import { NavigationDrawer } from '@/widgets/navigation-drawer'
|
||||
|
||||
type NavItem = { label: string; to: string }
|
||||
|
||||
@@ -175,7 +172,6 @@ export function AppHeader() {
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user) && !isAdmin,
|
||||
})
|
||||
|
||||
const cartCount = cartQuery.data?.items?.length ?? 0
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
@@ -183,53 +179,46 @@ export function AppHeader() {
|
||||
queryFn: fetchMyOrders,
|
||||
enabled: Boolean(user) && !isAdmin,
|
||||
})
|
||||
|
||||
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
|
||||
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
|
||||
).length
|
||||
|
||||
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const userMenuOpen = Boolean(userAnchorEl)
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const onSchemeChange = (e: SelectChangeEvent<string>) => {
|
||||
setScheme(e.target.value as ColorScheme)
|
||||
}
|
||||
|
||||
const onSchemeChange = (e: SelectChangeEvent<string>) => setScheme(e.target.value as ColorScheme)
|
||||
const onModeChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
|
||||
}
|
||||
|
||||
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => setUserAnchorEl(e.currentTarget)
|
||||
const closeUserMenu = () => setUserAnchorEl(null)
|
||||
|
||||
const openMobile = () => setMobileOpen(true)
|
||||
const closeMobile = () => setMobileOpen(false)
|
||||
|
||||
const go = (to: string) => {
|
||||
closeMobile()
|
||||
closeUserMenu()
|
||||
setMobileOpen(false)
|
||||
navigate(to)
|
||||
}
|
||||
|
||||
const onLogout = () => {
|
||||
tokenSet(null)
|
||||
logout()
|
||||
closeMobile()
|
||||
closeUserMenu()
|
||||
setMobileOpen(false)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const themeControls = { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode: cycleMode }
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton color="inherit" onClick={openMobile} aria-label="Открыть меню" edge="start" sx={{ mr: 1 }}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
edge="start"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<MenuOutlinedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -272,58 +261,11 @@ export function AppHeader() {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
if (!user) navigate('/auth')
|
||||
else navigate('/cart')
|
||||
}}
|
||||
aria-label="Корзина"
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCartOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={userAnchorEl}
|
||||
open={userMenuOpen}
|
||||
onClose={closeUserMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={() => go('/me')}>
|
||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
{!isAdmin && <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />}
|
||||
|
||||
{isAdmin && user && !isMobile && (
|
||||
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
|
||||
@@ -331,76 +273,21 @@ export function AppHeader() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<ThemeControlsDesktop
|
||||
scheme={scheme}
|
||||
mode={mode}
|
||||
resolvedMode={resolvedMode}
|
||||
onSchemeChange={onSchemeChange}
|
||||
onModeChange={onModeChange}
|
||||
onCycleMode={cycleMode}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && <ThemeControlsDesktop {...themeControls} />}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
<NavigationDrawer
|
||||
open={mobileOpen}
|
||||
onClose={closeMobile}
|
||||
slotProps={{ paper: { sx: { width: 320, maxWidth: '85vw' } } }}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BearLogo sx={{ fontSize: 28 }} />
|
||||
<Typography variant="h6">{STORE_NAME}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{headerNavItems.map((i) => (
|
||||
<Button key={i.to} variant="text" onClick={() => go(i.to)} sx={{ justifyContent: 'flex-start' }}>
|
||||
{i.label}
|
||||
</Button>
|
||||
))}
|
||||
{!isAdmin && (
|
||||
<Button variant="text" onClick={() => go(user ? '/cart' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Корзина
|
||||
</Button>
|
||||
)}
|
||||
{user && !isAdmin && (
|
||||
<Button variant="text" onClick={() => go('/me/orders')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Заказы
|
||||
</Button>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
{user ? 'Профиль' : 'Вход / регистрация'}
|
||||
</Button>
|
||||
)}
|
||||
{!user && isAdmin && (
|
||||
<Button variant="text" onClick={() => go('/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Вход / регистрация
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<Button variant="text" color="error" onClick={onLogout} sx={{ justifyContent: 'flex-start' }}>
|
||||
Выход
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<ThemeControlsMobile
|
||||
scheme={scheme}
|
||||
mode={mode}
|
||||
resolvedMode={resolvedMode}
|
||||
onSchemeChange={onSchemeChange}
|
||||
onModeChange={onModeChange}
|
||||
onCycleMode={cycleMode}
|
||||
/>
|
||||
</Box>
|
||||
</Drawer>
|
||||
onClose={() => setMobileOpen(false)}
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
navItems={headerNavItems}
|
||||
themeControls={themeControls}
|
||||
onNavigate={go}
|
||||
onLogout={onLogout}
|
||||
ThemeControlsMobile={ThemeControlsMobile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { MainLayout } from '@/app/layout/MainLayout'
|
||||
import { AboutPage } from '@/pages/about'
|
||||
import { AdminLayoutPage } from '@/pages/admin-layout'
|
||||
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
|
||||
import { CartPage } from '@/pages/cart'
|
||||
import { CheckoutPage } from '@/pages/checkout'
|
||||
import { HomePage } from '@/pages/home'
|
||||
import { InfoPage } from '@/pages/info'
|
||||
import { MeLayoutPage } from '@/pages/me'
|
||||
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
||||
import { ProductPage } from '@/pages/product'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin/*" element={<AdminLayoutPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/info" element={<InfoPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/me/*" element={<MeLayoutPage />} />
|
||||
<Route path="/products/:id" element={<ProductPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
galleryImageId: string
|
||||
}
|
||||
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
|
||||
|
||||
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
|
||||
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
|
||||
@@ -0,0 +1,9 @@
|
||||
export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
galleryImageId: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { fetchAdminGallery, deleteGalleryImage } from './api/gallery-api'
|
||||
export type { GalleryImageItem } from './model/types'
|
||||
export { GalleryGrid } from './ui/GalleryGrid'
|
||||
@@ -0,0 +1,61 @@
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import type { GalleryImageItem } from '../model/types'
|
||||
|
||||
type Props = {
|
||||
items: GalleryImageItem[]
|
||||
deleting?: boolean
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function GalleryGrid({ items, deleting, onDelete }: 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',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { InfoPageBlock } from '../model/types'
|
||||
|
||||
|
||||
export type InfoPageBlock = {
|
||||
id: string
|
||||
key: string
|
||||
title: string
|
||||
body: string
|
||||
sort: number
|
||||
published: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export async function fetchPublicInfoBlocks(): Promise<{ items: InfoPageBlock[] }> {
|
||||
const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('info-page/blocks')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
fetchPublicInfoBlocks,
|
||||
fetchAdminInfoBlocks,
|
||||
createInfoBlock,
|
||||
updateInfoBlock,
|
||||
deleteInfoBlock,
|
||||
} from './api/info-page-api'
|
||||
export type { InfoPageBlock } from './model/types'
|
||||
@@ -0,0 +1,10 @@
|
||||
export type InfoPageBlock = {
|
||||
id: string
|
||||
key: string
|
||||
title: string
|
||||
body: string
|
||||
sort: number
|
||||
published: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export type OrderDetailResponse = {
|
||||
}>
|
||||
messages: Array<{
|
||||
id: string
|
||||
authorType: string
|
||||
authorType: 'user' | 'admin'
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -13,37 +13,8 @@ import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||
|
||||
type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||
|
||||
async function reverseGeocode(pos: { lat: number; lng: number }): 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
|
||||
}
|
||||
|
||||
type LatLng = { lat: number; lng: number }
|
||||
|
||||
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 : []
|
||||
}
|
||||
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||
import type { LatLng } from '../model/types'
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
value: { lat: number; lng: number } | null
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CartBadge } from './ui/CartBadge'
|
||||
@@ -0,0 +1,31 @@
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
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="Корзина"
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCartOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderChat } from './ui/OrderChat'
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
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 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) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
||||
export { PaymentDialog } from './ui/PaymentDialog'
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { PaymentDialog } from './PaymentDialog'
|
||||
|
||||
type Props = {
|
||||
status: string
|
||||
paymentMethod: string | null
|
||||
deliveryType: string
|
||||
totalCents: number
|
||||
isPayPending: boolean
|
||||
payError: unknown
|
||||
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
||||
}
|
||||
|
||||
export function OrderPaymentSection({ status, paymentMethod, deliveryType, isPayPending, payError, onPay }: Props) {
|
||||
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||
const [payModalOpen, setPayModalOpen] = useState(false)
|
||||
|
||||
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 === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}».
|
||||
</Typography>
|
||||
)}
|
||||
{status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
||||
{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
||||
Оплатить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'PAYMENT_VERIFICATION' && (
|
||||
<Typography color="info.main" variant="body2">
|
||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||||
</Typography>
|
||||
)}
|
||||
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(status) && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате в этом блоке не требуется.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<PaymentDialog
|
||||
open={payModalOpen}
|
||||
isPending={isPayPending}
|
||||
error={payError}
|
||||
onClose={() => setPayModalOpen(false)}
|
||||
onSubmit={(params) => {
|
||||
onPay(params)
|
||||
setPayModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, 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 TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import axios from 'axios'
|
||||
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
isPending: boolean
|
||||
error: unknown
|
||||
onClose: () => void
|
||||
onSubmit: (params: { detail: string; receiptFile: File | null }) => void
|
||||
}
|
||||
|
||||
function paySubmitErrorMessage(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
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
|
||||
return apiMsg || err.message || 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
|
||||
export function PaymentDialog({ open, isPending, error, onClose, onSubmit }: Props) {
|
||||
const [detail, setDetail] = useState('')
|
||||
const [receiptFile, setReceiptFile] = useState<File | null>(null)
|
||||
const [clientError, setClientError] = useState<string | null>(null)
|
||||
|
||||
const receiptPreviewUrl = useMemo(() => {
|
||||
if (!receiptFile) return null
|
||||
return URL.createObjectURL(receiptFile)
|
||||
}, [receiptFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!receiptPreviewUrl) return
|
||||
return () => URL.revokeObjectURL(receiptPreviewUrl)
|
||||
}, [receiptPreviewUrl])
|
||||
|
||||
const reset = () => {
|
||||
setDetail('')
|
||||
setReceiptFile(null)
|
||||
setClientError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const hasText = detail.trim().length > 0
|
||||
const hasFile = Boolean(receiptFile)
|
||||
if (!hasText && !hasFile) {
|
||||
setClientError('Укажите комментарий и/или прикрепите чек.')
|
||||
return
|
||||
}
|
||||
setClientError(null)
|
||||
onSubmit({ detail: detail.trim(), receiptFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Подтверждение оплаты</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 2 }}>
|
||||
{PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Комментарий об оплате (сумма, время перевода и т.д.)"
|
||||
value={detail}
|
||||
onChange={(e) => {
|
||||
setDetail(e.target.value)
|
||||
setClientError(null)
|
||||
}}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 1, alignItems: { sm: 'center' } }}>
|
||||
<Button component="label" variant="outlined">
|
||||
Прикрепить чек (png, jpg, webp)
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
setReceiptFile(file ?? null)
|
||||
setClientError(null)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{receiptFile && (
|
||||
<Button color="error" variant="text" onClick={() => setReceiptFile(null)}>
|
||||
Убрать файл
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Нужен текст комментария и/или изображение чека.
|
||||
</Typography>
|
||||
{receiptPreviewUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={receiptPreviewUrl}
|
||||
alt="Предпросмотр чека"
|
||||
sx={{ maxWidth: '100%', maxHeight: 200, borderRadius: 1, border: 1, borderColor: 'divider', mb: 1 }}
|
||||
/>
|
||||
)}
|
||||
{clientError && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{clientError}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{paySubmitErrorMessage(error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
|
||||
Подтвердить оплату
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ReviewSection } from './ui/ReviewSection'
|
||||
export { ReviewDialog } from './ui/ReviewDialog'
|
||||
@@ -0,0 +1,150 @@
|
||||
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'
|
||||
|
||||
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 }) => void
|
||||
onUploadImage: (file: File) => void
|
||||
}
|
||||
|
||||
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 reset = () => {
|
||||
setRating(5)
|
||||
setText('')
|
||||
setImageUrl(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isPending) return
|
||||
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={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
onUploadImage(file)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</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 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 }) => 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={(params) => {
|
||||
if (!target) return
|
||||
onSubmitReview({
|
||||
productId: target.productId,
|
||||
...params,
|
||||
imageUrl: uploadedImageUrl,
|
||||
})
|
||||
}}
|
||||
onUploadImage={async (file) => {
|
||||
const result = await onUploadImage(file)
|
||||
setUploadedImageUrl(result.url)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserMenu } from './ui/UserMenu'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
||||
import Badge from '@mui/material/Badge'
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
user: AuthUser | null
|
||||
onNavigate: (to: string) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function UserMenu({ user, 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={openMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={closeMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={() => go('/me')}>
|
||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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/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={() => openCategoryEdit(c)}
|
||||
onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Новая категория</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Необязательно — можно сгенерировать из названия на сервере"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
|
||||
onClick={() => createCategoryMut.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={categoryEditOpen}
|
||||
onClose={() => {
|
||||
setCategoryEditOpen(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Редактировать категорию</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug"
|
||||
fullWidth
|
||||
{...field}
|
||||
disabled={editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG}
|
||||
helperText={
|
||||
editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG
|
||||
? 'Служебный slug нельзя изменить'
|
||||
: 'Идентификатор в URL'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="sort"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Порядок сортировки"
|
||||
fullWidth
|
||||
type="number"
|
||||
{...field}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCategoryEditOpen(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!categoryEditForm.watch('name').trim() || updateCategoryMut.isPending || !editingCategory}
|
||||
onClick={() => updateCategoryMut.mutate()}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(categoryDeleteTarget)}
|
||||
onClose={() => setCategoryDeleteTarget(null)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Удалить категорию?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
{categoryDeleteTarget && (
|
||||
<>
|
||||
Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано».
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCategoryDeleteTarget(null)}>Отмена</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteCategoryMut.isPending}
|
||||
onClick={() => {
|
||||
if (categoryDeleteTarget) deleteCategoryMut.mutate(categoryDeleteTarget.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useRef } from 'react'
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||
import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery'
|
||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
@@ -120,51 +117,7 @@ export function AdminGalleryPage() {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => deleteMut.mutate(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<GalleryGrid items={items} deleting={deleteMut.isPending} onDelete={(id) => deleteMut.mutate(id)} />
|
||||
|
||||
{!galleryQuery.isLoading && items.length === 0 && (
|
||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||
|
||||
@@ -14,8 +14,8 @@ 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/api/catalog-slider-api'
|
||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||
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 }
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
fetchAdminInfoBlocks,
|
||||
type InfoPageBlock,
|
||||
updateInfoBlock,
|
||||
} from '@/entities/info/api/info-page-api'
|
||||
} from '@/entities/info'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||
|
||||
@@ -24,10 +24,11 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||
import { AdminGalleryPage } from '@/pages/admin-gallery'
|
||||
import { AdminInfoPage } from '@/pages/admin-info'
|
||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminProductsPage } from '@/pages/admin-products'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
@@ -60,6 +61,7 @@ export function AdminLayoutPage() {
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
||||
{ to: '/admin/categories', label: 'Категории', icon: <AdminPanelSettingsOutlinedIcon /> },
|
||||
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||
@@ -185,7 +187,8 @@ export function AdminLayoutPage() {
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route index element={<AdminProductsPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||
|
||||
@@ -2,10 +2,6 @@ import { Fragment, useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
@@ -33,6 +29,7 @@ import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
@@ -247,156 +244,155 @@ export function AdminOrdersPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>Заказ</DialogTitle>
|
||||
<DialogContent>
|
||||
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка…</Typography>}
|
||||
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
||||
{detail && (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(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>
|
||||
<AdminDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="Заказ"
|
||||
maxWidth="md"
|
||||
loading={!detail && orderDetailQuery.isLoading}
|
||||
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
|
||||
>
|
||||
{detail && (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(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 ?? '—'}
|
||||
{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.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 variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<DeliveryFeeAdjustmentForm
|
||||
key={detail.id}
|
||||
orderId={detail.id}
|
||||
deliveryFeeCents={detail.deliveryFeeCents}
|
||||
/>
|
||||
)}
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<DeliveryFeeAdjustmentForm
|
||||
key={detail.id}
|
||||
orderId={detail.id}
|
||||
deliveryFeeCents={detail.deliveryFeeCents}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
labelId="next-status-label"
|
||||
label="Сменить статус"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const next = String(e.target.value)
|
||||
if (!next) return
|
||||
statusMut.mutate(next)
|
||||
}}
|
||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Выберите…</em>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
labelId="next-status-label"
|
||||
label="Сменить статус"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const next = String(e.target.value)
|
||||
if (!next) return
|
||||
statusMut.mutate(next)
|
||||
}}
|
||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Выберите…</em>
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||||
{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>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<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>
|
||||
)}
|
||||
</AdminDialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminProductsPage } from './ui/AdminProductsPage'
|
||||
@@ -0,0 +1,604 @@
|
||||
import { useRef, 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 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 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 { fetchAdminGallery } from '@/entities/gallery'
|
||||
import {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
fetchAdminProducts,
|
||||
fetchCategories,
|
||||
updateProduct,
|
||||
uploadAdminProductImages,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
inStock: boolean
|
||||
leadTimeDays: string
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
inStock: true,
|
||||
leadTimeDays: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
export function AdminProductsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
||||
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
||||
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const productForm = useForm<FormState>({
|
||||
defaultValues: emptyForm(),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const titleValue = productForm.watch('title')
|
||||
const inStockValue = productForm.watch('inStock')
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories(),
|
||||
})
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['admin', 'products'],
|
||||
queryFn: fetchAdminProducts,
|
||||
})
|
||||
|
||||
const galleryForPickQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: galleryPickOpen,
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
productForm.reset(emptyForm())
|
||||
openCreateDialog()
|
||||
}
|
||||
|
||||
const openEdit = (p: Product) => {
|
||||
openEditDialog(p)
|
||||
const urls =
|
||||
(p.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
|
||||
productForm.reset({
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
shortDescription: p.shortDescription ?? '',
|
||||
description: p.description ?? '',
|
||||
quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity),
|
||||
materials: (p.materials ?? []).join(', '),
|
||||
priceRub: String(p.priceCents / 100),
|
||||
imageUrls: urls,
|
||||
published: p.published,
|
||||
inStock: p.inStock,
|
||||
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
|
||||
categoryId: p.categoryId,
|
||||
})
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
const qty = form.quantity.trim() ? Number(form.quantity) : null
|
||||
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
|
||||
const materials = form.materials
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
await createProduct({
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim() || undefined,
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
quantity: qty === null ? null : Math.floor(qty),
|
||||
materials,
|
||||
priceCents,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
const qty = form.quantity.trim() ? Number(form.quantity) : null
|
||||
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
|
||||
const materials = form.materials
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
await updateProduct(editing!.id, {
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim(),
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
quantity: qty === null ? null : Math.floor(qty),
|
||||
materials,
|
||||
priceCents,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editing) updateMut.mutate()
|
||||
else createMut.mutate()
|
||||
}
|
||||
|
||||
const productImagesInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadImagesMut = useMutation({
|
||||
mutationFn: (picked: File[]) => uploadAdminProductImages(picked),
|
||||
onSuccess: (urls) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
|
||||
if (productImagesInputRef.current) {
|
||||
productImagesInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? uploadImagesMut.error
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue(
|
||||
'imageUrls',
|
||||
current.filter((u) => u !== url),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
|
||||
const toggleGalleryPickUrl = (url: string) => {
|
||||
setGallerySelectedUrls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(url)) {
|
||||
next.delete(url)
|
||||
} else {
|
||||
next.add(url)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const appendGalleryUrlsToForm = () => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
const merged = [...current]
|
||||
for (const url of gallerySelectedUrls) {
|
||||
if (!merged.includes(url)) {
|
||||
merged.push(url)
|
||||
}
|
||||
}
|
||||
productForm.setValue('imageUrls', merged, { shouldDirty: true })
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Управление товарами
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Новый товар
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{productsQuery.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>Категория</TableCell>
|
||||
<TableCell>Цена</TableCell>
|
||||
<TableCell>Витрина</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(productsQuery.data ?? []).map((p) => (
|
||||
<TableRow key={p.id} hover>
|
||||
<TableCell>{p.title}</TableCell>
|
||||
<TableCell>{p.category?.name ?? '—'}</TableCell>
|
||||
<TableCell>{formatPriceRub(p.priceCents)}</TableCell>
|
||||
<TableCell>{p.published ? 'да' : 'нет'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<EntityRowActions onEdit={() => openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="title"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="description"
|
||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="materials"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Материалы"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="quantity"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
helperText="Оставьте пустым, если не хотите вести учёт"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="priceRub"
|
||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (загрузка)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из
|
||||
карточки; файл остаётся на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
||||
Выбрать файлы
|
||||
<input
|
||||
ref={productImagesInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
uploadImagesMut.mutate(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setGallerySelectedUrls(new Set())
|
||||
setGalleryPickOpen(true)
|
||||
}}
|
||||
>
|
||||
Из галереи
|
||||
</Button>
|
||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
||||
</Box>
|
||||
|
||||
{productForm.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{productForm.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => removeImage(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={productForm.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
<MenuItem value="">
|
||||
<em>Не указано</em>
|
||||
</MenuItem>
|
||||
{(categoriesQuery.data ?? []).map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="inStock"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label={field.value ? 'В наличии' : 'Под заказ'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!inStockValue && (
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="leadTimeDays"
|
||||
render={({ field }) => <TextField label="Срок исполнения, дней" fullWidth {...field} />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!titleValue.trim() || createMut.isPending || updateMut.isPending}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={galleryPickOpen}
|
||||
onClose={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryForPickQuery.isError && (
|
||||
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
|
||||
)}
|
||||
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryForPickQuery.data?.items ?? []).map((item) => {
|
||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleGalleryPickUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={appendGalleryUrlsToForm}
|
||||
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,8 @@ import { useEffect, 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 TablePagination from '@mui/material/TablePagination'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
@@ -23,6 +16,8 @@ import type { AdminUser } from '@/entities/user/model/types'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||
import { AdminTable } from '@/shared/ui/AdminTable'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
|
||||
type UserFormState = {
|
||||
@@ -170,18 +165,25 @@ export function AdminUsersPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<AdminTable
|
||||
columns={[
|
||||
{ key: 'email', label: 'Почта' },
|
||||
{ key: 'name', label: 'Имя' },
|
||||
{ key: 'createdAt', label: 'Создан' },
|
||||
{ key: 'updatedAt', label: 'Обновлён' },
|
||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||
]}
|
||||
loading={usersQuery.isLoading}
|
||||
error={usersQuery.isError ? 'Ошибка загрузки.' : null}
|
||||
>
|
||||
{users.length === 0 && !usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell>Почта</TableCell>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>Создан</TableCell>
|
||||
<TableCell>Обновлён</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name ?? '—'}</TableCell>
|
||||
@@ -196,16 +198,9 @@ export function AdminUsersPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && !usersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
))
|
||||
)}
|
||||
</AdminTable>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
@@ -220,33 +215,37 @@ export function AdminUsersPage() {
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="email"
|
||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<AdminDialog
|
||||
open={dialogOpen}
|
||||
onClose={closeDialog}
|
||||
title={editing ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||||
maxWidth="xs"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="email"
|
||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
</AdminDialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import {
|
||||
createCategory,
|
||||
createProduct,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
|
||||
export type UseProductFiltersResult = ReturnType<typeof useProductFilters>
|
||||
|
||||
export function useProductFilters() {
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(12)
|
||||
const [priceMinRub, setPriceMinRub] = useState('')
|
||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
setPage(1)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
|
||||
setCategorySlug(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === '' || v === 'price_asc' || v === 'price_desc') {
|
||||
setSort(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvailabilityChange = (v: string) => {
|
||||
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
|
||||
setAvailability(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePriceMinChange = (v: string) => {
|
||||
setPriceMinRub(v)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handlePriceMaxChange = (v: string) => {
|
||||
setPriceMaxRub(v)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleCardScaleChange = (v: number) => {
|
||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v as 70 | 90 | 110 | 130)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setCategorySlug('')
|
||||
setAvailability('all')
|
||||
setQInput('')
|
||||
setSort('')
|
||||
setPriceMinRub('')
|
||||
setPriceMaxRub('')
|
||||
setPageSize(12)
|
||||
setCardScale(90)
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
const toCents = (v: string) => {
|
||||
const n = Number(String(v).trim().replace(',', '.'))
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
categorySlug,
|
||||
availability,
|
||||
qInput,
|
||||
q,
|
||||
moreOpen,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
setPage,
|
||||
setQInput,
|
||||
setMoreOpen,
|
||||
handleCategoryChange,
|
||||
handleSortChange,
|
||||
handlePageSizeChange,
|
||||
handleAvailabilityChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
toCents,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Pagination from '@mui/material/Pagination'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import ToggleButton from '@mui/material/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
@@ -26,108 +14,59 @@ import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { CatalogSlider } from '@/widgets/catalog-slider'
|
||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||
import { useProductFilters } from '../lib/use-product-filters'
|
||||
import { ProductFilters } from './ProductFilters'
|
||||
|
||||
export function HomePage() {
|
||||
const user = useUnit($user)
|
||||
const isAdmin = Boolean(user?.isAdmin)
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(12)
|
||||
const [priceMinRub, setPriceMinRub] = useState('')
|
||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
||||
const filters = useProductFilters()
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
setPage(1)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: [
|
||||
'products',
|
||||
'public',
|
||||
{
|
||||
categorySlug: categorySlug || 'all',
|
||||
availability,
|
||||
q,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
categorySlug: filters.categorySlug || 'all',
|
||||
availability: filters.availability,
|
||||
q: filters.q,
|
||||
sort: filters.sort,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
priceMinRub: filters.priceMinRub,
|
||||
priceMaxRub: filters.priceMaxRub,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
const toCents = (v: string) => {
|
||||
const n = Number(String(v).trim().replace(',', '.'))
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
|
||||
}
|
||||
return fetchPublicProducts({
|
||||
categorySlug: categorySlug || undefined,
|
||||
availability: availability === 'all' ? undefined : availability,
|
||||
q: q || undefined,
|
||||
sort: sort || '',
|
||||
page,
|
||||
pageSize,
|
||||
priceMinCents: toCents(priceMinRub),
|
||||
priceMaxCents: toCents(priceMaxRub),
|
||||
})
|
||||
},
|
||||
queryFn: () =>
|
||||
fetchPublicProducts({
|
||||
categorySlug: filters.categorySlug || undefined,
|
||||
availability: filters.availability === 'all' ? undefined : filters.availability,
|
||||
q: filters.q || undefined,
|
||||
sort: filters.sort || '',
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
priceMinCents: filters.toCents(filters.priceMinRub),
|
||||
priceMaxCents: filters.toCents(filters.priceMaxRub),
|
||||
}),
|
||||
})
|
||||
|
||||
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
|
||||
setCategorySlug(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === '' || v === 'price_asc' || v === 'price_desc') {
|
||||
setSort(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const title = useMemo(
|
||||
() =>
|
||||
categorySlug ? `Категория: ${categoriesQuery.data?.find((c) => c.slug === categorySlug)?.name ?? ''}` : 'Каталог',
|
||||
[categorySlug, categoriesQuery.data],
|
||||
filters.categorySlug
|
||||
? `Категория: ${categoriesQuery.data?.find((c) => c.slug === filters.categorySlug)?.name ?? ''}`
|
||||
: 'Каталог',
|
||||
[filters.categorySlug, categoriesQuery.data],
|
||||
)
|
||||
|
||||
const categoriesForFilter = useMemo(() => {
|
||||
const list = categoriesQuery.data ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.slug === 'ne-ukazano') return 1
|
||||
if (b.slug === 'ne-ukazano') return -1
|
||||
return a.sort - b.sort || a.name.localeCompare(b.name, 'ru')
|
||||
})
|
||||
}, [categoriesQuery.data])
|
||||
|
||||
const products = productsQuery.data?.items ?? []
|
||||
const total = productsQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const mediaHeight = Math.round(200 * (cardScale / 100))
|
||||
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
||||
const mediaHeight = Math.round(200 * (filters.cardScale / 100))
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -140,224 +79,14 @@ export function HomePage() {
|
||||
Игрушки, сувениры и другие изделия ручной работы.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||
<Select<string>
|
||||
labelId="category-filter-label"
|
||||
label="Категория"
|
||||
value={categorySlug}
|
||||
onChange={handleCategoryChange}
|
||||
disabled={categoriesQuery.isLoading}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{categoriesForFilter.map((c) => (
|
||||
<MenuItem key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Наличие</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Быстрый фильтр по наличию
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={availability}
|
||||
onChange={(_, v) => {
|
||||
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
|
||||
setAvailability(v)
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="all">Все</ToggleButton>
|
||||
<ToggleButton value="in_stock">В наличии</ToggleButton>
|
||||
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setCategorySlug('')
|
||||
setAvailability('all')
|
||||
setQInput('')
|
||||
setSort('')
|
||||
setPriceMinRub('')
|
||||
setPriceMaxRub('')
|
||||
setPageSize(12)
|
||||
setCardScale(90)
|
||||
setMoreOpen(false)
|
||||
}}
|
||||
sx={{ alignSelf: { xs: 'flex-start' } }}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={moreOpen} unmountOnExit>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
|
||||
<MenuItem value="">
|
||||
<em>Сначала новые</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
|
||||
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена от, ₽"
|
||||
value={priceMinRub}
|
||||
onChange={(e) => {
|
||||
setPriceMinRub(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена до, ₽"
|
||||
value={priceMaxRub}
|
||||
onChange={(e) => {
|
||||
setPriceMaxRub(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="page-size-label">На странице</InputLabel>
|
||||
<Select<string>
|
||||
labelId="page-size-label"
|
||||
label="На странице"
|
||||
value={String(pageSize)}
|
||||
onChange={handlePageSizeChange}
|
||||
>
|
||||
{[6, 12, 18, 24].map((n) => (
|
||||
<MenuItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Масштаб карточек</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Выберите размер карточек в каталоге
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={cardScale}
|
||||
onChange={(_, v) => {
|
||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
|
||||
}}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'none',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={70}>S</ToggleButton>
|
||||
<ToggleButton value={90}>M</ToggleButton>
|
||||
<ToggleButton value={110}>L</ToggleButton>
|
||||
<ToggleButton value={130}>XL</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
<ProductFilters
|
||||
{...filters}
|
||||
categories={categoriesQuery.data ?? []}
|
||||
categoriesLoading={categoriesQuery.isLoading}
|
||||
/>
|
||||
|
||||
{productsQuery.isLoading && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
||||
<Skeleton variant="rectangular" height={360} />
|
||||
@@ -367,16 +96,20 @@ export function HomePage() {
|
||||
)}
|
||||
|
||||
{productsQuery.isError && (
|
||||
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить товары. Проверьте, что API запущен.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length === 0 && (
|
||||
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||
Пока нет опубликованных товаров.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length > 0 && (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{products.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||
<ProductCard
|
||||
@@ -393,9 +126,9 @@ export function HomePage() {
|
||||
{totalPages > 1 && (
|
||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
page={page}
|
||||
page={filters.page}
|
||||
count={totalPages}
|
||||
onChange={(_, v) => setPage(v)}
|
||||
onChange={(_, v) => filters.setPage(v)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
showFirstButton
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useMemo } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import ToggleButton from '@mui/material/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import type { UseProductFiltersResult } from '../lib/use-product-filters'
|
||||
|
||||
type Props = UseProductFiltersResult & {
|
||||
categories: Category[]
|
||||
categoriesLoading: boolean
|
||||
}
|
||||
|
||||
export function ProductFilters({
|
||||
categorySlug,
|
||||
availability,
|
||||
qInput,
|
||||
moreOpen,
|
||||
sort,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
setQInput,
|
||||
setMoreOpen,
|
||||
handleCategoryChange,
|
||||
handleSortChange,
|
||||
handlePageSizeChange,
|
||||
handleAvailabilityChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
}: Props) {
|
||||
const categoriesForFilter = useMemo(() => {
|
||||
const list = categories ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.slug === 'ne-ukazano') return 1
|
||||
if (b.slug === 'ne-ukazano') return -1
|
||||
return a.sort - b.sort || a.name.localeCompare(b.name, 'ru')
|
||||
})
|
||||
}, [categories])
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||
<Select<string>
|
||||
labelId="category-filter-label"
|
||||
label="Категория"
|
||||
value={categorySlug}
|
||||
onChange={handleCategoryChange}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{categoriesForFilter.map((c) => (
|
||||
<MenuItem key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Наличие</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Быстрый фильтр по наличию
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={availability}
|
||||
onChange={(_, v) => handleAvailabilityChange(v)}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="all">Все</ToggleButton>
|
||||
<ToggleButton value="in_stock">В наличии</ToggleButton>
|
||||
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={resetFilters}
|
||||
sx={{ alignSelf: { xs: 'flex-start' } }}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={moreOpen} unmountOnExit>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
|
||||
<MenuItem value="">
|
||||
<em>Сначала новые</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
|
||||
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена от, ₽"
|
||||
value={priceMinRub}
|
||||
onChange={(e) => handlePriceMinChange(e.target.value)}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена до, ₽"
|
||||
value={priceMaxRub}
|
||||
onChange={(e) => handlePriceMaxChange(e.target.value)}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="page-size-label">На странице</InputLabel>
|
||||
<Select<string>
|
||||
labelId="page-size-label"
|
||||
label="На странице"
|
||||
value={String(pageSize)}
|
||||
onChange={handlePageSizeChange}
|
||||
>
|
||||
{[6, 12, 18, 24].map((n) => (
|
||||
<MenuItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Масштаб карточек</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Выберите размер карточек в каталоге
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={cardScale}
|
||||
onChange={(_, v) => handleCardScaleChange(v)}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'none',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={70}>S</ToggleButton>
|
||||
<ToggleButton value={90}>M</ToggleButton>
|
||||
<ToggleButton value={110}>L</ToggleButton>
|
||||
<ToggleButton value={130}>XL</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api'
|
||||
import { fetchPublicInfoBlocks } from '@/entities/info'
|
||||
|
||||
export function InfoPage() {
|
||||
const q = useQuery({
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
updateMyAddress,
|
||||
} from '@/entities/user/api/address-api'
|
||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker/ui/AddressMapPicker'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||
|
||||
export function AddressesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -1,92 +1,34 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo } 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 Divider from '@mui/material/Divider'
|
||||
import Link from '@mui/material/Link'
|
||||
import Rating from '@mui/material/Rating'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||
import {
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
postOrderMessage,
|
||||
submitOrderPayment,
|
||||
fetchOrderReviewEligibility,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { OrderChat } from '@/features/order-chat'
|
||||
import { OrderPaymentSection } from '@/features/order-payment'
|
||||
import { ReviewSection } from '@/features/product-review'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
|
||||
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
function paySubmitErrorMessage(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
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
|
||||
return apiMsg || err.message || 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
|
||||
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 OrderDetailPage() {
|
||||
const { id } = useParams()
|
||||
const qc = useQueryClient()
|
||||
const [text, setText] = useState('')
|
||||
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
|
||||
const [reviewRating, setReviewRating] = useState<number>(5)
|
||||
const [reviewText, setReviewText] = useState('')
|
||||
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
|
||||
|
||||
const [paymentModalOpen, setPaymentModalOpen] = useState(false)
|
||||
const [paymentDetail, setPaymentDetail] = useState('')
|
||||
const [paymentReceiptFile, setPaymentReceiptFile] = useState<File | null>(null)
|
||||
const [paymentClientError, setPaymentClientError] = useState<string | null>(null)
|
||||
|
||||
const paymentReceiptPreviewUrl = useMemo(() => {
|
||||
if (!paymentReceiptFile) return null
|
||||
return URL.createObjectURL(paymentReceiptFile)
|
||||
}, [paymentReceiptFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentReceiptPreviewUrl) return undefined
|
||||
return () => URL.revokeObjectURL(paymentReceiptPreviewUrl)
|
||||
}, [paymentReceiptPreviewUrl])
|
||||
|
||||
const orderQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id],
|
||||
@@ -95,16 +37,8 @@ export function OrderDetailPage() {
|
||||
})
|
||||
|
||||
const payMut = useMutation({
|
||||
mutationFn: () =>
|
||||
submitOrderPayment(id!, {
|
||||
detail: paymentDetail,
|
||||
receiptFile: paymentReceiptFile,
|
||||
}),
|
||||
mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params),
|
||||
onSuccess: async () => {
|
||||
setPaymentModalOpen(false)
|
||||
setPaymentDetail('')
|
||||
setPaymentReceiptFile(null)
|
||||
setPaymentClientError(null)
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||||
@@ -123,17 +57,14 @@ export function OrderDetailPage() {
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postOrderMessage(id!, text.trim()),
|
||||
mutationFn: (text: string) => postOrderMessage(id!, text),
|
||||
onSuccess: async () => {
|
||||
setText('')
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||
},
|
||||
})
|
||||
|
||||
const order = orderQuery.data?.item
|
||||
const payOnPickup = (order?.paymentMethod ?? 'online') === 'on_pickup'
|
||||
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
|
||||
const eligibilityQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
||||
@@ -142,27 +73,20 @@ export function OrderDetailPage() {
|
||||
})
|
||||
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!reviewTarget) return
|
||||
const t = reviewText.trim()
|
||||
await postProductReview(reviewTarget.productId, {
|
||||
rating: reviewRating,
|
||||
text: t.length ? t : null,
|
||||
imageUrl: reviewImageUrl,
|
||||
mutationFn: async (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => {
|
||||
await postProductReview(params.productId, {
|
||||
rating: params.rating,
|
||||
text: params.text.length ? params.text : null,
|
||||
imageUrl: params.imageUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||||
},
|
||||
})
|
||||
|
||||
const uploadReviewImageMut = useMutation({
|
||||
mutationFn: (file: File) => uploadReviewImage(file),
|
||||
onSuccess: ({ url }) => setReviewImageUrl(url),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,6 +103,8 @@ export function OrderDetailPage() {
|
||||
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
||||
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
|
||||
|
||||
const payOnPickup = (order.paymentMethod ?? 'online') === 'on_pickup'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
@@ -279,52 +205,15 @@ export function OrderDetailPage() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Оплата
|
||||
</Typography>
|
||||
{payOnPickup ? (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Оплата при получении на точке самовывоза (наличные или карта — по договорённости).
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{order.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в
|
||||
статус «{orderStatusLabelRu('PENDING_PAYMENT')}».
|
||||
</Typography>
|
||||
)}
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит
|
||||
статус «{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
payMut.reset()
|
||||
setPaymentModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Оплатить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{order.status === 'PAYMENT_VERIFICATION' && (
|
||||
<Typography color="info.main" variant="body2">
|
||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||||
</Typography>
|
||||
)}
|
||||
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате в этом блоке не требуется.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<OrderPaymentSection
|
||||
status={order.status}
|
||||
paymentMethod={order.paymentMethod}
|
||||
deliveryType={order.deliveryType}
|
||||
totalCents={order.totalCents}
|
||||
isPayPending={payMut.isPending}
|
||||
payError={payMut.error}
|
||||
onPay={(params) => payMut.mutate(params)}
|
||||
/>
|
||||
|
||||
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
||||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
|
||||
@@ -349,261 +238,22 @@ export function OrderDetailPage() {
|
||||
) : null}
|
||||
|
||||
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
|
||||
<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}>
|
||||
{eligibilityQuery.data.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={() => setReviewTarget({ productId: row.productId, title: row.title })}
|
||||
>
|
||||
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<ReviewSection
|
||||
items={eligibilityQuery.data.items}
|
||||
isSubmitPending={reviewMut.isPending}
|
||||
isUploadPending={uploadReviewImageMut.isPending}
|
||||
submitError={reviewMut.error}
|
||||
uploadError={uploadReviewImageMut.error}
|
||||
onSubmitReview={(params) => reviewMut.mutate(params)}
|
||||
onUploadImage={async (file) => {
|
||||
const result = await uploadReviewImageMut.mutateAsync(file)
|
||||
return result
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Чат по заказу
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
{order.messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{order.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={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !canSendMessage}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<OrderChat messages={order.messages} isPending={msgMut.isPending} onSend={(text) => msgMut.mutate(text)} />
|
||||
</Stack>
|
||||
|
||||
<Dialog open={paymentModalOpen} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Подтверждение оплаты</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 2 }}>
|
||||
{PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Комментарий об оплате (сумма, время перевода и т.д.)"
|
||||
value={paymentDetail}
|
||||
onChange={(e) => {
|
||||
setPaymentDetail(e.target.value)
|
||||
setPaymentClientError(null)
|
||||
}}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 1, alignItems: { sm: 'center' } }}>
|
||||
<Button component="label" variant="outlined">
|
||||
Прикрепить чек (png, jpg, webp)
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
setPaymentReceiptFile(file ?? null)
|
||||
setPaymentClientError(null)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{paymentReceiptFile && (
|
||||
<Button color="error" variant="text" onClick={() => setPaymentReceiptFile(null)}>
|
||||
Убрать файл
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Нужен текст комментария и/или изображение чека.
|
||||
</Typography>
|
||||
{paymentReceiptPreviewUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={paymentReceiptPreviewUrl}
|
||||
alt="Предпросмотр чека"
|
||||
sx={{ maxWidth: '100%', maxHeight: 200, borderRadius: 1, border: 1, borderColor: 'divider', mb: 1 }}
|
||||
/>
|
||||
)}
|
||||
{paymentClientError && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{paymentClientError}
|
||||
</Alert>
|
||||
)}
|
||||
{payMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{paySubmitErrorMessage(payMut.error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPaymentModalOpen(false)
|
||||
setPaymentDetail('')
|
||||
setPaymentReceiptFile(null)
|
||||
setPaymentClientError(null)
|
||||
payMut.reset()
|
||||
}}
|
||||
disabled={payMut.isPending}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={payMut.isPending}
|
||||
onClick={() => {
|
||||
const hasText = paymentDetail.trim().length > 0
|
||||
const hasFile = Boolean(paymentReceiptFile)
|
||||
if (!hasText && !hasFile) {
|
||||
setPaymentClientError('Укажите комментарий и/или прикрепите чек.')
|
||||
return
|
||||
}
|
||||
setPaymentClientError(null)
|
||||
payMut.mutate()
|
||||
}}
|
||||
>
|
||||
Подтвердить оплату
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reviewTarget)}
|
||||
onClose={() => {
|
||||
if (reviewMut.isPending) return
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Отзыв: {reviewTarget?.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Оценка
|
||||
</Typography>
|
||||
<Rating
|
||||
value={reviewRating}
|
||||
onChange={(_, v) => {
|
||||
if (v !== null) setReviewRating(v)
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<RichTextMessageEditor
|
||||
value={reviewText}
|
||||
onChange={setReviewText}
|
||||
placeholder="Комментарий (необязательно)"
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
|
||||
<Button component="label" variant="outlined" disabled={uploadReviewImageMut.isPending}>
|
||||
{reviewImageUrl ? 'Заменить фото' : 'Прикрепить фото'}
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
uploadReviewImageMut.mutate(file)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{reviewImageUrl && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="text"
|
||||
onClick={() => setReviewImageUrl(null)}
|
||||
disabled={reviewMut.isPending}
|
||||
>
|
||||
Удалить фото
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{reviewImageUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={reviewImageUrl}
|
||||
alt="Фото к отзыву"
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: 120,
|
||||
height: 120,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uploadReviewImageMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||||
</Alert>
|
||||
)}
|
||||
{reviewMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(reviewMut.error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
}}
|
||||
disabled={reviewMut.isPending}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export const DELIVERY_CARRIER_CODES = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] as const
|
||||
import { DELIVERY_CARRIERS as SHARED_DELIVERY_CARRIERS } from '@shared/constants/delivery-carrier'
|
||||
|
||||
export const DELIVERY_CARRIER_CODES = SHARED_DELIVERY_CARRIERS as typeof SHARED_DELIVERY_CARRIERS
|
||||
|
||||
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
|
||||
|
||||
/** Варианты для формы чекаута (код → подпись). */
|
||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
||||
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
||||
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
export const ORDER_STATUSES = [
|
||||
'DRAFT',
|
||||
'DELIVERY_FEE_ADJUSTMENT',
|
||||
'PENDING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'PAID',
|
||||
'IN_PROGRESS',
|
||||
'SHIPPED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DONE',
|
||||
'CANCELLED',
|
||||
] as const
|
||||
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
|
||||
|
||||
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||
|
||||
/** Следующие статусы, доступные админу (смена через PATCH). */
|
||||
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Должно совпадать с `getProductImageMaxFileBytes()` на сервере (по умолчанию 20 МБ). */
|
||||
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT } from '@shared/constants/upload-limits'
|
||||
|
||||
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||
|
||||
export function formatAdminImageMaxSizeHint(): string {
|
||||
return `${Math.round(ADMIN_UPLOAD_IMAGE_MAX_BYTES / (1024 * 1024))} МБ`
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createEffect, createEvent, createStore } from 'effector'
|
||||
|
||||
export function createErrorStore<Fx extends ReturnType<typeof createEffect>>(fx: Fx) {
|
||||
const reset = createEvent()
|
||||
const $error = createStore<unknown | null>(null)
|
||||
.on(fx.failData, (_, e) => e)
|
||||
.reset([fx, reset])
|
||||
|
||||
return { $error, reset }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const TOKEN_KEY = 'craftshop_auth_token'
|
||||
|
||||
export function readStoredToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function persistToken(token: string | null): void {
|
||||
try {
|
||||
if (!token) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function removeStoredToken(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,32 @@
|
||||
import { createEffect, createEvent, createStore, sample } from 'effector'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { createErrorStore } from '@/shared/lib/create-error-store'
|
||||
import { persistToken } from '@/shared/lib/persist-token'
|
||||
|
||||
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
|
||||
|
||||
const TOKEN_KEY = 'craftshop_auth_token'
|
||||
|
||||
export const tokenSet = createEvent<string | null>()
|
||||
export const logout = createEvent()
|
||||
|
||||
// ----- Token persistence -----
|
||||
|
||||
const persistTokenFx = createEffect<string | null, void>({
|
||||
handler: (token) => persistToken(token),
|
||||
})
|
||||
|
||||
export const $token = createStore<string | null>(null)
|
||||
.on(tokenSet, (_, t) => t)
|
||||
.reset(logout)
|
||||
|
||||
sample({
|
||||
clock: $token,
|
||||
target: persistTokenFx,
|
||||
})
|
||||
|
||||
// ----- User -----
|
||||
|
||||
export const $user = createStore<AuthUser | null>(null).reset(logout)
|
||||
|
||||
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
|
||||
await apiClient.post('me/change-email/request-code', { newEmail })
|
||||
})
|
||||
|
||||
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
|
||||
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export type UpdateProfileParams = { name: string | null; phone?: string | null }
|
||||
|
||||
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
|
||||
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
|
||||
.reset(requestEmailChangeCodeFx, logout)
|
||||
|
||||
export const $verifyEmailChangeError = createStore<unknown | null>(null)
|
||||
.on(verifyEmailChangeFx.failData, (_, e) => e)
|
||||
.reset(verifyEmailChangeFx, logout)
|
||||
|
||||
export const $updateProfileError = createStore<unknown | null>(null)
|
||||
.on(updateProfileFx.failData, (_, e) => e)
|
||||
.reset(updateProfileFx, logout)
|
||||
|
||||
export const meFx = createEffect(async (token: string) => {
|
||||
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -60,37 +45,39 @@ sample({
|
||||
target: $user,
|
||||
})
|
||||
|
||||
// ----- Email change -----
|
||||
|
||||
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
|
||||
await apiClient.post('me/change-email/request-code', { newEmail })
|
||||
})
|
||||
|
||||
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
|
||||
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
// ----- Profile update -----
|
||||
|
||||
export type UpdateProfileParams = { name: string | null; phone?: string | null }
|
||||
|
||||
export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
|
||||
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
|
||||
return data.user
|
||||
})
|
||||
|
||||
// ----- Error stores -----
|
||||
|
||||
export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error
|
||||
export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error
|
||||
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
||||
|
||||
// ----- Re-exports -----
|
||||
|
||||
export { readStoredToken } from '@/shared/lib/persist-token'
|
||||
|
||||
// ----- Sync user from profile/email changes -----
|
||||
|
||||
sample({
|
||||
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
||||
target: $user,
|
||||
})
|
||||
|
||||
let tokenPersistInitialized = false
|
||||
$token.watch((t) => {
|
||||
try {
|
||||
if (!tokenPersistInitialized) {
|
||||
tokenPersistInitialized = true
|
||||
return
|
||||
}
|
||||
if (!t) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, t)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
logout.watch(() => {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
export function readStoredToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
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 Typography from '@mui/material/Typography'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
actions?: ReactNode
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
maxWidth?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function AdminDialog({ open, onClose, title, children, actions, loading, error, maxWidth = 'sm' }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth={maxWidth}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading && <Typography>Загрузка…</Typography>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
{!loading && !error && children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{actions}
|
||||
<Button onClick={onClose}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
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'
|
||||
|
||||
export type AdminTableColumn = {
|
||||
key: string
|
||||
label: string
|
||||
align?: 'left' | 'right' | 'center'
|
||||
}
|
||||
|
||||
type Props = {
|
||||
columns: AdminTableColumn[]
|
||||
children: ReactNode
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
skeletonRows?: number
|
||||
}
|
||||
|
||||
export function AdminTable({ columns, children, loading, error, skeletonRows = 3 }: Props) {
|
||||
return (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align}>
|
||||
{col.label}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{error && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{loading && !error && (
|
||||
<>
|
||||
{Array.from({ length: skeletonRows }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!loading && !error && children}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AdminTable } from './AdminTable'
|
||||
export type { Column as AdminTableColumn } from './AdminTable'
|
||||
@@ -4,8 +4,8 @@ import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { CatalogSliderSlide } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||
import { fetchCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||
import type { CatalogSliderSlide } from '@/entities/catalog-slider'
|
||||
import { fetchCatalogSlider } from '@/entities/catalog-slider'
|
||||
|
||||
const AUTO_MS = 5500
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NavigationDrawer } from './ui/NavigationDrawer'
|
||||
@@ -0,0 +1,100 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import type { AuthUser } from '@/shared/model/auth'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
|
||||
type ThemeControls = {
|
||||
scheme: string
|
||||
mode: string
|
||||
resolvedMode: 'light' | 'dark'
|
||||
onSchemeChange: (e: SelectChangeEvent<string>) => void
|
||||
onModeChange: (e: SelectChangeEvent<string>) => void
|
||||
onCycleMode: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
user: AuthUser | null
|
||||
isAdmin: boolean
|
||||
navItems: { label: string; to: string }[]
|
||||
themeControls: ThemeControls
|
||||
onNavigate: (to: string) => void
|
||||
onLogout: () => void
|
||||
ThemeControlsMobile: React.ComponentType<ThemeControls>
|
||||
}
|
||||
|
||||
export function NavigationDrawer({
|
||||
open,
|
||||
onClose,
|
||||
user,
|
||||
isAdmin,
|
||||
navItems,
|
||||
themeControls,
|
||||
onNavigate,
|
||||
onLogout,
|
||||
ThemeControlsMobile,
|
||||
}: Props) {
|
||||
const go = (to: string) => {
|
||||
onClose()
|
||||
onNavigate(to)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slotProps={{ paper: { sx: { width: 320, maxWidth: '85vw' } } }}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BearLogo sx={{ fontSize: 28 }} />
|
||||
<Typography variant="h6">{STORE_NAME}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{navItems.map((i) => (
|
||||
<Button key={i.to} variant="text" onClick={() => go(i.to)} sx={{ justifyContent: 'flex-start' }}>
|
||||
{i.label}
|
||||
</Button>
|
||||
))}
|
||||
{!isAdmin && (
|
||||
<Button variant="text" onClick={() => go(user ? '/cart' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Корзина
|
||||
</Button>
|
||||
)}
|
||||
{user && !isAdmin && (
|
||||
<Button variant="text" onClick={() => go('/me/orders')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Заказы
|
||||
</Button>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
{user ? 'Профиль' : 'Вход / регистрация'}
|
||||
</Button>
|
||||
)}
|
||||
{!user && isAdmin && (
|
||||
<Button variant="text" onClick={() => go('/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
Вход / регистрация
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<Button variant="text" color="error" onClick={onLogout} sx={{ justifyContent: 'flex-start' }}>
|
||||
Выход
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<ThemeControlsMobile {...themeControls} />
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
|
||||
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
const projectRoot = path.resolve(rootDir, '..')
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -11,9 +12,13 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(rootDir, 'src'),
|
||||
'@shared': path.resolve(projectRoot, 'shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [projectRoot],
|
||||
},
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user