base commit
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
|
||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
||||
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
@@ -17,11 +18,14 @@ import Stack from '@mui/material/Stack'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
@@ -35,6 +39,17 @@ export function AdminLayoutPage() {
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null)
|
||||
|
||||
const ordersSummaryQuery = useQuery({
|
||||
queryKey: ['admin', 'orders', 'summary', adminToken],
|
||||
queryFn: () => fetchAdminOrdersSummary(adminToken!),
|
||||
enabled: Boolean(adminToken),
|
||||
refetchInterval: 45_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
const newOrdersAttention = ordersSummaryQuery.data?.attentionCount ?? 0
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
@@ -72,7 +87,15 @@ export function AdminLayoutPage() {
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{i.icon}</ListItemIcon>
|
||||
<ListItemIcon>
|
||||
{i.to === '/admin/orders' ? (
|
||||
<Badge color="error" badgeContent={newOrdersAttention} max={99} invisible={newOrdersAttention === 0}>
|
||||
{i.icon}
|
||||
</Badge>
|
||||
) : (
|
||||
i.icon
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
postAdminOrderMessage,
|
||||
setAdminOrderStatus,
|
||||
} from '@/entities/order/api/admin-order-api'
|
||||
import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@/shared/constants/order'
|
||||
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
|
||||
type TokenFormState = { token: string }
|
||||
|
||||
@@ -37,6 +38,7 @@ export function AdminOrdersPage() {
|
||||
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
@@ -59,8 +61,13 @@ export function AdminOrdersPage() {
|
||||
}
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin', 'orders', token, { q, status }],
|
||||
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }),
|
||||
queryKey: ['admin', 'orders', token, { q, status, deliveryType }],
|
||||
queryFn: () =>
|
||||
fetchAdminOrders(token!, {
|
||||
q: q.trim() || undefined,
|
||||
status: status || undefined,
|
||||
deliveryType: deliveryType || undefined,
|
||||
}),
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
@@ -75,6 +82,7 @@ export function AdminOrdersPage() {
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
|
||||
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -96,10 +104,9 @@ export function AdminOrdersPage() {
|
||||
const detail = orderDetailQuery.data?.item
|
||||
|
||||
const nextStatuses = useMemo(() => {
|
||||
const s = detail?.status
|
||||
if (!s) return []
|
||||
return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? []
|
||||
}, [detail?.status])
|
||||
if (!detail) return []
|
||||
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
|
||||
}, [detail])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -153,13 +160,31 @@ export function AdminOrdersPage() {
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => (
|
||||
{ORDER_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
|
||||
<Select
|
||||
labelId="delivery-type-label"
|
||||
label="Способ получения"
|
||||
value={deliveryType}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value)
|
||||
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="delivery">Доставка</MenuItem>
|
||||
<MenuItem value="pickup">Самовывоз</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
|
||||
@@ -180,7 +205,7 @@ export function AdminOrdersPage() {
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell>{o.id.slice(-8)}</TableCell>
|
||||
<TableCell>{o.user.email}</TableCell>
|
||||
<TableCell>{o.status}</TableCell>
|
||||
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
|
||||
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
|
||||
<TableCell>{o.itemsCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
@@ -210,7 +235,8 @@ export function AdminOrdersPage() {
|
||||
{detail && (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
|
||||
{formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
@@ -232,7 +258,7 @@ export function AdminOrdersPage() {
|
||||
</MenuItem>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { AuthCallbackPage } from './ui/AuthCallbackPage'
|
||||
export { AuthPage } from './ui/AuthPage'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
export function AuthCallbackPage() {
|
||||
const [params] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const t = params.get('token')
|
||||
if (t) {
|
||||
tokenSet(t)
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
navigate('/auth', { replace: true })
|
||||
}, [navigate, params])
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress />
|
||||
<Typography color="text.secondary">Завершение входа…</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
|
||||
import { $user, tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }
|
||||
@@ -26,6 +27,8 @@ function getApiErrorMessage(err: unknown): string | null {
|
||||
|
||||
export function AuthPage() {
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const user = useUnit($user)
|
||||
const { register, watch } = useForm<{
|
||||
@@ -45,6 +48,13 @@ export function AuthPage() {
|
||||
if (user) navigate('/', { replace: true })
|
||||
}, [navigate, user])
|
||||
|
||||
useEffect(() => {
|
||||
const err = searchParams.get('oauthError')
|
||||
if (!err) return
|
||||
setOauthError(err)
|
||||
setSearchParams({}, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const requestCode = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/request-code', { email })
|
||||
@@ -94,6 +104,11 @@ export function AuthPage() {
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
{oauthError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>
|
||||
{oauthError}
|
||||
</Alert>
|
||||
)}
|
||||
{errMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{errMsg}
|
||||
@@ -101,6 +116,18 @@ export function AuthPage() {
|
||||
)}
|
||||
|
||||
<Stack spacing={2} sx={{ maxWidth: 520 }}>
|
||||
<Typography variant="subtitle1">Быстрый вход</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<Button component="a" href={oauthAuthorizeUrl('vk')} variant="outlined" fullWidth>
|
||||
Войти через VK
|
||||
</Button>
|
||||
<Button component="a" href={oauthAuthorizeUrl('yandex')} variant="outlined" fullWidth>
|
||||
Войти через Яндекс
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider>или по email</Divider>
|
||||
|
||||
<TextField label="Email" {...register('email')} fullWidth />
|
||||
|
||||
<Typography variant="h6">Вариант 1: Email + код</Typography>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function CheckoutPage() {
|
||||
const user = useUnit($user)
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
|
||||
const [addressId, setAddressId] = useState('')
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
@@ -41,7 +42,12 @@ export function CheckoutPage() {
|
||||
const selectedAddressId = addressId || defaultAddressId
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
|
||||
mutationFn: () =>
|
||||
createOrder({
|
||||
deliveryType,
|
||||
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
|
||||
comment: comment.trim() || null,
|
||||
}),
|
||||
onSuccess: async (res) => {
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
navigate(`/me/orders/${res.orderId}`, { replace: true })
|
||||
@@ -61,7 +67,11 @@ export function CheckoutPage() {
|
||||
}
|
||||
|
||||
const items = cartQuery.data?.items ?? []
|
||||
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
|
||||
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
|
||||
const totalQty = items.reduce((s, x) => s + x.qty, 0)
|
||||
const deliveryFeeCents =
|
||||
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
|
||||
const total = itemsSubtotalCents + deliveryFeeCents
|
||||
const addresses = addressesQuery.data?.items ?? []
|
||||
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
|
||||
const hasMadeToOrder = items.some((x) => !x.product.inStock)
|
||||
@@ -117,29 +127,63 @@ export function CheckoutPage() {
|
||||
)}
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="addr-label">Адрес доставки</InputLabel>
|
||||
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
|
||||
<Select
|
||||
labelId="addr-label"
|
||||
label="Адрес доставки"
|
||||
value={selectedAddressId}
|
||||
onChange={(e) => setAddressId(String(e.target.value))}
|
||||
labelId="delivery-type-label"
|
||||
label="Способ получения"
|
||||
value={deliveryType}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value)
|
||||
if (v === 'delivery' || v === 'pickup') setDeliveryType(v)
|
||||
}}
|
||||
>
|
||||
{addresses.map((a) => (
|
||||
<MenuItem key={a.id} value={a.id}>
|
||||
{(a.label?.trim() ? `${a.label}: ` : '') + a.addressLine}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="delivery">Доставка</MenuItem>
|
||||
<MenuItem value="pickup">Самовывоз</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{addresses.length === 0 && (
|
||||
<Alert severity="warning">
|
||||
У вас нет адресов доставки. Добавьте адрес в{' '}
|
||||
<Typography component={RouterLink} to="/me/addresses" sx={{ textDecoration: 'underline' }}>
|
||||
кабинете
|
||||
</Typography>
|
||||
.
|
||||
</Alert>
|
||||
{deliveryType === 'delivery' && (
|
||||
<>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="addr-label">Адрес доставки</InputLabel>
|
||||
<Select
|
||||
labelId="addr-label"
|
||||
label="Адрес доставки"
|
||||
value={selectedAddressId}
|
||||
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>
|
||||
)}
|
||||
|
||||
<Alert severity="info">
|
||||
Стоимость доставки: 500 ₽ за каждые 2 единицы (минимум 500 ₽).
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
В этом заказе: {totalQty} шт. → доставка {formatPriceRub(deliveryFeeCents)}.
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{deliveryType === 'pickup' && (
|
||||
<Alert severity="info">Самовывоз: адрес доставки не нужен. Мы свяжемся с вами для согласования.</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
@@ -151,12 +195,25 @@ export function CheckoutPage() {
|
||||
minRows={2}
|
||||
/>
|
||||
|
||||
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Товары: {formatPriceRub(itemsSubtotalCents)}
|
||||
</Typography>
|
||||
{deliveryType === 'delivery' && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Доставка: {formatPriceRub(deliveryFeeCents)}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h6">Итого: {formatPriceRub(total)}</Typography>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
items.length === 0 || addresses.length === 0 || !selectedAddressId || hasOverLimit || createMut.isPending
|
||||
items.length === 0 ||
|
||||
hasOverLimit ||
|
||||
createMut.isPending ||
|
||||
(deliveryType === 'delivery' && (addresses.length === 0 || !selectedAddressId))
|
||||
}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
|
||||
@@ -22,9 +22,11 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
||||
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||
|
||||
export function HomePage() {
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
@@ -54,6 +56,7 @@ export function HomePage() {
|
||||
'public',
|
||||
{
|
||||
categorySlug: categorySlug || 'all',
|
||||
availability,
|
||||
q,
|
||||
sort,
|
||||
page,
|
||||
@@ -69,6 +72,7 @@ export function HomePage() {
|
||||
}
|
||||
return fetchPublicProducts({
|
||||
categorySlug: categorySlug || undefined,
|
||||
availability: availability === 'all' ? undefined : availability,
|
||||
q: q || undefined,
|
||||
sort: sort || '',
|
||||
page,
|
||||
@@ -155,6 +159,52 @@ export function HomePage() {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Наличие</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Быстрый фильтр по наличию
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={availability}
|
||||
onChange={(_, v) => {
|
||||
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
|
||||
setAvailability(v)
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="all">Все</ToggleButton>
|
||||
<ToggleButton value="in_stock">В наличии</ToggleButton>
|
||||
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
@@ -167,6 +217,7 @@ export function HomePage() {
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setCategorySlug('')
|
||||
setAvailability('all')
|
||||
setQInput('')
|
||||
setSort('')
|
||||
setPriceMinRub('')
|
||||
@@ -312,22 +363,37 @@ export function HomePage() {
|
||||
<Grid container spacing={2}>
|
||||
{products.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||
<ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
|
||||
<ProductCard
|
||||
product={p}
|
||||
mediaHeight={mediaHeight}
|
||||
actions={
|
||||
<ToggleCartIcon
|
||||
productId={p.id}
|
||||
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
page={page}
|
||||
count={totalPages}
|
||||
onChange={(_, v) => setPage(v)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Stack>
|
||||
{totalPages > 1 && (
|
||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
page={page}
|
||||
count={totalPages}
|
||||
onChange={(_, v) => setPage(v)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
showFirstButton
|
||||
showLastButton
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<ReviewsBlock />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -6,6 +6,7 @@ import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
@@ -18,8 +19,10 @@ import Stack from '@mui/material/Stack'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api'
|
||||
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
|
||||
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
|
||||
import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
|
||||
@@ -41,6 +44,16 @@ export function MeLayoutPage() {
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const unreadQuery = useQuery({
|
||||
queryKey: ['me', 'messages', 'unread-count'],
|
||||
queryFn: fetchUnreadMessageCount,
|
||||
enabled: Boolean(user),
|
||||
refetchInterval: 45_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
const unreadMessages = unreadQuery.data?.count ?? 0
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{ to: '/me/orders', label: 'Заказы', icon: <LocalShippingOutlinedIcon /> },
|
||||
@@ -81,7 +94,19 @@ export function MeLayoutPage() {
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{i.icon}</ListItemIcon>
|
||||
<ListItemIcon>
|
||||
{i.to === '/me/messages' ? (
|
||||
<Badge
|
||||
color="error"
|
||||
badgeContent={unreadMessages > 99 ? '99+' : unreadMessages}
|
||||
invisible={unreadMessages === 0}
|
||||
>
|
||||
{i.icon}
|
||||
</Badge>
|
||||
) : (
|
||||
i.icon
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={i.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,204 @@
|
||||
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 Chip from '@mui/material/Chip'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
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 { Link as RouterLink } from 'react-router-dom'
|
||||
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
||||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
|
||||
export function MessagesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['me', 'conversations'],
|
||||
queryFn: fetchMyConversations,
|
||||
})
|
||||
|
||||
const conversationsList = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items])
|
||||
|
||||
const activeThreadId = useMemo(() => {
|
||||
return selectedId ?? conversationsList[0]?.orderId ?? null
|
||||
}, [selectedId, conversationsList])
|
||||
|
||||
const orderQuery = useQuery({
|
||||
queryKey: ['me', 'orders', activeThreadId],
|
||||
queryFn: () => fetchMyOrder(activeThreadId!),
|
||||
enabled: Boolean(activeThreadId),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId || orderQuery.status !== 'success') return
|
||||
void (async () => {
|
||||
await markOrderMessagesRead(activeThreadId).catch(() => undefined)
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||
})()
|
||||
}, [activeThreadId, orderQuery.status, qc])
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postOrderMessage(activeThreadId!, text.trim()),
|
||||
onSuccess: async () => {
|
||||
setText('')
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', activeThreadId] })
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||
},
|
||||
})
|
||||
|
||||
const order = orderQuery.data?.item
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Typography color="text.secondary">Скоро здесь появятся сообщения и уведомления.</Typography>
|
||||
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||||
Переписка по всем заказам. Последнее сообщение в каждом заказе — в списке слева.
|
||||
</Typography>
|
||||
|
||||
{listQuery.isError && <Alert severity="error">Не удалось загрузить переписки.</Alert>}
|
||||
{listQuery.isSuccess && conversationsList.length === 0 && (
|
||||
<Alert severity="info">
|
||||
Пока нет сообщений в заказах. Их отправит администратор — или напишите сами на странице заказа.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{conversationsList.length > 0 && (
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: '100%', md: 320 },
|
||||
flexShrink: 0,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
maxHeight: 520,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{conversationsList.map((c) => (
|
||||
<ListItem
|
||||
key={c.orderId}
|
||||
disablePadding
|
||||
secondaryAction={
|
||||
c.unreadCount > 0 ? (
|
||||
<Chip sx={{ mr: 1 }} size="small" color="error" label={String(c.unreadCount)} />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ListItemButton selected={activeThreadId === c.orderId} onClick={() => setSelectedId(c.orderId)}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography component="span" sx={{ fontWeight: 700 }}>
|
||||
№{c.orderId.slice(-6)}
|
||||
</Typography>
|
||||
<Typography component="span" variant="caption" color="text.secondary">
|
||||
· {orderStatusLabelRu(c.status)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
sx: {
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
},
|
||||
}}
|
||||
secondary={c.preview}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
{!activeThreadId && <Typography color="text.secondary">Выберите заказ.</Typography>}
|
||||
{activeThreadId && orderQuery.isLoading && <Typography>Загрузка чата…</Typography>}
|
||||
{activeThreadId && orderQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
||||
{order && (
|
||||
<>
|
||||
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6">
|
||||
Чат заказа №{order.id.slice(-6)}{' '}
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
({orderStatusLabelRu(order.status)})
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
|
||||
Открыть заказ
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}>
|
||||
{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} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
label="Сообщение"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ minWidth: 140 }}
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !text.trim()}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 Divider from '@mui/material/Divider'
|
||||
import Rating from '@mui/material/Rating'
|
||||
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 axios from 'axios'
|
||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
|
||||
import {
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
payOrderStub,
|
||||
postOrderMessage,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview } from '@/entities/product/api/reviews-api'
|
||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
|
||||
function reviewSubmitErrorMessage(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const raw = err.response?.data
|
||||
const apiMsg =
|
||||
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
|
||||
? (raw as { error: string }).error
|
||||
: null
|
||||
if (status === 409 || apiMsg?.toLowerCase().includes('уже')) {
|
||||
return 'Вы уже оставляли отзыв на этот товар.'
|
||||
}
|
||||
return apiMsg || err.message || 'Не удалось отправить отзыв'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'Не удалось отправить отзыв'
|
||||
}
|
||||
|
||||
type AddressSnapshot = {
|
||||
deliveryType?: 'delivery' | 'pickup'
|
||||
label?: string | null
|
||||
recipientName?: string
|
||||
recipientPhone?: string
|
||||
@@ -23,6 +56,9 @@ export function OrderDetailPage() {
|
||||
const { id } = useParams()
|
||||
const qc = useQueryClient()
|
||||
const [text, setText] = useState('')
|
||||
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
|
||||
const [reviewRating, setReviewRating] = useState<number>(5)
|
||||
const [reviewText, setReviewText] = useState('')
|
||||
|
||||
const orderQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id],
|
||||
@@ -32,7 +68,20 @@ export function OrderDetailPage() {
|
||||
|
||||
const payMut = useMutation({
|
||||
mutationFn: () => payOrderStub(id!),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||||
]),
|
||||
})
|
||||
|
||||
const confirmMut = useMutation({
|
||||
mutationFn: () => confirmOrderReceived(id!),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||||
]),
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
@@ -40,11 +89,43 @@ export function OrderDetailPage() {
|
||||
onSuccess: async () => {
|
||||
setText('')
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||
},
|
||||
})
|
||||
|
||||
const order = orderQuery.data?.item
|
||||
|
||||
const eligibilityQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
||||
queryFn: () => fetchOrderReviewEligibility(id!),
|
||||
enabled: Boolean(id && order?.status === 'DONE'),
|
||||
})
|
||||
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!reviewTarget) return
|
||||
const t = reviewText.trim()
|
||||
await postProductReview(reviewTarget.productId, {
|
||||
rating: reviewRating,
|
||||
text: t.length ? t : null,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || orderQuery.status !== 'success' || !order) return
|
||||
void (async () => {
|
||||
await markOrderMessagesRead(id).catch(() => undefined)
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||
})()
|
||||
}, [id, order, orderQuery.status, qc])
|
||||
|
||||
const address = useMemo((): AddressSnapshot | null => {
|
||||
if (!order?.addressSnapshotJson) return null
|
||||
try {
|
||||
@@ -63,7 +144,7 @@ export function OrderDetailPage() {
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary">Статус: {order.status}</Typography>
|
||||
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
|
||||
</Box>
|
||||
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
||||
К списку
|
||||
@@ -89,27 +170,49 @@ export function OrderDetailPage() {
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Товары: {formatPriceRub(order.itemsSubtotalCents)}
|
||||
</Typography>
|
||||
{order.deliveryType === 'delivery' && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Доставка: {formatPriceRub(order.deliveryFeeCents)}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Доставка
|
||||
Получение
|
||||
</Typography>
|
||||
{address ? (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
|
||||
</Typography>
|
||||
{order.deliveryType === 'delivery' && (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography color="text.secondary">Адрес не распознан.</Typography>
|
||||
)}
|
||||
{order.deliveryType === 'pickup' && (
|
||||
<Typography color="text.secondary">
|
||||
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
|
||||
</Typography>
|
||||
)}
|
||||
{order.comment && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
|
||||
<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>
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
|
||||
Оплатить (заглушка)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{order.status === 'PAYMENT_VERIFICATION' && (
|
||||
<Typography color="info.main" variant="body2">
|
||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||||
</Typography>
|
||||
)}
|
||||
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате в этом блоке не требуется.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
||||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
|
||||
<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: 2 }}>
|
||||
{order.deliveryType === 'delivery'
|
||||
? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.'
|
||||
: 'Когда забрали заказ самовывозом — подтвердите получение.'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => confirmMut.mutate()}
|
||||
disabled={confirmMut.isPending}
|
||||
>
|
||||
Подтвердить получение
|
||||
</Button>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
|
||||
<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: 2 }}>
|
||||
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{eligibilityQuery.data.items.map((row) => (
|
||||
<Stack
|
||||
key={row.productId}
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1}
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
|
||||
>
|
||||
<Typography sx={{ flexGrow: 1 }}>{row.title}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={row.hasReview}
|
||||
onClick={() => setReviewTarget({ productId: row.productId, title: row.title })}
|
||||
>
|
||||
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Чат по заказу
|
||||
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reviewTarget)}
|
||||
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Отзыв: {reviewTarget?.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Оценка
|
||||
</Typography>
|
||||
<Rating
|
||||
value={reviewRating}
|
||||
onChange={(_, v) => {
|
||||
if (v !== null) setReviewRating(v)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ mt: 2 }}
|
||||
label="Комментарий (необязательно)"
|
||||
value={reviewText}
|
||||
onChange={(e) => setReviewText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
{reviewMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(reviewMut.error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
|
||||
export function OrdersPage() {
|
||||
const ordersQuery = useQuery({
|
||||
@@ -44,7 +45,7 @@ export function OrdersPage() {
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Статус: {o.status} · {o.itemsCount} поз.
|
||||
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Rating from '@mui/material/Rating'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
@@ -14,8 +19,10 @@ import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||
import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||
|
||||
export function ProductPage() {
|
||||
const { id } = useParams()
|
||||
@@ -28,6 +35,12 @@ export function ProductPage() {
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }],
|
||||
queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
const imageUrls = useMemo(() => {
|
||||
const p = productQuery.data
|
||||
if (!p) return []
|
||||
@@ -150,6 +163,73 @@ export function ProductPage() {
|
||||
) : (
|
||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Отзывы
|
||||
</Typography>
|
||||
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
|
||||
<Rating
|
||||
value={p.reviewsSummary.avgRating ?? 0}
|
||||
readOnly
|
||||
precision={0.25}
|
||||
icon={<StarRoundedIcon fontSize="inherit" />}
|
||||
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов…</Typography>}
|
||||
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
|
||||
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
|
||||
<Typography color="text.secondary">Пока нет опубликованных отзывов на этот товар.</Typography>
|
||||
)}
|
||||
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
|
||||
<Stack spacing={1.25}>
|
||||
{reviewsQuery.data.items.map((rv) => {
|
||||
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
|
||||
return (
|
||||
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Rating
|
||||
value={rv.rating}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<StarRoundedIcon fontSize="inherit" />}
|
||||
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
|
||||
/>
|
||||
{body ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Без текстового комментария.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние{' '}
|
||||
{reviewsQuery.data.items.length}.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
||||
|
||||
Reference in New Issue
Block a user