Merge branch 'refactor'
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { LatLng, NominatimItem } from '../model/types'
|
||||
|
||||
export async function reverseGeocode(pos: LatLng): Promise<string | null> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/reverse')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('lat', String(pos.lat))
|
||||
url.searchParams.set('lon', String(pos.lng))
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { display_name?: string }
|
||||
return data.display_name ? String(data.display_name) : null
|
||||
}
|
||||
|
||||
export async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/search')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('q', q)
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
url.searchParams.set('limit', '5')
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': 'craftshop-demo' },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as NominatimItem[]
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AddressMapPicker } from './ui/AddressMapPicker'
|
||||
@@ -0,0 +1,3 @@
|
||||
export type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||
|
||||
export type LatLng = { lat: number; lng: number }
|
||||
@@ -13,37 +13,8 @@ import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||
|
||||
type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||
|
||||
async function reverseGeocode(pos: { lat: number; lng: number }): Promise<string | null> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/reverse')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('lat', String(pos.lat))
|
||||
url.searchParams.set('lon', String(pos.lng))
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { display_name?: string }
|
||||
return data.display_name ? String(data.display_name) : null
|
||||
}
|
||||
|
||||
type LatLng = { lat: number; lng: number }
|
||||
|
||||
async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/search')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('q', q)
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
url.searchParams.set('limit', '5')
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': 'craftshop-demo' },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as NominatimItem[]
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||
import type { LatLng } from '../model/types'
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
value: { lat: number; lng: number } | null
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CartBadge } from './ui/CartBadge'
|
||||
@@ -0,0 +1,31 @@
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import type { AuthUser } from '@/shared/model/auth'
|
||||
|
||||
type Props = {
|
||||
user: AuthUser | null
|
||||
cartCount: number
|
||||
onNavigate: (to: string) => void
|
||||
}
|
||||
|
||||
export function CartBadge({ user, cartCount, onNavigate }: Props) {
|
||||
return (
|
||||
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
if (!user) onNavigate('/auth')
|
||||
else onNavigate('/cart')
|
||||
}}
|
||||
aria-label="Корзина"
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCartOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderChat } from './ui/OrderChat'
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
authorType: 'user' | 'admin'
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
messages: Message[]
|
||||
isPending: boolean
|
||||
onSend: (text: string) => void
|
||||
}
|
||||
|
||||
export function OrderChat({ messages, isPending, onSend }: Props) {
|
||||
const [text, setText] = useState('')
|
||||
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
|
||||
const handleSend = () => {
|
||||
if (!canSend || isPending) return
|
||||
onSend(text.trim())
|
||||
setText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Чат по заказу
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
{messages.map((m) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
|
||||
</Box>
|
||||
<Button variant="contained" onClick={handleSend} disabled={isPending || !canSend} sx={{ minWidth: 160 }}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
||||
export { PaymentDialog } from './ui/PaymentDialog'
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { PaymentDialog } from './PaymentDialog'
|
||||
|
||||
type Props = {
|
||||
status: string
|
||||
paymentMethod: string | null
|
||||
deliveryType: string
|
||||
totalCents: number
|
||||
isPayPending: boolean
|
||||
payError: unknown
|
||||
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
||||
}
|
||||
|
||||
export function OrderPaymentSection({ status, paymentMethod, deliveryType, isPayPending, payError, onPay }: Props) {
|
||||
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||
const [payModalOpen, setPayModalOpen] = useState(false)
|
||||
|
||||
if (payOnPickup) {
|
||||
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">
|
||||
Оплата при получении на точке самовывоза (наличные или карта — по договорённости).
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Оплата
|
||||
</Typography>
|
||||
{status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}».
|
||||
</Typography>
|
||||
)}
|
||||
{status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
||||
{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
||||
Оплатить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'PAYMENT_VERIFICATION' && (
|
||||
<Typography color="info.main" variant="body2">
|
||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||||
</Typography>
|
||||
)}
|
||||
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(status) && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате в этом блоке не требуется.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<PaymentDialog
|
||||
open={payModalOpen}
|
||||
isPending={isPayPending}
|
||||
error={payError}
|
||||
onClose={() => setPayModalOpen(false)}
|
||||
onSubmit={(params) => {
|
||||
onPay(params)
|
||||
setPayModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo, 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 Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import axios from 'axios'
|
||||
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
isPending: boolean
|
||||
error: unknown
|
||||
onClose: () => void
|
||||
onSubmit: (params: { detail: string; receiptFile: File | null }) => void
|
||||
}
|
||||
|
||||
function paySubmitErrorMessage(err: unknown): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
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
|
||||
return apiMsg || err.message || 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'Не удалось отправить данные оплаты'
|
||||
}
|
||||
|
||||
export function PaymentDialog({ open, isPending, error, onClose, onSubmit }: Props) {
|
||||
const [detail, setDetail] = useState('')
|
||||
const [receiptFile, setReceiptFile] = useState<File | null>(null)
|
||||
const [clientError, setClientError] = useState<string | null>(null)
|
||||
|
||||
const receiptPreviewUrl = useMemo(() => {
|
||||
if (!receiptFile) return null
|
||||
return URL.createObjectURL(receiptFile)
|
||||
}, [receiptFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!receiptPreviewUrl) return
|
||||
return () => URL.revokeObjectURL(receiptPreviewUrl)
|
||||
}, [receiptPreviewUrl])
|
||||
|
||||
const reset = () => {
|
||||
setDetail('')
|
||||
setReceiptFile(null)
|
||||
setClientError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const hasText = detail.trim().length > 0
|
||||
const hasFile = Boolean(receiptFile)
|
||||
if (!hasText && !hasFile) {
|
||||
setClientError('Укажите комментарий и/или прикрепите чек.')
|
||||
return
|
||||
}
|
||||
setClientError(null)
|
||||
onSubmit({ detail: detail.trim(), receiptFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Подтверждение оплаты</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 2 }}>
|
||||
{PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Комментарий об оплате (сумма, время перевода и т.д.)"
|
||||
value={detail}
|
||||
onChange={(e) => {
|
||||
setDetail(e.target.value)
|
||||
setClientError(null)
|
||||
}}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 1, alignItems: { sm: 'center' } }}>
|
||||
<Button component="label" variant="outlined">
|
||||
Прикрепить чек (png, jpg, webp)
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
setReceiptFile(file ?? null)
|
||||
setClientError(null)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{receiptFile && (
|
||||
<Button color="error" variant="text" onClick={() => setReceiptFile(null)}>
|
||||
Убрать файл
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
Нужен текст комментария и/или изображение чека.
|
||||
</Typography>
|
||||
{receiptPreviewUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={receiptPreviewUrl}
|
||||
alt="Предпросмотр чека"
|
||||
sx={{ maxWidth: '100%', maxHeight: 200, borderRadius: 1, border: 1, borderColor: 'divider', mb: 1 }}
|
||||
/>
|
||||
)}
|
||||
{clientError && (
|
||||
<Alert severity="warning" sx={{ mb: 1 }}>
|
||||
{clientError}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{paySubmitErrorMessage(error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
|
||||
Подтвердить оплату
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserMenu } from './ui/UserMenu'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import type { AuthUser } from '@/shared/model/auth'
|
||||
|
||||
type Props = {
|
||||
user: AuthUser | null
|
||||
onNavigate: (to: string) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function UserMenu({ user, onNavigate, onLogout }: Props) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
const openMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
|
||||
const closeMenu = () => setAnchorEl(null)
|
||||
|
||||
const go = (to: string) => {
|
||||
closeMenu()
|
||||
onNavigate(to)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
closeMenu()
|
||||
onLogout()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={openMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
invisible={!user}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<AccountCircleOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={closeMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={() => go('/me')}>
|
||||
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user