diff --git a/client/package-lock.json b/client/package-lock.json index 375b76d..fcd74ee 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,23 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@dicebear/adventurer": "^9.4.2", + "@dicebear/avataaars": "^9.4.2", + "@dicebear/big-ears": "^9.4.2", + "@dicebear/big-smile": "^9.4.2", + "@dicebear/bottts": "^9.4.2", + "@dicebear/core": "^9.4.2", + "@dicebear/croodles": "^9.4.2", + "@dicebear/fun-emoji": "^9.4.2", + "@dicebear/identicon": "^9.4.2", + "@dicebear/initials": "^9.4.2", + "@dicebear/lorelei": "^9.4.2", + "@dicebear/micah": "^9.4.2", + "@dicebear/notionists": "^9.4.2", + "@dicebear/pixel-art": "^9.4.2", + "@dicebear/rings": "^9.4.2", + "@dicebear/shapes": "^9.4.2", + "@dicebear/thumbs": "^9.4.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", @@ -465,6 +482,211 @@ "node": ">=18" } }, + "node_modules/@dicebear/adventurer": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz", + "integrity": "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.2.tgz", + "integrity": "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.2.tgz", + "integrity": "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.2.tgz", + "integrity": "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.2.tgz", + "integrity": "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", + "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.2.tgz", + "integrity": "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.2.tgz", + "integrity": "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.2.tgz", + "integrity": "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.2.tgz", + "integrity": "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.2.tgz", + "integrity": "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.2.tgz", + "integrity": "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.2.tgz", + "integrity": "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.2.tgz", + "integrity": "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.2.tgz", + "integrity": "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.2.tgz", + "integrity": "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.2.tgz", + "integrity": "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -3050,7 +3272,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { diff --git a/client/package.json b/client/package.json index 54fb1cc..d031569 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,23 @@ "test:watch": "vitest" }, "dependencies": { + "@dicebear/adventurer": "^9.4.2", + "@dicebear/avataaars": "^9.4.2", + "@dicebear/big-ears": "^9.4.2", + "@dicebear/big-smile": "^9.4.2", + "@dicebear/bottts": "^9.4.2", + "@dicebear/core": "^9.4.2", + "@dicebear/croodles": "^9.4.2", + "@dicebear/fun-emoji": "^9.4.2", + "@dicebear/identicon": "^9.4.2", + "@dicebear/initials": "^9.4.2", + "@dicebear/lorelei": "^9.4.2", + "@dicebear/micah": "^9.4.2", + "@dicebear/notionists": "^9.4.2", + "@dicebear/pixel-art": "^9.4.2", + "@dicebear/rings": "^9.4.2", + "@dicebear/shapes": "^9.4.2", + "@dicebear/thumbs": "^9.4.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 04900e2..00208a0 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -10,12 +10,16 @@ import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' export function MainLayout({ children }: PropsWithChildren) { const year = new Date().getFullYear() return ( + + diff --git a/client/src/app/routes/index.tsx b/client/src/app/routes/index.tsx index a2b1a20..5e9177e 100644 --- a/client/src/app/routes/index.tsx +++ b/client/src/app/routes/index.tsx @@ -2,13 +2,11 @@ import { lazy, Suspense } from 'react' import { 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 { NotFoundPage } from '@/pages/not-found' import { PrivacyPolicyPage } from '@/pages/privacy-policy' import { ProductPage } from '@/pages/product' diff --git a/client/src/entities/cart/index.ts b/client/src/entities/cart/index.ts new file mode 100644 index 0000000..6870839 --- /dev/null +++ b/client/src/entities/cart/index.ts @@ -0,0 +1,3 @@ +export type { CartItem } from './model/types' +export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api' +export type { CartResponse } from './api/cart-api' diff --git a/client/src/entities/notification/index.ts b/client/src/entities/notification/index.ts new file mode 100644 index 0000000..2394f9c --- /dev/null +++ b/client/src/entities/notification/index.ts @@ -0,0 +1,7 @@ +export { + fetchUserNotificationSettings, + updateUserNotificationSettings, + fetchAdminNotificationSettings, + updateAdminNotificationSettings, +} from './api/notifications-api' +export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api' diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 43df1d6..2c38e16 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -37,7 +37,7 @@ export type AdminOrderDetailResponse = { comment: string | null createdAt: string updatedAt: string - user: { id: string; email: string; displayName: string | null; phone: string | null } + user: { id: string; email: string; displayName: string | null } items: Array<{ id: string productId: string diff --git a/client/src/entities/order/index.ts b/client/src/entities/order/index.ts new file mode 100644 index 0000000..50d153d --- /dev/null +++ b/client/src/entities/order/index.ts @@ -0,0 +1,9 @@ +export { + fetchMyOrders, + createOrder, + confirmOrderReceived, + fetchMyOrder, + fetchOrderReviewEligibility, +} from './api/order-api' +export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api' +export type { OrderListResponse, OrderDetailResponse } from './api/order-api' diff --git a/client/src/entities/product/index.ts b/client/src/entities/product/index.ts new file mode 100644 index 0000000..98bfb2e --- /dev/null +++ b/client/src/entities/product/index.ts @@ -0,0 +1,2 @@ +export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api' +export type { PublicProductsResponse } from './api/product-api' diff --git a/client/src/entities/review/index.ts b/client/src/entities/review/index.ts new file mode 100644 index 0000000..48f31fd --- /dev/null +++ b/client/src/entities/review/index.ts @@ -0,0 +1,12 @@ +export { + postProductReview, + uploadReviewImage, + fetchLatestApprovedReviews, + fetchPublicProductReviews, +} from './api/reviews-api' +export type { + PublicReviewFeedItem, + PublicReviewsLatestResponse, + PublicProductReviewItem, + PublicProductReviewsResponse, +} from './api/reviews-api' diff --git a/client/src/entities/user/index.ts b/client/src/entities/user/index.ts new file mode 100644 index 0000000..cfb7e7f --- /dev/null +++ b/client/src/entities/user/index.ts @@ -0,0 +1,10 @@ +export type { AdminUser, ShippingAddress } from './model/types' +export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api' +export type { AdminUsersListResponse } from './api/user-api' +export { + fetchMyAddresses, + createMyAddress, + updateMyAddress, + deleteMyAddress, + setMyAddressDefault, +} from './api/address-api' diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index fd922bb..8a0ac36 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -11,14 +11,18 @@ import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { UserAvatar } from '@/shared/ui/UserAvatar' function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null return ( - - {rv.authorDisplay} + + + + {rv.authorDisplay} + {new Date(rv.createdAt).toLocaleString('ru-RU')} diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index ed93c19..fe72a6a 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -4,8 +4,8 @@ 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 { User } from 'lucide-react' import type { AuthUser } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' type Props = { user: AuthUser | null @@ -40,7 +40,17 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { invisible={!user} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - + {user ? ( + + ) : ( + + )} diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 69333d8..29c1ff5 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -25,6 +25,8 @@ import { AdminProductsPage } from '@/pages/admin-products' import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminUsersPage } from '@/pages/admin-users' import { $user } from '@/shared/model/auth' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' import { AdminNotificationsPage } from './AdminNotificationsPage' type NavItem = { @@ -60,7 +62,7 @@ export function AdminLayoutPage() { { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, - { to: '/admin/notifications', label: 'Оповещения', icon: }, + { to: '/admin/notifications', label: 'Уведомления', icon: }, ], [], ) @@ -124,6 +126,8 @@ export function AdminLayoutPage() { return ( + + {isMobile ? ( <> diff --git a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx index 3508376..71645b2 100644 --- a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx @@ -47,10 +47,10 @@ export function AdminNotificationsPage() { return ( - Оповещения + Уведомления - Настройка оповещений администратора. + Настройка уведомлений администратора. {error && ( diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 49edb29..6105a6c 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -16,7 +16,14 @@ import { $user, tokenSet } from '@/shared/model/auth' type AuthResponse = { token: string - user: { id: string; email: string; displayName?: string | null; phone?: string | null } + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null + } } function getApiErrorMessage(err: unknown): string | null { diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 0c24ad1..9cff739 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -26,6 +26,8 @@ import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage' import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage' import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage' import { $user } from '@/shared/model/auth' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' type NavItem = { to: string @@ -57,7 +59,7 @@ export function MeLayoutPage() { { to: '/me/messages', label: 'Сообщения', icon: }, { to: '/me/settings', label: 'Настройки', icon: }, { to: '/me/addresses', label: 'Адреса доставки', icon: }, - { to: '/me/notifications', label: 'Оповещения', icon: }, + { to: '/me/notifications', label: 'Уведомления', icon: }, ], [], ) @@ -128,6 +130,8 @@ export function MeLayoutPage() { return ( + + {isMobile ? ( <> diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx deleted file mode 100644 index 107da67..0000000 --- a/client/src/pages/me/ui/MePage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import Alert from '@mui/material/Alert' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' -import Stack from '@mui/material/Stack' -import TextField from '@mui/material/TextField' -import Typography from '@mui/material/Typography' -import { useUnit } from 'effector-react' -import { useForm } from 'react-hook-form' -import { - $requestEmailChangeCodeError, - $updateProfileError, - $user, - $verifyEmailChangeError, - requestEmailChangeCodeFx, - updateProfileFx, - verifyEmailChangeFx, -} from '@/shared/model/auth' -import type { AxiosError } from 'axios' - -function getApiErrorMessage(error: unknown): string | null { - const e = error as AxiosError<{ error?: string }> - const msg = e?.response?.data?.error - return msg ? String(msg) : null -} - -export function MePage() { - const user = useUnit($user) - const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) - const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) - const pendingProfile = useUnit(updateProfileFx.pending) - const errorEmailReq = useUnit($requestEmailChangeCodeError) - const errorProfile = useUnit($updateProfileError) - const errorEmailVerify = useUnit($verifyEmailChangeError) - - const emailForm = useForm<{ newEmail: string; code: string }>({ - defaultValues: { newEmail: '', code: '' }, - mode: 'onChange', - }) - - const profileForm = useForm<{ displayName: string }>({ - defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, - mode: 'onChange', - }) - - const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) - const profileErrorMsg = getApiErrorMessage(errorProfile) - - if (!user) { - return Нужно войти. Перейдите на страницу «Вход». - } - - return ( - - - Профиль - - - Текущая почта: {user.email} - - - {emailErrorMsg && ( - - {emailErrorMsg} - - )} - {profileErrorMsg && ( - - {profileErrorMsg} - - )} - - - - - Имя / ник - - - - - - - - - - - - Смена почты - - - - - - - - - - - - - ) -} diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index 85524c3..f1b07be 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -57,7 +57,7 @@ export function NotificationsPage() { return ( - Оповещения + Уведомления Настройте, какие уведомления вы хотите получать на почту. @@ -78,7 +78,7 @@ export function NotificationsPage() { onChange={(e) => handleToggle('globalEnabled', e.target.checked)} /> } - label={Получать оповещения} + label={Получать уведомления} /> Включите, чтобы получать уведомления о заказах на почту. diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 338ba1f..2b0e403 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -1,12 +1,19 @@ +import { useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' 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 Select from '@mui/material/Select' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' +import { createAvatar } from '@dicebear/core' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' +import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { $requestEmailChangeCodeError, $updateProfileError, @@ -16,6 +23,7 @@ import { updateProfileFx, verifyEmailChangeFx, } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' import type { AxiosError } from 'axios' function getApiErrorMessage(error: unknown): string | null { @@ -38,10 +46,9 @@ export function SettingsPage() { mode: 'onChange', }) - const profileForm = useForm<{ displayName: string; phone: string }>({ + const profileForm = useForm<{ displayName: string }>({ defaultValues: { displayName: user?.displayName ? String(user.displayName) : '', - phone: user?.phone ? String(user.phone) : '', }, mode: 'onChange', }) @@ -49,6 +56,16 @@ export function SettingsPage() { const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) + const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') + const useOAuth = user?.avatarType === 'oauth' + const useGenerated = user?.avatarType === 'generated' + + const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) + const [previewSrc, setPreviewSrc] = useState(null) + const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) + + const hasUnsavedPreview = previewSrc !== null + if (!user) { return Нужно войти. Перейдите на страницу «Вход». } @@ -85,20 +102,13 @@ export function SettingsPage() { slotProps={{ htmlInput: { maxLength: 40 } }} {...profileForm.register('displayName')} /> - + + + {hasUnsavedPreview && ( + + + + + )} + + {hasOAuthAvatar && !hasUnsavedPreview && ( + + )} + + + + Смена почты diff --git a/client/src/pages/terms/ui/TermsPage.tsx b/client/src/pages/terms/ui/TermsPage.tsx index e5340bf..7699ea3 100644 --- a/client/src/pages/terms/ui/TermsPage.tsx +++ b/client/src/pages/terms/ui/TermsPage.tsx @@ -1,7 +1,7 @@ import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import { STORE_EMAIL, STORE_PUBLIC_SITE_URL } from '@/shared/config' +import { STORE_EMAIL, STORE_PHONE, STORE_PUBLIC_SITE_URL } from '@/shared/config' const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') @@ -138,8 +138,8 @@ const sections = [ `ИНН: ${OP_INN}`, `ОГРН: ${OP_OGRN}`, `Адрес: ${OP_ADDR}`, - `Телефон: +7 (900) 000-00-00`, // TODO: заменить на реальный номер телефона - `Email: ${STORE_EMAIL}`, // TODO: заменить на реальный email при настройке STORE_EMAIL + `Телефон: ${STORE_PHONE}`, + `Email: ${STORE_EMAIL}`, ], }, ] diff --git a/client/src/shared/lib/avatar-styles.ts b/client/src/shared/lib/avatar-styles.ts new file mode 100644 index 0000000..ddd0290 --- /dev/null +++ b/client/src/shared/lib/avatar-styles.ts @@ -0,0 +1,88 @@ +import { create as adventurerCreate, meta as adventurerMeta, schema as adventurerSchema } from '@dicebear/adventurer' +import { create as avataaarsCreate, meta as avataaarsMeta, schema as avataaarsSchema } from '@dicebear/avataaars' +import { create as bigEarsCreate, meta as bigEarsMeta, schema as bigEarsSchema } from '@dicebear/big-ears' +import { create as bigSmileCreate, meta as bigSmileMeta, schema as bigSmileSchema } from '@dicebear/big-smile' +import { create as botttsCreate, meta as botttsMeta, schema as botttsSchema } from '@dicebear/bottts' +import { create as croodlesCreate, meta as croodlesMeta, schema as croodlesSchema } from '@dicebear/croodles' +import { create as funEmojiCreate, meta as funEmojiMeta, schema as funEmojiSchema } from '@dicebear/fun-emoji' +import { create as identiconCreate, meta as identiconMeta, schema as identiconSchema } from '@dicebear/identicon' +import { create as initialsCreate, meta as initialsMeta, schema as initialsSchema } from '@dicebear/initials' +import { create as loreleiCreate, meta as loreleiMeta, schema as loreleiSchema } from '@dicebear/lorelei' +import { create as micahCreate, meta as micahMeta, schema as micahSchema } from '@dicebear/micah' +import { create as notionistsCreate, meta as notionistsMeta, schema as notionistsSchema } from '@dicebear/notionists' +import { create as pixelArtCreate, meta as pixelArtMeta, schema as pixelArtSchema } from '@dicebear/pixel-art' +import { create as ringsCreate, meta as ringsMeta, schema as ringsSchema } from '@dicebear/rings' +import { create as shapesCreate, meta as shapesMeta, schema as shapesSchema } from '@dicebear/shapes' +import { create as thumbsCreate, meta as thumbsMeta, schema as thumbsSchema } from '@dicebear/thumbs' +import type { Style } from '@dicebear/core' + +type StyleDef = { + id: string + label: string + style: Style +} + +export const AVATAR_STYLES: StyleDef[] = [ + { id: 'bottts', label: 'Роботы', style: { create: botttsCreate, meta: botttsMeta, schema: botttsSchema } }, + { + id: 'identicon', + label: 'Узоры', + style: { create: identiconCreate, meta: identiconMeta, schema: identiconSchema }, + }, + { + id: 'avataaars', + label: 'Персонажи', + style: { create: avataaarsCreate, meta: avataaarsMeta, schema: avataaarsSchema }, + }, + { + id: 'notionists', + label: 'Notion', + style: { create: notionistsCreate, meta: notionistsMeta, schema: notionistsSchema }, + }, + { id: 'thumbs', label: 'Thumbs', style: { create: thumbsCreate, meta: thumbsMeta, schema: thumbsSchema } }, + { id: 'lorelei', label: 'Lorelei', style: { create: loreleiCreate, meta: loreleiMeta, schema: loreleiSchema } }, + { id: 'micah', label: 'Micah', style: { create: micahCreate, meta: micahMeta, schema: micahSchema } }, + { + id: 'pixel-art', + label: 'Пиксели', + style: { create: pixelArtCreate, meta: pixelArtMeta, schema: pixelArtSchema }, + }, + { id: 'rings', label: 'Кольца', style: { create: ringsCreate, meta: ringsMeta, schema: ringsSchema } }, + { id: 'shapes', label: 'Фигуры', style: { create: shapesCreate, meta: shapesMeta, schema: shapesSchema } }, + { + id: 'initials', + label: 'Инициалы', + style: { create: initialsCreate, meta: initialsMeta, schema: initialsSchema }, + }, + { + id: 'adventurer', + label: 'Adventurer', + style: { create: adventurerCreate, meta: adventurerMeta, schema: adventurerSchema }, + }, + { + id: 'big-ears', + label: 'Big Ears', + style: { create: bigEarsCreate, meta: bigEarsMeta, schema: bigEarsSchema }, + }, + { + id: 'big-smile', + label: 'Big Smile', + style: { create: bigSmileCreate, meta: bigSmileMeta, schema: bigSmileSchema }, + }, + { + id: 'croodles', + label: 'Croodles', + style: { create: croodlesCreate, meta: croodlesMeta, schema: croodlesSchema }, + }, + { + id: 'fun-emoji', + label: 'Fun Emoji', + style: { create: funEmojiCreate, meta: funEmojiMeta, schema: funEmojiSchema }, + }, +] + +export const DEFAULT_STYLE_ID = 'avataaars' + +export function getStyleById(id: string | null | undefined): StyleDef { + return AVATAR_STYLES.find((s) => s.id === id) ?? AVATAR_STYLES[0] +} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 3c4540f..6761582 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -11,7 +11,8 @@ export type AuthUser = { lastName?: string | null gender?: string | null avatar?: string | null - phone?: string | null + avatarType?: string | null + avatarStyle?: string | null isAdmin?: boolean } @@ -68,7 +69,12 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin // ----- Profile update ----- -export type UpdateProfileParams = { displayName: string | null; phone?: string | null } +export type UpdateProfileParams = { + displayName: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null +} export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) diff --git a/client/src/shared/ui/ScrollOnNavigate.tsx b/client/src/shared/ui/ScrollOnNavigate.tsx new file mode 100644 index 0000000..3925696 --- /dev/null +++ b/client/src/shared/ui/ScrollOnNavigate.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +export function ScrollOnNavigate() { + const { pathname } = useLocation() + + useEffect(() => { + window.scrollTo(0, 0) + }, [pathname]) + + return null +} diff --git a/client/src/shared/ui/ScrollToTop.tsx b/client/src/shared/ui/ScrollToTop.tsx new file mode 100644 index 0000000..4d521fc --- /dev/null +++ b/client/src/shared/ui/ScrollToTop.tsx @@ -0,0 +1,31 @@ +import Fab from '@mui/material/Fab' +import useScrollTrigger from '@mui/material/useScrollTrigger' +import Zoom from '@mui/material/Zoom' +import { ArrowUp } from 'lucide-react' + +export function ScrollToTop() { + const trigger = useScrollTrigger({ threshold: 400, disableHysteresis: true }) + + const handleClick = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + return ( + + + + + + ) +} diff --git a/client/src/shared/ui/UserAvatar.tsx b/client/src/shared/ui/UserAvatar.tsx new file mode 100644 index 0000000..bc3eecf --- /dev/null +++ b/client/src/shared/ui/UserAvatar.tsx @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import Avatar from '@mui/material/Avatar' +import type { SxProps, Theme } from '@mui/material/styles' +import { createAvatar } from '@dicebear/core' +import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' + +type UserAvatarProps = { + userId: string + avatarUrl?: string | null + avatarType?: string | null + avatarStyle?: string | null + size?: number + sx?: SxProps +} + +export function UserAvatar({ userId, avatarUrl, avatarType, avatarStyle, size = 40, sx }: UserAvatarProps) { + const generatedSrc = useMemo(() => { + const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) + const avatar = createAvatar(styleDef.style, { seed: userId }) + return avatar.toDataUri() + }, [userId, avatarStyle]) + + const src = avatarType && avatarUrl ? avatarUrl : generatedSrc + + return ( + + ? + + ) +} diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index d5a463b..f392849 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -1,6 +1,5 @@ import StarRoundedIcon from '@mui/icons-material/StarRounded' import Alert from '@mui/material/Alert' -import Avatar from '@mui/material/Avatar' import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import Rating from '@mui/material/Rating' @@ -12,12 +11,7 @@ import { Link as RouterLink } from 'react-router-dom' import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api' import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' - -function initials(display: string) { - const s = display.trim() - if (!s) return '?' - return s.slice(0, 1).toUpperCase() -} +import { UserAvatar } from '@/shared/ui/UserAvatar' function formatReviewDate(iso: string): string { try { @@ -107,9 +101,13 @@ export function ReviewsBlock() { )} - - {initials(r.authorDisplay)} - + {r.authorDisplay} diff --git a/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql new file mode 100644 index 0000000..384c607 --- /dev/null +++ b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE User DROP COLUMN phone; +ALTER TABLE User ADD COLUMN "avatarType" TEXT; diff --git a/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql new file mode 100644 index 0000000..acccfeb --- /dev/null +++ b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql @@ -0,0 +1 @@ +ALTER TABLE User ADD COLUMN "avatarStyle" TEXT; diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index c72d838..10de6af 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 60d79a7..3bc4946 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -82,7 +82,8 @@ model User { lastName String? gender String? avatar String? - phone String? + avatarType String? + avatarStyle String? passwordHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js index f252cbd..e33bc91 100644 --- a/server/src/routes/api/admin-categories.js +++ b/server/src/routes/api/admin-categories.js @@ -6,116 +6,136 @@ import { import { prisma } from '../../lib/prisma.js' export async function registerAdminCategoryRoutes(fastify) { - fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => { - const items = await prisma.category.findMany({ - orderBy: [{ sort: 'asc' }, { name: 'asc' }], - }) - return { items } + fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const items = await prisma.category.findMany({ + orderBy: [{ sort: 'asc' }, { name: 'asc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить категории' }) + } }) fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const body = request.body ?? {} - const name = String(body.name ?? '').trim() - if (!name) { - reply.code(400).send({ error: 'Укажите название категории' }) - return + try { + const body = request.body ?? {} + const name = String(body.name ?? '').trim() + if (!name) { + reply.code(400).send({ error: 'Укажите название категории' }) + return + } + 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 + } + const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined + const exists = await prisma.category.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + const category = await prisma.category.create({ + data: { + name, + slug, + sort: Number.isFinite(sort) ? Math.round(sort) : 0, + }, + }) + reply.code(201).send(category) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать категорию' }) } - 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 - } - const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined - const exists = await prisma.category.findUnique({ where: { slug } }) - if (exists) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - const category = await prisma.category.create({ - data: { - name, - slug, - sort: Number.isFinite(sort) ? Math.round(sort) : 0, - }, - }) - reply.code(201).send(category) }) fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } + try { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } - const data = {} - if (body.name !== undefined) data.name = String(body.name ?? '').trim() - if (body.sort !== undefined) { - const s = Number(body.sort) - if (!Number.isFinite(s)) { - reply.code(400).send({ error: 'Некорректный sort' }) - return - } - data.sort = Math.round(s) - } - if (body.slug !== undefined) { - const s = String(body.slug ?? '').trim() - if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { - reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) - return - } - if (!s) { - reply.code(400).send({ error: 'Slug не может быть пустым' }) - return - } - if (s !== existing.slug) { - if (isUnspecifiedCategorySlug(s)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + const data = {} + if (body.name !== undefined) data.name = String(body.name ?? '').trim() + if (body.sort !== undefined) { + const s = Number(body.sort) + if (!Number.isFinite(s)) { + reply.code(400).send({ error: 'Некорректный sort' }) return } - const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) - if (clash) { - reply.code(409).send({ error: 'Такой slug уже занят' }) + data.sort = Math.round(s) + } + if (body.slug !== undefined) { + const s = String(body.slug ?? '').trim() + if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { + reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) return } + if (!s) { + reply.code(400).send({ error: 'Slug не может быть пустым' }) + return + } + if (s !== existing.slug) { + if (isUnspecifiedCategorySlug(s)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + } + data.slug = s } - data.slug = s - } - if (Object.keys(data).length === 0) { - return existing - } - if (data.name !== undefined && !data.name) { - reply.code(400).send({ error: 'Укажите название' }) - return - } + if (Object.keys(data).length === 0) { + return existing + } + if (data.name !== undefined && !data.name) { + reply.code(400).send({ error: 'Укажите название' }) + return + } - const updated = await prisma.category.update({ where: { id }, data }) - return updated + const updated = await prisma.category.update({ where: { id }, data }) + return updated + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить категорию' }) + } }) fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } - if (isUnspecifiedCategorySlug(existing.slug)) { - reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) - return - } + try { + const { id } = request.params + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + if (isUnspecifiedCategorySlug(existing.slug)) { + reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) + return + } - const fallback = await getOrCreateUnspecifiedCategory() - await prisma.$transaction([ - prisma.product.updateMany({ - where: { categoryId: id }, - data: { categoryId: fallback.id }, - }), - prisma.category.delete({ where: { id } }), - ]) - return reply.code(204).send() + const fallback = await getOrCreateUnspecifiedCategory() + await prisma.$transaction([ + prisma.product.updateMany({ + where: { categoryId: id }, + data: { categoryId: fallback.id }, + }), + prisma.category.delete({ where: { id } }), + ]) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить категорию' }) + } }) } diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 8db819f..3092e76 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, displayName: true, phone: true } }, + user: { select: { id: true, email: true, displayName: true } }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/catalog-slider.js b/server/src/routes/api/catalog-slider.js index 4e494c6..76c5e61 100644 --- a/server/src/routes/api/catalog-slider.js +++ b/server/src/routes/api/catalog-slider.js @@ -3,89 +3,104 @@ import { prisma } from '../../lib/prisma.js' const MAX_SLIDES = 20 export async function registerCatalogSliderRoutes(fastify) { - fastify.get('/api/catalog-slider', async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/catalog-slider', async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) - fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const body = request.body ?? {} - const rawSlides = body.slides - if (!Array.isArray(rawSlides)) { - return reply.code(400).send({ error: 'Ожидается slides: массив' }) - } - if (rawSlides.length > MAX_SLIDES) { - return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) - } + try { + const body = request.body ?? {} + const rawSlides = body.slides + if (!Array.isArray(rawSlides)) { + return reply.code(400).send({ error: 'Ожидается slides: массив' }) + } + if (rawSlides.length > MAX_SLIDES) { + return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) + } - const seenGalleryIds = new Set() - const normalized = [] - for (let i = 0; i < rawSlides.length; i++) { - const row = rawSlides[i] - const galleryImageId = String(row?.galleryImageId ?? '').trim() - if (!galleryImageId) { - return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + const seenGalleryIds = new Set() + const normalized = [] + for (let i = 0; i < rawSlides.length; i++) { + const row = rawSlides[i] + const galleryImageId = String(row?.galleryImageId ?? '').trim() + if (!galleryImageId) { + return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + } + if (seenGalleryIds.has(galleryImageId)) { + return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) + } + seenGalleryIds.add(galleryImageId) + const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) + if (!img) { + return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) + } + const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) + normalized.push({ galleryImageId, caption, sortOrder: i }) } - if (seenGalleryIds.has(galleryImageId)) { - return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) - } - seenGalleryIds.add(galleryImageId) - const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) - if (!img) { - return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) - } - const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) - normalized.push({ galleryImageId, caption, sortOrder: i }) - } - await prisma.$transaction(async (tx) => { - await tx.catalogSliderSlide.deleteMany({}) - for (const n of normalized) { - await tx.catalogSliderSlide.create({ - data: { - sortOrder: n.sortOrder, - caption: n.caption, - galleryImageId: n.galleryImageId, - }, - }) - } - }) + await prisma.$transaction(async (tx) => { + await tx.catalogSliderSlide.deleteMany({}) + for (const n of normalized) { + await tx.catalogSliderSlide.create({ + data: { + sortOrder: n.sortOrder, + caption: n.caption, + galleryImageId: n.galleryImageId, + }, + }) + } + }) - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить слайдер' }) } }) } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 385371f..de8773f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -13,7 +13,8 @@ function mapUserForClient(user) { lastName: user.lastName, gender: user.gender, avatar: user.avatar, - phone: user.phone, + avatarType: user.avatarType, + avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } } @@ -119,25 +120,40 @@ export async function registerAuthRoutes(fastify) { const userId = request.user.sub const nameRaw = request.body?.displayName const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() - const phoneRaw = request.body?.phone - const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() + const avatarRaw = request.body?.avatar + const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() + const avatarTypeRaw = request.body?.avatarType + const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() + const avatarStyleRaw = request.body?.avatarStyle + const avatarStyle = + avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) - if (phone !== null) { - const compact = phone.replace(/[\s()-]/g, '') - if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) - if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { - return reply.code(400).send({ error: 'Некорректный телефон' }) - } + if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { + return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) + } + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) + if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { + return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) } + const data = { + displayName: displayName && displayName.length ? displayName : null, + } + + if (avatarType !== undefined) { + data.avatarType = avatarType === '' ? null : avatarType + } + if (avatar !== undefined) { + data.avatar = avatar === '' ? null : avatar + } + if (avatarStyle !== undefined) { + data.avatarStyle = avatarStyle === '' ? null : avatarStyle + } const updated = await prisma.user.update({ where: { id: userId }, - data: { - displayName: displayName && displayName.length ? displayName : null, - phone: phone && phone.length ? phone : null, - }, + data, }) return { user: mapUserForClient(updated) } }) diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js index 2440907..54d3659 100644 --- a/server/src/routes/user-addresses.js +++ b/server/src/routes/user-addresses.js @@ -45,133 +45,159 @@ function validateAddressPayload(body, reply) { } 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.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить адреса' }) + } }) 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 + try { + 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, - }, + 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 }) + return reply.code(201).send({ item: created }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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 = {} + 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 } }) + 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 } - return tx.shippingAddress.update({ - where: { id }, - data: { - ...data, - ...(setDefault ? { isDefault: true } : {}), - }, - }) - }) - return { item: updated } + 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 } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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() + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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 } }) - }) + 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 } + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' }) + } }) } diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index 136453b..8a5ba0e 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -1,76 +1,96 @@ 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.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + 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, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить корзину' }) } }) 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) + try { + 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' }) + 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 product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.quantity - 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 available = product.quantity + 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 }) + 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 }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось добавить в корзину' }) + } }) 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' }) + try { + 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: 'Позиция корзины не найдена' }) + 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() + if (qty === 0) { + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } + + const available = existing.product.quantity + 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 } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить количество' }) } - - const available = existing.product.quantity - 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() + try { + 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() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить из корзины' }) + } }) } diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index 6812f33..cd446ad 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -44,22 +44,21 @@ export async function registerUserMessageRoutes(fastify) { }) if (orders.length === 0) return { count: 0 } + const orderIds = orders.map((o) => o.id) const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId }, }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + 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 + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) count++ } return { count } }) @@ -86,25 +85,32 @@ export async function registerUserMessageRoutes(fastify) { }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const orderIds = orders.map((o) => o.id) + const unreadCounts = new Map() + if (orderIds.length > 0) { + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) { + unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1) + } + } + } + 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, + unreadCount: unreadCounts.get(o.id) ?? 0, }) } return { items } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 8ced32d..6916bb1 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -176,35 +176,45 @@ export async function registerUserOrderRoutes(fastify) { 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', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: { select: { qty: 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), + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказы' }) } }) 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 } + try { + 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 } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказ' }) + } }) fastify.get( @@ -251,19 +261,24 @@ export async function registerUserOrderRoutes(fastify) { '/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: 'Заказ не найден' }) + try { + 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: 'Сейчас нельзя подтвердить получение заказа' }) + 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' } + } catch (err) { + request.log.error(err) + return reply.code(500).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 index 8b8e99b..9d89bc4 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -60,7 +60,7 @@ export async function registerUserPaymentRoutes(fastify) { const receipt = buildReceipt({ orderItems: order.items, deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail, + userEmail: userEmail, }) let result