Merge branch 'refactor'
This commit is contained in:
@@ -0,0 +1,142 @@
|
|||||||
|
# План рефакторинга shop
|
||||||
|
|
||||||
|
> Составлен на основе анализа кода и правил `.cursor/rules`.
|
||||||
|
> Дата: 2026-05-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статус выполнения
|
||||||
|
|
||||||
|
- ✅ 1.1 Сервер: разбить `routes/auth.js` → 6 модулей
|
||||||
|
- ✅ 1.2 Клиент: разбить `AdminPage.tsx` → `AdminProductsPage` + `AdminCategoriesPage`
|
||||||
|
- ✅ 1.3 Клиент: разбить `OrderDetailPage.tsx` (чаты, оплата, отзывы → features)
|
||||||
|
- ✅ 2.2 FSD: роутинг из `App.tsx` → `app/routes/index.tsx`
|
||||||
|
- ✅ 3.1 Клиент: разбить `AppHeader.tsx` (UserMenu, CartBadge, NavigationDrawer)
|
||||||
|
- ✅ 4.1 Effector: рефакторинг `auth.ts` (persist, sample, createErrorStore)
|
||||||
|
- ✅ 2.1 Недостающие сегменты FSD (catalog-slider, gallery, info, address-map-picker)
|
||||||
|
- ✅ 2.3 Дублирование констант клиент/сервер
|
||||||
|
- ✅ 3.2 HomePage (вынесены фильтры в хук и компонент)
|
||||||
|
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
|
||||||
|
- ✅ 5.1 fastify.decorate вместо параметров
|
||||||
|
- ✅ 5.2 Валидация через Fastify Schema
|
||||||
|
- ⬜ 6.1 Error Boundary
|
||||||
|
- ⬜ 6.2 Тесты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Критические точки (высокий приоритет) — ✅ Выполнено
|
||||||
|
|
||||||
|
### 1.1 Сервер: разбить `server/src/routes/auth.js` (892 → ~200 строк)
|
||||||
|
|
||||||
|
| Файл | Роуты |
|
||||||
|
|---|---|
|
||||||
|
| `routes/auth.js` | `/api/auth/request-code`, `/api/auth/verify-code`, `/api/me`, `/api/me/change-email/*`, `/api/me/profile` |
|
||||||
|
| `routes/user-addresses.js` | `/api/me/addresses` (6 роутов CRUD + default) |
|
||||||
|
| `routes/user-cart.js` | `/api/me/cart` (4 роута CRUD) |
|
||||||
|
| `routes/user-orders.js` | `/api/me/orders` (создание, список, деталь, подтверждение, review-eligibility) |
|
||||||
|
| `routes/user-payments.js` | `/api/me/orders/:id/pay` |
|
||||||
|
| `routes/user-messages.js` | `/api/me/orders/:id/messages`, unread-count, conversations, mark-read |
|
||||||
|
|
||||||
|
### 1.2 Клиент: разбить `AdminPage.tsx` (891 → 604 + 295 строк)
|
||||||
|
|
||||||
|
- `pages/admin-products/` + `pages/admin-categories/`
|
||||||
|
- `AdminLayoutPage` — новый нав-айтем «Категории», роут `/admin/categories`
|
||||||
|
|
||||||
|
### 1.3 Клиент: разбить `OrderDetailPage.tsx` (609 → 258 строк)
|
||||||
|
|
||||||
|
- `features/order-chat/` — чат по заказу
|
||||||
|
- `features/order-payment/` — секция оплаты + модалка (`OrderPaymentSection`, `PaymentDialog`)
|
||||||
|
- `features/product-review/` — секция отзывов + модалка (`ReviewSection`, `ReviewDialog`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. FSD-архитектура
|
||||||
|
|
||||||
|
### 2.1 Создать недостающие сегменты ✅
|
||||||
|
|
||||||
|
| Слайс | Что сделано |
|
||||||
|
|---|---|
|
||||||
|
| `entities/catalog-slider` | `model/types.ts`, `index.ts` (barrel), импорты обновлены |
|
||||||
|
| `entities/gallery` | `ui/GalleryGrid.tsx`, `index.ts`, импорты обновлены |
|
||||||
|
| `entities/info` | `model/types.ts`, `index.ts`, импорты обновлены |
|
||||||
|
| `features/address-map-picker` | `api/map-geocoding.ts`, `model/types.ts`, `index.ts`, импорты обновлены |
|
||||||
|
|
||||||
|
### 2.2 Вынести роутинг из `App.tsx` → `app/routes/` ✅
|
||||||
|
|
||||||
|
`AppRoutes` в `app/routes/index.tsx`. `App.tsx` — чистая точка входа.
|
||||||
|
|
||||||
|
### 2.3 Устранить дублирование констант клиент/сервер ✅
|
||||||
|
|
||||||
|
Создан `shared/constants/` с каноничными значениями (`order-status.js`, `delivery-carrier.js`, `upload-limits.js`, `payment-method.js`). Все клиентские и серверные константы импортируются оттуда. Vite + tsconfig настроены на `@shared/*` alias.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Клиентские компоненты
|
||||||
|
|
||||||
|
### 3.1 `AppHeader.tsx` (406 → 293 строк) ✅
|
||||||
|
- `UserMenu` → `features/user/user-menu/`
|
||||||
|
- `CartBadge` → `features/cart/cart-badge/`
|
||||||
|
- `NavigationDrawer` → `widgets/navigation-drawer/`
|
||||||
|
|
||||||
|
### 3.2 `HomePage.tsx` (414 → 157 строк) ✅
|
||||||
|
|
||||||
|
- `useProductFilters` хук в `pages/home/lib/`
|
||||||
|
- `ProductFilters` компонент в `pages/home/ui/`
|
||||||
|
- Фильтры, сортировка, масштаб карточек вынесены из страницы
|
||||||
|
|
||||||
|
### 3.3 `AdminOrdersPage.tsx`, `AdminUsersPage.tsx` ✅
|
||||||
|
|
||||||
|
- `shared/ui/AdminTable/` — компонент таблицы с loading/error/skeleton
|
||||||
|
- `shared/ui/AdminDialog/` — компонент диалога с loading/error/title/actions
|
||||||
|
- `AdminUsersPage`: таблица и диалог заменены на общие компоненты
|
||||||
|
- `AdminOrdersPage`: диалог заменён на `AdminDialog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Effector + состояние — ✅ Выполнено
|
||||||
|
|
||||||
|
### 4.1 `shared/model/auth.ts` (96 → 83 строк)
|
||||||
|
|
||||||
|
- `.watch()` → `sample` + `persistTokenFx`
|
||||||
|
- Убран `tokenPersistInitialized` флаг
|
||||||
|
- `createErrorStore(effect)` — общий шаблон сторов ошибок
|
||||||
|
- `readStoredToken` → `shared/lib/persist-token.ts` (re-export из auth.ts)
|
||||||
|
- Создан `shared/lib/create-error-store.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Сервер (низкий приоритет)
|
||||||
|
|
||||||
|
### 5.1 `fastify.decorate` вместо передачи зависимостей параметрами ✅
|
||||||
|
|
||||||
|
`slugify`, `parseMaterialsInput`, `mapProductForApi` декорированы на fastify в `api.js`. Роуты используют `request.server.*` вместо получения через параметры.
|
||||||
|
|
||||||
|
### 5.2 Валидация через Fastify Schema ✅
|
||||||
|
|
||||||
|
Добавлены JSON Schema для:
|
||||||
|
- `POST /api/admin/products` — body
|
||||||
|
- `PATCH /api/admin/products/:id` — body
|
||||||
|
- `GET /api/products` — querystring (фильтры, пагинация)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Инфраструктура (низкий приоритет)
|
||||||
|
|
||||||
|
### 6.1 Error Boundary ⬜
|
||||||
|
### 6.2 Тесты ⬜
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка изменений
|
||||||
|
|
||||||
|
| Область | Файлов создано | Файлов изменено |
|
||||||
|
|---|---|---|
|
||||||
|
| Server routes | 0 | 4 (декораты + схемы) |
|
||||||
|
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
|
||||||
|
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
|
||||||
|
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
|
||||||
|
| Client shared/ui | 2 | 0 (AdminDialog, AdminTable) |
|
||||||
|
| Client app config | 0 | 2 (vite.config, tsconfig) |
|
||||||
|
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
|
||||||
|
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
|
||||||
|
| **Итого** | **22** | **15** |
|
||||||
+3
-28
@@ -1,37 +1,12 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { MainLayout } from '@/app/layout/MainLayout'
|
|
||||||
import { AppProviders } from '@/app/providers/AppProviders'
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
import { AboutPage } from '@/pages/about'
|
import { AppRoutes } from '@/app/routes'
|
||||||
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 App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MainLayout>
|
<AppRoutes />
|
||||||
<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>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
|
||||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
||||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from '@mui/material/AppBar'
|
||||||
import Badge from '@mui/material/Badge'
|
import Badge from '@mui/material/Badge'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Divider from '@mui/material/Divider'
|
|
||||||
import Drawer from '@mui/material/Drawer'
|
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
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 MenuItem from '@mui/material/MenuItem'
|
||||||
import Select from '@mui/material/Select'
|
import Select from '@mui/material/Select'
|
||||||
import type { SelectChangeEvent } 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 { useThemeController } from '@/app/providers/theme-controller'
|
||||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||||
import { fetchMyOrders } from '@/entities/order/api/order-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 { STORE_NAME } from '@/shared/config'
|
||||||
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||||
|
import { NavigationDrawer } from '@/widgets/navigation-drawer'
|
||||||
|
|
||||||
type NavItem = { label: string; to: string }
|
type NavItem = { label: string; to: string }
|
||||||
|
|
||||||
@@ -175,7 +172,6 @@ export function AppHeader() {
|
|||||||
queryFn: fetchMyCart,
|
queryFn: fetchMyCart,
|
||||||
enabled: Boolean(user) && !isAdmin,
|
enabled: Boolean(user) && !isAdmin,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cartCount = cartQuery.data?.items?.length ?? 0
|
const cartCount = cartQuery.data?.items?.length ?? 0
|
||||||
|
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
@@ -183,53 +179,46 @@ export function AppHeader() {
|
|||||||
queryFn: fetchMyOrders,
|
queryFn: fetchMyOrders,
|
||||||
enabled: Boolean(user) && !isAdmin,
|
enabled: Boolean(user) && !isAdmin,
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
|
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
|
||||||
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
|
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
|
|
||||||
const userMenuOpen = Boolean(userAnchorEl)
|
|
||||||
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
const onSchemeChange = (e: SelectChangeEvent<string>) => {
|
const onSchemeChange = (e: SelectChangeEvent<string>) => setScheme(e.target.value as ColorScheme)
|
||||||
setScheme(e.target.value as ColorScheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onModeChange = (e: SelectChangeEvent<string>) => {
|
const onModeChange = (e: SelectChangeEvent<string>) => {
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
|
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) => {
|
const go = (to: string) => {
|
||||||
closeMobile()
|
setMobileOpen(false)
|
||||||
closeUserMenu()
|
|
||||||
navigate(to)
|
navigate(to)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLogout = () => {
|
const onLogout = () => {
|
||||||
tokenSet(null)
|
tokenSet(null)
|
||||||
logout()
|
logout()
|
||||||
closeMobile()
|
setMobileOpen(false)
|
||||||
closeUserMenu()
|
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeControls = { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode: cycleMode }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
{isMobile && (
|
{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 />
|
<MenuOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
@@ -272,58 +261,11 @@ export function AppHeader() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
|
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isAdmin && (
|
{!isAdmin && <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />}
|
||||||
<>
|
|
||||||
<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 && user && !isMobile && (
|
{isAdmin && user && !isMobile && (
|
||||||
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
|
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
|
||||||
@@ -331,76 +273,21 @@ export function AppHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && <ThemeControlsDesktop {...themeControls} />}
|
||||||
<ThemeControlsDesktop
|
|
||||||
scheme={scheme}
|
|
||||||
mode={mode}
|
|
||||||
resolvedMode={resolvedMode}
|
|
||||||
onSchemeChange={onSchemeChange}
|
|
||||||
onModeChange={onModeChange}
|
|
||||||
onCycleMode={cycleMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
<Drawer
|
<NavigationDrawer
|
||||||
open={mobileOpen}
|
open={mobileOpen}
|
||||||
onClose={closeMobile}
|
onClose={() => setMobileOpen(false)}
|
||||||
slotProps={{ paper: { sx: { width: 320, maxWidth: '85vw' } } }}
|
user={user}
|
||||||
ModalProps={{ keepMounted: true }}
|
isAdmin={isAdmin}
|
||||||
>
|
navItems={headerNavItems}
|
||||||
<Box sx={{ p: 2 }}>
|
themeControls={themeControls}
|
||||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
onNavigate={go}
|
||||||
<BearLogo sx={{ fontSize: 28 }} />
|
onLogout={onLogout}
|
||||||
<Typography variant="h6">{STORE_NAME}</Typography>
|
ThemeControlsMobile={ThemeControlsMobile}
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
|
||||||
export type CatalogSliderSlide = {
|
|
||||||
id: string
|
|
||||||
url: string
|
|
||||||
caption: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
|
||||||
galleryImageId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
||||||
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
|
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 { 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[] }> {
|
export async function fetchPublicInfoBlocks(): Promise<{ items: InfoPageBlock[] }> {
|
||||||
const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('info-page/blocks')
|
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<{
|
messages: Array<{
|
||||||
id: string
|
id: string
|
||||||
authorType: string
|
authorType: 'user' | 'admin'
|
||||||
text: string
|
text: string
|
||||||
attachmentUrl?: string | null
|
attachmentUrl?: string | null
|
||||||
createdAt: string
|
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 Typography from '@mui/material/Typography'
|
||||||
import * as maplibregl from 'maplibre-gl'
|
import * as maplibregl from 'maplibre-gl'
|
||||||
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||||
|
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||||
type NominatimItem = { display_name: string; lat: string; lon: string }
|
import type { LatLng } from '../model/types'
|
||||||
|
|
||||||
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 : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddressMapPicker(props: {
|
export function AddressMapPicker(props: {
|
||||||
value: { lat: number; lng: number } | null
|
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 { useRef } from 'react'
|
||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import IconButton from '@mui/material/IconButton'
|
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||||
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery'
|
||||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
@@ -120,51 +117,7 @@ export function AdminGalleryPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
<GalleryGrid items={items} deleting={deleteMut.isPending} onDelete={(id) => deleteMut.mutate(id)} />
|
||||||
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>
|
|
||||||
|
|
||||||
{!galleryQuery.isLoading && items.length === 0 && (
|
{!galleryQuery.isLoading && items.length === 0 && (
|
||||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import Stack from '@mui/material/Stack'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { putAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
import { putAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
import type { GalleryImageItem } from '@/entities/gallery'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
|
|
||||||
export type SlideDraft = { galleryImageId: string; caption: string }
|
export type SlideDraft = { galleryImageId: string; caption: string }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
fetchAdminInfoBlocks,
|
fetchAdminInfoBlocks,
|
||||||
type InfoPageBlock,
|
type InfoPageBlock,
|
||||||
updateInfoBlock,
|
updateInfoBlock,
|
||||||
} from '@/entities/info/api/info-page-api'
|
} from '@/entities/info'
|
||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
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 { useUnit } from 'effector-react'
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
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 { AdminGalleryPage } from '@/pages/admin-gallery'
|
||||||
import { AdminInfoPage } from '@/pages/admin-info'
|
import { AdminInfoPage } from '@/pages/admin-info'
|
||||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||||
|
import { AdminProductsPage } from '@/pages/admin-products'
|
||||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||||
import { AdminUsersPage } from '@/pages/admin-users'
|
import { AdminUsersPage } from '@/pages/admin-users'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
@@ -60,6 +61,7 @@ export function AdminLayoutPage() {
|
|||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
||||||
|
{ to: '/admin/categories', label: 'Категории', icon: <AdminPanelSettingsOutlinedIcon /> },
|
||||||
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
||||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||||
@@ -185,7 +187,8 @@ export function AdminLayoutPage() {
|
|||||||
|
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminProductsPage />} />
|
||||||
|
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||||
<Route path="orders" element={<AdminOrdersPage />} />
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ import { Fragment, useMemo, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import 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 FormControl from '@mui/material/FormControl'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
@@ -33,6 +29,7 @@ import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
|||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
@@ -247,156 +244,155 @@ export function AdminOrdersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
|
<AdminDialog
|
||||||
<DialogTitle>Заказ</DialogTitle>
|
open={dialogOpen}
|
||||||
<DialogContent>
|
onClose={() => setDialogOpen(false)}
|
||||||
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка…</Typography>}
|
title="Заказ"
|
||||||
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
maxWidth="md"
|
||||||
{detail && (
|
loading={!detail && orderDetailQuery.isLoading}
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
|
||||||
<Typography sx={{ fontWeight: 700 }}>
|
>
|
||||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
|
{detail && (
|
||||||
{formatPriceRub(detail.totalCents)}
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
</Typography>
|
<Typography sx={{ fontWeight: 700 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
|
||||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
{formatPriceRub(detail.totalCents)}
|
||||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
</Typography>
|
||||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
<Typography variant="body2" color="text.secondary">
|
||||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||||
)}
|
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||||
</Typography>
|
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||||
|
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{detail.deliveryType === 'delivery' && (
|
{detail.deliveryType === 'delivery' && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
bgcolor: 'action.hover',
|
bgcolor: 'action.hover',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||||
Адрес и получатель (на момент заказа)
|
Адрес и получатель (на момент заказа)
|
||||||
</Typography>
|
</Typography>
|
||||||
{deliverySnapshot ? (
|
{deliverySnapshot ? (
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
{deliverySnapshot.label?.trim() && (
|
{deliverySnapshot.label?.trim() && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Метка: {deliverySnapshot.label}
|
Метка: {deliverySnapshot.label}
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="body2">
|
|
||||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
|
||||||
Адрес:
|
|
||||||
</Box>{' '}
|
|
||||||
{deliverySnapshot.addressLine ?? '—'}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
)}
|
||||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2">
|
||||||
Получатель:
|
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||||
</Box>{' '}
|
Адрес:
|
||||||
{deliverySnapshot.recipientName ?? '—'}
|
</Box>{' '}
|
||||||
</Typography>
|
{deliverySnapshot.addressLine ?? '—'}
|
||||||
<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>
|
</Typography>
|
||||||
)}
|
<Typography variant="body2">
|
||||||
</Box>
|
<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' && (
|
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||||
<DeliveryFeeAdjustmentForm
|
<DeliveryFeeAdjustmentForm
|
||||||
key={detail.id}
|
key={detail.id}
|
||||||
orderId={detail.id}
|
orderId={detail.id}
|
||||||
deliveryFeeCents={detail.deliveryFeeCents}
|
deliveryFeeCents={detail.deliveryFeeCents}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="next-status-label"
|
labelId="next-status-label"
|
||||||
label="Сменить статус"
|
label="Сменить статус"
|
||||||
value=""
|
value=""
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = String(e.target.value)
|
const next = String(e.target.value)
|
||||||
if (!next) return
|
if (!next) return
|
||||||
statusMut.mutate(next)
|
statusMut.mutate(next)
|
||||||
}}
|
}}
|
||||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<em>Выберите…</em>
|
<em>Выберите…</em>
|
||||||
|
</MenuItem>
|
||||||
|
{nextStatuses.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{orderStatusLabelRu(s)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{nextStatuses.map((s) => (
|
))}
|
||||||
<MenuItem key={s} value={s}>
|
</Select>
|
||||||
{orderStatusLabelRu(s)}
|
</FormControl>
|
||||||
</MenuItem>
|
</Stack>
|
||||||
))}
|
|
||||||
</Select>
|
<Box>
|
||||||
</FormControl>
|
<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>
|
||||||
|
|
||||||
<Box>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||||
Сообщения
|
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||||||
</Typography>
|
</Box>
|
||||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
<Button
|
||||||
{detail.messages.map((m) => (
|
variant="contained"
|
||||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
onClick={() => msgMut.mutate()}
|
||||||
<Typography variant="caption" color="text.secondary">
|
disabled={msgMut.isPending || !canSendMessage}
|
||||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
sx={{ minWidth: 160 }}
|
||||||
{new Date(m.createdAt).toLocaleString()}
|
>
|
||||||
</Typography>
|
Отправить
|
||||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
</Button>
|
||||||
</ChatMessageBubble>
|
</Stack>
|
||||||
))}
|
</Box>
|
||||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
</Stack>
|
||||||
</Stack>
|
)}
|
||||||
|
</AdminDialog>
|
||||||
<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>
|
|
||||||
</Box>
|
</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 Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import 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 Stack from '@mui/material/Stack'
|
||||||
import Table from '@mui/material/Table'
|
|
||||||
import TableBody from '@mui/material/TableBody'
|
|
||||||
import TableCell from '@mui/material/TableCell'
|
import TableCell from '@mui/material/TableCell'
|
||||||
import TableHead from '@mui/material/TableHead'
|
|
||||||
import TablePagination from '@mui/material/TablePagination'
|
import TablePagination from '@mui/material/TablePagination'
|
||||||
import TableRow from '@mui/material/TableRow'
|
import TableRow from '@mui/material/TableRow'
|
||||||
import TextField from '@mui/material/TextField'
|
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 { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
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'
|
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||||
|
|
||||||
type UserFormState = {
|
type UserFormState = {
|
||||||
@@ -170,18 +165,25 @@ export function AdminUsersPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table size="small">
|
<AdminTable
|
||||||
<TableHead>
|
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>
|
<TableRow>
|
||||||
<TableCell>Почта</TableCell>
|
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||||
<TableCell>Имя</TableCell>
|
Пользователей пока нет.
|
||||||
<TableCell>Создан</TableCell>
|
</TableCell>
|
||||||
<TableCell>Обновлён</TableCell>
|
|
||||||
<TableCell align="right">Действия</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
) : (
|
||||||
<TableBody>
|
users.map((u) => (
|
||||||
{users.map((u) => (
|
|
||||||
<TableRow key={u.id} hover>
|
<TableRow key={u.id} hover>
|
||||||
<TableCell>{u.email}</TableCell>
|
<TableCell>{u.email}</TableCell>
|
||||||
<TableCell>{u.name ?? '—'}</TableCell>
|
<TableCell>{u.name ?? '—'}</TableCell>
|
||||||
@@ -196,16 +198,9 @@ export function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
{users.length === 0 && !usersQuery.isLoading && (
|
)}
|
||||||
<TableRow>
|
</AdminTable>
|
||||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
|
||||||
Пользователей пока нет.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
@@ -220,33 +215,37 @@ export function AdminUsersPage() {
|
|||||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
|
<AdminDialog
|
||||||
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
open={dialogOpen}
|
||||||
<DialogContent>
|
onClose={closeDialog}
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
title={editing ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||||||
<Controller
|
maxWidth="xs"
|
||||||
control={userForm.control}
|
actions={
|
||||||
name="email"
|
<>
|
||||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
<Button onClick={closeDialog}>Отмена</Button>
|
||||||
/>
|
<Button
|
||||||
<Controller
|
variant="contained"
|
||||||
control={userForm.control}
|
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||||
name="name"
|
disabled={isSaveDisabled}
|
||||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
>
|
||||||
/>
|
{editing ? 'Сохранить' : 'Создать'}
|
||||||
</Stack>
|
</Button>
|
||||||
</DialogContent>
|
</>
|
||||||
<DialogActions>
|
}
|
||||||
<Button onClick={closeDialog}>Отмена</Button>
|
>
|
||||||
<Button
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
variant="contained"
|
<Controller
|
||||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
control={userForm.control}
|
||||||
disabled={isSaveDisabled}
|
name="email"
|
||||||
>
|
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||||
{editing ? 'Сохранить' : 'Создать'}
|
/>
|
||||||
</Button>
|
<Controller
|
||||||
</DialogActions>
|
control={userForm.control}
|
||||||
</Dialog>
|
name="name"
|
||||||
|
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</AdminDialog>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
import { fetchAdminGallery } from '@/entities/gallery'
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
createProduct,
|
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 Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
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 Grid from '@mui/material/Grid'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
|
||||||
import Pagination from '@mui/material/Pagination'
|
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 Skeleton from '@mui/material/Skeleton'
|
||||||
import Stack from '@mui/material/Stack'
|
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 Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
@@ -26,108 +14,59 @@ import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
|||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { CatalogSlider } from '@/widgets/catalog-slider'
|
import { CatalogSlider } from '@/widgets/catalog-slider'
|
||||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||||
|
import { useProductFilters } from '../lib/use-product-filters'
|
||||||
|
import { ProductFilters } from './ProductFilters'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
const isAdmin = Boolean(user?.isAdmin)
|
const isAdmin = Boolean(user?.isAdmin)
|
||||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
const filters = useProductFilters()
|
||||||
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 categoriesQuery = useQuery({
|
const categoriesQuery = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: () => fetchCategories(),
|
queryFn: () => fetchCategories(),
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
setQ(qInput.trim())
|
|
||||||
setPage(1)
|
|
||||||
}, 250)
|
|
||||||
return () => window.clearTimeout(t)
|
|
||||||
}, [qInput])
|
|
||||||
|
|
||||||
const productsQuery = useQuery({
|
const productsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'products',
|
'products',
|
||||||
'public',
|
'public',
|
||||||
{
|
{
|
||||||
categorySlug: categorySlug || 'all',
|
categorySlug: filters.categorySlug || 'all',
|
||||||
availability,
|
availability: filters.availability,
|
||||||
q,
|
q: filters.q,
|
||||||
sort,
|
sort: filters.sort,
|
||||||
page,
|
page: filters.page,
|
||||||
pageSize,
|
pageSize: filters.pageSize,
|
||||||
priceMinRub,
|
priceMinRub: filters.priceMinRub,
|
||||||
priceMaxRub,
|
priceMaxRub: filters.priceMaxRub,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () =>
|
||||||
const toCents = (v: string) => {
|
fetchPublicProducts({
|
||||||
const n = Number(String(v).trim().replace(',', '.'))
|
categorySlug: filters.categorySlug || undefined,
|
||||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
|
availability: filters.availability === 'all' ? undefined : filters.availability,
|
||||||
}
|
q: filters.q || undefined,
|
||||||
return fetchPublicProducts({
|
sort: filters.sort || '',
|
||||||
categorySlug: categorySlug || undefined,
|
page: filters.page,
|
||||||
availability: availability === 'all' ? undefined : availability,
|
pageSize: filters.pageSize,
|
||||||
q: q || undefined,
|
priceMinCents: filters.toCents(filters.priceMinRub),
|
||||||
sort: sort || '',
|
priceMaxCents: filters.toCents(filters.priceMaxRub),
|
||||||
page,
|
}),
|
||||||
pageSize,
|
|
||||||
priceMinCents: toCents(priceMinRub),
|
|
||||||
priceMaxCents: toCents(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(
|
const title = useMemo(
|
||||||
() =>
|
() =>
|
||||||
categorySlug ? `Категория: ${categoriesQuery.data?.find((c) => c.slug === categorySlug)?.name ?? ''}` : 'Каталог',
|
filters.categorySlug
|
||||||
[categorySlug, categoriesQuery.data],
|
? `Категория: ${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 products = productsQuery.data?.items ?? []
|
||||||
const total = productsQuery.data?.total ?? 0
|
const total = productsQuery.data?.total ?? 0
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
||||||
const mediaHeight = Math.round(200 * (cardScale / 100))
|
const mediaHeight = Math.round(200 * (filters.cardScale / 100))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -140,224 +79,14 @@ export function HomePage() {
|
|||||||
Игрушки, сувениры и другие изделия ручной работы.
|
Игрушки, сувениры и другие изделия ручной работы.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
<ProductFilters
|
||||||
<Stack
|
{...filters}
|
||||||
direction={{ xs: 'column', md: 'row' }}
|
categories={categoriesQuery.data ?? []}
|
||||||
spacing={2}
|
categoriesLoading={categoriesQuery.isLoading}
|
||||||
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>
|
|
||||||
|
|
||||||
{productsQuery.isLoading && (
|
{productsQuery.isLoading && (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
||||||
<Skeleton variant="rectangular" height={360} />
|
<Skeleton variant="rectangular" height={360} />
|
||||||
@@ -367,16 +96,20 @@ export function HomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{productsQuery.isError && (
|
{productsQuery.isError && (
|
||||||
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Не удалось загрузить товары. Проверьте, что API запущен.
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{productsQuery.isSuccess && products.length === 0 && (
|
{productsQuery.isSuccess && products.length === 0 && (
|
||||||
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
|
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Пока нет опубликованных товаров.
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{productsQuery.isSuccess && products.length > 0 && (
|
{productsQuery.isSuccess && products.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
@@ -393,9 +126,9 @@ export function HomePage() {
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={filters.page}
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
onChange={(_, v) => setPage(v)}
|
onChange={(_, v) => filters.setPage(v)}
|
||||||
color="primary"
|
color="primary"
|
||||||
shape="rounded"
|
shape="rounded"
|
||||||
showFirstButton
|
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 Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api'
|
import { fetchPublicInfoBlocks } from '@/entities/info'
|
||||||
|
|
||||||
export function InfoPage() {
|
export function InfoPage() {
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
updateMyAddress,
|
updateMyAddress,
|
||||||
} from '@/entities/user/api/address-api'
|
} from '@/entities/user/api/address-api'
|
||||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
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() {
|
export function AddressesPage() {
|
||||||
const queryClient = useQueryClient()
|
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 Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import 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 Divider from '@mui/material/Divider'
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
import Rating from '@mui/material/Rating'
|
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import axios from 'axios'
|
|
||||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
confirmOrderReceived,
|
confirmOrderReceived,
|
||||||
fetchMyOrder,
|
fetchMyOrder,
|
||||||
fetchOrderReviewEligibility,
|
|
||||||
postOrderMessage,
|
postOrderMessage,
|
||||||
submitOrderPayment,
|
submitOrderPayment,
|
||||||
|
fetchOrderReviewEligibility,
|
||||||
} from '@/entities/order/api/order-api'
|
} from '@/entities/order/api/order-api'
|
||||||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-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 { 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 { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { 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() {
|
export function OrderDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const qc = useQueryClient()
|
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({
|
const orderQuery = useQuery({
|
||||||
queryKey: ['me', 'orders', id],
|
queryKey: ['me', 'orders', id],
|
||||||
@@ -95,16 +37,8 @@ export function OrderDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const payMut = useMutation({
|
const payMut = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params),
|
||||||
submitOrderPayment(id!, {
|
|
||||||
detail: paymentDetail,
|
|
||||||
receiptFile: paymentReceiptFile,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setPaymentModalOpen(false)
|
|
||||||
setPaymentDetail('')
|
|
||||||
setPaymentReceiptFile(null)
|
|
||||||
setPaymentClientError(null)
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||||||
@@ -123,17 +57,14 @@ export function OrderDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const msgMut = useMutation({
|
const msgMut = useMutation({
|
||||||
mutationFn: () => postOrderMessage(id!, text.trim()),
|
mutationFn: (text: string) => postOrderMessage(id!, text),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setText('')
|
|
||||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
||||||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const order = orderQuery.data?.item
|
const order = orderQuery.data?.item
|
||||||
const payOnPickup = (order?.paymentMethod ?? 'online') === 'on_pickup'
|
|
||||||
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
|
||||||
|
|
||||||
const eligibilityQuery = useQuery({
|
const eligibilityQuery = useQuery({
|
||||||
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
||||||
@@ -142,27 +73,20 @@ export function OrderDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reviewMut = useMutation({
|
const reviewMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => {
|
||||||
if (!reviewTarget) return
|
await postProductReview(params.productId, {
|
||||||
const t = reviewText.trim()
|
rating: params.rating,
|
||||||
await postProductReview(reviewTarget.productId, {
|
text: params.text.length ? params.text : null,
|
||||||
rating: reviewRating,
|
imageUrl: params.imageUrl,
|
||||||
text: t.length ? t : null,
|
|
||||||
imageUrl: reviewImageUrl,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setReviewTarget(null)
|
|
||||||
setReviewRating(5)
|
|
||||||
setReviewText('')
|
|
||||||
setReviewImageUrl(null)
|
|
||||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadReviewImageMut = useMutation({
|
const uploadReviewImageMut = useMutation({
|
||||||
mutationFn: (file: File) => uploadReviewImage(file),
|
mutationFn: (file: File) => uploadReviewImage(file),
|
||||||
onSuccess: ({ url }) => setReviewImageUrl(url),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +103,8 @@ export function OrderDetailPage() {
|
|||||||
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
||||||
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
|
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
|
||||||
|
|
||||||
|
const payOnPickup = (order.paymentMethod ?? 'online') === 'on_pickup'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||||
@@ -279,52 +205,15 @@ export function OrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
<OrderPaymentSection
|
||||||
<Typography variant="h6" gutterBottom>
|
status={order.status}
|
||||||
Оплата
|
paymentMethod={order.paymentMethod}
|
||||||
</Typography>
|
deliveryType={order.deliveryType}
|
||||||
{payOnPickup ? (
|
totalCents={order.totalCents}
|
||||||
<Typography color="text.secondary" variant="body2">
|
isPayPending={payMut.isPending}
|
||||||
Оплата при получении на точке самовывоза (наличные или карта — по договорённости).
|
payError={payMut.error}
|
||||||
</Typography>
|
onPay={(params) => payMut.mutate(params)}
|
||||||
) : (
|
/>
|
||||||
<>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
||||||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
|
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
|
||||||
@@ -349,261 +238,22 @@ export function OrderDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
|
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
|
||||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
<ReviewSection
|
||||||
<Typography variant="h6" gutterBottom>
|
items={eligibilityQuery.data.items}
|
||||||
Отзывы
|
isSubmitPending={reviewMut.isPending}
|
||||||
</Typography>
|
isUploadPending={uploadReviewImageMut.isPending}
|
||||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
submitError={reviewMut.error}
|
||||||
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
|
uploadError={uploadReviewImageMut.error}
|
||||||
</Typography>
|
onSubmitReview={(params) => reviewMut.mutate(params)}
|
||||||
<Stack spacing={1}>
|
onUploadImage={async (file) => {
|
||||||
{eligibilityQuery.data.items.map((row) => (
|
const result = await uploadReviewImageMut.mutateAsync(file)
|
||||||
<Stack
|
return result
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
<OrderChat messages={order.messages} isPending={msgMut.isPending} onSend={(text) => msgMut.mutate(text)} />
|
||||||
<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>
|
|
||||||
</Stack>
|
</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>
|
</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 type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
|
||||||
|
|
||||||
/** Варианты для формы чекаута (код → подпись). */
|
|
||||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
||||||
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
||||||
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
export const ORDER_STATUSES = [
|
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
|
||||||
'DRAFT',
|
|
||||||
'DELIVERY_FEE_ADJUSTMENT',
|
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
|
||||||
'PENDING_PAYMENT',
|
|
||||||
'PAYMENT_VERIFICATION',
|
|
||||||
'PAID',
|
|
||||||
'IN_PROGRESS',
|
|
||||||
'SHIPPED',
|
|
||||||
'READY_FOR_PICKUP',
|
|
||||||
'DONE',
|
|
||||||
'CANCELLED',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||||
|
|
||||||
/** Следующие статусы, доступные админу (смена через PATCH). */
|
|
||||||
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'DRAFT':
|
case 'DRAFT':
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Должно совпадать с `getProductImageMaxFileBytes()` на сервере (по умолчанию 20 МБ). */
|
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT } from '@shared/constants/upload-limits'
|
||||||
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
|
||||||
|
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
|
|
||||||
export function formatAdminImageMaxSizeHint(): string {
|
export function formatAdminImageMaxSizeHint(): string {
|
||||||
return `${Math.round(ADMIN_UPLOAD_IMAGE_MAX_BYTES / (1024 * 1024))} МБ`
|
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 { createEffect, createEvent, createStore, sample } from 'effector'
|
||||||
import { apiClient } from '@/shared/api/client'
|
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 }
|
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 tokenSet = createEvent<string | null>()
|
||||||
export const logout = createEvent()
|
export const logout = createEvent()
|
||||||
|
|
||||||
|
// ----- Token persistence -----
|
||||||
|
|
||||||
|
const persistTokenFx = createEffect<string | null, void>({
|
||||||
|
handler: (token) => persistToken(token),
|
||||||
|
})
|
||||||
|
|
||||||
export const $token = createStore<string | null>(null)
|
export const $token = createStore<string | null>(null)
|
||||||
.on(tokenSet, (_, t) => t)
|
.on(tokenSet, (_, t) => t)
|
||||||
.reset(logout)
|
.reset(logout)
|
||||||
|
|
||||||
|
sample({
|
||||||
|
clock: $token,
|
||||||
|
target: persistTokenFx,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ----- User -----
|
||||||
|
|
||||||
export const $user = createStore<AuthUser | null>(null).reset(logout)
|
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) => {
|
export const meFx = createEffect(async (token: string) => {
|
||||||
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
|
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -60,37 +45,39 @@ sample({
|
|||||||
target: $user,
|
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({
|
sample({
|
||||||
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
|
||||||
target: $user,
|
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 Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { CatalogSliderSlide } from '@/entities/catalog-slider/api/catalog-slider-api'
|
import type { CatalogSliderSlide } from '@/entities/catalog-slider'
|
||||||
import { fetchCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
import { fetchCatalogSlider } from '@/entities/catalog-slider'
|
||||||
|
|
||||||
const AUTO_MS = 5500
|
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": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
const rootDir = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const projectRoot = path.resolve(rootDir, '..')
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -11,9 +12,13 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(rootDir, 'src'),
|
'@': path.resolve(rootDir, 'src'),
|
||||||
|
'@shared': path.resolve(projectRoot, 'shared'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: [projectRoot],
|
||||||
|
},
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
4. Если не помогло: вручную `cd client && npm run build`, затем **`./scripts/deploy-ssh.sh --frontend-only --skip-build`** (выложится уже готовый `client/dist`).
|
4. Если не помогло: вручную `cd client && npm run build`, затем **`./scripts/deploy-ssh.sh --frontend-only --skip-build`** (выложится уже готовый `client/dist`).
|
||||||
|
|
||||||
- **Бэкенд**: при изменениях в `server/prisma` — миграции должны быть в репозитории; на сервере выполнится `prisma migrate deploy` (см. скрипт деплоя).
|
- **Бэкенд**: при изменениях в `server/prisma` — миграции должны быть в репозитории; на сервере выполнится `prisma migrate deploy` (см. скрипт деплоя).
|
||||||
|
- **Общие константы**: каталог `shared/constants/` синхронизируется скриптом деплоя вместе с `server/` (автоматически в `deploy_backend`).
|
||||||
|
|
||||||
## 2. Переменные окружения на сервере
|
## 2. Переменные окружения на сервере
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ npm run build
|
|||||||
|
|
||||||
## 6. Что не потерять при деплое
|
## 6. Что не потерять при деплое
|
||||||
|
|
||||||
|
- Каталоги **`shared/`** и **`server/`** должны быть рядом на одном уровне (например, `/opt/craftshop/shared/constants/order-status.js` и `/opt/craftshop/server/src/lib/order-status.js`). Скрипт деплоя синхронизирует оба.
|
||||||
- Файл **SQLite** и каталог **`server/uploads/`** должны лежать на **персистентном диске** (не внутри временного слоя контейнера без тома).
|
- Файл **SQLite** и каталог **`server/uploads/`** должны лежать на **персистентном диске** (не внутри временного слоя контейнера без тома).
|
||||||
- Nginx (или аналог): **`/api`** → прокси на Fastify, **`/uploads`** → те же файлы, что пишет сервер, либо прокси на `@fastify/static` (см. [test-deploy-proxmox.md](test-deploy-proxmox.md)).
|
- Nginx (или аналог): **`/api`** → прокси на Fastify, **`/uploads`** → те же файлы, что пишет сервер, либо прокси на `@fastify/static` (см. [test-deploy-proxmox.md](test-deploy-proxmox.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"context7": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.context7.com/mcp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
+18
-2
@@ -115,9 +115,10 @@ build_rsync_rsh() {
|
|||||||
|
|
||||||
deploy_backend() {
|
deploy_backend() {
|
||||||
remote_exec mkdir -p "$DEPLOY_PATH/server"
|
remote_exec mkdir -p "$DEPLOY_PATH/server"
|
||||||
|
remote_exec mkdir -p "$DEPLOY_PATH/shared"
|
||||||
|
|
||||||
if should_use_tar_transport; then
|
if should_use_tar_transport; then
|
||||||
echo ">>> Бэкенд: tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
|
echo ">>> Бэкенд (server): tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
|
||||||
if [[ -n "$DRY_RUN" ]]; then
|
if [[ -n "$DRY_RUN" ]]; then
|
||||||
echo "(dry-run) без передачи tar"
|
echo "(dry-run) без передачи tar"
|
||||||
else
|
else
|
||||||
@@ -132,9 +133,17 @@ deploy_backend() {
|
|||||||
--exclude=.dev_env \
|
--exclude=.dev_env \
|
||||||
.
|
.
|
||||||
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
|
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
|
||||||
|
|
||||||
|
echo ">>> Бэкенд (shared): tar|ssh → $REMOTE:$DEPLOY_PATH/shared/"
|
||||||
|
(
|
||||||
|
cd "$ROOT/shared" || exit 1
|
||||||
|
tar -czf - \
|
||||||
|
--exclude=.git \
|
||||||
|
.
|
||||||
|
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo ">>> Бэкенд: rsync → $REMOTE:$DEPLOY_PATH/server/"
|
echo ">>> Бэкенд (server): rsync → $REMOTE:$DEPLOY_PATH/server/"
|
||||||
local rsh
|
local rsh
|
||||||
rsh="$(build_rsync_rsh)"
|
rsh="$(build_rsync_rsh)"
|
||||||
|
|
||||||
@@ -147,6 +156,12 @@ deploy_backend() {
|
|||||||
--exclude .env \
|
--exclude .env \
|
||||||
--exclude .dev_env \
|
--exclude .dev_env \
|
||||||
"${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
|
"${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
|
||||||
|
|
||||||
|
echo ">>> Бэкенд (shared): rsync → $REMOTE:$DEPLOY_PATH/shared/"
|
||||||
|
rsync "${RSYNC_OPTS[@]}" \
|
||||||
|
-e "$rsh" \
|
||||||
|
--exclude .git \
|
||||||
|
"${ROOT}/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$DRY_RUN" ]]; then
|
if [[ -n "$DRY_RUN" ]]; then
|
||||||
@@ -164,6 +179,7 @@ deploy_backend() {
|
|||||||
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
|
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
|
||||||
echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)"
|
echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)"
|
||||||
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
|
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
|
||||||
|
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
|
||||||
fi
|
fi
|
||||||
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
|
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
|
||||||
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload
|
|||||||
import { registerAuth } from './plugins/auth.js'
|
import { registerAuth } from './plugins/auth.js'
|
||||||
import { registerApiRoutes } from './routes/api.js'
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
import { registerAuthRoutes } from './routes/auth.js'
|
import { registerAuthRoutes } from './routes/auth.js'
|
||||||
|
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||||
|
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||||
|
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||||
|
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||||
|
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3333
|
const port = Number(process.env.PORT) || 3333
|
||||||
@@ -57,6 +62,11 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
|
|||||||
|
|
||||||
registerAuth(fastify)
|
registerAuth(fastify)
|
||||||
await registerAuthRoutes(fastify)
|
await registerAuthRoutes(fastify)
|
||||||
|
await registerUserAddressRoutes(fastify)
|
||||||
|
await registerUserCartRoutes(fastify)
|
||||||
|
await registerUserMessageRoutes(fastify)
|
||||||
|
await registerUserOrderRoutes(fastify)
|
||||||
|
await registerUserPaymentRoutes(fastify)
|
||||||
await registerOAuthSocialRoutes(fastify)
|
await registerOAuthSocialRoutes(fastify)
|
||||||
await registerApiRoutes(fastify)
|
await registerApiRoutes(fastify)
|
||||||
await ensureAdminUser()
|
await ensureAdminUser()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const DELIVERY_CARRIERS = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
|
export { DELIVERY_CARRIERS } from '../../../shared/constants/delivery-carrier.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
export const ORDER_STATUSES = [
|
export { ORDER_STATUSES } from '../../../shared/constants/order-status.js'
|
||||||
'DRAFT',
|
|
||||||
'DELIVERY_FEE_ADJUSTMENT',
|
|
||||||
'PENDING_PAYMENT',
|
|
||||||
'PAYMENT_VERIFICATION',
|
|
||||||
'PAID',
|
|
||||||
'IN_PROGRESS',
|
|
||||||
'SHIPPED',
|
|
||||||
'READY_FOR_PICKUP',
|
|
||||||
'DONE',
|
|
||||||
'CANCELLED',
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
|
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
|
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT as SHARED_DEFAULT } from '../../../shared/constants/upload-limits.js'
|
||||||
|
|
||||||
const MB = 1024 * 1024
|
const MB = 1024 * 1024
|
||||||
|
|
||||||
/**
|
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = SHARED_DEFAULT
|
||||||
* Один файл изображения в админке: товары, галерея (`POST /api/admin/uploads`).
|
|
||||||
* Должно совпадать с лимитом плагина multipart в `server/src/index.js`.
|
|
||||||
*/
|
|
||||||
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB
|
|
||||||
|
|
||||||
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
|
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
|
||||||
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
|||||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||||
|
|
||||||
export async function registerApiRoutes(fastify) {
|
export async function registerApiRoutes(fastify) {
|
||||||
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
|
fastify.decorate('slugify', slugify)
|
||||||
|
fastify.decorate('parseMaterialsInput', parseMaterialsInput)
|
||||||
|
fastify.decorate('mapProductForApi', mapProductForApi)
|
||||||
|
|
||||||
|
await registerPublicCatalogRoutes(fastify)
|
||||||
await registerPublicReviewRoutes(fastify)
|
await registerPublicReviewRoutes(fastify)
|
||||||
await registerInfoPageRoutes(fastify)
|
await registerInfoPageRoutes(fastify)
|
||||||
await registerCatalogSliderRoutes(fastify)
|
await registerCatalogSliderRoutes(fastify)
|
||||||
|
|
||||||
await registerAdminProductRoutes(fastify, {
|
await registerAdminProductRoutes(fastify)
|
||||||
slugify,
|
|
||||||
parseMaterialsInput,
|
|
||||||
mapProductForApi,
|
|
||||||
})
|
|
||||||
await registerAdminGalleryRoutes(fastify)
|
await registerAdminGalleryRoutes(fastify)
|
||||||
await registerAdminCategoryRoutes(fastify, { slugify })
|
await registerAdminCategoryRoutes(fastify)
|
||||||
await registerAdminOrderRoutes(fastify)
|
await registerAdminOrderRoutes(fastify)
|
||||||
await registerAdminReviewRoutes(fastify)
|
await registerAdminReviewRoutes(fastify)
|
||||||
await registerAdminUserRoutes(fastify)
|
await registerAdminUserRoutes(fastify)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '../../lib/default-category.js'
|
} from '../../lib/default-category.js'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
export async function registerAdminCategoryRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/api/admin/categories',
|
'/api/admin/categories',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
@@ -27,7 +27,7 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
|||||||
reply.code(400).send({ error: 'Укажите название категории' })
|
reply.code(400).send({ error: 'Укажите название категории' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
|
const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}`
|
||||||
if (isUnspecifiedCategorySlug(slug)) {
|
if (isUnspecifiedCategorySlug(slug)) {
|
||||||
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,19 +8,59 @@ import {
|
|||||||
} from '../../lib/upload-limits.js'
|
} from '../../lib/upload-limits.js'
|
||||||
import { persistMultipartImages } from '../../lib/upload-images.js'
|
import { persistMultipartImages } from '../../lib/upload-images.js'
|
||||||
|
|
||||||
export async function registerAdminProductRoutes(
|
const CREATE_PRODUCT_SCHEMA = {
|
||||||
fastify,
|
body: {
|
||||||
{ slugify, parseMaterialsInput, mapProductForApi } = {},
|
type: 'object',
|
||||||
) {
|
required: ['title', 'priceCents'],
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1 },
|
||||||
|
slug: { type: 'string' },
|
||||||
|
categoryId: { type: 'string' },
|
||||||
|
priceCents: { type: 'number', minimum: 0 },
|
||||||
|
quantity: { type: 'number', minimum: 0 },
|
||||||
|
inStock: { type: 'boolean' },
|
||||||
|
leadTimeDays: { type: 'number', minimum: 1 },
|
||||||
|
shortDescription: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
|
||||||
|
imageUrl: { type: 'string' },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
published: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATCH_PRODUCT_SCHEMA = {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', minLength: 1 },
|
||||||
|
slug: { type: 'string' },
|
||||||
|
categoryId: { type: 'string' },
|
||||||
|
priceCents: { type: 'number', minimum: 0 },
|
||||||
|
quantity: { type: 'number', minimum: 0 },
|
||||||
|
inStock: { type: 'boolean' },
|
||||||
|
leadTimeDays: { type: 'number', minimum: 1 },
|
||||||
|
shortDescription: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] },
|
||||||
|
imageUrl: { type: 'string' },
|
||||||
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||||
|
published: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAdminProductRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/api/admin/products',
|
'/api/admin/products',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
async () => {
|
async (request) => {
|
||||||
const items = await prisma.product.findMany({
|
const items = await prisma.product.findMany({
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
})
|
})
|
||||||
return items.map(mapProductForApi)
|
return items.map((p) => request.server.mapProductForApi(p))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +92,7 @@ export async function registerAdminProductRoutes(
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/api/admin/products',
|
'/api/admin/products',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const title = String(body.title ?? '').trim()
|
const title = String(body.title ?? '').trim()
|
||||||
@@ -60,7 +100,7 @@ export async function registerAdminProductRoutes(
|
|||||||
reply.code(400).send({ error: 'Укажите название' })
|
reply.code(400).send({ error: 'Укажите название' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
|
const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}`
|
||||||
let categoryId = String(body.categoryId ?? '').trim()
|
let categoryId = String(body.categoryId ?? '').trim()
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
categoryId = (await getOrCreateUnspecifiedCategory()).id
|
categoryId = (await getOrCreateUnspecifiedCategory()).id
|
||||||
@@ -115,7 +155,7 @@ export async function registerAdminProductRoutes(
|
|||||||
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
||||||
description: body.description ? String(body.description) : null,
|
description: body.description ? String(body.description) : null,
|
||||||
quantity,
|
quantity,
|
||||||
materials: JSON.stringify(parseMaterialsInput(body.materials)),
|
materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)),
|
||||||
priceCents: Math.round(priceCents),
|
priceCents: Math.round(priceCents),
|
||||||
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
||||||
published: Boolean(body.published),
|
published: Boolean(body.published),
|
||||||
@@ -134,13 +174,13 @@ export async function registerAdminProductRoutes(
|
|||||||
},
|
},
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
})
|
})
|
||||||
reply.code(201).send(mapProductForApi(product))
|
reply.code(201).send(request.server.mapProductForApi(product))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fastify.patch(
|
fastify.patch(
|
||||||
'/api/admin/products/:id',
|
'/api/admin/products/:id',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
@@ -182,7 +222,7 @@ export async function registerAdminProductRoutes(
|
|||||||
data.quantity = Math.floor(n)
|
data.quantity = Math.floor(n)
|
||||||
}
|
}
|
||||||
if (body.materials !== undefined) {
|
if (body.materials !== undefined) {
|
||||||
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
|
data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
|
||||||
}
|
}
|
||||||
if (body.priceCents !== undefined) {
|
if (body.priceCents !== undefined) {
|
||||||
const p = Number(body.priceCents)
|
const p = Number(body.priceCents)
|
||||||
@@ -254,7 +294,7 @@ export async function registerAdminProductRoutes(
|
|||||||
data: { ...data, images: imagesUpdate },
|
data: { ...data, images: imagesUpdate },
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
})
|
})
|
||||||
return mapProductForApi(product)
|
return request.server.mapProductForApi(product)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
const PUBLIC_PRODUCTS_QUERY_SCHEMA = {
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
categorySlug: { type: 'string' },
|
||||||
|
q: { type: 'string' },
|
||||||
|
availability: { type: 'string', enum: ['all', 'in_stock', 'made_to_order'] },
|
||||||
|
sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] },
|
||||||
|
page: { type: 'integer', minimum: 1 },
|
||||||
|
pageSize: { type: 'integer', minimum: 1, maximum: 100 },
|
||||||
|
priceMin: { type: 'number', minimum: 0 },
|
||||||
|
priceMax: { type: 'number', minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
|
const EMPTY_REVIEWS_SUMMARY = Object.freeze({
|
||||||
approvedReviewCount: 0,
|
approvedReviewCount: 0,
|
||||||
avgRating: null,
|
avgRating: null,
|
||||||
@@ -58,12 +74,13 @@ export async function approvedReviewSummariesForProducts(productIds) {
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
|
export async function registerPublicCatalogRoutes(fastify) {
|
||||||
fastify.get('/api/categories', async () => {
|
fastify.get('/api/categories', async () => {
|
||||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.get('/api/products', async (request, reply) => {
|
fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => {
|
||||||
|
const { mapProductForApi } = request.server
|
||||||
const { categorySlug } = request.query
|
const { categorySlug } = request.query
|
||||||
const qRaw = request.query?.q
|
const qRaw = request.query?.q
|
||||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||||
@@ -134,7 +151,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
|||||||
|
|
||||||
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
|
const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id))
|
||||||
return {
|
return {
|
||||||
items: items.map((p) => mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
|
items: items.map((p) => request.server.mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -152,7 +169,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const summaries = await approvedReviewSummariesForProducts([product.id])
|
const summaries = await approvedReviewSummariesForProducts([product.id])
|
||||||
return mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
|
||||||
import { escapeHtml } from '../lib/escape-html.js'
|
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
|
||||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
|
||||||
|
|
||||||
function mapUserForClient(user) {
|
function mapUserForClient(user) {
|
||||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
@@ -22,7 +18,6 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
const email = normalizeEmail(request.body?.email)
|
const email = normalizeEmail(request.body?.email)
|
||||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
|
||||||
// purpose: login (включает и регистрацию — пользователь создастся при verify)
|
|
||||||
await issueEmailCode({ email, purpose: 'login' })
|
await issueEmailCode({ email, purpose: 'login' })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
@@ -123,770 +118,4 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
return { user: mapUserForClient(updated) }
|
return { user: mapUserForClient(updated) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Адреса доставки ----
|
|
||||||
|
|
||||||
function normalizePhoneLite(input) {
|
|
||||||
const s = String(input || '').trim()
|
|
||||||
if (!s) return ''
|
|
||||||
return s.replace(/[\s()-]/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAddressPayload(body, reply) {
|
|
||||||
const labelRaw = body?.label
|
|
||||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
|
||||||
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
|
||||||
|
|
||||||
const recipientName = String(body?.recipientName || '').trim()
|
|
||||||
if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
|
||||||
if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
|
||||||
|
|
||||||
const recipientPhone = normalizePhoneLite(body?.recipientPhone)
|
|
||||||
if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
|
||||||
if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
|
||||||
|
|
||||||
const addressLine = String(body?.addressLine || '').trim()
|
|
||||||
if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' })
|
|
||||||
if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
|
||||||
|
|
||||||
const commentRaw = body?.comment
|
|
||||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
|
||||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
|
||||||
|
|
||||||
const lat = Number(body?.lat)
|
|
||||||
const lng = Number(body?.lng)
|
|
||||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
|
||||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
recipientName,
|
|
||||||
recipientPhone,
|
|
||||||
addressLine,
|
|
||||||
comment,
|
|
||||||
lat,
|
|
||||||
lng,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/addresses',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const items = await prisma.shippingAddress.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
|
||||||
})
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/addresses',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const validated = validateAddressPayload(request.body, reply)
|
|
||||||
if (!validated) return
|
|
||||||
|
|
||||||
const isDefault = Boolean(request.body?.isDefault)
|
|
||||||
const created = await prisma.$transaction(async (tx) => {
|
|
||||||
if (isDefault) {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
}
|
|
||||||
return tx.shippingAddress.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
...validated,
|
|
||||||
isDefault,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return reply.code(201).send({ item: created })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/me/addresses/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
const body = request.body ?? {}
|
|
||||||
const data = {}
|
|
||||||
|
|
||||||
if (body.label !== undefined) {
|
|
||||||
const labelRaw = body.label
|
|
||||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
|
||||||
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
|
||||||
data.label = label && label.length ? label : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientName !== undefined) {
|
|
||||||
const v = String(body.recipientName || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
|
||||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
|
||||||
data.recipientName = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientPhone !== undefined) {
|
|
||||||
const v = normalizePhoneLite(body.recipientPhone)
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
|
||||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
|
||||||
data.recipientPhone = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.addressLine !== undefined) {
|
|
||||||
const v = String(body.addressLine || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
|
||||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
|
||||||
data.addressLine = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.comment !== undefined) {
|
|
||||||
const commentRaw = body.comment
|
|
||||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
|
||||||
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
|
||||||
data.comment = comment && comment.length ? comment : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lat !== undefined) {
|
|
||||||
const lat = Number(body.lat)
|
|
||||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
|
||||||
data.lat = lat
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lng !== undefined) {
|
|
||||||
const lng = Number(body.lng)
|
|
||||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
|
||||||
data.lng = lng
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDefault = body.isDefault === true
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
|
||||||
if (setDefault) {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
}
|
|
||||||
return tx.shippingAddress.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
...(setDefault ? { isDefault: true } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/me/addresses/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
await prisma.shippingAddress.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/addresses/:id/default',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
|
||||||
})
|
|
||||||
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Корзина ----
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/cart',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const items = await prisma.cartItem.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
items: items.map((x) => ({
|
|
||||||
id: x.id,
|
|
||||||
qty: x.qty,
|
|
||||||
product: x.product,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/cart/items',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const productId = String(request.body?.productId || '').trim()
|
|
||||||
const qtyRaw = request.body?.qty
|
|
||||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
|
||||||
|
|
||||||
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
|
||||||
|
|
||||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
|
|
||||||
const available = product.inStock ? product.quantity : 1
|
|
||||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
|
||||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
|
||||||
|
|
||||||
const item = await prisma.cartItem.upsert({
|
|
||||||
where: { userId_productId: { userId, productId } },
|
|
||||||
update: { qty: nextQty },
|
|
||||||
create: { userId, productId, qty: nextQty },
|
|
||||||
})
|
|
||||||
return reply.code(201).send({ item })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/me/cart/items/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const qtyRaw = request.body?.qty
|
|
||||||
const qty = Number(qtyRaw)
|
|
||||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
|
||||||
|
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
|
||||||
|
|
||||||
if (qty === 0) {
|
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = existing.product.inStock ? existing.product.quantity : 1
|
|
||||||
const nextQty = Math.floor(qty)
|
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
|
||||||
|
|
||||||
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/me/cart/items/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
|
||||||
return reply.code(204).send()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Заказы (checkout) ----
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/orders',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const deliveryTypeRaw = request.body?.deliveryType
|
|
||||||
const deliveryType =
|
|
||||||
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
|
||||||
? 'delivery'
|
|
||||||
: String(deliveryTypeRaw).trim()
|
|
||||||
|
|
||||||
const addressId = String(request.body?.addressId || '').trim()
|
|
||||||
const commentRaw = request.body?.comment
|
|
||||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
|
||||||
|
|
||||||
const paymentMethodRaw = request.body?.paymentMethod
|
|
||||||
const paymentMethod =
|
|
||||||
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
|
|
||||||
? 'online'
|
|
||||||
: String(paymentMethodRaw).trim()
|
|
||||||
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
|
|
||||||
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
|
||||||
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const carrierRaw = request.body?.deliveryCarrier
|
|
||||||
let deliveryCarrier = null
|
|
||||||
if (deliveryType === 'delivery') {
|
|
||||||
const carrierStr =
|
|
||||||
carrierRaw === undefined || carrierRaw === null || carrierRaw === ''
|
|
||||||
? ''
|
|
||||||
: String(carrierRaw).trim()
|
|
||||||
if (!isDeliveryCarrier(carrierStr)) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({
|
|
||||||
error:
|
|
||||||
'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
deliveryCarrier = carrierStr
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
|
||||||
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
|
|
||||||
}
|
|
||||||
|
|
||||||
let address = null
|
|
||||||
if (deliveryType === 'delivery') {
|
|
||||||
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
|
||||||
address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } })
|
|
||||||
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cartItems = await prisma.cartItem.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: { product: true },
|
|
||||||
})
|
|
||||||
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
|
||||||
|
|
||||||
for (const ci of cartItems) {
|
|
||||||
const available = ci.product.inStock ? ci.product.quantity : 1
|
|
||||||
if (ci.qty > available) {
|
|
||||||
return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsPayload = cartItems.map((ci) => ({
|
|
||||||
productId: ci.productId,
|
|
||||||
qty: ci.qty,
|
|
||||||
titleSnapshot: ci.product.title,
|
|
||||||
priceCentsSnapshot: ci.product.priceCents,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
|
||||||
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
|
||||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
|
||||||
|
|
||||||
const addressSnapshotJson =
|
|
||||||
deliveryType === 'pickup'
|
|
||||||
? JSON.stringify({ deliveryType: 'pickup' })
|
|
||||||
: JSON.stringify({
|
|
||||||
deliveryType: 'delivery',
|
|
||||||
id: address.id,
|
|
||||||
label: address.label,
|
|
||||||
recipientName: address.recipientName,
|
|
||||||
recipientPhone: address.recipientPhone,
|
|
||||||
addressLine: address.addressLine,
|
|
||||||
comment: address.comment,
|
|
||||||
lat: address.lat,
|
|
||||||
lng: address.lng,
|
|
||||||
})
|
|
||||||
|
|
||||||
let initialStatus = 'PENDING_PAYMENT'
|
|
||||||
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
|
||||||
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
|
||||||
|
|
||||||
let created
|
|
||||||
try {
|
|
||||||
created = await prisma.$transaction(async (tx) => {
|
|
||||||
for (const ci of cartItems) {
|
|
||||||
if (!ci.product.inStock) continue
|
|
||||||
|
|
||||||
const res = await tx.product.updateMany({
|
|
||||||
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
|
||||||
data: { quantity: { decrement: ci.qty } },
|
|
||||||
})
|
|
||||||
if (res.count !== 1) {
|
|
||||||
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = await tx.order.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
status: initialStatus,
|
|
||||||
deliveryType,
|
|
||||||
deliveryCarrier,
|
|
||||||
paymentMethod,
|
|
||||||
itemsSubtotalCents,
|
|
||||||
deliveryFeeCents,
|
|
||||||
totalCents,
|
|
||||||
currency: 'RUB',
|
|
||||||
addressSnapshotJson,
|
|
||||||
comment: comment && comment.length ? comment : null,
|
|
||||||
items: {
|
|
||||||
create: itemsPayload.map((i) => ({
|
|
||||||
productId: i.productId,
|
|
||||||
qty: i.qty,
|
|
||||||
titleSnapshot: i.titleSnapshot,
|
|
||||||
priceCentsSnapshot: i.priceCentsSnapshot,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await tx.cartItem.deleteMany({ where: { userId } })
|
|
||||||
return order
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(201).send({ orderId: created.id })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/orders',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const orders = await prisma.order.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: { items: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
items: orders.map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
status: o.status,
|
|
||||||
totalCents: o.totalCents,
|
|
||||||
currency: o.currency,
|
|
||||||
createdAt: o.createdAt,
|
|
||||||
updatedAt: o.updatedAt,
|
|
||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/orders/:id',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({
|
|
||||||
where: { id, userId },
|
|
||||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
|
||||||
})
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
return { item: order }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/orders/:id/messages',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } })
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/orders/:id/messages',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
const text = String(request.body?.text || '').trim()
|
|
||||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
|
||||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
|
||||||
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } })
|
|
||||||
return reply.code(201).send({ item: msg })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/messages/unread-count',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } })
|
|
||||||
if (orders.length === 0) return { count: 0 }
|
|
||||||
|
|
||||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
|
||||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
for (const o of orders) {
|
|
||||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
|
||||||
const n = await prisma.orderMessage.count({
|
|
||||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
|
||||||
})
|
|
||||||
count += n
|
|
||||||
}
|
|
||||||
return { count }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/conversations',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const orders = await prisma.order.findMany({
|
|
||||||
where: { userId, messages: { some: {} } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
deliveryType: true,
|
|
||||||
messages: { orderBy: { createdAt: 'desc' }, take: 1, select: { text: true, createdAt: true } },
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
|
||||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
|
||||||
|
|
||||||
const items = []
|
|
||||||
for (const o of orders) {
|
|
||||||
const lastMsg = o.messages[0]
|
|
||||||
if (!lastMsg) continue
|
|
||||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
|
||||||
const unreadCount = await prisma.orderMessage.count({
|
|
||||||
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
|
||||||
})
|
|
||||||
items.push({
|
|
||||||
orderId: o.id,
|
|
||||||
status: o.status,
|
|
||||||
deliveryType: o.deliveryType,
|
|
||||||
lastMessageAt: lastMsg.createdAt,
|
|
||||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
|
||||||
unreadCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { items }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/orders/:id/messages/read',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
await prisma.userOrderMessageReadState.upsert({
|
|
||||||
where: { userId_orderId: { userId, orderId: id } },
|
|
||||||
create: { userId, orderId: id, lastReadAt: now },
|
|
||||||
update: { lastReadAt: now },
|
|
||||||
})
|
|
||||||
return { ok: true }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/orders/:id/pay',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
const paymentMethod = order.paymentMethod ?? 'online'
|
|
||||||
if (paymentMethod === 'on_pickup') {
|
|
||||||
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
|
|
||||||
return reply
|
|
||||||
.code(409)
|
|
||||||
.send({
|
|
||||||
error:
|
|
||||||
'Оплата станет доступна после корректировки стоимости доставки администратором.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextStatus = order.status
|
|
||||||
if (order.status === 'DRAFT') {
|
|
||||||
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
|
||||||
nextStatus = 'PENDING_PAYMENT'
|
|
||||||
return { ok: true, status: nextStatus }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status === 'PAYMENT_VERIFICATION') {
|
|
||||||
return { ok: true, status: nextStatus }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status === 'PENDING_PAYMENT') {
|
|
||||||
if (!request.isMultipart()) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = ''
|
|
||||||
let receiptBuffer = null
|
|
||||||
let receiptFilename = ''
|
|
||||||
try {
|
|
||||||
const otherLimit = getOtherUploadMaxFileBytes()
|
|
||||||
const parts = request.parts({
|
|
||||||
limits: {
|
|
||||||
fileSize: otherLimit,
|
|
||||||
files: 2,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.file) {
|
|
||||||
if (part.fieldname === 'receipt') {
|
|
||||||
if (receiptBuffer !== null) {
|
|
||||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
|
||||||
}
|
|
||||||
receiptBuffer = await part.toBuffer()
|
|
||||||
receiptFilename = part.filename ?? 'receipt'
|
|
||||||
}
|
|
||||||
} else if (part.fieldname === 'detail') {
|
|
||||||
detail = String(part.value ?? '').trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
|
||||||
return reply.code(400).send({ error: msg })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDetail = detail.length > 0
|
|
||||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
|
||||||
|
|
||||||
if (!hasDetail && !hasReceipt) {
|
|
||||||
return reply
|
|
||||||
.code(400)
|
|
||||||
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDetail = 2000
|
|
||||||
if (detail.length > maxDetail) {
|
|
||||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachmentUrl = null
|
|
||||||
if (hasReceipt) {
|
|
||||||
try {
|
|
||||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
|
||||||
const statusCode =
|
|
||||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
|
||||||
? Number(err.statusCode)
|
|
||||||
: 400
|
|
||||||
return reply.code(statusCode).send({ error: message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyHtml = hasDetail
|
|
||||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
|
||||||
: ''
|
|
||||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
|
|
||||||
await tx.orderMessage.create({
|
|
||||||
data: {
|
|
||||||
orderId: id,
|
|
||||||
authorType: 'user',
|
|
||||||
text: messageText,
|
|
||||||
attachmentUrl,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, status: 'PAYMENT_VERIFICATION' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/me/orders/:id/review-eligibility',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
if (order.status !== 'DONE') {
|
|
||||||
return { canReview: false, items: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniq = new Map()
|
|
||||||
for (const it of order.items) {
|
|
||||||
if (!uniq.has(it.productId)) {
|
|
||||||
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const productIds = [...uniq.keys()]
|
|
||||||
const existing = await prisma.review.findMany({
|
|
||||||
where: { userId, productId: { in: productIds } },
|
|
||||||
select: { productId: true },
|
|
||||||
})
|
|
||||||
const reviewed = new Set(existing.map((r) => r.productId))
|
|
||||||
return {
|
|
||||||
canReview: true,
|
|
||||||
items: [...uniq.values()].map((x) => ({
|
|
||||||
...x,
|
|
||||||
hasReview: reviewed.has(x.productId),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/me/orders/:id/confirm-received',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
|
||||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
|
||||||
if (!okDelivery && !okPickup) {
|
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
|
||||||
return { ok: true, status: 'DONE' }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
|
function normalizePhoneLite(input) {
|
||||||
|
const s = String(input || '').trim()
|
||||||
|
if (!s) return ''
|
||||||
|
return s.replace(/[\s()-]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAddressPayload(body, reply) {
|
||||||
|
const labelRaw = body?.label
|
||||||
|
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||||
|
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||||
|
|
||||||
|
const recipientName = String(body?.recipientName || '').trim()
|
||||||
|
if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||||
|
if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||||
|
|
||||||
|
const recipientPhone = normalizePhoneLite(body?.recipientPhone)
|
||||||
|
if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||||
|
if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||||
|
|
||||||
|
const addressLine = String(body?.addressLine || '').trim()
|
||||||
|
if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||||
|
if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||||
|
|
||||||
|
const commentRaw = body?.comment
|
||||||
|
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||||
|
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||||
|
|
||||||
|
const lat = Number(body?.lat)
|
||||||
|
const lng = Number(body?.lng)
|
||||||
|
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||||
|
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
recipientName,
|
||||||
|
recipientPhone,
|
||||||
|
addressLine,
|
||||||
|
comment,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUserAddressRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/addresses',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const items = await prisma.shippingAddress.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||||
|
})
|
||||||
|
return { items }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/addresses',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const validated = validateAddressPayload(request.body, reply)
|
||||||
|
if (!validated) return
|
||||||
|
|
||||||
|
const isDefault = Boolean(request.body?.isDefault)
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
if (isDefault) {
|
||||||
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
|
}
|
||||||
|
return tx.shippingAddress.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
...validated,
|
||||||
|
isDefault,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return reply.code(201).send({ item: created })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/me/addresses/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
const body = request.body ?? {}
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (body.label !== undefined) {
|
||||||
|
const labelRaw = body.label
|
||||||
|
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||||
|
if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||||
|
data.label = label && label.length ? label : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.recipientName !== undefined) {
|
||||||
|
const v = String(body.recipientName || '').trim()
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
||||||
|
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
||||||
|
data.recipientName = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.recipientPhone !== undefined) {
|
||||||
|
const v = normalizePhoneLite(body.recipientPhone)
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
||||||
|
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
||||||
|
data.recipientPhone = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.addressLine !== undefined) {
|
||||||
|
const v = String(body.addressLine || '').trim()
|
||||||
|
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
||||||
|
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
||||||
|
data.addressLine = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.comment !== undefined) {
|
||||||
|
const commentRaw = body.comment
|
||||||
|
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||||
|
if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
||||||
|
data.comment = comment && comment.length ? comment : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.lat !== undefined) {
|
||||||
|
const lat = Number(body.lat)
|
||||||
|
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
||||||
|
data.lat = lat
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.lng !== undefined) {
|
||||||
|
const lng = Number(body.lng)
|
||||||
|
if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' })
|
||||||
|
data.lng = lng
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDefault = body.isDefault === true
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (setDefault) {
|
||||||
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
|
}
|
||||||
|
return tx.shippingAddress.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
...(setDefault ? { isDefault: true } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
'/api/me/addresses/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
await prisma.shippingAddress.delete({ where: { id } })
|
||||||
|
return reply.code(204).send()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/addresses/:id/default',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
|
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerUserCartRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/cart',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const items = await prisma.cartItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: items.map((x) => ({
|
||||||
|
id: x.id,
|
||||||
|
qty: x.qty,
|
||||||
|
product: x.product,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/cart/items',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const productId = String(request.body?.productId || '').trim()
|
||||||
|
const qtyRaw = request.body?.qty
|
||||||
|
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
||||||
|
|
||||||
|
if (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||||
|
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
|
const available = product.inStock ? product.quantity : 1
|
||||||
|
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||||
|
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||||
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
|
const item = await prisma.cartItem.upsert({
|
||||||
|
where: { userId_productId: { userId, productId } },
|
||||||
|
update: { qty: nextQty },
|
||||||
|
create: { userId, productId, qty: nextQty },
|
||||||
|
})
|
||||||
|
return reply.code(201).send({ item })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/me/cart/items/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const qtyRaw = request.body?.qty
|
||||||
|
const qty = Number(qtyRaw)
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
||||||
|
|
||||||
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
|
|
||||||
|
if (qty === 0) {
|
||||||
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = existing.product.inStock ? existing.product.quantity : 1
|
||||||
|
const nextQty = Math.floor(qty)
|
||||||
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
|
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
'/api/me/cart/items/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
|
return reply.code(204).send()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerUserMessageRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/orders/:id/messages',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } })
|
||||||
|
return { items }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders/:id/messages',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
const text = String(request.body?.text || '').trim()
|
||||||
|
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||||
|
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||||
|
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } })
|
||||||
|
return reply.code(201).send({ item: msg })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/messages/unread-count',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const orders = await prisma.order.findMany({ where: { userId }, select: { id: true } })
|
||||||
|
if (orders.length === 0) return { count: 0 }
|
||||||
|
|
||||||
|
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||||
|
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const o of orders) {
|
||||||
|
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||||
|
const n = await prisma.orderMessage.count({
|
||||||
|
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||||
|
})
|
||||||
|
count += n
|
||||||
|
}
|
||||||
|
return { count }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/conversations',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { userId, messages: { some: {} } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
deliveryType: true,
|
||||||
|
messages: { orderBy: { createdAt: 'desc' }, take: 1, select: { text: true, createdAt: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId } })
|
||||||
|
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
||||||
|
|
||||||
|
const items = []
|
||||||
|
for (const o of orders) {
|
||||||
|
const lastMsg = o.messages[0]
|
||||||
|
if (!lastMsg) continue
|
||||||
|
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
||||||
|
const unreadCount = await prisma.orderMessage.count({
|
||||||
|
where: { orderId: o.id, authorType: 'admin', createdAt: { gt: lastRead } },
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
orderId: o.id,
|
||||||
|
status: o.status,
|
||||||
|
deliveryType: o.deliveryType,
|
||||||
|
lastMessageAt: lastMsg.createdAt,
|
||||||
|
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||||
|
unreadCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { items }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders/:id/messages/read',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
await prisma.userOrderMessageReadState.upsert({
|
||||||
|
where: { userId_orderId: { userId, orderId: id } },
|
||||||
|
create: { userId, orderId: id, lastReadAt: now },
|
||||||
|
update: { lastReadAt: now },
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerUserOrderRoutes(fastify) {
|
||||||
|
// ---- Создание заказа (checkout) ----
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const deliveryTypeRaw = request.body?.deliveryType
|
||||||
|
const deliveryType =
|
||||||
|
deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === ''
|
||||||
|
? 'delivery'
|
||||||
|
: String(deliveryTypeRaw).trim()
|
||||||
|
|
||||||
|
const addressId = String(request.body?.addressId || '').trim()
|
||||||
|
const commentRaw = request.body?.comment
|
||||||
|
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
||||||
|
|
||||||
|
const paymentMethodRaw = request.body?.paymentMethod
|
||||||
|
const paymentMethod =
|
||||||
|
paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === ''
|
||||||
|
? 'online'
|
||||||
|
: String(paymentMethodRaw).trim()
|
||||||
|
if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') {
|
||||||
|
return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryType !== 'delivery' && deliveryType !== 'pickup') {
|
||||||
|
return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const carrierRaw = request.body?.deliveryCarrier
|
||||||
|
let deliveryCarrier = null
|
||||||
|
if (deliveryType === 'delivery') {
|
||||||
|
const carrierStr =
|
||||||
|
carrierRaw === undefined || carrierRaw === null || carrierRaw === ''
|
||||||
|
? ''
|
||||||
|
: String(carrierRaw).trim()
|
||||||
|
if (!isDeliveryCarrier(carrierStr)) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({
|
||||||
|
error:
|
||||||
|
'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deliveryCarrier = carrierStr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||||
|
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let address = null
|
||||||
|
if (deliveryType === 'delivery') {
|
||||||
|
if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' })
|
||||||
|
address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } })
|
||||||
|
if (!address) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartItems = await prisma.cartItem.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { product: true },
|
||||||
|
})
|
||||||
|
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
|
||||||
|
|
||||||
|
for (const ci of cartItems) {
|
||||||
|
const available = ci.product.inStock ? ci.product.quantity : 1
|
||||||
|
if (ci.qty > available) {
|
||||||
|
return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsPayload = cartItems.map((ci) => ({
|
||||||
|
productId: ci.productId,
|
||||||
|
qty: ci.qty,
|
||||||
|
titleSnapshot: ci.product.title,
|
||||||
|
priceCentsSnapshot: ci.product.priceCents,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||||
|
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||||
|
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||||
|
|
||||||
|
const addressSnapshotJson =
|
||||||
|
deliveryType === 'pickup'
|
||||||
|
? JSON.stringify({ deliveryType: 'pickup' })
|
||||||
|
: JSON.stringify({
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
id: address.id,
|
||||||
|
label: address.label,
|
||||||
|
recipientName: address.recipientName,
|
||||||
|
recipientPhone: address.recipientPhone,
|
||||||
|
addressLine: address.addressLine,
|
||||||
|
comment: address.comment,
|
||||||
|
lat: address.lat,
|
||||||
|
lng: address.lng,
|
||||||
|
})
|
||||||
|
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
||||||
|
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
||||||
|
|
||||||
|
let created
|
||||||
|
try {
|
||||||
|
created = await prisma.$transaction(async (tx) => {
|
||||||
|
for (const ci of cartItems) {
|
||||||
|
if (!ci.product.inStock) continue
|
||||||
|
|
||||||
|
const res = await tx.product.updateMany({
|
||||||
|
where: { id: ci.productId, quantity: { gte: ci.qty } },
|
||||||
|
data: { quantity: { decrement: ci.qty } },
|
||||||
|
})
|
||||||
|
if (res.count !== 1) {
|
||||||
|
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
status: initialStatus,
|
||||||
|
deliveryType,
|
||||||
|
deliveryCarrier,
|
||||||
|
paymentMethod,
|
||||||
|
itemsSubtotalCents,
|
||||||
|
deliveryFeeCents,
|
||||||
|
totalCents,
|
||||||
|
currency: 'RUB',
|
||||||
|
addressSnapshotJson,
|
||||||
|
comment: comment && comment.length ? comment : null,
|
||||||
|
items: {
|
||||||
|
create: itemsPayload.map((i) => ({
|
||||||
|
productId: i.productId,
|
||||||
|
qty: i.qty,
|
||||||
|
titleSnapshot: i.titleSnapshot,
|
||||||
|
priceCentsSnapshot: i.priceCentsSnapshot,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await tx.cartItem.deleteMany({ where: { userId } })
|
||||||
|
return order
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send({ orderId: created.id })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/orders',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { items: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: orders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
status: o.status,
|
||||||
|
totalCents: o.totalCents,
|
||||||
|
currency: o.currency,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
updatedAt: o.updatedAt,
|
||||||
|
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/orders/:id',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||||
|
})
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
return { item: order }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/me/orders/:id/review-eligibility',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId }, include: { items: true } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
if (order.status !== 'DONE') {
|
||||||
|
return { canReview: false, items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniq = new Map()
|
||||||
|
for (const it of order.items) {
|
||||||
|
if (!uniq.has(it.productId)) {
|
||||||
|
uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const productIds = [...uniq.keys()]
|
||||||
|
const existing = await prisma.review.findMany({
|
||||||
|
where: { userId, productId: { in: productIds } },
|
||||||
|
select: { productId: true },
|
||||||
|
})
|
||||||
|
const reviewed = new Set(existing.map((r) => r.productId))
|
||||||
|
return {
|
||||||
|
canReview: true,
|
||||||
|
items: [...uniq.values()].map((x) => ({
|
||||||
|
...x,
|
||||||
|
hasReview: reviewed.has(x.productId),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders/:id/confirm-received',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||||
|
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||||
|
if (!okDelivery && !okPickup) {
|
||||||
|
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
|
||||||
|
return { ok: true, status: 'DONE' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
import { escapeHtml } from '../lib/escape-html.js'
|
||||||
|
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||||
|
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||||
|
|
||||||
|
export async function registerUserPaymentRoutes(fastify) {
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders/:id/pay',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findFirst({ where: { id, userId } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const paymentMethod = order.paymentMethod ?? 'online'
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
|
||||||
|
return reply
|
||||||
|
.code(409)
|
||||||
|
.send({
|
||||||
|
error:
|
||||||
|
'Оплата станет доступна после корректировки стоимости доставки администратором.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextStatus = order.status
|
||||||
|
if (order.status === 'DRAFT') {
|
||||||
|
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
||||||
|
nextStatus = 'PENDING_PAYMENT'
|
||||||
|
return { ok: true, status: nextStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'PAYMENT_VERIFICATION') {
|
||||||
|
return { ok: true, status: nextStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'PENDING_PAYMENT') {
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = ''
|
||||||
|
let receiptBuffer = null
|
||||||
|
let receiptFilename = ''
|
||||||
|
try {
|
||||||
|
const otherLimit = getOtherUploadMaxFileBytes()
|
||||||
|
const parts = request.parts({
|
||||||
|
limits: {
|
||||||
|
fileSize: otherLimit,
|
||||||
|
files: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.file) {
|
||||||
|
if (part.fieldname === 'receipt') {
|
||||||
|
if (receiptBuffer !== null) {
|
||||||
|
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||||
|
}
|
||||||
|
receiptBuffer = await part.toBuffer()
|
||||||
|
receiptFilename = part.filename ?? 'receipt'
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === 'detail') {
|
||||||
|
detail = String(part.value ?? '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||||
|
return reply.code(400).send({ error: msg })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDetail = detail.length > 0
|
||||||
|
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||||
|
|
||||||
|
if (!hasDetail && !hasReceipt) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDetail = 2000
|
||||||
|
if (detail.length > maxDetail) {
|
||||||
|
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachmentUrl = null
|
||||||
|
if (hasReceipt) {
|
||||||
|
try {
|
||||||
|
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||||
|
const statusCode =
|
||||||
|
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||||
|
? Number(err.statusCode)
|
||||||
|
: 400
|
||||||
|
return reply.code(statusCode).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyHtml = hasDetail
|
||||||
|
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||||
|
: ''
|
||||||
|
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
|
||||||
|
await tx.orderMessage.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
authorType: 'user',
|
||||||
|
text: messageText,
|
||||||
|
attachmentUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: 'PAYMENT_VERIFICATION' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'])
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export declare const ORDER_STATUSES: readonly [
|
||||||
|
'DRAFT',
|
||||||
|
'DELIVERY_FEE_ADJUSTMENT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAYMENT_VERIFICATION',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const ORDER_STATUSES = Object.freeze([
|
||||||
|
'DRAFT',
|
||||||
|
'DELIVERY_FEE_ADJUSTMENT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAYMENT_VERIFICATION',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
])
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export declare const PAYMENT_METHODS: readonly ['online', 'on_pickup']
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const PAYMENT_METHODS = Object.freeze(['online', 'on_pickup'])
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const MB = 1024 * 1024
|
||||||
|
|
||||||
|
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB
|
||||||
Reference in New Issue
Block a user