From bfc9661d22903feaa947f9521e396ee4786ed7d9 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Wed, 29 Apr 2026 19:14:34 +0500 Subject: [PATCH] base commit --- client/src/app/App.tsx | 8 + client/src/app/layout/AppHeader.tsx | 1 + client/src/entities/cart/api/cart-api.ts | 21 ++ client/src/entities/cart/model/types.ts | 7 + .../src/entities/order/api/admin-order-api.ts | 84 +++++ client/src/entities/order/api/order-api.ts | 62 ++++ .../src/entities/product/ui/ProductCard.tsx | 24 ++ .../entities/review/api/admin-review-api.ts | 40 +++ client/src/pages/admin-orders/index.ts | 1 + .../pages/admin-orders/ui/AdminOrdersPage.tsx | 301 ++++++++++++++++++ client/src/pages/admin-reviews/index.ts | 1 + .../admin-reviews/ui/AdminReviewsPage.tsx | 153 +++++++++ client/src/pages/admin/ui/AdminPage.tsx | 6 + client/src/pages/cart/index.ts | 1 + client/src/pages/cart/ui/CartPage.tsx | 127 ++++++++ client/src/pages/checkout/index.ts | 1 + client/src/pages/checkout/ui/CheckoutPage.tsx | 132 ++++++++ client/src/pages/me/ui/MeLayoutPage.tsx | 2 + .../pages/me/ui/sections/OrderDetailPage.tsx | 180 +++++++++++ .../src/pages/me/ui/sections/OrdersPage.tsx | 52 ++- client/src/pages/product/ui/ProductPage.tsx | 26 +- .../migration.sql | 87 +++++ server/prisma/schema.prisma | 90 ++++++ server/src/routes/api.js | 247 +++++++++++++- server/src/routes/auth.js | 234 ++++++++++++++ 25 files changed, 1885 insertions(+), 3 deletions(-) create mode 100644 client/src/entities/cart/api/cart-api.ts create mode 100644 client/src/entities/cart/model/types.ts create mode 100644 client/src/entities/order/api/admin-order-api.ts create mode 100644 client/src/entities/order/api/order-api.ts create mode 100644 client/src/entities/review/api/admin-review-api.ts create mode 100644 client/src/pages/admin-orders/index.ts create mode 100644 client/src/pages/admin-orders/ui/AdminOrdersPage.tsx create mode 100644 client/src/pages/admin-reviews/index.ts create mode 100644 client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx create mode 100644 client/src/pages/cart/index.ts create mode 100644 client/src/pages/cart/ui/CartPage.tsx create mode 100644 client/src/pages/checkout/index.ts create mode 100644 client/src/pages/checkout/ui/CheckoutPage.tsx create mode 100644 client/src/pages/me/ui/sections/OrderDetailPage.tsx create mode 100644 server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 979859e..342aca6 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -2,8 +2,12 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AppProviders } from '@/app/providers/AppProviders' import { AdminPage } from '@/pages/admin' +import { AdminOrdersPage } from '@/pages/admin-orders' +import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminUsersPage } from '@/pages/admin-users' import { AuthPage } from '@/pages/auth' +import { CartPage } from '@/pages/cart' +import { CheckoutPage } from '@/pages/checkout' import { HomePage } from '@/pages/home' import { MeLayoutPage } from '@/pages/me' import { ProductPage } from '@/pages/product' @@ -16,8 +20,12 @@ export function App() { } /> } /> + } /> + } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 6366586..716a24d 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -33,6 +33,7 @@ type NavItem = { label: string; to: string } const navItems: NavItem[] = [ { label: 'Каталог', to: '/' }, + { label: 'Корзина', to: '/cart' }, { label: 'Админка', to: '/admin' }, ] diff --git a/client/src/entities/cart/api/cart-api.ts b/client/src/entities/cart/api/cart-api.ts new file mode 100644 index 0000000..d509ca8 --- /dev/null +++ b/client/src/entities/cart/api/cart-api.ts @@ -0,0 +1,21 @@ +import type { CartItem } from '@/entities/cart/model/types' +import { apiClient } from '@/shared/api/client' + +export type CartResponse = { items: CartItem[] } + +export async function fetchMyCart(): Promise { + const { data } = await apiClient.get('me/cart') + return data +} + +export async function addToCart(body: { productId: string; qty?: number }): Promise { + await apiClient.post('me/cart/items', body) +} + +export async function setCartQty(id: string, qty: number): Promise { + await apiClient.patch(`me/cart/items/${id}`, { qty }) +} + +export async function removeCartItem(id: string): Promise { + await apiClient.delete(`me/cart/items/${id}`) +} diff --git a/client/src/entities/cart/model/types.ts b/client/src/entities/cart/model/types.ts new file mode 100644 index 0000000..a64b3bc --- /dev/null +++ b/client/src/entities/cart/model/types.ts @@ -0,0 +1,7 @@ +import type { Product } from '@/entities/product/model/types' + +export type CartItem = { + id: string + qty: number + product: Product +} diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts new file mode 100644 index 0000000..23d0721 --- /dev/null +++ b/client/src/entities/order/api/admin-order-api.ts @@ -0,0 +1,84 @@ +import { apiClient } from '@/shared/api/client' + +export type AdminOrderListItem = { + id: string + status: string + totalCents: number + currency: string + createdAt: string + updatedAt: string + user: { id: string; email: string } + itemsCount: number +} + +export type AdminOrdersListResponse = { + items: AdminOrderListItem[] + total: number + page: number + pageSize: number +} + +export type AdminOrderDetailResponse = { + item: { + id: string + status: string + totalCents: number + currency: string + addressSnapshotJson: string + comment: string | null + createdAt: string + updatedAt: string + user: { id: string; email: string; name: string | null; phone: string | null } + items: Array<{ + id: string + productId: string + qty: number + titleSnapshot: string + priceCentsSnapshot: number + }> + messages: Array<{ + id: string + authorType: string + text: string + createdAt: string + }> + } +} + +export async function fetchAdminOrders( + token: string, + params?: { status?: string; q?: string; page?: number; pageSize?: number }, +): Promise { + const { data } = await apiClient.get('admin/orders', { + params, + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function fetchAdminOrder(token: string, id: string): Promise { + const { data } = await apiClient.get(`admin/orders/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function setAdminOrderStatus(token: string, id: string, status: string): Promise { + await apiClient.patch( + `admin/orders/${id}/status`, + { status }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ) +} + +export async function postAdminOrderMessage(token: string, id: string, text: string): Promise { + await apiClient.post( + `admin/orders/${id}/messages`, + { text }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ) +} diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts new file mode 100644 index 0000000..3d02f52 --- /dev/null +++ b/client/src/entities/order/api/order-api.ts @@ -0,0 +1,62 @@ +import { apiClient } from '@/shared/api/client' + +export type OrderListItem = { + id: string + status: string + totalCents: number + currency: string + createdAt: string + updatedAt: string + itemsCount: number +} + +export type OrderListResponse = { items: OrderListItem[] } + +export type OrderDetailResponse = { + item: { + id: string + status: string + totalCents: number + currency: string + addressSnapshotJson: string + comment: string | null + createdAt: string + updatedAt: string + items: Array<{ + id: string + productId: string + qty: number + titleSnapshot: string + priceCentsSnapshot: number + }> + messages: Array<{ + id: string + authorType: string + text: string + createdAt: string + }> + } +} + +export async function createOrder(body: { addressId: string; comment?: string | null }): Promise<{ orderId: string }> { + const { data } = await apiClient.post<{ orderId: string }>('me/orders', body) + return data +} + +export async function fetchMyOrders(): Promise { + const { data } = await apiClient.get('me/orders') + return data +} + +export async function fetchMyOrder(id: string): Promise { + const { data } = await apiClient.get(`me/orders/${id}`) + return data +} + +export async function postOrderMessage(id: string, text: string): Promise { + await apiClient.post(`me/orders/${id}/messages`, { text }) +} + +export async function payOrderStub(id: string): Promise { + await apiClient.post(`me/orders/${id}/pay`) +} diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index b85f804..644267a 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -1,4 +1,5 @@ import { useMemo, useRef } from 'react' +import Button from '@mui/material/Button' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import CardMedia from '@mui/material/CardMedia' @@ -7,16 +8,22 @@ import Box from '@mui/material/Box' import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { Swiper, SwiperSlide } from 'swiper/react' import type { Swiper as SwiperType } from 'swiper/types' import 'swiper/css' import { Link as RouterLink } from 'react-router-dom' +import { useUnit } from 'effector-react' +import { addToCart } from '@/entities/cart/api/cart-api' import type { Product } from '@/entities/product/model/types' import { formatPriceRub } from '@/shared/lib/format-price' +import { $user } from '@/shared/model/auth' type Props = { product: Product; mediaHeight?: number } export function ProductCard({ product, mediaHeight = 200 }: Props) { + const qc = useQueryClient() + const user = useUnit($user) const swiperRef = useRef(null) const imageUrls = useMemo(() => { const fromImages = (product.images ?? []) @@ -39,6 +46,11 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) { swiperRef.current.slideTo(idx, 0) } + const addMut = useMutation({ + mutationFn: () => addToCart({ productId: product.id, qty: 1 }), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), + }) + return ( {formatPriceRub(product.priceCents)} + diff --git a/client/src/entities/review/api/admin-review-api.ts b/client/src/entities/review/api/admin-review-api.ts new file mode 100644 index 0000000..07587a8 --- /dev/null +++ b/client/src/entities/review/api/admin-review-api.ts @@ -0,0 +1,40 @@ +import { apiClient } from '@/shared/api/client' + +export type AdminReview = { + id: string + rating: number + text: string | null + status: string + createdAt: string + moderatedAt: string | null + user: { id: string; email: string; name: string | null } + product: { id: string; title: string } +} + +export type AdminReviewsListResponse = { + items: AdminReview[] + total: number + page: number + pageSize: number +} + +export async function fetchAdminReviews( + token: string, + params?: { status?: string; page?: number; pageSize?: number }, +): Promise { + const { data } = await apiClient.get('admin/reviews', { + params, + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise { + await apiClient.patch( + `admin/reviews/${id}`, + { action }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ) +} diff --git a/client/src/pages/admin-orders/index.ts b/client/src/pages/admin-orders/index.ts new file mode 100644 index 0000000..b59d4cd --- /dev/null +++ b/client/src/pages/admin-orders/index.ts @@ -0,0 +1 @@ +export { AdminOrdersPage } from './ui/AdminOrdersPage' diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx new file mode 100644 index 0000000..979e385 --- /dev/null +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import 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 Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' +import { + fetchAdminOrder, + fetchAdminOrders, + postAdminOrderMessage, + setAdminOrderStatus, +} from '@/entities/order/api/admin-order-api' +import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' +import { formatPriceRub } from '@/shared/lib/format-price' + +type TokenFormState = { token: string } + +export function AdminOrdersPage() { + const qc = useQueryClient() + const [token, setTokenState] = useState(() => getAdminToken()) + const [q, setQ] = useState('') + const [status, setStatus] = useState('') + const [dialogOpen, setDialogOpen] = useState(false) + const [selectedId, setSelectedId] = useState(null) + const [msg, setMsg] = useState('') + + const tokenForm = useForm({ defaultValues: { token: '' }, mode: 'onChange' }) + + useEffect(() => { + tokenForm.reset({ token: '' }) + }, [token, tokenForm]) + + const saveToken = () => { + const t = tokenForm.getValues('token').trim() + if (!t) { + clearAdminToken() + setTokenState(null) + return + } + setAdminToken(t) + setTokenState(t) + } + + const ordersQuery = useQuery({ + queryKey: ['admin', 'orders', token, { q, status }], + queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }), + enabled: Boolean(token), + }) + + const orderDetailQuery = useQuery({ + queryKey: ['admin', 'orders', 'detail', token, selectedId], + queryFn: () => fetchAdminOrder(token!, selectedId!), + enabled: Boolean(token && selectedId), + }) + + const statusMut = useMutation({ + mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ['admin', 'orders'] }) + await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) + }, + }) + + const msgMut = useMutation({ + mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()), + onSuccess: async () => { + setMsg('') + await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) + }, + }) + + const open = (id: string) => { + setSelectedId(id) + setDialogOpen(true) + } + + const items = ordersQuery.data?.items ?? [] + + const detail = orderDetailQuery.data?.item + + const nextStatuses = useMemo(() => { + const s = detail?.status + if (!s) return [] + const map: Record = { + DRAFT: ['PENDING_PAYMENT', 'CANCELLED'], + PENDING_PAYMENT: ['PAID', 'CANCELLED'], + PAID: ['IN_PROGRESS', 'CANCELLED'], + IN_PROGRESS: ['SHIPPED', 'CANCELLED'], + SHIPPED: ['DONE'], + } + return map[s] ?? [] + }, [detail?.status]) + + return ( + + + + Админка — заказы + + + + + + + Введите API-токен из ADMIN_API_TOKEN (сохраняется в sessionStorage). + + + + ( + + )} + /> + + + + {!token && После сохранения токена появится список заказов.} + + {token && ( + <> + + setQ(e.target.value)} + fullWidth + /> + + Статус + + + + + {ordersQuery.isError && Не удалось загрузить заказы.} + + + + + ID + Покупатель + Статус + Сумма + Позиций + Действия + + + + {items.map((o) => ( + + {o.id.slice(-8)} + {o.user.email} + {o.status} + {formatPriceRub(o.totalCents)} + {o.itemsCount} + + + + + ))} + {ordersQuery.isSuccess && items.length === 0 && ( + + + Заказов пока нет. + + + )} + +
+ + )} + + setDialogOpen(false)} fullWidth maxWidth="md"> + Заказ + + {!detail && orderDetailQuery.isLoading && Загрузка…} + {orderDetailQuery.isError && Не удалось загрузить заказ.} + {detail && ( + + + #{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)} + + + + + Сменить статус + + + + + + + Сообщения + + + {detail.messages.map((m) => ( + + + {m.authorType} · {new Date(m.createdAt).toLocaleString()} + + {m.text} + + ))} + {detail.messages.length === 0 && Нет сообщений.} + + + + setMsg(e.target.value)} + fullWidth + multiline + minRows={2} + /> + + + + + )} + + + + + +
+ ) +} diff --git a/client/src/pages/admin-reviews/index.ts b/client/src/pages/admin-reviews/index.ts new file mode 100644 index 0000000..2db5408 --- /dev/null +++ b/client/src/pages/admin-reviews/index.ts @@ -0,0 +1 @@ +export { AdminReviewsPage } from './ui/AdminReviewsPage' diff --git a/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx b/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx new file mode 100644 index 0000000..abe33a8 --- /dev/null +++ b/client/src/pages/admin-reviews/ui/AdminReviewsPage.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import Stack from '@mui/material/Stack' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' +import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api' +import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' + +type TokenFormState = { token: string } + +export function AdminReviewsPage() { + const qc = useQueryClient() + const [token, setTokenState] = useState(() => getAdminToken()) + const tokenForm = useForm({ defaultValues: { token: '' }, mode: 'onChange' }) + + useEffect(() => { + tokenForm.reset({ token: '' }) + }, [token, tokenForm]) + + const saveToken = () => { + const t = tokenForm.getValues('token').trim() + if (!t) { + clearAdminToken() + setTokenState(null) + return + } + setAdminToken(t) + setTokenState(t) + } + + const reviewsQuery = useQuery({ + queryKey: ['admin', 'reviews', token], + queryFn: () => fetchAdminReviews(token!, { status: 'pending', page: 1, pageSize: 50 }), + enabled: Boolean(token), + }) + + const modMut = useMutation({ + mutationFn: (params: { id: string; action: 'approve' | 'reject' }) => + moderateReview(token!, params.id, params.action), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin', 'reviews'] }), + }) + + const error = modMut.error + + const items = reviewsQuery.data?.items ?? [] + + return ( + + + + Админка — отзывы + + + + + + + Введите API-токен из ADMIN_API_TOKEN. + + + + ( + + )} + /> + + + + {!token && После сохранения токена появится список отзывов на модерации.} + + {token && ( + <> + {reviewsQuery.isError && Не удалось загрузить отзывы.} + {error && {(error as Error).message}} + + + + + Товар + Пользователь + Оценка + Текст + Действия + + + + {items.map((r) => ( + + {r.product.title} + {r.user.email} + + + + {r.text ?? '—'} + + + + + + ))} + {reviewsQuery.isSuccess && items.length === 0 && ( + + + Нет отзывов на модерации. + + + )} + +
+ + )} +
+ ) +} diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index 42c0c58..c6b86d4 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -328,6 +328,12 @@ export function AdminPage() { + + diff --git a/client/src/pages/cart/index.ts b/client/src/pages/cart/index.ts new file mode 100644 index 0000000..dd6301c --- /dev/null +++ b/client/src/pages/cart/index.ts @@ -0,0 +1 @@ +export { CartPage } from './ui/CartPage' diff --git a/client/src/pages/cart/ui/CartPage.tsx b/client/src/pages/cart/ui/CartPage.tsx new file mode 100644 index 0000000..e0bf381 --- /dev/null +++ b/client/src/pages/cart/ui/CartPage.tsx @@ -0,0 +1,127 @@ +import AddIcon from '@mui/icons-material/Add' +import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' +import RemoveIcon from '@mui/icons-material/Remove' +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 IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { Link as RouterLink } from 'react-router-dom' +import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api' +import { $user } from '@/shared/model/auth' +import { formatPriceRub } from '@/shared/lib/format-price' + +export function CartPage() { + const user = useUnit($user) + const qc = useQueryClient() + + const cartQuery = useQuery({ + queryKey: ['me', 'cart'], + queryFn: fetchMyCart, + enabled: Boolean(user), + }) + + const qtyMut = useMutation({ + mutationFn: (params: { id: string; qty: number }) => setCartQty(params.id, params.qty), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), + }) + + const removeMut = useMutation({ + mutationFn: (id: string) => removeCartItem(id), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), + }) + + if (!user) { + return ( + + Чтобы пользоваться корзиной, нужно войти. Перейдите на страницу{' '} + + Вход + + . + + ) + } + + const items = cartQuery.data?.items ?? [] + const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0) + + return ( + + + Корзина + + + {cartQuery.isError && Не удалось загрузить корзину.} + + {cartQuery.isSuccess && items.length === 0 && Корзина пуста.} + + {items.length > 0 && ( + + {items.map((x) => ( + + + + {x.product.title} + + {formatPriceRub(x.product.priceCents)} · {x.qty} шт. + + + + + qtyMut.mutate({ id: x.id, qty: Math.max(0, x.qty - 1) })} + disabled={qtyMut.isPending} + aria-label="Уменьшить количество" + > + + + {x.qty} + qtyMut.mutate({ id: x.id, qty: x.qty + 1 })} + disabled={qtyMut.isPending} + aria-label="Увеличить количество" + > + + + + removeMut.mutate(x.id)} + disabled={removeMut.isPending} + aria-label="Удалить" + > + + + + + + ))} + + + + + + Итого: {formatPriceRub(total)} + + + + + )} + + ) +} diff --git a/client/src/pages/checkout/index.ts b/client/src/pages/checkout/index.ts new file mode 100644 index 0000000..bfa90f1 --- /dev/null +++ b/client/src/pages/checkout/index.ts @@ -0,0 +1 @@ +export { CheckoutPage } from './ui/CheckoutPage' diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx new file mode 100644 index 0000000..29838e9 --- /dev/null +++ b/client/src/pages/checkout/ui/CheckoutPage.tsx @@ -0,0 +1,132 @@ +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { Link as RouterLink, useNavigate } from 'react-router-dom' +import { useUnit } from 'effector-react' +import { fetchMyCart } from '@/entities/cart/api/cart-api' +import { createOrder } from '@/entities/order/api/order-api' +import { fetchMyAddresses } from '@/entities/user/api/address-api' +import { formatPriceRub } from '@/shared/lib/format-price' +import { $user } from '@/shared/model/auth' + +export function CheckoutPage() { + const user = useUnit($user) + const qc = useQueryClient() + const navigate = useNavigate() + const [addressId, setAddressId] = useState('') + const [comment, setComment] = useState('') + + const cartQuery = useQuery({ + queryKey: ['me', 'cart'], + queryFn: fetchMyCart, + enabled: Boolean(user), + }) + + const addressesQuery = useQuery({ + queryKey: ['me', 'addresses'], + queryFn: fetchMyAddresses, + enabled: Boolean(user), + }) + + const createMut = useMutation({ + mutationFn: () => createOrder({ addressId, comment: comment.trim() || null }), + onSuccess: async (res) => { + await qc.invalidateQueries({ queryKey: ['me', 'cart'] }) + navigate(`/me/orders/${res.orderId}`, { replace: true }) + }, + }) + + if (!user) { + return ( + + Чтобы оформить заказ, нужно войти. Перейдите на страницу{' '} + + Вход + + . + + ) + } + + const items = cartQuery.data?.items ?? [] + const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0) + const addresses = addressesQuery.data?.items ?? [] + + const defaultAddr = addresses.find((a) => a.isDefault) + + return ( + + + Оформление заказа + + + {cartQuery.isSuccess && items.length === 0 && ( + + Корзина пуста. Вернитесь в{' '} + + каталог + + . + + )} + + + + Адрес доставки + + + + {addresses.length === 0 && ( + + У вас нет адресов доставки. Добавьте адрес в{' '} + + кабинете + + . + + )} + + setComment(e.target.value)} + fullWidth + multiline + minRows={2} + /> + + Итого: {formatPriceRub(total)} + + + + {createMut.isError && {(createMut.error as Error).message}} + + + ) +} diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 2b8ae20..d362101 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -22,6 +22,7 @@ import { useUnit } from 'effector-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage' import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage' +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' @@ -125,6 +126,7 @@ export function MeLayoutPage() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx new file mode 100644 index 0000000..f5cb979 --- /dev/null +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -0,0 +1,180 @@ +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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { Link as RouterLink, useParams } from 'react-router-dom' +import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api' +import { formatPriceRub } from '@/shared/lib/format-price' + +type AddressSnapshot = { + label?: string | null + recipientName?: string + recipientPhone?: string + addressLine?: string + comment?: string | null +} + +export function OrderDetailPage() { + const { id } = useParams() + const qc = useQueryClient() + const [text, setText] = useState('') + + const orderQuery = useQuery({ + queryKey: ['me', 'orders', id], + queryFn: () => fetchMyOrder(id!), + enabled: Boolean(id), + }) + + const payMut = useMutation({ + mutationFn: () => payOrderStub(id!), + onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', id] }), + }) + + const msgMut = useMutation({ + mutationFn: () => postOrderMessage(id!, text.trim()), + onSuccess: async () => { + setText('') + await qc.invalidateQueries({ queryKey: ['me', 'orders', id] }) + }, + }) + + const order = orderQuery.data?.item + + const address = useMemo((): AddressSnapshot | null => { + if (!order?.addressSnapshotJson) return null + try { + return JSON.parse(order.addressSnapshotJson) as AddressSnapshot + } catch { + return null + } + }, [order]) + + if (!id) return Некорректный заказ. + if (orderQuery.isLoading) return Загрузка… + if (orderQuery.isError || !order) return Не удалось загрузить заказ. + + return ( + + + + Заказ #{order.id.slice(-6)} + Статус: {order.status} + + + + + + + + Позиции + + + {order.items.map((i) => ( + + + {i.titleSnapshot} + + {i.qty} × {formatPriceRub(i.priceCentsSnapshot)} + + + {formatPriceRub(i.priceCentsSnapshot * i.qty)} + + ))} + + + Итого: {formatPriceRub(order.totalCents)} + + + + + Доставка + + {address ? ( + <> + {address.addressLine} + + Получатель: {address.recipientName} · {address.recipientPhone} + + {address.comment && ( + + Комментарий: {address.comment} + + )} + + ) : ( + Адрес не распознан. + )} + {order.comment && ( + + Комментарий к заказу: {order.comment} + + )} + + + + + Оплата + + + Пока это заглушка. Позже подключим реальную оплату. + + + + + + + Чат по заказу + + + {order.messages.map((m) => ( + + + {m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()} + + {m.text} + + ))} + {order.messages.length === 0 && Пока сообщений нет.} + + + + setText(e.target.value)} + fullWidth + multiline + minRows={2} + /> + + + + + + ) +} diff --git a/client/src/pages/me/ui/sections/OrdersPage.tsx b/client/src/pages/me/ui/sections/OrdersPage.tsx index ad86416..5c27168 100644 --- a/client/src/pages/me/ui/sections/OrdersPage.tsx +++ b/client/src/pages/me/ui/sections/OrdersPage.tsx @@ -1,13 +1,63 @@ +import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' +import { useQuery } from '@tanstack/react-query' +import { Link as RouterLink } from 'react-router-dom' +import { fetchMyOrders } from '@/entities/order/api/order-api' +import { formatPriceRub } from '@/shared/lib/format-price' export function OrdersPage() { + const ordersQuery = useQuery({ + queryKey: ['me', 'orders'], + queryFn: fetchMyOrders, + }) + + const items = ordersQuery.data?.items ?? [] + return ( Заказы - Скоро здесь появится история заказов. + + {ordersQuery.isError && Не удалось загрузить заказы.} + + {ordersQuery.isSuccess && items.length === 0 && ( + Заказов пока нет. Оформите заказ из корзины. + )} + + + {items.map((o) => ( + + + + Заказ #{o.id.slice(-6)} + + Статус: {o.status} · {o.itemsCount} поз. + + + {formatPriceRub(o.totalCents)} + + + + + + + ))} + ) } diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index cf396cb..6d9c0dc 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -2,22 +2,28 @@ import { useMemo, useState } from 'react' import CloseIcon from '@mui/icons-material/Close' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' +import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' import Dialog from '@mui/material/Dialog' import IconButton from '@mui/material/IconButton' import Skeleton from '@mui/material/Skeleton' import Typography from '@mui/material/Typography' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useParams } from 'react-router-dom' import { Swiper, SwiperSlide } from 'swiper/react' import { Navigation } from 'swiper/modules' import 'swiper/css' import 'swiper/css/navigation' +import { useUnit } from 'effector-react' +import { addToCart } from '@/entities/cart/api/cart-api' import { fetchPublicProduct } from '@/entities/product/api/product-api' import { formatPriceRub } from '@/shared/lib/format-price' +import { $user } from '@/shared/model/auth' export function ProductPage() { const { id } = useParams() + const qc = useQueryClient() + const user = useUnit($user) const [viewerOpen, setViewerOpen] = useState(false) const [viewerIndex, setViewerIndex] = useState(0) @@ -27,6 +33,15 @@ export function ProductPage() { enabled: Boolean(id), }) + const addMut = useMutation({ + mutationFn: async () => { + const pid = productQuery.data?.id + if (!pid) throw new Error('Товар ещё не загружен') + await addToCart({ productId: pid, qty: 1 }) + }, + onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), + }) + const imageUrls = useMemo(() => { const p = productQuery.data if (!p) return [] @@ -136,6 +151,15 @@ export function ProductPage() { {formatPriceRub(p.priceCents)} + + {p.description ? ( {p.description} ) : ( diff --git a/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql b/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql new file mode 100644 index 0000000..a4c1291 --- /dev/null +++ b/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql @@ -0,0 +1,87 @@ +-- CreateTable +CREATE TABLE "CartItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "qty" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + CONSTRAINT "CartItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL PRIMARY KEY, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "totalCents" INTEGER NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'RUB', + "addressSnapshotJson" TEXT NOT NULL, + "comment" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "qty" INTEGER NOT NULL, + "titleSnapshot" TEXT NOT NULL, + "priceCentsSnapshot" INTEGER NOT NULL, + "orderId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "OrderMessage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "authorType" TEXT NOT NULL, + "text" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "orderId" TEXT NOT NULL, + CONSTRAINT "OrderMessage_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Review" ( + "id" TEXT NOT NULL PRIMARY KEY, + "rating" INTEGER NOT NULL, + "text" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "moderatedAt" DATETIME, + "productId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "CartItem_userId_idx" ON "CartItem"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CartItem_userId_productId_key" ON "CartItem"("userId", "productId"); + +-- CreateIndex +CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId"); + +-- CreateIndex +CREATE INDEX "OrderMessage_orderId_createdAt_idx" ON "OrderMessage"("orderId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Review_productId_status_createdAt_idx" ON "Review"("productId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "Review_status_createdAt_idx" ON "Review"("status", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Review_productId_userId_key" ON "Review"("productId", "userId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 90fc11f..bf4a8a3 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -38,6 +38,9 @@ model Product { updatedAt DateTime @updatedAt images ProductImage[] + reviews Review[] + orderItems OrderItem[] + cartItems CartItem[] } model ProductImage { @@ -63,6 +66,93 @@ model User { codes AuthCode[] addresses ShippingAddress[] + cartItems CartItem[] + orders Order[] + reviews Review[] +} + +model CartItem { + id String @id @default(cuid()) + qty Int + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String + + @@unique([userId, productId]) + @@index([userId]) +} + +model Order { + id String @id @default(cuid()) + /// Статус заказа (валидация переходов на уровне API) + status String @default("DRAFT") + totalCents Int @default(0) + currency String @default("RUB") + addressSnapshotJson String + comment String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + items OrderItem[] + messages OrderMessage[] + + @@index([userId, createdAt]) + @@index([status, updatedAt]) +} + +model OrderItem { + id String @id @default(cuid()) + qty Int + titleSnapshot String + priceCentsSnapshot Int + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + orderId String + + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + productId String + + @@index([orderId]) +} + +model OrderMessage { + id String @id @default(cuid()) + /// 'user' | 'admin' + authorType String + text String + createdAt DateTime @default(now()) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + orderId String + + @@index([orderId, createdAt]) +} + +model Review { + id String @id @default(cuid()) + rating Int + text String? + /// 'pending' | 'approved' | 'rejected' + status String @default("pending") + createdAt DateTime @default(now()) + moderatedAt DateTime? + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + @@index([productId, status, createdAt]) + @@index([status, createdAt]) + @@unique([productId, userId]) } model ShippingAddress { diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 8702ed3..b8c1a66 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -60,7 +60,7 @@ export async function registerApiRoutes(fastify) { return prisma.category.findMany({ orderBy: { sort: 'asc' } }) }) - fastify.get('/api/products', async (request) => { + fastify.get('/api/products', async (request, reply) => { const { categorySlug } = request.query const qRaw = request.query?.q const q = typeof qRaw === 'string' ? qRaw.trim() : '' @@ -141,6 +141,71 @@ export async function registerApiRoutes(fastify) { return mapProductForApi(product) }) + // ---- Отзывы к товарам ---- + + fastify.get('/api/products/:id/reviews', async (request, reply) => { + const { id } = request.params + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 10 + if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' }) + + const product = await prisma.product.findFirst({ where: { id, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const where = { productId: id, status: 'approved' } + const total = await prisma.review.count({ where }) + const items = await prisma.review.findMany({ + where, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + return { items, total, page, pageSize } + }) + + fastify.post( + '/api/products/:id/reviews', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id: productId } = request.params + + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const rating = Number(request.body?.rating) + if (!Number.isFinite(rating) || rating < 1 || rating > 5) { + return reply.code(400).send({ error: 'rating должен быть от 1 до 5' }) + } + const textRaw = request.body?.text + const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() + if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) + + try { + const created = await prisma.review.create({ + data: { + productId, + userId, + rating: Math.floor(rating), + text: text && text.length ? text : null, + status: 'pending', + }, + }) + return reply.code(201).send({ item: created }) + } catch { + return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' }) + } + }, + ) + // ---- Админ (тот же фронт, другой раздел) ---- fastify.get( @@ -423,6 +488,186 @@ export async function registerApiRoutes(fastify) { }, ) + // ---- Админ: заказы ---- + + function canTransition(from, to) { + if (from === to) return true + const allowed = { + DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']), + PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']), + PAID: new Set(['IN_PROGRESS', 'CANCELLED']), + IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']), + SHIPPED: new Set(['DONE']), + DONE: new Set([]), + CANCELLED: new Set([]), + } + return Boolean(allowed[from]?.has(to)) + } + + fastify.get( + '/api/admin/orders', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' + const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + + const where = {} + if (status) where.status = status + if (q) { + where.OR = [ + { id: { contains: q } }, + { user: { email: { contains: q } } }, + ] + } + + const total = await prisma.order.count({ where }) + const items = await prisma.order.findMany({ + where, + include: { user: { select: { id: true, email: true } }, items: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + return { + items: items.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + user: o.user, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + total, + page, + pageSize, + } + }, + ) + + fastify.get( + '/api/admin/orders/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const order = await prisma.order.findUnique({ + where: { id }, + include: { + user: { select: { id: true, email: true, name: true, phone: true } }, + items: true, + messages: { orderBy: { createdAt: 'asc' } }, + }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + }, + ) + + fastify.patch( + '/api/admin/orders/:id/status', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const next = String(request.body?.status || '').trim() + if (!next) return reply.code(400).send({ error: 'status обязателен' }) + + const existing = await prisma.order.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + if (!canTransition(existing.status, next)) { + return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` }) + } + + const updated = await prisma.order.update({ where: { id }, data: { status: next } }) + return { item: updated } + }, + ) + + fastify.post( + '/api/admin/orders/:id/messages', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + + const order = await prisma.order.findUnique({ where: { id } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } }) + return reply.code(201).send({ item: msg }) + }, + ) + + // ---- Админ: отзывы (модерация) ---- + + fastify.get( + '/api/admin/reviews', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + + const where = status ? { status } : {} + const total = await prisma.review.count({ where }) + const items = await prisma.review.findMany({ + where, + include: { + user: { select: { id: true, email: true, name: true } }, + product: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + return { items, total, page, pageSize } + }, + ) + + fastify.patch( + '/api/admin/reviews/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const action = String(request.body?.action || '').trim() + if (action !== 'approve' && action !== 'reject') { + return reply.code(400).send({ error: 'action должен быть approve или reject' }) + } + + const existing = await prisma.review.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) + + const updated = await prisma.review.update({ + where: { id }, + data: { + status: action === 'approve' ? 'approved' : 'rejected', + moderatedAt: new Date(), + }, + }) + return { item: updated } + }, + ) + // ---- Админ: пользователи ---- fastify.get( diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 76adc64..fa43bbc 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -355,5 +355,239 @@ export async function registerAuthRoutes(fastify) { return { item: updated } }, ) + + // ---- Корзина ---- + + fastify.get( + '/api/me/cart', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const items = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, + orderBy: { createdAt: 'asc' }, + }) + return { + items: items.map((x) => ({ + id: x.id, + qty: x.qty, + product: x.product, + })), + } + }, + ) + + fastify.post( + '/api/me/cart/items', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const productId = String(request.body?.productId || '').trim() + const qtyRaw = request.body?.qty + const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) + + if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) + if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) + + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const item = await prisma.cartItem.upsert({ + where: { userId_productId: { userId, productId } }, + update: { qty: { increment: Math.floor(qty) } }, + create: { userId, productId, qty: Math.floor(qty) }, + }) + return reply.code(201).send({ item }) + }, + ) + + fastify.patch( + '/api/me/cart/items/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const qtyRaw = request.body?.qty + const qty = Number(qtyRaw) + if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) + + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + + if (qty === 0) { + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } + + const updated = await prisma.cartItem.update({ where: { id }, data: { qty: Math.floor(qty) } }) + return { item: updated } + }, + ) + + fastify.delete( + '/api/me/cart/items/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + }, + ) + + // ---- Заказы (checkout) ---- + + fastify.post( + '/api/me/orders', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const addressId = String(request.body?.addressId || '').trim() + const commentRaw = request.body?.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + + if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) + + const address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId } }) + if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) + + const cartItems = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: true }, + }) + if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) + + const itemsPayload = cartItems.map((ci) => ({ + productId: ci.productId, + qty: ci.qty, + titleSnapshot: ci.product.title, + priceCentsSnapshot: ci.product.priceCents, + })) + + const totalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) + const addressSnapshotJson = JSON.stringify({ + id: address.id, + label: address.label, + recipientName: address.recipientName, + recipientPhone: address.recipientPhone, + addressLine: address.addressLine, + comment: address.comment, + lat: address.lat, + lng: address.lng, + }) + + const created = await prisma.$transaction(async (tx) => { + const order = await tx.order.create({ + data: { + userId, + status: 'PENDING_PAYMENT', + totalCents, + currency: 'RUB', + addressSnapshotJson, + comment: comment && comment.length ? comment : null, + items: { + create: itemsPayload.map((i) => ({ + productId: i.productId, + qty: i.qty, + titleSnapshot: i.titleSnapshot, + priceCentsSnapshot: i.priceCentsSnapshot, + })), + }, + }, + }) + await tx.cartItem.deleteMany({ where: { userId } }) + return order + }) + + return reply.code(201).send({ orderId: created.id }) + }, + ) + + fastify.get( + '/api/me/orders', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: true }, + orderBy: { createdAt: 'desc' }, + }) + return { + items: orders.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + } + }, + ) + + fastify.get( + '/api/me/orders/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + }, + ) + + fastify.get( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + const items = await prisma.orderMessage.findMany({ where: { orderId: id }, orderBy: { createdAt: 'asc' } }) + return { items } + }, + ) + + fastify.post( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } }) + return reply.code(201).send({ item: msg }) + }, + ) + + fastify.post( + '/api/me/orders/:id/pay', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + // Заглушка: пока ничего не оплачиваем, просто подтверждаем намерение оплатить + if (order.status === 'DRAFT') { + await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } }) + } + return { ok: true, status: order.status === 'DRAFT' ? 'PENDING_PAYMENT' : order.status } + }, + ) }