base commit
This commit is contained in:
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/orders" element={<AdminOrdersPage />} />
|
||||
<Route path="/admin/reviews" element={<AdminReviewsPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/me/*" element={<MeLayoutPage />} />
|
||||
<Route path="/products/:id" element={<ProductPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -33,6 +33,7 @@ type NavItem = { label: string; to: string }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Каталог', to: '/' },
|
||||
{ label: 'Корзина', to: '/cart' },
|
||||
{ label: 'Админка', to: '/admin' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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<CartResponse> {
|
||||
const { data } = await apiClient.get<CartResponse>('me/cart')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
|
||||
await apiClient.post('me/cart/items', body)
|
||||
}
|
||||
|
||||
export async function setCartQty(id: string, qty: number): Promise<void> {
|
||||
await apiClient.patch(`me/cart/items/${id}`, { qty })
|
||||
}
|
||||
|
||||
export async function removeCartItem(id: string): Promise<void> {
|
||||
await apiClient.delete(`me/cart/items/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
|
||||
export type CartItem = {
|
||||
id: string
|
||||
qty: number
|
||||
product: Product
|
||||
}
|
||||
@@ -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<AdminOrdersListResponse> {
|
||||
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(token: string, id: string): Promise<AdminOrderDetailResponse> {
|
||||
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function setAdminOrderStatus(token: string, id: string, status: string): Promise<void> {
|
||||
await apiClient.patch(
|
||||
`admin/orders/${id}/status`,
|
||||
{ status },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function postAdminOrderMessage(token: string, id: string, text: string): Promise<void> {
|
||||
await apiClient.post(
|
||||
`admin/orders/${id}/messages`,
|
||||
{ text },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<OrderListResponse> {
|
||||
const { data } = await apiClient.get<OrderListResponse>('me/orders')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
|
||||
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||
}
|
||||
|
||||
export async function payOrderStub(id: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${id}/pay`)
|
||||
}
|
||||
@@ -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<SwiperType | null>(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 (
|
||||
<Card
|
||||
variant="outlined"
|
||||
@@ -148,6 +160,18 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) {
|
||||
<Typography variant="h6" color="primary">
|
||||
{formatPriceRub(product.priceCents)}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!user || addMut.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
addMut.mutate()
|
||||
}}
|
||||
>
|
||||
{user ? 'В корзину' : 'Войдите, чтобы купить'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<AdminReviewsListResponse> {
|
||||
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', {
|
||||
params,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise<void> {
|
||||
await apiClient.patch(
|
||||
`admin/reviews/${id}`,
|
||||
{ action },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminOrdersPage } from './ui/AdminOrdersPage'
|
||||
@@ -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<string | null>(() => getAdminToken())
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const tokenForm = useForm<TokenFormState>({ 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<string, string[]> = {
|
||||
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
|
||||
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
|
||||
PAID: ['IN_PROGRESS', 'CANCELLED'],
|
||||
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
|
||||
SHIPPED: ['DONE'],
|
||||
}
|
||||
return map[s] ?? []
|
||||
}, [detail?.status])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
Админка — заказы
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||
Товары
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
|
||||
Отзывы
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
|
||||
<Controller
|
||||
control={tokenForm.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Токен (Bearer)"
|
||||
type="password"
|
||||
fullWidth
|
||||
{...field}
|
||||
placeholder={token ? '••••••••' : ''}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск (id/email)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel id="status-label">Статус</InputLabel>
|
||||
<Select
|
||||
labelId="status-label"
|
||||
label="Статус"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(String(e.target.value))}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'DONE', 'CANCELLED'].map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Покупатель</TableCell>
|
||||
<TableCell>Статус</TableCell>
|
||||
<TableCell>Сумма</TableCell>
|
||||
<TableCell>Позиций</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((o) => (
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell>{o.id.slice(-8)}</TableCell>
|
||||
<TableCell>{o.user.email}</TableCell>
|
||||
<TableCell>{o.status}</TableCell>
|
||||
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
|
||||
<TableCell>{o.itemsCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => open(o.id)}>
|
||||
Открыть
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{ordersQuery.isSuccess && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
|
||||
Заказов пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>Заказ</DialogTitle>
|
||||
<DialogContent>
|
||||
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка…</Typography>}
|
||||
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
||||
{detail && (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
labelId="next-status-label"
|
||||
label="Сменить статус"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const next = String(e.target.value)
|
||||
if (!next) return
|
||||
statusMut.mutate(next)
|
||||
}}
|
||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Выберите…</em>
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => (
|
||||
<Box key={m.id} sx={{ p: 1, border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
|
||||
<TextField
|
||||
label="Ответ админа"
|
||||
value={msg}
|
||||
onChange={(e) => setMsg(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !msg.trim()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminReviewsPage } from './ui/AdminReviewsPage'
|
||||
@@ -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<string | null>(() => getAdminToken())
|
||||
const tokenForm = useForm<TokenFormState>({ 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 (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
Админка — отзывы
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||
Товары
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/orders" variant="outlined">
|
||||
Заказы
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
|
||||
<Controller
|
||||
control={tokenForm.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Токен (Bearer)"
|
||||
type="password"
|
||||
fullWidth
|
||||
{...field}
|
||||
placeholder={token ? '••••••••' : ''}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{!token && <Alert severity="info">После сохранения токена появится список отзывов на модерации.</Alert>}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
|
||||
{error && <Alert severity="error">{(error as Error).message}</Alert>}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Товар</TableCell>
|
||||
<TableCell>Пользователь</TableCell>
|
||||
<TableCell>Оценка</TableCell>
|
||||
<TableCell>Текст</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((r) => (
|
||||
<TableRow key={r.id} hover>
|
||||
<TableCell>{r.product.title}</TableCell>
|
||||
<TableCell>{r.user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={String(r.rating)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{r.text ?? '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => modMut.mutate({ id: r.id, action: 'approve' })}
|
||||
disabled={modMut.isPending}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => modMut.mutate({ id: r.id, action: 'reject' })}
|
||||
disabled={modMut.isPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{reviewsQuery.isSuccess && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Нет отзывов на модерации.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -328,6 +328,12 @@ export function AdminPage() {
|
||||
<Button variant="outlined" onClick={() => setCatOpen(true)}>
|
||||
Новая категория
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/orders" variant="outlined">
|
||||
Заказы
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
|
||||
Отзывы
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/users" variant="outlined">
|
||||
Пользователи
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CartPage } from './ui/CartPage'
|
||||
@@ -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 (
|
||||
<Alert severity="info">
|
||||
Чтобы пользоваться корзиной, нужно войти. Перейдите на страницу{' '}
|
||||
<Typography component={RouterLink} to="/auth" sx={{ textDecoration: 'underline' }}>
|
||||
Вход
|
||||
</Typography>
|
||||
.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const items = cartQuery.data?.items ?? []
|
||||
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Корзина
|
||||
</Typography>
|
||||
|
||||
{cartQuery.isError && <Alert severity="error">Не удалось загрузить корзину.</Alert>}
|
||||
|
||||
{cartQuery.isSuccess && items.length === 0 && <Alert severity="info">Корзина пуста.</Alert>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<Stack spacing={2}>
|
||||
{items.map((x) => (
|
||||
<Box
|
||||
key={x.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{x.product.title}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{formatPriceRub(x.product.priceCents)} · {x.qty} шт.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<IconButton
|
||||
onClick={() => qtyMut.mutate({ id: x.id, qty: Math.max(0, x.qty - 1) })}
|
||||
disabled={qtyMut.isPending}
|
||||
aria-label="Уменьшить количество"
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<Typography sx={{ minWidth: 24, textAlign: 'center' }}>{x.qty}</Typography>
|
||||
<IconButton
|
||||
onClick={() => qtyMut.mutate({ id: x.id, qty: x.qty + 1 })}
|
||||
disabled={qtyMut.isPending}
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
onClick={() => removeMut.mutate(x.id)}
|
||||
disabled={removeMut.isPending}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Итого: {formatPriceRub(total)}
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/checkout" variant="contained">
|
||||
Оформить заказ
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CheckoutPage } from './ui/CheckoutPage'
|
||||
@@ -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 (
|
||||
<Alert severity="info">
|
||||
Чтобы оформить заказ, нужно войти. Перейдите на страницу{' '}
|
||||
<Typography component={RouterLink} to="/auth" sx={{ textDecoration: 'underline' }}>
|
||||
Вход
|
||||
</Typography>
|
||||
.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Оформление заказа
|
||||
</Typography>
|
||||
|
||||
{cartQuery.isSuccess && items.length === 0 && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Корзина пуста. Вернитесь в{' '}
|
||||
<Typography component={RouterLink} to="/" sx={{ textDecoration: 'underline' }}>
|
||||
каталог
|
||||
</Typography>
|
||||
.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2} sx={{ maxWidth: 720 }}>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="addr-label">Адрес доставки</InputLabel>
|
||||
<Select
|
||||
labelId="addr-label"
|
||||
label="Адрес доставки"
|
||||
value={addressId || (defaultAddr?.id ?? '')}
|
||||
onChange={(e) => setAddressId(String(e.target.value))}
|
||||
>
|
||||
{addresses.map((a) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<Alert severity="warning">
|
||||
У вас нет адресов доставки. Добавьте адрес в{' '}
|
||||
<Typography component={RouterLink} to="/me/addresses" sx={{ textDecoration: 'underline' }}>
|
||||
кабинете
|
||||
</Typography>
|
||||
.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Комментарий к заказу (необязательно)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={items.length === 0 || addresses.length === 0 || createMut.isPending}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
Создать заказ
|
||||
</Button>
|
||||
|
||||
{createMut.isError && <Alert severity="error">{(createMut.error as Error).message}</Alert>}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/me/settings" replace />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="addresses" element={<AddressesPage />} />
|
||||
|
||||
@@ -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 <Alert severity="error">Некорректный заказ.</Alert>
|
||||
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
||||
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }} sx={{ mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary">Статус: {order.status}</Typography>
|
||||
</Box>
|
||||
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
||||
К списку
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2} sx={{ maxWidth: 900 }}>
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Позиции
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{order.items.map((i) => (
|
||||
<Stack key={i.id} direction="row" spacing={2} alignItems="center">
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{i.qty} × {formatPriceRub(i.priceCentsSnapshot)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(i.priceCentsSnapshot * i.qty)}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Доставка
|
||||
</Typography>
|
||||
{address ? (
|
||||
<>
|
||||
<Typography sx={{ fontWeight: 700 }}>{address.addressLine}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Получатель: {address.recipientName} · {address.recipientPhone}
|
||||
</Typography>
|
||||
{address.comment && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Комментарий: {address.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography color="text.secondary">Адрес не распознан.</Typography>
|
||||
)}
|
||||
{order.comment && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||
Комментарий к заказу: {order.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Оплата
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Пока это заглушка. Позже подключим реальную оплату.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
|
||||
Оплатить (заглушка)
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Чат по заказу
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
{order.messages.map((m) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
|
||||
<TextField
|
||||
label="Сообщение"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !text.trim()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Заказы
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Скоро здесь появится история заказов.</Typography>
|
||||
|
||||
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
|
||||
|
||||
{ordersQuery.isSuccess && items.length === 0 && (
|
||||
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2}>
|
||||
{items.map((o) => (
|
||||
<Box
|
||||
key={o.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Статус: {o.status} · {o.itemsCount} поз.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
|
||||
Открыть
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!user || addMut.isPending || !p}
|
||||
onClick={() => addMut.mutate()}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{user ? 'В корзину' : 'Войдите, чтобы купить'}
|
||||
</Button>
|
||||
|
||||
{p.description ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||
) : (
|
||||
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
+246
-1
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user