diff --git a/client/src/features/product-review/ui/ReviewDialog.tsx b/client/src/features/product-review/ui/ReviewDialog.tsx index 2f9a01c..959ad2c 100644 --- a/client/src/features/product-review/ui/ReviewDialog.tsx +++ b/client/src/features/product-review/ui/ReviewDialog.tsx @@ -20,8 +20,8 @@ type Props = { error: unknown uploadError: unknown onClose: () => void - onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => void - onUploadImage: (file: File) => void + onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => Promise + onUploadImage: (file: File) => Promise<{ url: string }> } function reviewSubmitErrorMessage(err: unknown): string { @@ -55,11 +55,13 @@ export function ReviewDialog({ const [rating, setRating] = useState(5) const [text, setText] = useState('') const [imageUrl, setImageUrl] = useState(null) + const [localUploadError, setLocalUploadError] = useState(null) const reset = () => { setRating(5) setText('') setImageUrl(null) + setLocalUploadError(null) } const handleClose = () => { @@ -68,9 +70,9 @@ export function ReviewDialog({ onClose() } - const handleSubmit = () => { + const handleSubmit = async () => { if (isPending) return - onSubmit({ rating, text: text.trim(), imageUrl }) + await onSubmit({ rating, text: text.trim(), imageUrl }) } return ( @@ -96,11 +98,19 @@ export function ReviewDialog({ hidden type="file" accept="image/png,image/jpeg,image/webp" - onChange={(e) => { + onChange={async (e) => { const file = e.target.files?.[0] if (!file) return - onUploadImage(file) e.currentTarget.value = '' + setLocalUploadError(null) + try { + const result = await onUploadImage(file) + setImageUrl(result.url) + } catch (err) { + setLocalUploadError( + err instanceof Error ? err.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.', + ) + } }} /> @@ -126,11 +136,13 @@ export function ReviewDialog({ }} /> )} - {uploadError ? ( + {uploadError || localUploadError ? ( - {uploadError instanceof Error - ? uploadError.message - : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} + {localUploadError + ? localUploadError + : uploadError instanceof Error + ? uploadError.message + : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} ) : null} {error ? ( diff --git a/client/src/features/product-review/ui/ReviewSection.tsx b/client/src/features/product-review/ui/ReviewSection.tsx index a9191e8..4cc1db4 100644 --- a/client/src/features/product-review/ui/ReviewSection.tsx +++ b/client/src/features/product-review/ui/ReviewSection.tsx @@ -17,7 +17,12 @@ type Props = { isUploadPending: boolean submitError: unknown uploadError: unknown - onSubmitReview: (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => void + onSubmitReview: (params: { + productId: string + rating: number + text: string + imageUrl: string | null + }) => Promise onUploadImage: (file: File) => Promise<{ url: string }> } @@ -75,17 +80,20 @@ export function ReviewSection({ setTarget(null) setUploadedImageUrl(null) }} - onSubmit={(params) => { + onSubmit={async (params) => { if (!target) return - onSubmitReview({ + await onSubmitReview({ productId: target.productId, ...params, imageUrl: uploadedImageUrl, }) + setTarget(null) + setUploadedImageUrl(null) }} onUploadImage={async (file) => { const result = await onUploadImage(file) setUploadedImageUrl(result.url) + return result }} /> diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 90ca7b9..6a18d72 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -9,6 +9,7 @@ import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' import { Controller, useForm } from 'react-hook-form' import { Link as RouterLink } from 'react-router-dom' import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api' @@ -16,6 +17,7 @@ import type { AdminUser } from '@/entities/user/model/types' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' +import { $user } from '@/shared/model/auth' import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog' import { AdminTable } from '@/shared/ui/AdminTable' import { EntityRowActions } from '@/shared/ui/EntityRowActions' @@ -44,6 +46,8 @@ export function AdminUsersPage() { const [q, setQ] = useState('') const [page, setPage] = useState(0) const [rowsPerPage, setRowsPerPage] = useState(20) + const currentUser = useUnit($user) + const currentUserId = currentUser?.id const userForm = useForm({ defaultValues: emptyUserForm(), @@ -192,7 +196,7 @@ export function AdminUsersPage() { openEdit(u)} - onDelete={() => deleteMut.mutate(u.id)} + onDelete={u.id === currentUserId ? undefined : () => deleteMut.mutate(u.id)} deleteDisabled={deleteMut.isPending} confirmDeleteMessage={`Удалить пользователя ${u.email}?`} /> @@ -237,7 +241,15 @@ export function AdminUsersPage() { } + render={({ field }) => ( + + )} /> ({ + orderCreated: on, + orderStatusChanged: on, + paymentStatusChanged: on, + deliveryFeeAdjusted: on, +}) export function NotificationsPage() { const queryClient = useQueryClient() @@ -45,9 +49,11 @@ export function NotificationsPage() { const handleToggle = (field: string, value: boolean) => { setError(null) - mutation.mutate({ [field]: value } as Record) + mutation.mutate({ [field]: value }) } + const statusChangesOn = isOrderStatusChangesOn(settings) + return ( @@ -80,19 +86,26 @@ export function NotificationsPage() { - {eventFields.map(({ key, label }) => ( - handleToggle(key, e.target.checked)} - /> - } - label={label} - /> - ))} + mutation.mutate(orderStatusChangesPayload(e.target.checked))} + /> + } + label="Изменения статуса заказа" + /> + handleToggle('orderMessageReceived', e.target.checked)} + /> + } + label="Сообщения в чате заказа" + /> diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index 353c515..ae3d180 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -7,7 +7,7 @@ import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' +import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { confirmOrderReceived, createOrderPayment, @@ -30,6 +30,7 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' export function OrderDetailPage() { const { id } = useParams() const qc = useQueryClient() + const navigate = useNavigate() const [searchParams] = useSearchParams() const paidParam = searchParams.get('paid') @@ -58,6 +59,14 @@ export function OrderDetailPage() { }, }) + useEffect(() => { + const data = paymentStatusQuery.data + if (data && (data.paid || data.status === 'canceled') && paidParam === '1') { + qc.invalidateQueries({ queryKey: ['me', 'orders', id] }) + navigate(`/me/orders/${id}`, { replace: true }) + } + }, [paymentStatusQuery.data, paidParam, qc, id, navigate]) + const confirmMut = useMutation({ mutationFn: () => confirmOrderReceived(id!), onSuccess: () => @@ -224,7 +233,7 @@ export function OrderDetailPage() { {PICKUP_ADDRESS_FULL} - Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа. + Заберите заказ ко времени, которое согласуем в чате заказа. )} @@ -274,7 +283,9 @@ export function OrderDetailPage() { isUploadPending={uploadReviewImageMut.isPending} submitError={reviewMut.error} uploadError={uploadReviewImageMut.error} - onSubmitReview={(params) => reviewMut.mutate(params)} + onSubmitReview={async (params) => { + await reviewMut.mutateAsync(params) + }} onUploadImage={async (file) => { const result = await uploadReviewImageMut.mutateAsync(file) return result diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 5d204db..d5a463b 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -96,7 +96,7 @@ export function ReviewsBlock() { { const { id } = request.params const body = request.body ?? {} + const adminUserId = request.user.sub const existing = await prisma.user.findUnique({ where: { id } }) if (!existing) { @@ -99,9 +100,15 @@ export async function registerAdminUserRoutes(fastify) { return } + const isSelf = id === adminUserId + const data = {} if (body.email !== undefined) { + if (isSelf) { + reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' }) + return + } const email = normalizeEmail(body.email) if (!email || !email.includes('@')) { reply.code(400).send({ error: 'Некорректная почта' }) @@ -139,6 +146,13 @@ export async function registerAdminUserRoutes(fastify) { fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { const { id } = request.params + const adminUserId = request.user.sub + + if (id === adminUserId) { + reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' }) + return + } + try { await prisma.user.delete({ where: { id } }) reply.code(204).send() diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index b432d1c..136453b 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -29,7 +29,7 @@ export async function registerUserCartRoutes(fastify) { const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.inStock ? product.quantity : 1 + const 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} шт.` }) @@ -57,7 +57,7 @@ export async function registerUserCartRoutes(fastify) { return reply.code(204).send() } - const available = existing.product.inStock ? existing.product.quantity : 1 + const available = existing.product.quantity const nextQty = Math.floor(qty) if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 0e20eb1..8ced32d 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -65,7 +65,7 @@ export async function registerUserOrderRoutes(fastify) { if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) for (const ci of cartItems) { - const available = ci.product.inStock ? ci.product.quantity : 1 + const available = ci.product.quantity if (ci.qty > available) { return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, @@ -112,8 +112,6 @@ export async function registerUserOrderRoutes(fastify) { try { created = await prisma.$transaction(async (tx) => { for (const ci of cartItems) { - if (!ci.product.inStock) continue - const res = await tx.product.updateMany({ where: { id: ci.productId, quantity: { gte: ci.qty } }, data: { quantity: { decrement: ci.qty } },