Merge branch 'refack2'
This commit is contained in:
Generated
+222
-1
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<ScrollOnNavigate />
|
||||
<ScrollToTop />
|
||||
<AppHeader />
|
||||
|
||||
<Box component="main" sx={{ flex: 1, py: { xs: 4, md: 6 } }}>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fetchUserNotificationSettings,
|
||||
updateUserNotificationSettings,
|
||||
fetchAdminNotificationSettings,
|
||||
updateAdminNotificationSettings,
|
||||
} from './api/notifications-api'
|
||||
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
|
||||
export type { PublicProductsResponse } from './api/product-api'
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
postProductReview,
|
||||
uploadReviewImage,
|
||||
fetchLatestApprovedReviews,
|
||||
fetchPublicProductReviews,
|
||||
} from './api/reviews-api'
|
||||
export type {
|
||||
PublicReviewFeedItem,
|
||||
PublicReviewsLatestResponse,
|
||||
PublicProductReviewItem,
|
||||
PublicProductReviewsResponse,
|
||||
} from './api/reviews-api'
|
||||
@@ -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'
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<UserAvatar userId={rv.authorDisplay} avatarUrl={null} avatarType={null} avatarStyle={null} size={32} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
|
||||
@@ -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 />
|
||||
{user ? (
|
||||
<UserAvatar
|
||||
userId={user.id}
|
||||
avatarUrl={user.avatar}
|
||||
avatarType={user.avatarType}
|
||||
avatarStyle={user.avatarStyle}
|
||||
size={28}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar userId="guest" avatarUrl={null} avatarType={null} avatarStyle={null} size={28} />
|
||||
)}
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
|
||||
@@ -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: <ListOrdered /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||
{ to: '/admin/notifications', label: 'Оповещения', icon: <Bell /> },
|
||||
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -124,6 +126,8 @@ export function AdminLayoutPage() {
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
|
||||
<ScrollOnNavigate />
|
||||
<ScrollToTop />
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||
|
||||
@@ -47,10 +47,10 @@ export function AdminNotificationsPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Оповещения
|
||||
Уведомления
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Настройка оповещений администратора.
|
||||
Настройка уведомлений администратора.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <MessageCircle /> },
|
||||
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> },
|
||||
{ to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> },
|
||||
{ to: '/me/notifications', label: 'Оповещения', icon: <Bell /> },
|
||||
{ to: '/me/notifications', label: 'Уведомления', icon: <Bell /> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -128,6 +130,8 @@ export function MeLayoutPage() {
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
|
||||
<ScrollOnNavigate />
|
||||
<ScrollToTop />
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||
|
||||
@@ -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 <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Профиль
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Текущая почта: <b>{user.email}</b>
|
||||
</Typography>
|
||||
|
||||
{emailErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{emailErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{profileErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{profileErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Имя / ник
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Имя или ник"
|
||||
helperText="До 40 символов"
|
||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||
{...profileForm.register('displayName')}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
const raw = profileForm.getValues('displayName')
|
||||
const name = raw.trim()
|
||||
updateProfileFx({ displayName: name.length ? name : null })
|
||||
}}
|
||||
>
|
||||
Сохранить имя
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена почты
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
|
||||
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
|
||||
>
|
||||
Отправить код на новую почту
|
||||
</Button>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
|
||||
onClick={() =>
|
||||
verifyEmailChangeFx({
|
||||
newEmail: emailForm.getValues('newEmail').trim(),
|
||||
code: emailForm.getValues('code').trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Подтвердить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export function NotificationsPage() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Оповещения
|
||||
Уведомления
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||
Настройте, какие уведомления вы хотите получать на почту.
|
||||
@@ -78,7 +78,7 @@ export function NotificationsPage() {
|
||||
onChange={(e) => handleToggle('globalEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={<Typography sx={{ fontWeight: 600 }}>Получать оповещения</Typography>}
|
||||
label={<Typography sx={{ fontWeight: 600 }}>Получать уведомления</Typography>}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||
Включите, чтобы получать уведомления о заказах на почту.
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
||||
|
||||
const hasUnsavedPreview = previewSrc !== null
|
||||
|
||||
if (!user) {
|
||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||
}
|
||||
@@ -85,20 +102,13 @@ export function SettingsPage() {
|
||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||
{...profileForm.register('displayName')}
|
||||
/>
|
||||
<TextField
|
||||
label="Телефон"
|
||||
helperText="Можно указать для связи по заказам"
|
||||
{...profileForm.register('phone')}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
const raw = profileForm.getValues('displayName')
|
||||
const name = raw.trim()
|
||||
const phoneRaw = profileForm.getValues('phone')
|
||||
const phone = phoneRaw.trim()
|
||||
updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null })
|
||||
updateProfileFx({ displayName: name.length ? name : null })
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
@@ -108,6 +118,112 @@ export function SettingsPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Аватар
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<UserAvatar
|
||||
userId={user.id}
|
||||
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
||||
avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
|
||||
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
||||
size={80}
|
||||
sx={{
|
||||
border: 2,
|
||||
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{hasUnsavedPreview && (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<UserAvatar
|
||||
userId={user.id}
|
||||
avatarUrl={user.avatar}
|
||||
avatarType={user.avatarType}
|
||||
avatarStyle={user.avatarStyle}
|
||||
size={80}
|
||||
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Текущий
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Стиль</InputLabel>
|
||||
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
|
||||
{AVATAR_STYLES.map((s) => (
|
||||
<MenuItem key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const seed = `${user.id}_${Date.now()}`
|
||||
const styleDef = getStyleById(selectedStyle)
|
||||
const avatar = createAvatar(styleDef.style, { seed })
|
||||
setPreviewSrc(avatar.toDataUri())
|
||||
setPreviewStyle(selectedStyle)
|
||||
}}
|
||||
>
|
||||
Сгенерировать
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{hasUnsavedPreview && (
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
updateProfileFx({
|
||||
displayName: user.displayName?.trim() || null,
|
||||
avatar: previewSrc,
|
||||
avatarType: 'generated',
|
||||
avatarStyle: previewStyle,
|
||||
})
|
||||
setPreviewSrc(null)
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{hasOAuthAvatar && !hasUnsavedPreview && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={pendingProfile || useOAuth}
|
||||
onClick={() => {
|
||||
updateProfileFx({
|
||||
displayName: user.displayName?.trim() || null,
|
||||
avatarType: 'oauth',
|
||||
})
|
||||
}}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Использовать OAuth
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена почты
|
||||
|
||||
@@ -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}`,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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<any>
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
<Zoom in={trigger}>
|
||||
<Fab
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
aria-label="К началу страницы"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1100,
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={20} />
|
||||
</Fab>
|
||||
</Zoom>
|
||||
)
|
||||
}
|
||||
@@ -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<Theme>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
|
||||
?
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', fontWeight: 800 }}>
|
||||
{initials(r.authorDisplay)}
|
||||
</Avatar>
|
||||
<UserAvatar
|
||||
userId={r.authorDisplay}
|
||||
avatarUrl={null}
|
||||
avatarType={null}
|
||||
avatarStyle={null}
|
||||
size={40}
|
||||
/>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE User DROP COLUMN phone;
|
||||
ALTER TABLE User ADD COLUMN "avatarType" TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE User ADD COLUMN "avatarStyle" TEXT;
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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: 'Не удалось удалить категорию' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
},
|
||||
|
||||
@@ -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: 'Не удалось обновить слайдер' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+29
-13
@@ -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) }
|
||||
})
|
||||
|
||||
+133
-107
@@ -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: 'Не удалось установить адрес по умолчанию' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: 'Не удалось удалить из корзины' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function registerUserPaymentRoutes(fastify) {
|
||||
const receipt = buildReceipt({
|
||||
orderItems: order.items,
|
||||
deliveryFeeCents: order.deliveryFeeCents,
|
||||
userEmail: userEmail,
|
||||
userEmail: userEmail,
|
||||
})
|
||||
|
||||
let result
|
||||
|
||||
Reference in New Issue
Block a user