base commit
This commit is contained in:
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
$changePasswordError,
|
||||
$requestEmailChangeCodeError,
|
||||
$updateProfileError,
|
||||
$user,
|
||||
$verifyEmailChangeError,
|
||||
changePasswordFx,
|
||||
requestEmailChangeCodeFx,
|
||||
updateProfileFx,
|
||||
verifyEmailChangeFx,
|
||||
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
|
||||
|
||||
export function MePage() {
|
||||
const user = useUnit($user)
|
||||
const pendingPassword = useUnit(changePasswordFx.pending)
|
||||
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
|
||||
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
|
||||
const pendingProfile = useUnit(updateProfileFx.pending)
|
||||
const errorPassword = useUnit($changePasswordError)
|
||||
const errorEmailReq = useUnit($requestEmailChangeCodeError)
|
||||
const errorProfile = useUnit($updateProfileError)
|
||||
const errorEmailVerify = useUnit($verifyEmailChangeError)
|
||||
|
||||
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
|
||||
defaultValues: { currentPassword: '', newPassword: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const emailForm = useForm<{ newEmail: string; code: string }>({
|
||||
defaultValues: { newEmail: '', code: '' },
|
||||
mode: 'onChange',
|
||||
@@ -52,7 +43,6 @@ export function MePage() {
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const passwordErrorMsg = getApiErrorMessage(errorPassword)
|
||||
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
|
||||
const profileErrorMsg = getApiErrorMessage(errorProfile)
|
||||
|
||||
@@ -69,11 +59,6 @@ export function MePage() {
|
||||
Текущая почта: <b>{user.email}</b>
|
||||
</Typography>
|
||||
|
||||
{passwordErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{passwordErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{emailErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{emailErrorMsg}
|
||||
@@ -143,34 +128,6 @@ export function MePage() {
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена пароля
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Текущий пароль (если установлен)"
|
||||
type="password"
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
|
||||
onClick={() =>
|
||||
changePasswordFx({
|
||||
currentPassword: passwordForm.getValues('currentPassword') || undefined,
|
||||
newPassword: passwordForm.getValues('newPassword'),
|
||||
})
|
||||
}
|
||||
>
|
||||
Сохранить пароль
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -8,13 +8,13 @@ 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'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
export function MessagesPage() {
|
||||
const qc = useQueryClient()
|
||||
@@ -177,14 +177,9 @@ export function MessagesPage() {
|
||||
{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}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ minWidth: 140 }}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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'
|
||||
@@ -21,10 +20,11 @@ import {
|
||||
payOrderStub,
|
||||
postOrderMessage,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview } from '@/entities/product/api/reviews-api'
|
||||
import { postProductReview, uploadReviewImage } 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'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
function reviewSubmitErrorMessage(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
@@ -59,6 +59,7 @@ export function OrderDetailPage() {
|
||||
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
|
||||
const [reviewRating, setReviewRating] = useState<number>(5)
|
||||
const [reviewText, setReviewText] = useState('')
|
||||
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
|
||||
|
||||
const orderQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id],
|
||||
@@ -108,16 +109,23 @@ export function OrderDetailPage() {
|
||||
await postProductReview(reviewTarget.productId, {
|
||||
rating: reviewRating,
|
||||
text: t.length ? t : null,
|
||||
imageUrl: reviewImageUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||||
},
|
||||
})
|
||||
|
||||
const uploadReviewImageMut = useMutation({
|
||||
mutationFn: (file: File) => uploadReviewImage(file),
|
||||
onSuccess: ({ url }) => setReviewImageUrl(url),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || orderQuery.status !== 'success' || !order) return
|
||||
void (async () => {
|
||||
@@ -326,14 +334,9 @@ export function OrderDetailPage() {
|
||||
</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}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
@@ -348,7 +351,13 @@ export function OrderDetailPage() {
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reviewTarget)}
|
||||
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
|
||||
onClose={() => {
|
||||
if (reviewMut.isPending) return
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
@@ -363,15 +372,60 @@ export function OrderDetailPage() {
|
||||
if (v !== null) setReviewRating(v)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ mt: 2 }}
|
||||
label="Комментарий (необязательно)"
|
||||
value={reviewText}
|
||||
onChange={(e) => setReviewText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<RichTextMessageEditor
|
||||
value={reviewText}
|
||||
onChange={setReviewText}
|
||||
placeholder="Комментарий (необязательно)"
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
|
||||
<Button component="label" variant="outlined" disabled={uploadReviewImageMut.isPending}>
|
||||
{reviewImageUrl ? 'Заменить фото' : 'Прикрепить фото'}
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
uploadReviewImageMut.mutate(file)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{reviewImageUrl && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="text"
|
||||
onClick={() => setReviewImageUrl(null)}
|
||||
disabled={reviewMut.isPending}
|
||||
>
|
||||
Удалить фото
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{reviewImageUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={reviewImageUrl}
|
||||
alt="Фото к отзыву"
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: 120,
|
||||
height: 120,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uploadReviewImageMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||||
</Alert>
|
||||
)}
|
||||
{reviewMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(reviewMut.error)}
|
||||
@@ -379,7 +433,15 @@ export function OrderDetailPage() {
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
setReviewImageUrl(null)
|
||||
}}
|
||||
disabled={reviewMut.isPending}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { fetchMyOrders } from '@/entities/order/api/order-api'
|
||||
import { ORDER_STATUSES } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
|
||||
export function OrdersPage() {
|
||||
@@ -15,7 +19,8 @@ export function OrdersPage() {
|
||||
queryFn: fetchMyOrders,
|
||||
})
|
||||
|
||||
const items = ordersQuery.data?.items ?? []
|
||||
const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items])
|
||||
const groups = useMemo(() => groupOrdersByStatus(items, ORDER_STATUSES), [items])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -29,33 +34,43 @@ export function OrdersPage() {
|
||||
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
|
||||
)}
|
||||
|
||||
<Stack spacing={2}>
|
||||
{items.map((o) => (
|
||||
<Box
|
||||
key={o.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
{groups.map((group) => (
|
||||
<Box key={group.status}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{orderStatusLabelRu(group.status)} ({group.items.length})
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{group.items.map((o) => (
|
||||
<Box
|
||||
key={o.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{new Date(o.createdAt).toLocaleString('ru-RU')} · {o.itemsCount} поз.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
|
||||
Открыть
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
|
||||
Открыть
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
$changePasswordError,
|
||||
$requestEmailChangeCodeError,
|
||||
$updateProfileError,
|
||||
$user,
|
||||
$verifyEmailChangeError,
|
||||
changePasswordFx,
|
||||
requestEmailChangeCodeFx,
|
||||
updateProfileFx,
|
||||
verifyEmailChangeFx,
|
||||
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
|
||||
|
||||
export function SettingsPage() {
|
||||
const user = useUnit($user)
|
||||
const pendingPassword = useUnit(changePasswordFx.pending)
|
||||
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
|
||||
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
|
||||
const pendingProfile = useUnit(updateProfileFx.pending)
|
||||
const errorPassword = useUnit($changePasswordError)
|
||||
const errorEmailReq = useUnit($requestEmailChangeCodeError)
|
||||
const errorProfile = useUnit($updateProfileError)
|
||||
const errorEmailVerify = useUnit($verifyEmailChangeError)
|
||||
|
||||
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
|
||||
defaultValues: { currentPassword: '', newPassword: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const emailForm = useForm<{ newEmail: string; code: string }>({
|
||||
defaultValues: { newEmail: '', code: '' },
|
||||
mode: 'onChange',
|
||||
@@ -52,7 +43,6 @@ export function SettingsPage() {
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const passwordErrorMsg = getApiErrorMessage(errorPassword)
|
||||
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
|
||||
const profileErrorMsg = getApiErrorMessage(errorProfile)
|
||||
|
||||
@@ -69,11 +59,6 @@ export function SettingsPage() {
|
||||
Текущая почта: <b>{user.email}</b>
|
||||
</Typography>
|
||||
|
||||
{passwordErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{passwordErrorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
{emailErrorMsg && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{emailErrorMsg}
|
||||
@@ -150,34 +135,6 @@ export function SettingsPage() {
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена пароля
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Текущий пароль (если установлен)"
|
||||
type="password"
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
|
||||
onClick={() =>
|
||||
changePasswordFx({
|
||||
currentPassword: passwordForm.getValues('currentPassword') || undefined,
|
||||
newPassword: passwordForm.getValues('newPassword'),
|
||||
})
|
||||
}
|
||||
>
|
||||
Сохранить пароль
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user