base commit
This commit is contained in:
@@ -171,6 +171,7 @@ export function AppHeader() {
|
|||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isAdmin = Boolean(user?.isAdmin)
|
const isAdmin = Boolean(user?.isAdmin)
|
||||||
|
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
|
||||||
|
|
||||||
const cartQuery = useQuery({
|
const cartQuery = useQuery({
|
||||||
queryKey: ['me', 'cart'],
|
queryKey: ['me', 'cart'],
|
||||||
@@ -256,7 +257,7 @@ export function AppHeader() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!isMobile &&
|
{!isMobile &&
|
||||||
navItems.map((i) => (
|
headerNavItems.map((i) => (
|
||||||
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
|
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
|
||||||
{i.label}
|
{i.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,36 +293,46 @@ export function AppHeader() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
{!isAdmin && (
|
||||||
<Badge
|
<>
|
||||||
variant="dot"
|
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||||
color="success"
|
<Badge
|
||||||
overlap="circular"
|
variant="dot"
|
||||||
invisible={!user}
|
color="success"
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
overlap="circular"
|
||||||
>
|
invisible={!user}
|
||||||
<AccountCircleOutlinedIcon />
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
</Badge>
|
>
|
||||||
</IconButton>
|
<AccountCircleOutlinedIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={userAnchorEl}
|
anchorEl={userAnchorEl}
|
||||||
open={userMenuOpen}
|
open={userMenuOpen}
|
||||||
onClose={closeUserMenu}
|
onClose={closeUserMenu}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
>
|
>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<MenuItem onClick={() => go('/me')}>
|
<MenuItem onClick={() => go('/me')}>
|
||||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={onLogout}>Выход</MenuItem>
|
<MenuItem onClick={onLogout}>Выход</MenuItem>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && user && !isMobile && (
|
||||||
|
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
|
||||||
|
Выход
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<ThemeControlsDesktop
|
<ThemeControlsDesktop
|
||||||
@@ -349,7 +360,7 @@ export function AppHeader() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{navItems.map((i) => (
|
{headerNavItems.map((i) => (
|
||||||
<Button key={i.to} variant="text" onClick={() => go(i.to)} sx={{ justifyContent: 'flex-start' }}>
|
<Button key={i.to} variant="text" onClick={() => go(i.to)} sx={{ justifyContent: 'flex-start' }}>
|
||||||
{i.label}
|
{i.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -364,9 +375,16 @@ export function AppHeader() {
|
|||||||
Заказы
|
Заказы
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
{!isAdmin && (
|
||||||
{user ? 'Профиль' : 'Вход / регистрация'}
|
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||||
</Button>
|
{user ? 'Профиль' : 'Вход / регистрация'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!user && isAdmin && (
|
||||||
|
<Button variant="text" onClick={() => go('/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||||
|
Вход / регистрация
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<Button variant="text" color="error" onClick={onLogout} sx={{ justifyContent: 'flex-start' }}>
|
<Button variant="text" color="error" onClick={onLogout} sx={{ justifyContent: 'flex-start' }}>
|
||||||
Выход
|
Выход
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'
|
||||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
|
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
|
||||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
|
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
|
||||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
|
||||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
||||||
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
||||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
||||||
@@ -76,8 +76,8 @@ export function AdminLayoutPage() {
|
|||||||
null
|
null
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
<Box sx={{ width: 280, maxWidth: '85vw' }}>
|
<Box sx={{ width: 300, maxWidth: '88vw', py: 1 }}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ px: 2, py: 2, mx: 1, borderRadius: 2, bgcolor: 'warning.50' }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
Админка
|
Админка
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -85,12 +85,23 @@ export function AdminLayoutPage() {
|
|||||||
Управление магазином
|
Управление магазином
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
<Divider sx={{ my: 1 }} />
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{navItems.map((i) => (
|
{navItems.map((i) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={i.to}
|
key={i.to}
|
||||||
selected={activeTo === 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={() => {
|
onClick={() => {
|
||||||
navigate(i.to)
|
navigate(i.to)
|
||||||
setMobileOpen(false)
|
setMobileOpen(false)
|
||||||
@@ -117,14 +128,38 @@ export function AdminLayoutPage() {
|
|||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||||
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню админки">
|
<IconButton
|
||||||
<MenuOutlinedIcon />
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Открыть панель админки"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'warning.300',
|
||||||
|
bgcolor: 'warning.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AdminPanelSettingsOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||||
Админка
|
Админка
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</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}
|
{nav}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { formatPriceRub } from '@/shared/lib/format-price'
|
|||||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
|
|
||||||
export function AdminOrdersPage() {
|
export function AdminOrdersPage() {
|
||||||
@@ -92,6 +93,7 @@ export function AdminOrdersPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const detail = orderDetailQuery.data?.item
|
const detail = orderDetailQuery.data?.item
|
||||||
|
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||||
|
|
||||||
const nextStatuses = useMemo(() => {
|
const nextStatuses = useMemo(() => {
|
||||||
if (!detail) return []
|
if (!detail) return []
|
||||||
@@ -238,11 +240,24 @@ export function AdminOrdersPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||||
{detail.messages.map((m) => (
|
{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">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{m.authorType} · {new Date(m.createdAt).toLocaleString()}
|
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||||||
|
{new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
|
<RichTextMessageContent value={m.text} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||||
@@ -255,7 +270,7 @@ export function AdminOrdersPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => msgMut.mutate()}
|
onClick={() => msgMut.mutate()}
|
||||||
disabled={msgMut.isPending || !msg.trim()}
|
disabled={msgMut.isPending || !canSendMessage}
|
||||||
sx={{ minWidth: 160 }}
|
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 { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
|
||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
|
|
||||||
export function AdminReviewsPage() {
|
export function AdminReviewsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -68,7 +69,7 @@ export function AdminReviewsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip label={String(r.rating)} size="small" />
|
<Chip label={String(r.rating)} size="small" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{r.text ?? '—'}</TableCell>
|
<TableCell>{r.text?.trim() ? <RichTextMessageContent value={r.text} /> : '—'}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ import ToggleButton from '@mui/material/ToggleButton'
|
|||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
||||||
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
||||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
const isAdmin = Boolean(user?.isAdmin)
|
||||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||||
const [qInput, setQInput] = useState('')
|
const [qInput, setQInput] = useState('')
|
||||||
@@ -367,10 +371,12 @@ export function HomePage() {
|
|||||||
product={p}
|
product={p}
|
||||||
mediaHeight={mediaHeight}
|
mediaHeight={mediaHeight}
|
||||||
actions={
|
actions={
|
||||||
<ToggleCartIcon
|
!isAdmin ? (
|
||||||
productId={p.id}
|
<ToggleCartIcon
|
||||||
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
|
productId={p.id}
|
||||||
/>
|
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { ReactNode } from 'react'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'
|
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined'
|
||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
|
||||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
|
||||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'
|
||||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
|
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
|
||||||
|
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Badge from '@mui/material/Badge'
|
import Badge from '@mui/material/Badge'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
@@ -67,6 +67,9 @@ export function MeLayoutPage() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||||
}
|
}
|
||||||
|
if (user.isAdmin) {
|
||||||
|
return <Navigate to="/admin" replace />
|
||||||
|
}
|
||||||
|
|
||||||
const activeTo =
|
const activeTo =
|
||||||
navItems.find((x) => location.pathname === x.to)?.to ??
|
navItems.find((x) => location.pathname === x.to)?.to ??
|
||||||
@@ -74,8 +77,8 @@ export function MeLayoutPage() {
|
|||||||
null
|
null
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
<Box sx={{ width: 280, maxWidth: '85vw' }}>
|
<Box sx={{ width: 300, maxWidth: '88vw', py: 1 }}>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ px: 2, py: 2, mx: 1, borderRadius: 2, bgcolor: 'action.hover' }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
Кабинет
|
Кабинет
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -83,12 +86,23 @@ export function MeLayoutPage() {
|
|||||||
{user.name?.trim() || user.email}
|
{user.name?.trim() || user.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
<Divider sx={{ my: 1 }} />
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{navItems.map((i) => (
|
{navItems.map((i) => (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
key={i.to}
|
key={i.to}
|
||||||
selected={activeTo === 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={() => {
|
onClick={() => {
|
||||||
navigate(i.to)
|
navigate(i.to)
|
||||||
setMobileOpen(false)
|
setMobileOpen(false)
|
||||||
@@ -119,14 +133,38 @@ export function MeLayoutPage() {
|
|||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<>
|
<>
|
||||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||||
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
|
<IconButton
|
||||||
<MenuOutlinedIcon />
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Открыть меню кабинета"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TuneOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||||
Профиль
|
Профиль
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</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}
|
{nav}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Link as RouterLink } from 'react-router-dom'
|
|||||||
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
||||||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
|
|
||||||
export function MessagesPage() {
|
export function MessagesPage() {
|
||||||
@@ -57,6 +58,7 @@ export function MessagesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const order = orderQuery.data?.item
|
const order = orderQuery.data?.item
|
||||||
|
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -166,12 +168,15 @@ export function MessagesPage() {
|
|||||||
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
|
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
|
||||||
|
width: 'fit-content',
|
||||||
|
maxWidth: '85%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
|
<RichTextMessageContent value={m.text} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||||
@@ -184,7 +189,7 @@ export function MessagesPage() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{ minWidth: 140 }}
|
sx={{ minWidth: 140 }}
|
||||||
onClick={() => msgMut.mutate()}
|
onClick={() => msgMut.mutate()}
|
||||||
disabled={msgMut.isPending || !text.trim()}
|
disabled={msgMut.isPending || !canSendMessage}
|
||||||
>
|
>
|
||||||
Отправить
|
Отправить
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { postProductReview, uploadReviewImage } from '@/entities/product/api/rev
|
|||||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
|
|
||||||
function reviewSubmitErrorMessage(err: unknown): string {
|
function reviewSubmitErrorMessage(err: unknown): string {
|
||||||
@@ -95,6 +96,7 @@ export function OrderDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const order = orderQuery.data?.item
|
const order = orderQuery.data?.item
|
||||||
|
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||||
|
|
||||||
const eligibilityQuery = useQuery({
|
const eligibilityQuery = useQuery({
|
||||||
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
||||||
@@ -322,12 +324,15 @@ export function OrderDetailPage() {
|
|||||||
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
|
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
|
||||||
|
width: 'fit-content',
|
||||||
|
maxWidth: '85%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
|
<RichTextMessageContent value={m.text} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||||
@@ -340,7 +345,7 @@ export function OrderDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => msgMut.mutate()}
|
onClick={() => msgMut.mutate()}
|
||||||
disabled={msgMut.isPending || !text.trim()}
|
disabled={msgMut.isPending || !canSendMessage}
|
||||||
sx={{ minWidth: 160 }}
|
sx={{ minWidth: 160 }}
|
||||||
>
|
>
|
||||||
Отправить
|
Отправить
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Skeleton from '@mui/material/Skeleton'
|
|||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Navigation } from 'swiper/modules'
|
import { Navigation } from 'swiper/modules'
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
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 { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
|
|
||||||
export function ProductPage() {
|
export function ProductPage() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
const isAdmin = Boolean(user?.isAdmin)
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [viewerOpen, setViewerOpen] = useState(false)
|
const [viewerOpen, setViewerOpen] = useState(false)
|
||||||
const [viewerIndex, setViewerIndex] = useState(0)
|
const [viewerIndex, setViewerIndex] = useState(0)
|
||||||
@@ -150,7 +155,7 @@ export function ProductPage() {
|
|||||||
{formatPriceRub(p.priceCents)}
|
{formatPriceRub(p.priceCents)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<ToggleCartIcon productId={p.id} size="medium" />
|
{!isAdmin && <ToggleCartIcon productId={p.id} size="medium" />}
|
||||||
|
|
||||||
{!p.inStock && (
|
{!p.inStock && (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
@@ -210,9 +215,9 @@ export function ProductPage() {
|
|||||||
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
|
emptyIcon={<StarRoundedIcon fontSize="inherit" />}
|
||||||
/>
|
/>
|
||||||
{body ? (
|
{body ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap' }}>
|
<Box sx={{ color: 'text.secondary' }}>
|
||||||
{body}
|
<RichTextMessageContent value={body} />
|
||||||
</Typography>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Без текстового комментария.
|
Без текстового комментария.
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
|
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||||
|
|
||||||
|
type RichTextMessageContentProps = {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextMessageContent({ value }: RichTextMessageContentProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
|
||||||
|
],
|
||||||
|
content: value.trim() ? value : '<p></p>',
|
||||||
|
editable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const normalizedValue = value.trim() ? value : '<p></p>'
|
||||||
|
if (editor.getHTML() === normalizedValue) return
|
||||||
|
editor.commands.setContent(normalizedValue, false)
|
||||||
|
}, [editor, value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
'& .ProseMirror': {
|
||||||
|
outline: 'none',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
},
|
||||||
|
'& .ProseMirror p': {
|
||||||
|
m: 0,
|
||||||
|
},
|
||||||
|
'& .ProseMirror ul, & .ProseMirror ol': {
|
||||||
|
m: 0,
|
||||||
|
pl: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,14 +22,19 @@ export function RichTextMessageEditor({
|
|||||||
placeholder = 'Введите сообщение',
|
placeholder = 'Введите сообщение',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: RichTextMessageEditorProps) {
|
}: RichTextMessageEditorProps) {
|
||||||
|
const initialContent = value.trim() ? value : '<p></p>'
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
|
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
|
||||||
Placeholder.configure({ placeholder }),
|
Placeholder.configure({ placeholder }),
|
||||||
],
|
],
|
||||||
content: value,
|
content: initialContent,
|
||||||
editable: !disabled,
|
editable: !disabled,
|
||||||
onUpdate: ({ editor: tiptap }) => onChange(tiptap.getText()),
|
onUpdate: ({ editor: tiptap }) => {
|
||||||
|
const plainText = tiptap.getText().trim()
|
||||||
|
onChange(plainText ? tiptap.getHTML() : '')
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,8 +44,9 @@ export function RichTextMessageEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
if (editor.getText() === value) return
|
const normalizedValue = value.trim() ? value : '<p></p>'
|
||||||
editor.commands.setContent(value, false)
|
if (editor.getHTML() === normalizedValue) return
|
||||||
|
editor.commands.setContent(normalizedValue, false)
|
||||||
}, [editor, value])
|
}, [editor, value])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api'
|
import { fetchLatestApprovedReviews } from '@/entities/product/api/reviews-api'
|
||||||
|
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||||
|
|
||||||
function initials(display: string) {
|
function initials(display: string) {
|
||||||
const s = display.trim()
|
const s = display.trim()
|
||||||
@@ -105,9 +106,9 @@ export function ReviewsBlock() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Typography color="text.secondary" sx={{ whiteSpace: 'pre-wrap', flex: 1 }}>
|
<Box sx={{ color: 'text.secondary', flex: 1 }}>
|
||||||
{text}
|
<RichTextMessageContent value={text} />
|
||||||
</Typography>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
{r.imageUrl && (
|
{r.imageUrl && (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Reference in New Issue
Block a user