Merge branch 'refactor'

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