base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
-43
View File
@@ -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()}>
+41 -26
View File
@@ -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>
)