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,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>
)
}
+1
View File
@@ -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>
</>
)
}