base commit

This commit is contained in:
@kirill.komarov
2026-05-03 20:30:21 +05:00
parent fe10f25b8c
commit 6885e39017
13 changed files with 253 additions and 72 deletions
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
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'
@@ -76,8 +76,8 @@ export function AdminLayoutPage() {
null
const nav = (
<Box sx={{ width: 280, maxWidth: '85vw' }}>
<Box sx={{ p: 2 }}>
<Box sx={{ width: 300, maxWidth: '88vw', py: 1 }}>
<Box sx={{ px: 2, py: 2, mx: 1, borderRadius: 2, bgcolor: 'warning.50' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Админка
</Typography>
@@ -85,12 +85,23 @@ export function AdminLayoutPage() {
Управление магазином
</Typography>
</Box>
<Divider />
<Divider sx={{ my: 1 }} />
<List disablePadding>
{navItems.map((i) => (
<ListItemButton
key={i.to}
selected={activeTo === i.to}
sx={{
mx: 1,
mb: 0.5,
borderRadius: 2,
'&.Mui-selected': {
bgcolor: 'warning.100',
},
'&.Mui-selected:hover': {
bgcolor: 'warning.200',
},
}}
onClick={() => {
navigate(i.to)
setMobileOpen(false)
@@ -117,14 +128,38 @@ export function AdminLayoutPage() {
{isMobile ? (
<>
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню админки">
<MenuOutlinedIcon />
<IconButton
onClick={() => setMobileOpen(true)}
aria-label="Открыть панель админки"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'warning.300',
bgcolor: 'warning.50',
}}
>
<AdminPanelSettingsOutlinedIcon />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Админка
</Typography>
</Stack>
<Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }}>
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
anchor="right"
ModalProps={{ keepMounted: true }}
slotProps={{
paper: {
sx: {
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
borderLeft: 1,
borderColor: 'divider',
},
},
}}
>
{nav}
</Drawer>
</>
@@ -30,6 +30,7 @@ import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function AdminOrdersPage() {
@@ -92,6 +93,7 @@ export function AdminOrdersPage() {
)
const detail = orderDetailQuery.data?.item
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const nextStatuses = useMemo(() => {
if (!detail) return []
@@ -238,11 +240,24 @@ export function AdminOrdersPage() {
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<Box key={m.id} sx={{ p: 1, border: 1, borderColor: 'divider', borderRadius: 2 }}>
<Box
key={m.id}
sx={{
p: 1.25,
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: m.authorType === 'admin' ? 'primary.50' : 'grey.100',
alignSelf: m.authorType === 'admin' ? 'flex-end' : 'flex-start',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary">
{m.authorType} · {new Date(m.createdAt).toLocaleString()}
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
{new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
<RichTextMessageContent value={m.text} />
</Box>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
@@ -255,7 +270,7 @@ export function AdminOrdersPage() {
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !msg.trim()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
@@ -15,6 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
export function AdminReviewsPage() {
const qc = useQueryClient()
@@ -68,7 +69,7 @@ export function AdminReviewsPage() {
<TableCell>
<Chip label={String(r.rating)} size="small" />
</TableCell>
<TableCell>{r.text ?? '—'}</TableCell>
<TableCell>{r.text?.trim() ? <RichTextMessageContent value={r.text} /> : '—'}</TableCell>
<TableCell align="right">
<Button
size="small"
+10 -4
View File
@@ -19,12 +19,16 @@ import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
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 { $user } from '@/shared/model/auth'
import { ReviewsBlock } from '@/widgets/reviews-block'
export function HomePage() {
const user = useUnit($user)
const isAdmin = Boolean(user?.isAdmin)
const [categorySlug, setCategorySlug] = useState<string>('')
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
const [qInput, setQInput] = useState('')
@@ -367,10 +371,12 @@ export function HomePage() {
product={p}
mediaHeight={mediaHeight}
actions={
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
!isAdmin ? (
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
) : undefined
}
/>
</Grid>
+45 -7
View File
@@ -2,9 +2,9 @@ import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'
import Alert from '@mui/material/Alert'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
@@ -67,6 +67,9 @@ export function MeLayoutPage() {
if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
}
if (user.isAdmin) {
return <Navigate to="/admin" replace />
}
const activeTo =
navItems.find((x) => location.pathname === x.to)?.to ??
@@ -74,8 +77,8 @@ export function MeLayoutPage() {
null
const nav = (
<Box sx={{ width: 280, maxWidth: '85vw' }}>
<Box sx={{ p: 2 }}>
<Box sx={{ width: 300, maxWidth: '88vw', py: 1 }}>
<Box sx={{ px: 2, py: 2, mx: 1, borderRadius: 2, bgcolor: 'action.hover' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Кабинет
</Typography>
@@ -83,12 +86,23 @@ export function MeLayoutPage() {
{user.name?.trim() || user.email}
</Typography>
</Box>
<Divider />
<Divider sx={{ my: 1 }} />
<List disablePadding>
{navItems.map((i) => (
<ListItemButton
key={i.to}
selected={activeTo === i.to}
sx={{
mx: 1,
mb: 0.5,
borderRadius: 2,
'&.Mui-selected': {
bgcolor: 'primary.50',
},
'&.Mui-selected:hover': {
bgcolor: 'primary.100',
},
}}
onClick={() => {
navigate(i.to)
setMobileOpen(false)
@@ -119,14 +133,38 @@ export function MeLayoutPage() {
{isMobile ? (
<>
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
<MenuOutlinedIcon />
<IconButton
onClick={() => setMobileOpen(true)}
aria-label="Открыть меню кабинета"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<TuneOutlinedIcon />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Профиль
</Typography>
</Stack>
<Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }}>
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
anchor="right"
ModalProps={{ keepMounted: true }}
slotProps={{
paper: {
sx: {
borderTopLeftRadius: 16,
borderBottomLeftRadius: 16,
borderLeft: 1,
borderColor: 'divider',
},
},
}}
>
{nav}
</Drawer>
</>
@@ -14,6 +14,7 @@ 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'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function MessagesPage() {
@@ -57,6 +58,7 @@ export function MessagesPage() {
})
const order = orderQuery.data?.item
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
return (
<Box>
@@ -166,12 +168,15 @@ export function MessagesPage() {
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
<RichTextMessageContent value={m.text} />
</Box>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
@@ -184,7 +189,7 @@ export function MessagesPage() {
variant="contained"
sx={{ minWidth: 140 }}
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !text.trim()}
disabled={msgMut.isPending || !canSendMessage}
>
Отправить
</Button>
@@ -24,6 +24,7 @@ import { postProductReview, uploadReviewImage } from '@/entities/product/api/rev
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function reviewSubmitErrorMessage(err: unknown): string {
@@ -95,6 +96,7 @@ export function OrderDetailPage() {
})
const order = orderQuery.data?.item
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const eligibilityQuery = useQuery({
queryKey: ['me', 'orders', id, 'review-eligibility'],
@@ -322,12 +324,15 @@ export function OrderDetailPage() {
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
border: 1,
borderColor: 'divider',
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
}}
>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
<RichTextMessageContent value={m.text} />
</Box>
))}
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
@@ -340,7 +345,7 @@ export function OrderDetailPage() {
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !text.trim()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
+9 -4
View File
@@ -13,6 +13,7 @@ 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 { useUnit } from 'effector-react'
import { useParams } from 'react-router-dom'
import { Navigation } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react'
@@ -23,8 +24,12 @@ 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'
import { $user } from '@/shared/model/auth'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
export function ProductPage() {
const user = useUnit($user)
const isAdmin = Boolean(user?.isAdmin)
const { id } = useParams()
const [viewerOpen, setViewerOpen] = useState(false)
const [viewerIndex, setViewerIndex] = useState(0)
@@ -150,7 +155,7 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)}
</Typography>
<ToggleCartIcon productId={p.id} size="medium" />
{!isAdmin && <ToggleCartIcon productId={p.id} size="medium" />}
{!p.inStock && (
<Alert severity="info">
@@ -210,9 +215,9 @@ export function ProductPage() {
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
/>
{body ? (
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap' }}>
{body}
</Typography>
<Box sx={{ color: 'text.secondary' }}>
<RichTextMessageContent value={body} />
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.