Merge branch 'refactor'

This commit is contained in:
@kirill.komarov
2026-05-13 22:07:46 +05:00
parent 3c9797af4a
commit a06f9cf2c4
85 changed files with 3762 additions and 2072 deletions
@@ -0,0 +1,2 @@
export { ReviewSection } from './ui/ReviewSection'
export { ReviewDialog } from './ui/ReviewDialog'
@@ -0,0 +1,150 @@
import { 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 Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import axios from 'axios'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
type Props = {
productTitle: string | null
open: boolean
isPending: boolean
isUploadingImage: boolean
error: unknown
uploadError: unknown
onClose: () => void
onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => void
onUploadImage: (file: File) => void
}
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 'Не удалось отправить отзыв'
}
export function ReviewDialog({
productTitle,
open,
isPending,
isUploadingImage,
error,
uploadError,
onClose,
onSubmit,
onUploadImage,
}: Props) {
const [rating, setRating] = useState<number>(5)
const [text, setText] = useState('')
const [imageUrl, setImageUrl] = useState<string | null>(null)
const reset = () => {
setRating(5)
setText('')
setImageUrl(null)
}
const handleClose = () => {
if (isPending) return
reset()
onClose()
}
const handleSubmit = () => {
if (isPending) return
onSubmit({ rating, text: text.trim(), imageUrl })
}
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Отзыв: {productTitle}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Оценка
</Typography>
<Rating
value={rating}
onChange={(_, v) => {
if (v !== null) setRating(v)
}}
/>
<Box sx={{ mt: 2 }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Комментарий (необязательно)" />
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
<Button component="label" variant="outlined" disabled={isUploadingImage}>
{imageUrl ? 'Заменить фото' : 'Прикрепить фото'}
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
onUploadImage(file)
e.currentTarget.value = ''
}}
/>
</Button>
{imageUrl && (
<Button color="error" variant="text" onClick={() => setImageUrl(null)} disabled={isPending}>
Удалить фото
</Button>
)}
</Stack>
{imageUrl && (
<Box
component="img"
src={imageUrl}
alt="Фото к отзыву"
sx={{
mt: 1,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
{uploadError && (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(error)}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={isPending}>
Отмена
</Button>
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
Отправить
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,93 @@
import { useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { ReviewDialog } from './ReviewDialog'
type EligibileItem = {
productId: string
title: string
hasReview: boolean
}
type Props = {
items: EligibileItem[]
isSubmitPending: boolean
isUploadPending: boolean
submitError: unknown
uploadError: unknown
onSubmitReview: (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => void
onUploadImage: (file: File) => Promise<{ url: string }>
}
export function ReviewSection({
items,
isSubmitPending,
isUploadPending,
submitError,
uploadError,
onSubmitReview,
onUploadImage,
}: Props) {
const [target, setTarget] = useState<{ productId: string; title: string } | null>(null)
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null)
if (items.length === 0) return null
return (
<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}>
{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={() => setTarget({ productId: row.productId, title: row.title })}
>
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
</Button>
</Stack>
))}
</Stack>
<ReviewDialog
productTitle={target?.title ?? null}
open={Boolean(target)}
isPending={isSubmitPending}
isUploadingImage={isUploadPending}
error={submitError}
uploadError={uploadError}
onClose={() => {
setTarget(null)
setUploadedImageUrl(null)
}}
onSubmit={(params) => {
if (!target) return
onSubmitReview({
productId: target.productId,
...params,
imageUrl: uploadedImageUrl,
})
}}
onUploadImage={async (file) => {
const result = await onUploadImage(file)
setUploadedImageUrl(result.url)
}}
/>
</Box>
)
}