test commit
This commit is contained in:
@@ -2,13 +2,11 @@ import { lazy, Suspense } from 'react'
|
|||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { MainLayout } from '@/app/layout/MainLayout'
|
import { MainLayout } from '@/app/layout/MainLayout'
|
||||||
import { AboutPage } from '@/pages/about'
|
import { AboutPage } from '@/pages/about'
|
||||||
// import { AdminLayoutPage } from '@/pages/admin-layout'
|
|
||||||
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
|
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
|
||||||
import { CartPage } from '@/pages/cart'
|
import { CartPage } from '@/pages/cart'
|
||||||
import { CheckoutPage } from '@/pages/checkout'
|
import { CheckoutPage } from '@/pages/checkout'
|
||||||
import { HomePage } from '@/pages/home'
|
import { HomePage } from '@/pages/home'
|
||||||
import { InfoPage } from '@/pages/info'
|
import { InfoPage } from '@/pages/info'
|
||||||
// import { MeLayoutPage } from '@/pages/me'
|
|
||||||
import { NotFoundPage } from '@/pages/not-found'
|
import { NotFoundPage } from '@/pages/not-found'
|
||||||
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
||||||
import { ProductPage } from '@/pages/product'
|
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'
|
||||||
@@ -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'
|
||||||
@@ -60,7 +60,7 @@ export function AdminLayoutPage() {
|
|||||||
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
|
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
|
||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||||
{ to: '/admin/notifications', label: 'Оповещения', icon: <Bell /> },
|
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export function AdminNotificationsPage() {
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Оповещения
|
Уведомления
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
Настройка оповещений администратора.
|
Настройка уведомлений администратора.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function MeLayoutPage() {
|
|||||||
{ to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> },
|
{ to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> },
|
||||||
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> },
|
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> },
|
||||||
{ to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> },
|
{ to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> },
|
||||||
{ to: '/me/notifications', label: 'Оповещения', icon: <Bell /> },
|
{ to: '/me/notifications', label: 'Уведомления', icon: <Bell /> },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
Оповещения
|
Уведомления
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
Настройте, какие уведомления вы хотите получать на почту.
|
Настройте, какие уведомления вы хотите получать на почту.
|
||||||
@@ -78,7 +78,7 @@ export function NotificationsPage() {
|
|||||||
onChange={(e) => handleToggle('globalEnabled', e.target.checked)}
|
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 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||||
Включите, чтобы получать уведомления о заказах на почту.
|
Включите, чтобы получать уведомления о заказах на почту.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import Typography from '@mui/material/Typography'
|
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 : '')
|
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
|
||||||
@@ -138,8 +138,8 @@ const sections = [
|
|||||||
`ИНН: ${OP_INN}`,
|
`ИНН: ${OP_INN}`,
|
||||||
`ОГРН: ${OP_OGRN}`,
|
`ОГРН: ${OP_OGRN}`,
|
||||||
`Адрес: ${OP_ADDR}`,
|
`Адрес: ${OP_ADDR}`,
|
||||||
`Телефон: +7 (900) 000-00-00`, // TODO: заменить на реальный номер телефона
|
`Телефон: ${STORE_PHONE}`,
|
||||||
`Email: ${STORE_EMAIL}`, // TODO: заменить на реальный email при настройке STORE_EMAIL
|
`Email: ${STORE_EMAIL}`,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
@@ -6,116 +6,136 @@ import {
|
|||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerAdminCategoryRoutes(fastify) {
|
export async function registerAdminCategoryRoutes(fastify) {
|
||||||
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => {
|
fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const items = await prisma.category.findMany({
|
try {
|
||||||
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
const items = await prisma.category.findMany({
|
||||||
})
|
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
|
||||||
return { items }
|
})
|
||||||
|
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) => {
|
fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const body = request.body ?? {}
|
try {
|
||||||
const name = String(body.name ?? '').trim()
|
const body = request.body ?? {}
|
||||||
if (!name) {
|
const name = String(body.name ?? '').trim()
|
||||||
reply.code(400).send({ error: 'Укажите название категории' })
|
if (!name) {
|
||||||
return
|
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) => {
|
fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const { id } = request.params
|
try {
|
||||||
const body = request.body ?? {}
|
const { id } = request.params
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
const body = request.body ?? {}
|
||||||
if (!existing) {
|
const existing = await prisma.category.findUnique({ where: { id } })
|
||||||
reply.code(404).send({ error: 'Категория не найдена' })
|
if (!existing) {
|
||||||
return
|
reply.code(404).send({ error: 'Категория не найдена' })
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const data = {}
|
const data = {}
|
||||||
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
|
||||||
if (body.sort !== undefined) {
|
if (body.sort !== undefined) {
|
||||||
const s = Number(body.sort)
|
const s = Number(body.sort)
|
||||||
if (!Number.isFinite(s)) {
|
if (!Number.isFinite(s)) {
|
||||||
reply.code(400).send({ error: 'Некорректный sort' })
|
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}» зарезервирован` })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
|
data.sort = Math.round(s)
|
||||||
if (clash) {
|
}
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
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
|
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) {
|
if (Object.keys(data).length === 0) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
if (data.name !== undefined && !data.name) {
|
if (data.name !== undefined && !data.name) {
|
||||||
reply.code(400).send({ error: 'Укажите название' })
|
reply.code(400).send({ error: 'Укажите название' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.category.update({ where: { id }, data })
|
const updated = await prisma.category.update({ where: { id }, data })
|
||||||
return updated
|
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) => {
|
fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const { id } = request.params
|
try {
|
||||||
const existing = await prisma.category.findUnique({ where: { id } })
|
const { id } = request.params
|
||||||
if (!existing) {
|
const existing = await prisma.category.findUnique({ where: { id } })
|
||||||
reply.code(404).send({ error: 'Категория не найдена' })
|
if (!existing) {
|
||||||
return
|
reply.code(404).send({ error: 'Категория не найдена' })
|
||||||
}
|
return
|
||||||
if (isUnspecifiedCategorySlug(existing.slug)) {
|
}
|
||||||
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
if (isUnspecifiedCategorySlug(existing.slug)) {
|
||||||
return
|
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fallback = await getOrCreateUnspecifiedCategory()
|
const fallback = await getOrCreateUnspecifiedCategory()
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.product.updateMany({
|
prisma.product.updateMany({
|
||||||
where: { categoryId: id },
|
where: { categoryId: id },
|
||||||
data: { categoryId: fallback.id },
|
data: { categoryId: fallback.id },
|
||||||
}),
|
}),
|
||||||
prisma.category.delete({ where: { id } }),
|
prisma.category.delete({ where: { id } }),
|
||||||
])
|
])
|
||||||
return reply.code(204).send()
|
return reply.code(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(err)
|
||||||
|
return reply.code(500).send({ error: 'Не удалось удалить категорию' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,89 +3,104 @@ import { prisma } from '../../lib/prisma.js'
|
|||||||
const MAX_SLIDES = 20
|
const MAX_SLIDES = 20
|
||||||
|
|
||||||
export async function registerCatalogSliderRoutes(fastify) {
|
export async function registerCatalogSliderRoutes(fastify) {
|
||||||
fastify.get('/api/catalog-slider', async () => {
|
fastify.get('/api/catalog-slider', async (request, reply) => {
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
try {
|
||||||
orderBy: { sortOrder: 'asc' },
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
include: { galleryImage: true },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
include: { galleryImage: true },
|
||||||
return {
|
})
|
||||||
slides: slides.map((s) => ({
|
return {
|
||||||
id: s.id,
|
slides: slides.map((s) => ({
|
||||||
url: s.galleryImage.url,
|
id: s.id,
|
||||||
caption: s.caption,
|
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 () => {
|
fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
try {
|
||||||
orderBy: { sortOrder: 'asc' },
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
include: { galleryImage: true },
|
orderBy: { sortOrder: 'asc' },
|
||||||
})
|
include: { galleryImage: true },
|
||||||
return {
|
})
|
||||||
slides: slides.map((s) => ({
|
return {
|
||||||
id: s.id,
|
slides: slides.map((s) => ({
|
||||||
galleryImageId: s.galleryImageId,
|
id: s.id,
|
||||||
url: s.galleryImage.url,
|
galleryImageId: s.galleryImageId,
|
||||||
caption: s.caption,
|
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) => {
|
fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
const body = request.body ?? {}
|
try {
|
||||||
const rawSlides = body.slides
|
const body = request.body ?? {}
|
||||||
if (!Array.isArray(rawSlides)) {
|
const rawSlides = body.slides
|
||||||
return reply.code(400).send({ error: 'Ожидается 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} слайдов` })
|
if (rawSlides.length > MAX_SLIDES) {
|
||||||
}
|
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
|
||||||
|
}
|
||||||
|
|
||||||
const seenGalleryIds = new Set()
|
const seenGalleryIds = new Set()
|
||||||
const normalized = []
|
const normalized = []
|
||||||
for (let i = 0; i < rawSlides.length; i++) {
|
for (let i = 0; i < rawSlides.length; i++) {
|
||||||
const row = rawSlides[i]
|
const row = rawSlides[i]
|
||||||
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
const galleryImageId = String(row?.galleryImageId ?? '').trim()
|
||||||
if (!galleryImageId) {
|
if (!galleryImageId) {
|
||||||
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите 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 prisma.$transaction(async (tx) => {
|
||||||
await tx.catalogSliderSlide.deleteMany({})
|
await tx.catalogSliderSlide.deleteMany({})
|
||||||
for (const n of normalized) {
|
for (const n of normalized) {
|
||||||
await tx.catalogSliderSlide.create({
|
await tx.catalogSliderSlide.create({
|
||||||
data: {
|
data: {
|
||||||
sortOrder: n.sortOrder,
|
sortOrder: n.sortOrder,
|
||||||
caption: n.caption,
|
caption: n.caption,
|
||||||
galleryImageId: n.galleryImageId,
|
galleryImageId: n.galleryImageId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const slides = await prisma.catalogSliderSlide.findMany({
|
const slides = await prisma.catalogSliderSlide.findMany({
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: { galleryImage: true },
|
include: { galleryImage: true },
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
slides: slides.map((s) => ({
|
slides: slides.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
galleryImageId: s.galleryImageId,
|
galleryImageId: s.galleryImageId,
|
||||||
url: s.galleryImage.url,
|
url: s.galleryImage.url,
|
||||||
caption: s.caption,
|
caption: s.caption,
|
||||||
})),
|
})),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(err)
|
||||||
|
return reply.code(500).send({ error: 'Не удалось обновить слайдер' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
const avatarRaw = request.body?.avatar
|
const avatarRaw = request.body?.avatar
|
||||||
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim()
|
||||||
const avatarTypeRaw = request.body?.avatarType
|
const avatarTypeRaw = request.body?.avatarType
|
||||||
const avatarType =
|
const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
|
||||||
avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
|
|
||||||
const avatarStyleRaw = request.body?.avatarStyle
|
const avatarStyleRaw = request.body?.avatarStyle
|
||||||
const avatarStyle =
|
const avatarStyle =
|
||||||
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim()
|
||||||
|
|||||||
+133
-107
@@ -45,133 +45,159 @@ function validateAddressPayload(body, reply) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUserAddressRoutes(fastify) {
|
export async function registerUserAddressRoutes(fastify) {
|
||||||
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const items = await prisma.shippingAddress.findMany({
|
const userId = request.user.sub
|
||||||
where: { userId },
|
const items = await prisma.shippingAddress.findMany({
|
||||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
where: { userId },
|
||||||
})
|
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||||
return { items }
|
})
|
||||||
|
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) => {
|
fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const validated = validateAddressPayload(request.body, reply)
|
const userId = request.user.sub
|
||||||
if (!validated) return
|
const validated = validateAddressPayload(request.body, reply)
|
||||||
|
if (!validated) return
|
||||||
|
|
||||||
const isDefault = Boolean(request.body?.isDefault)
|
const isDefault = Boolean(request.body?.isDefault)
|
||||||
const created = await prisma.$transaction(async (tx) => {
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
}
|
}
|
||||||
return tx.shippingAddress.create({
|
return tx.shippingAddress.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
...validated,
|
...validated,
|
||||||
isDefault,
|
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) => {
|
fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const { id } = request.params
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
const body = request.body ?? {}
|
const body = request.body ?? {}
|
||||||
const data = {}
|
const data = {}
|
||||||
|
|
||||||
if (body.label !== undefined) {
|
if (body.label !== undefined) {
|
||||||
const labelRaw = body.label
|
const labelRaw = body.label
|
||||||
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim()
|
||||||
if (label !== null && label.length > 40)
|
if (label !== null && label.length > 40)
|
||||||
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' })
|
||||||
data.label = label && label.length ? label : null
|
data.label = label && label.length ? label : null
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientName !== undefined) {
|
|
||||||
const v = String(body.recipientName || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' })
|
|
||||||
if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' })
|
|
||||||
data.recipientName = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.recipientPhone !== undefined) {
|
|
||||||
const v = normalizePhoneLite(body.recipientPhone)
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' })
|
|
||||||
if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' })
|
|
||||||
data.recipientPhone = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.addressLine !== undefined) {
|
|
||||||
const v = String(body.addressLine || '').trim()
|
|
||||||
if (!v) return reply.code(400).send({ error: 'Укажите адрес' })
|
|
||||||
if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' })
|
|
||||||
data.addressLine = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.comment !== undefined) {
|
|
||||||
const commentRaw = body.comment
|
|
||||||
const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim()
|
|
||||||
if (comment !== null && comment.length > 200)
|
|
||||||
return reply.code(400).send({ error: 'Комментарий максимум 200 символов' })
|
|
||||||
data.comment = comment && comment.length ? comment : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lat !== undefined) {
|
|
||||||
const lat = Number(body.lat)
|
|
||||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' })
|
|
||||||
data.lat = lat
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.lng !== undefined) {
|
|
||||||
const lng = Number(body.lng)
|
|
||||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180)
|
|
||||||
return reply.code(400).send({ error: 'Некорректная долгота' })
|
|
||||||
data.lng = lng
|
|
||||||
}
|
|
||||||
|
|
||||||
const setDefault = body.isDefault === true
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
|
||||||
if (setDefault) {
|
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
|
||||||
}
|
}
|
||||||
return tx.shippingAddress.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
...(setDefault ? { isDefault: true } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return { item: updated }
|
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) => {
|
fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const { id } = request.params
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
await prisma.shippingAddress.delete({ where: { id } })
|
await prisma.shippingAddress.delete({ where: { id } })
|
||||||
return reply.code(204).send()
|
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) => {
|
fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
const { id } = request.params
|
||||||
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Адрес не найден' })
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } })
|
||||||
return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } })
|
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'
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerUserCartRoutes(fastify) {
|
export async function registerUserCartRoutes(fastify) {
|
||||||
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const items = await prisma.cartItem.findMany({
|
const userId = request.user.sub
|
||||||
where: { userId },
|
const items = await prisma.cartItem.findMany({
|
||||||
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'asc' },
|
include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } },
|
||||||
})
|
orderBy: { createdAt: 'asc' },
|
||||||
return {
|
})
|
||||||
items: items.map((x) => ({
|
return {
|
||||||
id: x.id,
|
items: items.map((x) => ({
|
||||||
qty: x.qty,
|
id: x.id,
|
||||||
product: x.product,
|
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) => {
|
fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const productId = String(request.body?.productId || '').trim()
|
const userId = request.user.sub
|
||||||
const qtyRaw = request.body?.qty
|
const productId = String(request.body?.productId || '').trim()
|
||||||
const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw)
|
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 (!productId) return reply.code(400).send({ error: 'productId обязателен' })
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' })
|
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 } })
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
const available = product.quantity
|
const available = product.quantity
|
||||||
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
|
||||||
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
|
||||||
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
|
||||||
|
|
||||||
const item = await prisma.cartItem.upsert({
|
const item = await prisma.cartItem.upsert({
|
||||||
where: { userId_productId: { userId, productId } },
|
where: { userId_productId: { userId, productId } },
|
||||||
update: { qty: nextQty },
|
update: { qty: nextQty },
|
||||||
create: { userId, productId, qty: nextQty },
|
create: { userId, productId, qty: nextQty },
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ item })
|
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) => {
|
fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const qtyRaw = request.body?.qty
|
const { id } = request.params
|
||||||
const qty = Number(qtyRaw)
|
const qtyRaw = request.body?.qty
|
||||||
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
|
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 } })
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
|
|
||||||
if (qty === 0) {
|
if (qty === 0) {
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
await prisma.cartItem.delete({ where: { id } })
|
||||||
return reply.code(204).send()
|
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) => {
|
fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
const { id } = request.params
|
||||||
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
|
||||||
await prisma.cartItem.delete({ where: { id } })
|
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
|
||||||
return reply.code(204).send()
|
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 }
|
if (orders.length === 0) return { count: 0 }
|
||||||
|
|
||||||
|
const orderIds = orders.map((o) => o.id)
|
||||||
const readStates = await prisma.userOrderMessageReadState.findMany({
|
const readStates = await prisma.userOrderMessageReadState.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
})
|
})
|
||||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
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
|
let count = 0
|
||||||
for (const o of orders) {
|
for (const msg of adminMessages) {
|
||||||
const lastRead = lastReadByOrder.get(o.id) ?? new Date(0)
|
const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0)
|
||||||
const n = await prisma.orderMessage.count({
|
if (msg.createdAt > lastRead) count++
|
||||||
where: {
|
|
||||||
orderId: o.id,
|
|
||||||
authorType: 'admin',
|
|
||||||
createdAt: { gt: lastRead },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
count += n
|
|
||||||
}
|
}
|
||||||
return { count }
|
return { count }
|
||||||
})
|
})
|
||||||
@@ -86,25 +85,32 @@ export async function registerUserMessageRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt]))
|
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 = []
|
const items = []
|
||||||
for (const o of orders) {
|
for (const o of orders) {
|
||||||
const lastMsg = o.messages[0]
|
const lastMsg = o.messages[0]
|
||||||
if (!lastMsg) continue
|
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({
|
items.push({
|
||||||
orderId: o.id,
|
orderId: o.id,
|
||||||
status: o.status,
|
status: o.status,
|
||||||
deliveryType: o.deliveryType,
|
deliveryType: o.deliveryType,
|
||||||
lastMessageAt: lastMsg.createdAt,
|
lastMessageAt: lastMsg.createdAt,
|
||||||
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text,
|
||||||
unreadCount,
|
unreadCount: unreadCounts.get(o.id) ?? 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { items }
|
return { items }
|
||||||
|
|||||||
@@ -176,35 +176,45 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
return reply.code(201).send({ orderId: created.id })
|
return reply.code(201).send({ orderId: created.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
|
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const orders = await prisma.order.findMany({
|
const userId = request.user.sub
|
||||||
where: { userId },
|
const orders = await prisma.order.findMany({
|
||||||
include: { items: true },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'desc' },
|
include: { items: { select: { qty: true } } },
|
||||||
})
|
orderBy: { createdAt: 'desc' },
|
||||||
return {
|
})
|
||||||
items: orders.map((o) => ({
|
return {
|
||||||
id: o.id,
|
items: orders.map((o) => ({
|
||||||
status: o.status,
|
id: o.id,
|
||||||
totalCents: o.totalCents,
|
status: o.status,
|
||||||
currency: o.currency,
|
totalCents: o.totalCents,
|
||||||
createdAt: o.createdAt,
|
currency: o.currency,
|
||||||
updatedAt: o.updatedAt,
|
createdAt: o.createdAt,
|
||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
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) => {
|
fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const order = await prisma.order.findFirst({
|
const { id } = request.params
|
||||||
where: { id, userId },
|
const order = await prisma.order.findFirst({
|
||||||
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
where: { id, userId },
|
||||||
})
|
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
})
|
||||||
return { item: order }
|
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(
|
fastify.get(
|
||||||
@@ -251,19 +261,24 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
'/api/me/orders/:id/confirm-received',
|
'/api/me/orders/:id/confirm-received',
|
||||||
{ preHandler: [fastify.authenticate] },
|
{ preHandler: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = request.user.sub
|
try {
|
||||||
const { id } = request.params
|
const userId = request.user.sub
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
const { id } = request.params
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
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 okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED'
|
||||||
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP'
|
||||||
if (!okDelivery && !okPickup) {
|
if (!okDelivery && !okPickup) {
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
|
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' }
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user