From a06f9cf2c43f2669d6baa649da78bfa79dfc23d4 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Wed, 13 May 2026 22:07:46 +0500 Subject: [PATCH] Merge branch 'refactor' --- REFACTORING_PLAN.md | 142 ++++ client/src/app/App.tsx | 31 +- client/src/app/layout/AppHeader.tsx | 169 +--- client/src/app/routes/index.tsx | 33 + .../catalog-slider/api/catalog-slider-api.ts | 11 +- client/src/entities/catalog-slider/index.ts | 2 + .../entities/catalog-slider/model/types.ts | 9 + client/src/entities/gallery/index.ts | 3 + .../src/entities/gallery/ui/GalleryGrid.tsx | 61 ++ client/src/entities/info/api/info-page-api.ts | 12 +- client/src/entities/info/index.ts | 8 + client/src/entities/info/model/types.ts | 10 + client/src/entities/order/api/order-api.ts | 2 +- .../address-map-picker/api/map-geocoding.ts | 28 + .../src/features/address-map-picker/index.ts | 1 + .../address-map-picker/model/types.ts | 3 + .../ui/AddressMapPicker.tsx | 33 +- client/src/features/cart/cart-badge/index.ts | 1 + .../features/cart/cart-badge/ui/CartBadge.tsx | 31 + client/src/features/order-chat/index.ts | 1 + .../src/features/order-chat/ui/OrderChat.tsx | 61 ++ client/src/features/order-payment/index.ts | 2 + .../order-payment/ui/OrderPaymentSection.tsx | 80 ++ .../order-payment/ui/PaymentDialog.tsx | 146 ++++ client/src/features/product-review/index.ts | 2 + .../product-review/ui/ReviewDialog.tsx | 150 ++++ .../product-review/ui/ReviewSection.tsx | 93 +++ client/src/features/user/user-menu/index.ts | 1 + .../features/user/user-menu/ui/UserMenu.tsx | 67 ++ client/src/pages/admin-categories/index.ts | 1 + .../ui/AdminCategoriesPage.tsx | 295 +++++++ .../admin-gallery/ui/AdminGalleryPage.tsx | 53 +- .../admin-gallery/ui/GallerySliderSection.tsx | 4 +- .../src/pages/admin-info/ui/AdminInfoPage.tsx | 2 +- .../pages/admin-layout/ui/AdminLayoutPage.tsx | 7 +- .../pages/admin-orders/ui/AdminOrdersPage.tsx | 286 ++++--- client/src/pages/admin-products/index.ts | 1 + .../admin-products/ui/AdminProductsPage.tsx | 604 ++++++++++++++ .../pages/admin-users/ui/AdminUsersPage.tsx | 107 ++- client/src/pages/admin/ui/AdminPage.tsx | 2 +- .../src/pages/home/lib/use-product-filters.ts | 111 +++ client/src/pages/home/ui/HomePage.tsx | 355 +------- client/src/pages/home/ui/ProductFilters.tsx | 250 ++++++ client/src/pages/info/ui/InfoPage.tsx | 2 +- .../pages/me/ui/sections/AddressesPage.tsx | 2 +- .../pages/me/ui/sections/OrderDetailPage.tsx | 422 +--------- .../src/shared/constants/delivery-carrier.ts | 5 +- client/src/shared/constants/order.ts | 16 +- client/src/shared/constants/upload-limits.ts | 5 +- client/src/shared/lib/create-error-store.ts | 10 + client/src/shared/lib/persist-token.ts | 26 + client/src/shared/model/auth.ts | 107 ++- .../src/shared/ui/AdminDialog/AdminDialog.tsx | 36 + .../src/shared/ui/AdminTable/AdminTable.tsx | 61 ++ client/src/shared/ui/AdminTable/index.ts | 2 + .../catalog-slider/ui/CatalogSlider.tsx | 4 +- client/src/widgets/navigation-drawer/index.ts | 1 + .../navigation-drawer/ui/NavigationDrawer.tsx | 100 +++ client/tsconfig.app.json | 3 +- client/vite.config.ts | 5 + docs/deploy-changes.md | 2 + opencode.jsonc | 9 + scripts/deploy-ssh.sh | 20 +- server/src/index.js | 10 + server/src/lib/delivery-carrier.js | 2 +- server/src/lib/order-status.js | 13 +- server/src/lib/upload-limits.js | 8 +- server/src/routes/api.js | 14 +- server/src/routes/api/admin-categories.js | 4 +- server/src/routes/api/admin-products.js | 66 +- server/src/routes/api/public-catalog.js | 25 +- server/src/routes/auth.js | 771 ------------------ server/src/routes/user-addresses.js | 193 +++++ server/src/routes/user-cart.js | 92 +++ server/src/routes/user-messages.js | 114 +++ server/src/routes/user-orders.js | 249 ++++++ server/src/routes/user-payments.js | 132 +++ shared/constants/delivery-carrier.d.ts | 1 + shared/constants/delivery-carrier.js | 1 + shared/constants/order-status.d.ts | 12 + shared/constants/order-status.js | 12 + shared/constants/payment-method.d.ts | 1 + shared/constants/payment-method.js | 1 + shared/constants/upload-limits.d.ts | 1 + shared/constants/upload-limits.js | 3 + 85 files changed, 3762 insertions(+), 2072 deletions(-) create mode 100644 REFACTORING_PLAN.md create mode 100644 client/src/app/routes/index.tsx create mode 100644 client/src/entities/catalog-slider/index.ts create mode 100644 client/src/entities/catalog-slider/model/types.ts create mode 100644 client/src/entities/gallery/index.ts create mode 100644 client/src/entities/gallery/ui/GalleryGrid.tsx create mode 100644 client/src/entities/info/index.ts create mode 100644 client/src/entities/info/model/types.ts create mode 100644 client/src/features/address-map-picker/api/map-geocoding.ts create mode 100644 client/src/features/address-map-picker/index.ts create mode 100644 client/src/features/address-map-picker/model/types.ts create mode 100644 client/src/features/cart/cart-badge/index.ts create mode 100644 client/src/features/cart/cart-badge/ui/CartBadge.tsx create mode 100644 client/src/features/order-chat/index.ts create mode 100644 client/src/features/order-chat/ui/OrderChat.tsx create mode 100644 client/src/features/order-payment/index.ts create mode 100644 client/src/features/order-payment/ui/OrderPaymentSection.tsx create mode 100644 client/src/features/order-payment/ui/PaymentDialog.tsx create mode 100644 client/src/features/product-review/index.ts create mode 100644 client/src/features/product-review/ui/ReviewDialog.tsx create mode 100644 client/src/features/product-review/ui/ReviewSection.tsx create mode 100644 client/src/features/user/user-menu/index.ts create mode 100644 client/src/features/user/user-menu/ui/UserMenu.tsx create mode 100644 client/src/pages/admin-categories/index.ts create mode 100644 client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx create mode 100644 client/src/pages/admin-products/index.ts create mode 100644 client/src/pages/admin-products/ui/AdminProductsPage.tsx create mode 100644 client/src/pages/home/lib/use-product-filters.ts create mode 100644 client/src/pages/home/ui/ProductFilters.tsx create mode 100644 client/src/shared/lib/create-error-store.ts create mode 100644 client/src/shared/lib/persist-token.ts create mode 100644 client/src/shared/ui/AdminDialog/AdminDialog.tsx create mode 100644 client/src/shared/ui/AdminTable/AdminTable.tsx create mode 100644 client/src/shared/ui/AdminTable/index.ts create mode 100644 client/src/widgets/navigation-drawer/index.ts create mode 100644 client/src/widgets/navigation-drawer/ui/NavigationDrawer.tsx create mode 100644 opencode.jsonc create mode 100644 server/src/routes/user-addresses.js create mode 100644 server/src/routes/user-cart.js create mode 100644 server/src/routes/user-messages.js create mode 100644 server/src/routes/user-orders.js create mode 100644 server/src/routes/user-payments.js create mode 100644 shared/constants/delivery-carrier.d.ts create mode 100644 shared/constants/delivery-carrier.js create mode 100644 shared/constants/order-status.d.ts create mode 100644 shared/constants/order-status.js create mode 100644 shared/constants/payment-method.d.ts create mode 100644 shared/constants/payment-method.js create mode 100644 shared/constants/upload-limits.d.ts create mode 100644 shared/constants/upload-limits.js diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..85805b3 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -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** | diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index bdbadb9..353f1dd 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -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 ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ) diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 187880b..2eda4d6 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -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) - const userMenuOpen = Boolean(userAnchorEl) - const [mobileOpen, setMobileOpen] = useState(false) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const onSchemeChange = (e: SelectChangeEvent) => { - setScheme(e.target.value as ColorScheme) - } - + const onSchemeChange = (e: SelectChangeEvent) => setScheme(e.target.value as ColorScheme) const onModeChange = (e: SelectChangeEvent) => { const v = e.target.value if (v === 'system' || v === 'light' || v === 'dark') setMode(v) } - const openUserMenu = (e: React.MouseEvent) => 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 ( <> {isMobile && ( - + setMobileOpen(true)} + aria-label="Открыть меню" + edge="start" + sx={{ mr: 1 }} + > )} @@ -272,58 +261,11 @@ export function AppHeader() { )} - - { - if (!user) navigate('/auth') - else navigate('/cart') - }} - aria-label="Корзина" - > - - - - - + )} - {!isAdmin && ( - <> - - - - - - - - {user ? ( - <> - go('/me')}> - - - Выход - - ) : ( - go('/auth')}>Войти / регистрация - )} - - - )} + {!isAdmin && } {isAdmin && user && !isMobile && ( )} - {!isMobile && ( - - )} + {!isMobile && } - - - - - {STORE_NAME} - - - - {headerNavItems.map((i) => ( - - ))} - {!isAdmin && ( - - )} - {user && !isAdmin && ( - - )} - {!isAdmin && ( - - )} - {!user && isAdmin && ( - - )} - {user && ( - - )} - - - - - - - + onClose={() => setMobileOpen(false)} + user={user} + isAdmin={isAdmin} + navItems={headerNavItems} + themeControls={themeControls} + onNavigate={go} + onLogout={onLogout} + ThemeControlsMobile={ThemeControlsMobile} + /> ) } diff --git a/client/src/app/routes/index.tsx b/client/src/app/routes/index.tsx new file mode 100644 index 0000000..f6cbde5 --- /dev/null +++ b/client/src/app/routes/index.tsx @@ -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 ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/client/src/entities/catalog-slider/api/catalog-slider-api.ts b/client/src/entities/catalog-slider/api/catalog-slider-api.ts index 1042b69..eb83f5d 100644 --- a/client/src/entities/catalog-slider/api/catalog-slider-api.ts +++ b/client/src/entities/catalog-slider/api/catalog-slider-api.ts @@ -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') diff --git a/client/src/entities/catalog-slider/index.ts b/client/src/entities/catalog-slider/index.ts new file mode 100644 index 0000000..1c38c32 --- /dev/null +++ b/client/src/entities/catalog-slider/index.ts @@ -0,0 +1,2 @@ +export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api' +export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types' diff --git a/client/src/entities/catalog-slider/model/types.ts b/client/src/entities/catalog-slider/model/types.ts new file mode 100644 index 0000000..b939bbd --- /dev/null +++ b/client/src/entities/catalog-slider/model/types.ts @@ -0,0 +1,9 @@ +export type CatalogSliderSlide = { + id: string + url: string + caption: string +} + +export type AdminCatalogSliderSlide = CatalogSliderSlide & { + galleryImageId: string +} diff --git a/client/src/entities/gallery/index.ts b/client/src/entities/gallery/index.ts new file mode 100644 index 0000000..7fa845f --- /dev/null +++ b/client/src/entities/gallery/index.ts @@ -0,0 +1,3 @@ +export { fetchAdminGallery, deleteGalleryImage } from './api/gallery-api' +export type { GalleryImageItem } from './model/types' +export { GalleryGrid } from './ui/GalleryGrid' diff --git a/client/src/entities/gallery/ui/GalleryGrid.tsx b/client/src/entities/gallery/ui/GalleryGrid.tsx new file mode 100644 index 0000000..023aacb --- /dev/null +++ b/client/src/entities/gallery/ui/GalleryGrid.tsx @@ -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 ( + + {items.map((item) => ( + + + + onDelete(item.id)} + > + + + + + ))} + + ) +} diff --git a/client/src/entities/info/api/info-page-api.ts b/client/src/entities/info/api/info-page-api.ts index 343fd2a..0a37103 100644 --- a/client/src/entities/info/api/info-page-api.ts +++ b/client/src/entities/info/api/info-page-api.ts @@ -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') diff --git a/client/src/entities/info/index.ts b/client/src/entities/info/index.ts new file mode 100644 index 0000000..5714569 --- /dev/null +++ b/client/src/entities/info/index.ts @@ -0,0 +1,8 @@ +export { + fetchPublicInfoBlocks, + fetchAdminInfoBlocks, + createInfoBlock, + updateInfoBlock, + deleteInfoBlock, +} from './api/info-page-api' +export type { InfoPageBlock } from './model/types' diff --git a/client/src/entities/info/model/types.ts b/client/src/entities/info/model/types.ts new file mode 100644 index 0000000..76fb54a --- /dev/null +++ b/client/src/entities/info/model/types.ts @@ -0,0 +1,10 @@ +export type InfoPageBlock = { + id: string + key: string + title: string + body: string + sort: number + published: boolean + createdAt: string + updatedAt: string +} diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts index e68d79e..c52634a 100644 --- a/client/src/entities/order/api/order-api.ts +++ b/client/src/entities/order/api/order-api.ts @@ -39,7 +39,7 @@ export type OrderDetailResponse = { }> messages: Array<{ id: string - authorType: string + authorType: 'user' | 'admin' text: string attachmentUrl?: string | null createdAt: string diff --git a/client/src/features/address-map-picker/api/map-geocoding.ts b/client/src/features/address-map-picker/api/map-geocoding.ts new file mode 100644 index 0000000..e448a4e --- /dev/null +++ b/client/src/features/address-map-picker/api/map-geocoding.ts @@ -0,0 +1,28 @@ +import type { LatLng, NominatimItem } from '../model/types' + +export async function reverseGeocode(pos: LatLng): Promise { + 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 { + 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 : [] +} diff --git a/client/src/features/address-map-picker/index.ts b/client/src/features/address-map-picker/index.ts new file mode 100644 index 0000000..1a7d1b6 --- /dev/null +++ b/client/src/features/address-map-picker/index.ts @@ -0,0 +1 @@ +export { AddressMapPicker } from './ui/AddressMapPicker' diff --git a/client/src/features/address-map-picker/model/types.ts b/client/src/features/address-map-picker/model/types.ts new file mode 100644 index 0000000..0661142 --- /dev/null +++ b/client/src/features/address-map-picker/model/types.ts @@ -0,0 +1,3 @@ +export type NominatimItem = { display_name: string; lat: string; lon: string } + +export type LatLng = { lat: number; lng: number } diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx index 3bebe61..dfb8786 100644 --- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -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 { - 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 { - 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 diff --git a/client/src/features/cart/cart-badge/index.ts b/client/src/features/cart/cart-badge/index.ts new file mode 100644 index 0000000..436a567 --- /dev/null +++ b/client/src/features/cart/cart-badge/index.ts @@ -0,0 +1 @@ +export { CartBadge } from './ui/CartBadge' diff --git a/client/src/features/cart/cart-badge/ui/CartBadge.tsx b/client/src/features/cart/cart-badge/ui/CartBadge.tsx new file mode 100644 index 0000000..d37ec61 --- /dev/null +++ b/client/src/features/cart/cart-badge/ui/CartBadge.tsx @@ -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 ( + + { + if (!user) onNavigate('/auth') + else onNavigate('/cart') + }} + aria-label="Корзина" + > + + + + + + ) +} diff --git a/client/src/features/order-chat/index.ts b/client/src/features/order-chat/index.ts new file mode 100644 index 0000000..cb5b17a --- /dev/null +++ b/client/src/features/order-chat/index.ts @@ -0,0 +1 @@ +export { OrderChat } from './ui/OrderChat' diff --git a/client/src/features/order-chat/ui/OrderChat.tsx b/client/src/features/order-chat/ui/OrderChat.tsx new file mode 100644 index 0000000..a9c269d --- /dev/null +++ b/client/src/features/order-chat/ui/OrderChat.tsx @@ -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 ( + + + Чат по заказу + + + {messages.map((m) => ( + + + {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} + + + + ))} + {messages.length === 0 && Пока сообщений нет.} + + + + + + + + + + ) +} diff --git a/client/src/features/order-payment/index.ts b/client/src/features/order-payment/index.ts new file mode 100644 index 0000000..a7a90a5 --- /dev/null +++ b/client/src/features/order-payment/index.ts @@ -0,0 +1,2 @@ +export { OrderPaymentSection } from './ui/OrderPaymentSection' +export { PaymentDialog } from './ui/PaymentDialog' diff --git a/client/src/features/order-payment/ui/OrderPaymentSection.tsx b/client/src/features/order-payment/ui/OrderPaymentSection.tsx new file mode 100644 index 0000000..44ae854 --- /dev/null +++ b/client/src/features/order-payment/ui/OrderPaymentSection.tsx @@ -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 ( + + + Оплата + + + Оплата при получении на точке самовывоза (наличные или карта — по договорённости). + + + ) + } + + return ( + + + Оплата + + {status === 'DELIVERY_FEE_ADJUSTMENT' && ( + + Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в статус « + {orderStatusLabelRu('PENDING_PAYMENT')}». + + )} + {status === 'PENDING_PAYMENT' && ( + <> + + После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус « + {orderStatusLabelRu('PAYMENT_VERIFICATION')}». + + + + )} + {status === 'PAYMENT_VERIFICATION' && ( + + Оплата отправлена на проверку. Мы проверим поступление и обновим статус. + + )} + {!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(status) && ( + + На этом этапе действий по оплате в этом блоке не требуется. + + )} + + setPayModalOpen(false)} + onSubmit={(params) => { + onPay(params) + setPayModalOpen(false) + }} + /> + + ) +} diff --git a/client/src/features/order-payment/ui/PaymentDialog.tsx b/client/src/features/order-payment/ui/PaymentDialog.tsx new file mode 100644 index 0000000..73dd832 --- /dev/null +++ b/client/src/features/order-payment/ui/PaymentDialog.tsx @@ -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(null) + const [clientError, setClientError] = useState(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 ( + + Подтверждение оплаты + + + {PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN} + + { + setDetail(e.target.value) + setClientError(null) + }} + fullWidth + multiline + minRows={3} + sx={{ mb: 2 }} + /> + + + {receiptFile && ( + + )} + + + Нужен текст комментария и/или изображение чека. + + {receiptPreviewUrl && ( + + )} + {clientError && ( + + {clientError} + + )} + {error && ( + + {paySubmitErrorMessage(error)} + + )} + + + + + + + ) +} diff --git a/client/src/features/product-review/index.ts b/client/src/features/product-review/index.ts new file mode 100644 index 0000000..6b8f6f1 --- /dev/null +++ b/client/src/features/product-review/index.ts @@ -0,0 +1,2 @@ +export { ReviewSection } from './ui/ReviewSection' +export { ReviewDialog } from './ui/ReviewDialog' diff --git a/client/src/features/product-review/ui/ReviewDialog.tsx b/client/src/features/product-review/ui/ReviewDialog.tsx new file mode 100644 index 0000000..7fe5096 --- /dev/null +++ b/client/src/features/product-review/ui/ReviewDialog.tsx @@ -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(5) + const [text, setText] = useState('') + const [imageUrl, setImageUrl] = useState(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 ( + + Отзыв: {productTitle} + + + Оценка + + { + if (v !== null) setRating(v) + }} + /> + + + + + + {imageUrl && ( + + )} + + {imageUrl && ( + + )} + {uploadError && ( + + Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp. + + )} + {error && ( + + {reviewSubmitErrorMessage(error)} + + )} + + + + + + + ) +} diff --git a/client/src/features/product-review/ui/ReviewSection.tsx b/client/src/features/product-review/ui/ReviewSection.tsx new file mode 100644 index 0000000..a9191e8 --- /dev/null +++ b/client/src/features/product-review/ui/ReviewSection.tsx @@ -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(null) + + if (items.length === 0) return null + + return ( + + + Отзывы + + + Поделитесь впечатлением о товарах. Отзывы появляются после модерации. + + + {items.map((row) => ( + + {row.title} + + + ))} + + + { + 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) + }} + /> + + ) +} diff --git a/client/src/features/user/user-menu/index.ts b/client/src/features/user/user-menu/index.ts new file mode 100644 index 0000000..f3bd1a4 --- /dev/null +++ b/client/src/features/user/user-menu/index.ts @@ -0,0 +1 @@ +export { UserMenu } from './ui/UserMenu' diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx new file mode 100644 index 0000000..2c866fe --- /dev/null +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -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) + const open = Boolean(anchorEl) + + const openMenu = (e: React.MouseEvent) => setAnchorEl(e.currentTarget) + const closeMenu = () => setAnchorEl(null) + + const go = (to: string) => { + closeMenu() + onNavigate(to) + } + + const handleLogout = () => { + closeMenu() + onLogout() + } + + return ( + <> + + + + + + + + {user ? ( + <> + go('/me')}> + + + Выход + + ) : ( + go('/auth')}>Войти / регистрация + )} + + + ) +} diff --git a/client/src/pages/admin-categories/index.ts b/client/src/pages/admin-categories/index.ts new file mode 100644 index 0000000..2a2f49a --- /dev/null +++ b/client/src/pages/admin-categories/index.ts @@ -0,0 +1 @@ +export { AdminCategoriesPage } from './ui/AdminCategoriesPage' diff --git a/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx new file mode 100644 index 0000000..22040bf --- /dev/null +++ b/client/src/pages/admin-categories/ui/AdminCategoriesPage.tsx @@ -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(null) + const [categoryDeleteTarget, setCategoryDeleteTarget] = useState(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 ( + + + Управление категориями + + + + + + + {adminCategoriesQuery.isError && ( + + Не удалось загрузить категории. + + )} + + {mutationError && ( + + {getErrorMessage(mutationError)} + + )} + + + + + Название + Slug + Порядок + Действия + + + + {(adminCategoriesQuery.data ?? []).map((c) => ( + + {c.name} + {c.slug} + {c.sort} + + openCategoryEdit(c)} + onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)} + /> + + + ))} + +
+ + setCatOpen(false)} fullWidth maxWidth="xs"> + Новая категория + + + } + /> + ( + + )} + /> + + + + + + + + + { + setCategoryEditOpen(false) + setEditingCategory(null) + }} + fullWidth + maxWidth="xs" + > + Редактировать категорию + + + } + /> + ( + + )} + /> + ( + + )} + /> + + + + + + + + + setCategoryDeleteTarget(null)} + maxWidth="xs" + fullWidth + > + Удалить категорию? + + + {categoryDeleteTarget && ( + <> + Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано». + + )} + + + + + + + +
+ ) +} diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx index 91147bf..4f1450f 100644 --- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -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() { )} - - {items.map((item) => ( - - - - deleteMut.mutate(item.id)} - > - - - - - ))} - + deleteMut.mutate(id)} /> {!galleryQuery.isLoading && items.length === 0 && ( Пока нет загруженных изображений. diff --git a/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx b/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx index 113563b..c702b2a 100644 --- a/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx +++ b/client/src/pages/admin-gallery/ui/GallerySliderSection.tsx @@ -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 } diff --git a/client/src/pages/admin-info/ui/AdminInfoPage.tsx b/client/src/pages/admin-info/ui/AdminInfoPage.tsx index f08cd28..2c537e3 100644 --- a/client/src/pages/admin-info/ui/AdminInfoPage.tsx +++ b/client/src/pages/admin-info/ui/AdminInfoPage.tsx @@ -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' diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 757a098..a375aa2 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -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: }, + { to: '/admin/categories', label: 'Категории', icon: }, { to: '/admin/gallery', label: 'Галерея', icon: }, { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, @@ -185,7 +187,8 @@ export function AdminLayoutPage() { - } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index 05ed4ae..9b13a91 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -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() { - setDialogOpen(false)} fullWidth maxWidth="md"> - Заказ - - {!detail && orderDetailQuery.isLoading && Загрузка…} - {orderDetailQuery.isError && Не удалось загрузить заказ.} - {detail && ( - - - #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} - {formatPriceRub(detail.totalCents)} - - - Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} - {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} - {detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && ( - <> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)} - )} - + setDialogOpen(false)} + title="Заказ" + maxWidth="md" + loading={!detail && orderDetailQuery.isLoading} + error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null} + > + {detail && ( + + + #{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '} + {formatPriceRub(detail.totalCents)} + + + Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} + {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} + {detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && ( + <> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)} + )} + - {detail.deliveryType === 'delivery' && ( - - - Адрес и получатель (на момент заказа) - - {deliverySnapshot ? ( - - {deliverySnapshot.label?.trim() && ( - - Метка: {deliverySnapshot.label} - - )} - - - Адрес: - {' '} - {deliverySnapshot.addressLine ?? '—'} + {detail.deliveryType === 'delivery' && ( + + + Адрес и получатель (на момент заказа) + + {deliverySnapshot ? ( + + {deliverySnapshot.label?.trim() && ( + + Метка: {deliverySnapshot.label} - - - Получатель: - {' '} - {deliverySnapshot.recipientName ?? '—'} - - - - Телефон: - {' '} - {deliverySnapshot.recipientPhone ?? '—'} - - {deliverySnapshot.comment?.trim() && ( - - Комментарий к адресу: {deliverySnapshot.comment} - - )} - - ) : ( - - Данные адреса в заказе отсутствуют или не распознаны. + )} + + + Адрес: + {' '} + {deliverySnapshot.addressLine ?? '—'} - )} - - )} + + + Получатель: + {' '} + {deliverySnapshot.recipientName ?? '—'} + + + + Телефон: + {' '} + {deliverySnapshot.recipientPhone ?? '—'} + + {deliverySnapshot.comment?.trim() && ( + + Комментарий к адресу: {deliverySnapshot.comment} + + )} + + ) : ( + + Данные адреса в заказе отсутствуют или не распознаны. + + )} + + )} - {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && ( - - Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус « - {orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы. - - )} + {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && ( + + Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус « + {orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы. + + )} - {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && ( - - )} + {detail.status === 'DELIVERY_FEE_ADJUSTMENT' && ( + + )} - - - Сменить статус - { + const next = String(e.target.value) + if (!next) return + statusMut.mutate(next) + }} + disabled={statusMut.isPending || nextStatuses.length === 0} + > + + Выберите… + + {nextStatuses.map((s) => ( + + {orderStatusLabelRu(s)} - {nextStatuses.map((s) => ( - - {orderStatusLabelRu(s)} - - ))} - - + ))} + + + + + + + Сообщения + + + {detail.messages.map((m) => ( + + + {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '} + {new Date(m.createdAt).toLocaleString()} + + + + ))} + {detail.messages.length === 0 && Нет сообщений.} - - - Сообщения - - - {detail.messages.map((m) => ( - - - {m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '} - {new Date(m.createdAt).toLocaleString()} - - - - ))} - {detail.messages.length === 0 && Нет сообщений.} - - - - - - - - - - - )} - - - - - + + + + + + + + + )} + ) } diff --git a/client/src/pages/admin-products/index.ts b/client/src/pages/admin-products/index.ts new file mode 100644 index 0000000..cbb390e --- /dev/null +++ b/client/src/pages/admin-products/index.ts @@ -0,0 +1 @@ +export { AdminProductsPage } from './ui/AdminProductsPage' diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx new file mode 100644 index 0000000..2064825 --- /dev/null +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -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() + const [galleryPickOpen, setGalleryPickOpen] = useState(false) + const [gallerySelectedUrls, setGallerySelectedUrls] = useState>(() => new Set()) + + const productForm = useForm({ + 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(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 ( + + + Управление товарами + + + + + + + {productsQuery.isError && ( + + Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора. + + )} + + {mutationError && ( + + {getErrorMessage(mutationError)} + + )} + + + + + Название + Категория + Цена + Витрина + Действия + + + + {(productsQuery.data ?? []).map((p) => ( + + {p.title} + {p.category?.name ?? '—'} + {formatPriceRub(p.priceCents)} + {p.published ? 'да' : 'нет'} + + openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} /> + + + ))} + +
+ + + {editing ? 'Редактировать товар' : 'Новый товар'} + + + } + /> + ( + + )} + /> + ( + + )} + /> + } + /> + ( + + )} + /> + ( + + )} + /> + } + /> + + + Фото (загрузка) + + + PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из + карточки; файл остаётся на сервере и в галерее. + + + + + {uploadImagesMut.isPending && Загрузка…} + {uploadImagesMut.isError && Не удалось загрузить фото} + + + {productForm.watch('imageUrls').length > 0 && ( + + {productForm.watch('imageUrls').map((url) => ( + + + + + ))} + + )} + + ( + + Категория + + + )} + /> + ( + field.onChange(v)} />} + label="Показывать в каталоге" + /> + )} + /> + ( + field.onChange(v)} />} + label={field.value ? 'В наличии' : 'Под заказ'} + /> + )} + /> + {!inStockValue && ( + } + /> + )} + + + + + + + + + { + setGalleryPickOpen(false) + setGallerySelectedUrls(new Set()) + }} + fullWidth + maxWidth="sm" + > + Изображения из галереи + + {galleryForPickQuery.isLoading && Загрузка списка…} + {galleryForPickQuery.isError && ( + Не удалось загрузить галерею. Попробуйте ещё раз. + )} + {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( + В галерее пока нет файлов. Загрузите их в разделе «Галерея». + )} + + {(galleryForPickQuery.data?.items ?? []).map((item) => { + const alreadyInCard = productForm.watch('imageUrls').includes(item.url) + return ( + toggleGalleryPickUrl(item.url)} + /> + } + label={ + + } + /> + ) + })} + + + + + + + +
+ ) +} diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index d4cff67..90ca7b9 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -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() { )} - - + + {users.length === 0 && !usersQuery.isLoading ? ( - Почта - Имя - Создан - Обновлён - Действия + + Пользователей пока нет. + - - - {users.map((u) => ( + ) : ( + users.map((u) => ( {u.email} {u.name ?? '—'} @@ -196,16 +198,9 @@ export function AdminUsersPage() { /> - ))} - {users.length === 0 && !usersQuery.isLoading && ( - - - Пользователей пока нет. - - - )} - -
+ )) + )} + - - {editing ? 'Редактировать пользователя' : 'Новый пользователь'} - - - } - /> - } - /> - - - - - - - + + + + + } + > + + } + /> + } + /> + +
) } diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index 1d7d06b..9253d1e 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -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, diff --git a/client/src/pages/home/lib/use-product-filters.ts b/client/src/pages/home/lib/use-product-filters.ts new file mode 100644 index 0000000..388ac94 --- /dev/null +++ b/client/src/pages/home/lib/use-product-filters.ts @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import type { SelectChangeEvent } from '@mui/material/Select' + +export type UseProductFiltersResult = ReturnType + +export function useProductFilters() { + const [categorySlug, setCategorySlug] = useState('') + 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) => { + setCategorySlug(e.target.value) + setPage(1) + } + + const handleSortChange = (e: SelectChangeEvent) => { + const v = e.target.value + if (v === '' || v === 'price_asc' || v === 'price_desc') { + setSort(v) + setPage(1) + } + } + + const handlePageSizeChange = (e: SelectChangeEvent) => { + 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, + } +} diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx index f2701cb..42acf25 100644 --- a/client/src/pages/home/ui/HomePage.tsx +++ b/client/src/pages/home/ui/HomePage.tsx @@ -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('') - 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) => { - setCategorySlug(e.target.value) - setPage(1) - } - - const handleSortChange = (e: SelectChangeEvent) => { - const v = e.target.value - if (v === '' || v === 'price_asc' || v === 'price_desc') { - setSort(v) - setPage(1) - } - } - - const handlePageSizeChange = (e: SelectChangeEvent) => { - 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 ( @@ -140,224 +79,14 @@ export function HomePage() { Игрушки, сувениры и другие изделия ручной работы. - - - - Категория - - labelId="category-filter-label" - label="Категория" - value={categorySlug} - onChange={handleCategoryChange} - disabled={categoriesQuery.isLoading} - > - - Все - - {categoriesForFilter.map((c) => ( - - {c.name} - - ))} - - - - setQInput(e.target.value)} - sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }} - /> - - - - - Наличие - - Быстрый фильтр по наличию - - - - { - 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' }, - }, - }} - > - Все - В наличии - Под заказ - - - - - - - - - - - - Сортировка - labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}> - - Сначала новые - - Цена: по возрастанию - Цена: по убыванию - - - - { - setPriceMinRub(e.target.value) - setPage(1) - }} - sx={{ width: { xs: '100%', md: 180 } }} - /> - { - setPriceMaxRub(e.target.value) - setPage(1) - }} - sx={{ width: { xs: '100%', md: 180 } }} - /> - - - На странице - - labelId="page-size-label" - label="На странице" - value={String(pageSize)} - onChange={handlePageSizeChange} - > - {[6, 12, 18, 24].map((n) => ( - - {n} - - ))} - - - - - - - - - Масштаб карточек - - Выберите размер карточек в каталоге - - - - { - 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' }, - }, - }} - > - S - M - L - XL - - - - + {productsQuery.isLoading && ( - + {[1, 2, 3].map((i) => ( @@ -367,16 +96,20 @@ export function HomePage() { )} {productsQuery.isError && ( - Не удалось загрузить товары. Проверьте, что API запущен. + + Не удалось загрузить товары. Проверьте, что API запущен. + )} {productsQuery.isSuccess && products.length === 0 && ( - Пока нет опубликованных товаров. + + Пока нет опубликованных товаров. + )} {productsQuery.isSuccess && products.length > 0 && ( <> - + {products.map((p) => ( 1 && ( setPage(v)} + onChange={(_, v) => filters.setPage(v)} color="primary" shape="rounded" showFirstButton diff --git a/client/src/pages/home/ui/ProductFilters.tsx b/client/src/pages/home/ui/ProductFilters.tsx new file mode 100644 index 0000000..a27a8f9 --- /dev/null +++ b/client/src/pages/home/ui/ProductFilters.tsx @@ -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 ( + + + + Категория + + labelId="category-filter-label" + label="Категория" + value={categorySlug} + onChange={handleCategoryChange} + disabled={categoriesLoading} + > + + Все + + {categoriesForFilter.map((c) => ( + + {c.name} + + ))} + + + + setQInput(e.target.value)} + sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }} + /> + + + + + Наличие + + Быстрый фильтр по наличию + + + + 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' }, + }, + }} + > + Все + В наличии + Под заказ + + + + + + + + + + + + Сортировка + labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}> + + Сначала новые + + Цена: по возрастанию + Цена: по убыванию + + + + handlePriceMinChange(e.target.value)} + sx={{ width: { xs: '100%', md: 180 } }} + /> + handlePriceMaxChange(e.target.value)} + sx={{ width: { xs: '100%', md: 180 } }} + /> + + + На странице + + labelId="page-size-label" + label="На странице" + value={String(pageSize)} + onChange={handlePageSizeChange} + > + {[6, 12, 18, 24].map((n) => ( + + {n} + + ))} + + + + + + + + + Масштаб карточек + + Выберите размер карточек в каталоге + + + + 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' }, + }, + }} + > + S + M + L + XL + + + + + ) +} diff --git a/client/src/pages/info/ui/InfoPage.tsx b/client/src/pages/info/ui/InfoPage.tsx index 7d2dd77..318b328 100644 --- a/client/src/pages/info/ui/InfoPage.tsx +++ b/client/src/pages/info/ui/InfoPage.tsx @@ -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({ diff --git a/client/src/pages/me/ui/sections/AddressesPage.tsx b/client/src/pages/me/ui/sections/AddressesPage.tsx index 50d12e9..a596d04 100644 --- a/client/src/pages/me/ui/sections/AddressesPage.tsx +++ b/client/src/pages/me/ui/sections/AddressesPage.tsx @@ -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() diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index 57f093f..e6e5596 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -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(5) - const [reviewText, setReviewText] = useState('') - const [reviewImageUrl, setReviewImageUrl] = useState(null) - - const [paymentModalOpen, setPaymentModalOpen] = useState(false) - const [paymentDetail, setPaymentDetail] = useState('') - const [paymentReceiptFile, setPaymentReceiptFile] = useState(null) - const [paymentClientError, setPaymentClientError] = useState(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 Загрузка… if (orderQuery.isError || !order) return Не удалось загрузить заказ. + const payOnPickup = (order.paymentMethod ?? 'online') === 'on_pickup' + return ( @@ -279,52 +205,15 @@ export function OrderDetailPage() { )} - - - Оплата - - {payOnPickup ? ( - - Оплата при получении на точке самовывоза (наличные или карта — по договорённости). - - ) : ( - <> - {order.status === 'DELIVERY_FEE_ADJUSTMENT' && ( - - Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в - статус «{orderStatusLabelRu('PENDING_PAYMENT')}». - - )} - {order.status === 'PENDING_PAYMENT' && ( - <> - - После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит - статус «{orderStatusLabelRu('PAYMENT_VERIFICATION')}». - - - - )} - {order.status === 'PAYMENT_VERIFICATION' && ( - - Оплата отправлена на проверку. Мы проверим поступление и обновим статус. - - )} - {!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && ( - - На этом этапе действий по оплате в этом блоке не требуется. - - )} - - )} - + 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 && ( - - - Отзывы - - - Поделитесь впечатлением о товарах. Отзывы появляются после модерации. - - - {eligibilityQuery.data.items.map((row) => ( - - {row.title} - - - ))} - - + reviewMut.mutate(params)} + onUploadImage={async (file) => { + const result = await uploadReviewImageMut.mutateAsync(file) + return result + }} + /> )} - - - Чат по заказу - - - {order.messages.map((m) => ( - - - {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} - - - - ))} - {order.messages.length === 0 && Пока сообщений нет.} - - - - - - - - - + msgMut.mutate(text)} /> - - - Подтверждение оплаты - - - {PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN} - - { - setPaymentDetail(e.target.value) - setPaymentClientError(null) - }} - fullWidth - multiline - minRows={3} - sx={{ mb: 2 }} - /> - - - {paymentReceiptFile && ( - - )} - - - Нужен текст комментария и/или изображение чека. - - {paymentReceiptPreviewUrl && ( - - )} - {paymentClientError && ( - - {paymentClientError} - - )} - {payMut.isError && ( - - {paySubmitErrorMessage(payMut.error)} - - )} - - - - - - - - { - if (reviewMut.isPending) return - setReviewTarget(null) - setReviewRating(5) - setReviewText('') - setReviewImageUrl(null) - }} - fullWidth - maxWidth="sm" - > - Отзыв: {reviewTarget?.title} - - - Оценка - - { - if (v !== null) setReviewRating(v) - }} - /> - - - - - - {reviewImageUrl && ( - - )} - - {reviewImageUrl && ( - - )} - {uploadReviewImageMut.isError && ( - - Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp. - - )} - {reviewMut.isError && ( - - {reviewSubmitErrorMessage(reviewMut.error)} - - )} - - - - - - ) } diff --git a/client/src/shared/constants/delivery-carrier.ts b/client/src/shared/constants/delivery-carrier.ts index 30e34be..a6f6cb7 100644 --- a/client/src/shared/constants/delivery-carrier.ts +++ b/client/src/shared/constants/delivery-carrier.ts @@ -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: 'Озон доставка (пункт выдачи)' }, diff --git a/client/src/shared/constants/order.ts b/client/src/shared/constants/order.ts index d6b92c8..7a5c2fb 100644 --- a/client/src/shared/constants/order.ts +++ b/client/src/shared/constants/order.ts @@ -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': diff --git a/client/src/shared/constants/upload-limits.ts b/client/src/shared/constants/upload-limits.ts index 75bc162..2792064 100644 --- a/client/src/shared/constants/upload-limits.ts +++ b/client/src/shared/constants/upload-limits.ts @@ -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))} МБ` diff --git a/client/src/shared/lib/create-error-store.ts b/client/src/shared/lib/create-error-store.ts new file mode 100644 index 0000000..a534903 --- /dev/null +++ b/client/src/shared/lib/create-error-store.ts @@ -0,0 +1,10 @@ +import { createEffect, createEvent, createStore } from 'effector' + +export function createErrorStore>(fx: Fx) { + const reset = createEvent() + const $error = createStore(null) + .on(fx.failData, (_, e) => e) + .reset([fx, reset]) + + return { $error, reset } +} diff --git a/client/src/shared/lib/persist-token.ts b/client/src/shared/lib/persist-token.ts new file mode 100644 index 0000000..62635b6 --- /dev/null +++ b/client/src/shared/lib/persist-token.ts @@ -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 + } +} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index f752a70..be9b4fe 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -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() export const logout = createEvent() +// ----- Token persistence ----- + +const persistTokenFx = createEffect({ + handler: (token) => persistToken(token), +}) + export const $token = createStore(null) .on(tokenSet, (_, t) => t) .reset(logout) +sample({ + clock: $token, + target: persistTokenFx, +}) + +// ----- User ----- + export const $user = createStore(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(null) - .on(requestEmailChangeCodeFx.failData, (_, e) => e) - .reset(requestEmailChangeCodeFx, logout) - -export const $verifyEmailChangeError = createStore(null) - .on(verifyEmailChangeFx.failData, (_, e) => e) - .reset(verifyEmailChangeFx, logout) - -export const $updateProfileError = createStore(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 - } -} diff --git a/client/src/shared/ui/AdminDialog/AdminDialog.tsx b/client/src/shared/ui/AdminDialog/AdminDialog.tsx new file mode 100644 index 0000000..f67002e --- /dev/null +++ b/client/src/shared/ui/AdminDialog/AdminDialog.tsx @@ -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 ( + + {title} + + {loading && Загрузка…} + {error && {error}} + {!loading && !error && children} + + + {actions} + + + + ) +} diff --git a/client/src/shared/ui/AdminTable/AdminTable.tsx b/client/src/shared/ui/AdminTable/AdminTable.tsx new file mode 100644 index 0000000..4a2d5f2 --- /dev/null +++ b/client/src/shared/ui/AdminTable/AdminTable.tsx @@ -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 ( + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {error && ( + + + {error} + + + )} + {loading && !error && ( + <> + {Array.from({ length: skeletonRows }).map((_, i) => ( + + {columns.map((col) => ( + + + + ))} + + ))} + + )} + {!loading && !error && children} + +
+ ) +} diff --git a/client/src/shared/ui/AdminTable/index.ts b/client/src/shared/ui/AdminTable/index.ts new file mode 100644 index 0000000..da626b8 --- /dev/null +++ b/client/src/shared/ui/AdminTable/index.ts @@ -0,0 +1,2 @@ +export { AdminTable } from './AdminTable' +export type { Column as AdminTableColumn } from './AdminTable' diff --git a/client/src/widgets/catalog-slider/ui/CatalogSlider.tsx b/client/src/widgets/catalog-slider/ui/CatalogSlider.tsx index 2b47f3a..7ebf236 100644 --- a/client/src/widgets/catalog-slider/ui/CatalogSlider.tsx +++ b/client/src/widgets/catalog-slider/ui/CatalogSlider.tsx @@ -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 diff --git a/client/src/widgets/navigation-drawer/index.ts b/client/src/widgets/navigation-drawer/index.ts new file mode 100644 index 0000000..03f432d --- /dev/null +++ b/client/src/widgets/navigation-drawer/index.ts @@ -0,0 +1 @@ +export { NavigationDrawer } from './ui/NavigationDrawer' diff --git a/client/src/widgets/navigation-drawer/ui/NavigationDrawer.tsx b/client/src/widgets/navigation-drawer/ui/NavigationDrawer.tsx new file mode 100644 index 0000000..0e30d20 --- /dev/null +++ b/client/src/widgets/navigation-drawer/ui/NavigationDrawer.tsx @@ -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) => void + onModeChange: (e: SelectChangeEvent) => 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 +} + +export function NavigationDrawer({ + open, + onClose, + user, + isAdmin, + navItems, + themeControls, + onNavigate, + onLogout, + ThemeControlsMobile, +}: Props) { + const go = (to: string) => { + onClose() + onNavigate(to) + } + + return ( + + + + + {STORE_NAME} + + + + {navItems.map((i) => ( + + ))} + {!isAdmin && ( + + )} + {user && !isAdmin && ( + + )} + {!isAdmin && ( + + )} + {!user && isAdmin && ( + + )} + {user && ( + + )} + + + + + + + + ) +} diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index 3e0bd24..b2b639a 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -2,7 +2,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@shared/*": ["../shared/*"] }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "es2023", diff --git a/client/vite.config.ts b/client/vite.config.ts index 10c196a..106c3fb 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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': { diff --git a/docs/deploy-changes.md b/docs/deploy-changes.md index 404fd32..8dd5e1b 100644 --- a/docs/deploy-changes.md +++ b/docs/deploy-changes.md @@ -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)). diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 0000000..e0ed0db --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + }, + }, +} diff --git a/scripts/deploy-ssh.sh b/scripts/deploy-ssh.sh index b6d072f..8ea53c3 100644 --- a/scripts/deploy-ssh.sh +++ b/scripts/deploy-ssh.sh @@ -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" diff --git a/server/src/index.js b/server/src/index.js index 26769c4..5ea1985 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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() diff --git a/server/src/lib/delivery-carrier.js b/server/src/lib/delivery-carrier.js index 34ca6c2..4656f69 100644 --- a/server/src/lib/delivery-carrier.js +++ b/server/src/lib/delivery-carrier.js @@ -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 diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js index 4971b57..aeb8d71 100644 --- a/server/src/lib/order-status.js +++ b/server/src/lib/order-status.js @@ -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 diff --git a/server/src/lib/upload-limits.js b/server/src/lib/upload-limits.js index e22384e..94e6acb 100644 --- a/server/src/lib/upload-limits.js +++ b/server/src/lib/upload-limits.js @@ -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 diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 35d0bbb..b0c30c7 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -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) diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js index 022cfae..32b2270 100644 --- a/server/src/routes/api/admin-categories.js +++ b/server/src/routes/api/admin-categories.js @@ -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 diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 87a8d82..e4caafe 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -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) }, ) diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js index 57cdc98..8012dd1 100644 --- a/server/src/routes/api/public-catalog.js +++ b/server/src/routes/api/public-catalog.js @@ -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) }) } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index a2006b6..2d3e218 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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 - ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` - : '' - const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${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' } - }, - ) } - diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js new file mode 100644 index 0000000..9a731d1 --- /dev/null +++ b/server/src/routes/user-addresses.js @@ -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 } + }, + ) +} diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js new file mode 100644 index 0000000..c7980a3 --- /dev/null +++ b/server/src/routes/user-cart.js @@ -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() + }, + ) +} diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js new file mode 100644 index 0000000..76eb835 --- /dev/null +++ b/server/src/routes/user-messages.js @@ -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 } + }, + ) +} diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js new file mode 100644 index 0000000..addeade --- /dev/null +++ b/server/src/routes/user-orders.js @@ -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' } + }, + ) +} diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js new file mode 100644 index 0000000..1d3a3d0 --- /dev/null +++ b/server/src/routes/user-payments.js @@ -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 + ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` + : '' + const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${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: 'Сейчас нельзя выполнить оплату для этого заказа' }) + }, + ) +} diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts new file mode 100644 index 0000000..cec41db --- /dev/null +++ b/shared/constants/delivery-carrier.d.ts @@ -0,0 +1 @@ +export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js new file mode 100644 index 0000000..1233e6e --- /dev/null +++ b/shared/constants/delivery-carrier.js @@ -0,0 +1 @@ +export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']) diff --git a/shared/constants/order-status.d.ts b/shared/constants/order-status.d.ts new file mode 100644 index 0000000..c3178f2 --- /dev/null +++ b/shared/constants/order-status.d.ts @@ -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', +] diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js new file mode 100644 index 0000000..d5e25c0 --- /dev/null +++ b/shared/constants/order-status.js @@ -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', +]) diff --git a/shared/constants/payment-method.d.ts b/shared/constants/payment-method.d.ts new file mode 100644 index 0000000..70a9fee --- /dev/null +++ b/shared/constants/payment-method.d.ts @@ -0,0 +1 @@ +export declare const PAYMENT_METHODS: readonly ['online', 'on_pickup'] diff --git a/shared/constants/payment-method.js b/shared/constants/payment-method.js new file mode 100644 index 0000000..9616e19 --- /dev/null +++ b/shared/constants/payment-method.js @@ -0,0 +1 @@ +export const PAYMENT_METHODS = Object.freeze(['online', 'on_pickup']) diff --git a/shared/constants/upload-limits.d.ts b/shared/constants/upload-limits.d.ts new file mode 100644 index 0000000..8f9caf1 --- /dev/null +++ b/shared/constants/upload-limits.d.ts @@ -0,0 +1 @@ +export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520 diff --git a/shared/constants/upload-limits.js b/shared/constants/upload-limits.js new file mode 100644 index 0000000..5c020a3 --- /dev/null +++ b/shared/constants/upload-limits.js @@ -0,0 +1,3 @@ +const MB = 1024 * 1024 + +export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB