initial: client
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { AddressFormDialog } from './ui/AddressFormDialog'
|
||||
export type { AddressFormValues } from './ui/AddressFormDialog'
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
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 FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||
|
||||
export type AddressFormValues = {
|
||||
label: string
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export function AddressFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
editing,
|
||||
form,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
editing: boolean
|
||||
form: UseFormReturn<AddressFormValues>
|
||||
onSubmit: () => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientName"
|
||||
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientPhone"
|
||||
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="addressLine"
|
||||
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lat"
|
||||
render={({ field: latField }) => (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lng"
|
||||
render={({ field: lngField }) => (
|
||||
<AddressMapPicker
|
||||
value={
|
||||
latField.value !== null && lngField.value !== null
|
||||
? { lat: latField.value, lng: lngField.value }
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
latField.onChange(v.lat)
|
||||
lngField.onChange(v.lng)
|
||||
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isDefault"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Адрес по умолчанию"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
isPending ||
|
||||
!form.watch('recipientName').trim() ||
|
||||
!form.watch('recipientPhone').trim() ||
|
||||
!form.watch('addressLine').trim()
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 : []
|
||||
}
|
||||
+1
@@ -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 }
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import List from '@mui/material/List'
|
||||
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 { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||
import { MapPickerMap } from './MapPickerMap'
|
||||
import type { LatLng, NominatimItem } from '../model/types'
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
value: { lat: number; lng: number } | null
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
}) {
|
||||
const { value, onChange } = props
|
||||
const [q, setQ] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<NominatimItem[]>([])
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastQueryRef = useRef<string>('')
|
||||
const lastRequestAtRef = useRef<number>(0)
|
||||
|
||||
const qTrimmed = q.trim()
|
||||
const visibleResults = qTrimmed.length >= 3 ? results : []
|
||||
|
||||
const center = useMemo(() => {
|
||||
if (value) return { lat: value.lat, lng: value.lng }
|
||||
return { lat: 55.751244, lng: 37.618423 }
|
||||
}, [value])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
setHint(null)
|
||||
onChange({ lat: pos.lat, lng: pos.lng })
|
||||
try {
|
||||
const addr = await reverseGeocode(pos)
|
||||
if (addr) {
|
||||
setHint(addr)
|
||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[address-map-picker] Failed to reverse geocode', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const s = qTrimmed
|
||||
if (s.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
const t = window.setTimeout(async () => {
|
||||
const now = Date.now()
|
||||
if (now - lastRequestAtRef.current < 900) return
|
||||
if (s === lastQueryRef.current) return
|
||||
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = now
|
||||
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} catch (e) {
|
||||
if ((e as { name?: string })?.name !== 'AbortError') {
|
||||
setResults([])
|
||||
}
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 450)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
}
|
||||
}, [qTrimmed])
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Выбор на карте</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={async () => {
|
||||
const s = q.trim()
|
||||
if (!s) return
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
setSearching(true)
|
||||
try {
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = Date.now()
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}}
|
||||
disabled={searching || !q.trim()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
{searching ? <CircularProgress size={18} /> : 'Найти'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{visibleResults.length > 0 && (
|
||||
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||
{visibleResults.map((r) => (
|
||||
<ListItemButton
|
||||
key={`${r.lat}:${r.lon}:${r.display_name}`}
|
||||
onClick={() => {
|
||||
const lat = Number(r.lat)
|
||||
const lng = Number(r.lon)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={r.display_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<MapPickerMap value={value} onChange={onChange} center={center} />
|
||||
|
||||
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{hint && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Подсказка адреса: {hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||
import { reverseGeocode } from '../api/map-geocoding'
|
||||
import type { LatLng } from '../model/types'
|
||||
import type * as maplibregl from 'maplibre-gl'
|
||||
|
||||
let maplibreglPromise: Promise<typeof maplibregl> | null = null
|
||||
|
||||
function loadMaplibre() {
|
||||
if (!maplibreglPromise) {
|
||||
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
|
||||
([mod]) => mod,
|
||||
)
|
||||
}
|
||||
return maplibreglPromise
|
||||
}
|
||||
|
||||
type MapPickerMapProps = {
|
||||
value: { lat: number; lng: number } | null
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
center: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
|
||||
const mapRef = useRef<MapRef | null>(null)
|
||||
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [locating, setLocating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadMaplibre().then((mod) => {
|
||||
if (!cancelled) {
|
||||
setMaplibre(mod)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
onChange({ lat: pos.lat, lng: pos.lng })
|
||||
try {
|
||||
const addr = await reverseGeocode(pos)
|
||||
if (addr) {
|
||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[map-picker] Failed to reverse geocode', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !maplibre) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 280,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 280,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Map
|
||||
mapLib={maplibre}
|
||||
ref={mapRef}
|
||||
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
|
||||
style={{ width: '100%', height: 280 }}
|
||||
mapStyle={{
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
}}
|
||||
onClick={(e: MapMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
{value && (
|
||||
<Marker
|
||||
longitude={value.lng}
|
||||
latitude={value.lat}
|
||||
draggable
|
||||
onDragEnd={(e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
anchor="bottom"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '50%',
|
||||
border: 2,
|
||||
borderColor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
<Tooltip title="Моё местоположение">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={locating || !('geolocation' in navigator)}
|
||||
onClick={() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setLocating(true)
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude
|
||||
const lng = pos.coords.longitude
|
||||
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
|
||||
void pick({ lat, lng })
|
||||
setLocating(false)
|
||||
},
|
||||
() => {
|
||||
setLocating(false)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
|
||||
)
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'background.paper',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
}}
|
||||
aria-label="Моё местоположение"
|
||||
>
|
||||
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthCodeForm } from './ui/AuthCodeForm'
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCodeForm } from '../ui/AuthCodeForm'
|
||||
|
||||
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
|
||||
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
|
||||
|
||||
function renderForm() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const onSuccess = vi.fn()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<AuthCodeForm onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthCodeForm', () => {
|
||||
it('renders email field, code field, and buttons', () => {
|
||||
renderForm()
|
||||
expect(screen.getByLabelText(/Email/i)).toBeTruthy()
|
||||
expect(screen.getByLabelText(/Код/i)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables send button when email is empty', () => {
|
||||
renderForm()
|
||||
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables login button when code.length !== 6', () => {
|
||||
renderForm()
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123' } })
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables login button when code is 6 digits', async () => {
|
||||
renderForm()
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSuccess after successful verify', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
const { tokenSet } = await import('@/shared/model/auth')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
|
||||
renderForm()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
|
||||
await waitFor(() => {
|
||||
expect(tokenSet).toHaveBeenCalledWith('test-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function AuthCodeForm({ onSuccess }: Props) {
|
||||
const { register, watch } = useForm<FormValues>({
|
||||
defaultValues: { email: '', code: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const code = watch('code')
|
||||
|
||||
const requestCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/request-code', { email })
|
||||
},
|
||||
})
|
||||
|
||||
const verifyCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => requestCodeMutation.mutate()}
|
||||
disabled={!email || requestCodeMutation.isPending}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Отправить код
|
||||
</Button>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => verifyCodeMutation.mutate()}
|
||||
disabled={!email || code.length !== 6 || verifyCodeMutation.isPending}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(requestCodeMutation.error || verifyCodeMutation.error) && (
|
||||
<TextField
|
||||
error
|
||||
helperText={getApiErrorMessage(requestCodeMutation.error) || getApiErrorMessage(verifyCodeMutation.error)}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Нажимая «Войти», вы принимаете{' '}
|
||||
<Link component={RouterLink} to="/terms" underline="hover">
|
||||
пользовательское соглашение
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||
политику конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthForgotForm } from './ui/AuthForgotForm'
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Lock, Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
|
||||
type Step = 'request' | 'reset'
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
code: string
|
||||
newPassword: string
|
||||
passwordConfirm: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function AuthForgotForm({ onBack }: Props) {
|
||||
const [step, setStep] = useState<Step>('request')
|
||||
|
||||
const { register, watch } = useForm<FormValues>({
|
||||
defaultValues: { email: '', code: '', newPassword: '', passwordConfirm: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const code = watch('code')
|
||||
const newPassword = watch('newPassword')
|
||||
const passwordConfirm = watch('passwordConfirm')
|
||||
|
||||
const forgotCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/forgot-password', { email })
|
||||
},
|
||||
onSuccess: () => setStep('reset'),
|
||||
})
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/reset-password', { email, code, newPassword })
|
||||
},
|
||||
})
|
||||
|
||||
const passwordError = newPassword && passwordConfirm && newPassword !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
{step === 'request'
|
||||
? 'Введите email, на который будет отправлен код для сброса пароля'
|
||||
: 'Введите код и новый пароль'}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email')}
|
||||
disabled={step === 'reset'}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{step === 'reset' && (
|
||||
<>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} fullWidth />
|
||||
<TextField
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
{...register('newPassword')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
type="password"
|
||||
{...register('passwordConfirm')}
|
||||
fullWidth
|
||||
error={Boolean(passwordError)}
|
||||
helperText={passwordError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'request' ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!email || forgotCodeMutation.isPending}
|
||||
onClick={() => forgotCodeMutation.mutate()}
|
||||
>
|
||||
Отправить код
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
!code ||
|
||||
code.length !== 6 ||
|
||||
!newPassword ||
|
||||
newPassword.length < 8 ||
|
||||
Boolean(passwordError) ||
|
||||
resetPasswordMutation.isPending
|
||||
}
|
||||
onClick={() => resetPasswordMutation.mutate()}
|
||||
>
|
||||
Сменить пароль
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="text" size="small" onClick={onBack}>
|
||||
Назад к входу
|
||||
</Button>
|
||||
|
||||
{(forgotCodeMutation.error || resetPasswordMutation.error) && (
|
||||
<TextField
|
||||
error
|
||||
helperText={getApiErrorMessage(forgotCodeMutation.error) || getApiErrorMessage(resetPasswordMutation.error)}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { OAuthButtons } from '../ui/OAuthButtons'
|
||||
|
||||
describe('OAuthButtons', () => {
|
||||
it('renders Yandex and VK buttons', () => {
|
||||
render(<OAuthButtons />)
|
||||
expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
|
||||
expect(screen.getByText('Войти через VK ID')).toBeDefined()
|
||||
})
|
||||
|
||||
it('buttons have correct href', () => {
|
||||
render(<OAuthButtons />)
|
||||
const yaBtn = screen.getByText('Войти через Яндекс ID').closest('a')
|
||||
const vkBtn = screen.getByText('Войти через VK ID').closest('a')
|
||||
expect(yaBtn?.getAttribute('href')).toContain('/auth/oauth/yandex')
|
||||
expect(vkBtn?.getAttribute('href')).toContain('/auth/oauth/vk')
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { OAuthButtons } from './ui/OAuthButtons'
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
|
||||
|
||||
export type OAuthProvider = {
|
||||
id: 'yandex' | 'vk'
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const oauthProviders: OAuthProvider[] = [
|
||||
{
|
||||
id: 'yandex',
|
||||
label: 'Яндекс ID',
|
||||
color: '#FC3F1D',
|
||||
},
|
||||
{
|
||||
id: 'vk',
|
||||
label: 'VK ID',
|
||||
color: '#0077FF',
|
||||
},
|
||||
]
|
||||
|
||||
export function getOAuthUrl(provider: 'yandex' | 'vk'): string {
|
||||
return oauthAuthorizeUrl(provider)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers'
|
||||
|
||||
export function OAuthButtons() {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} sx={{ justifyContent: 'center' }}>
|
||||
{oauthProviders.map((p) => (
|
||||
<Button
|
||||
key={p.id}
|
||||
variant="outlined"
|
||||
href={getOAuthUrl(p.id)}
|
||||
sx={{
|
||||
borderColor: p.color,
|
||||
color: p.color,
|
||||
'&:hover': {
|
||||
borderColor: p.color,
|
||||
bgcolor: `${p.color}14`,
|
||||
borderWidth: '1px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Войти через {p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { AuthPasswordForm } from './ui/AuthPasswordForm'
|
||||
@@ -0,0 +1,74 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AuthPasswordForm } from '../ui/AuthPasswordForm'
|
||||
|
||||
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
|
||||
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
|
||||
|
||||
function renderForm(isRegister: boolean) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const onSuccess = vi.fn()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<AuthPasswordForm isRegister={isRegister} onRegisterChange={vi.fn()} onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthPasswordForm', () => {
|
||||
it('renders login button when isRegister=false', () => {
|
||||
renderForm(false)
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
|
||||
expect(screen.getByText('Вход')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders register button and passwordConfirm when isRegister=true', () => {
|
||||
renderForm(true)
|
||||
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeTruthy()
|
||||
expect(screen.getByLabelText(/Подтверждение пароля/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables button when password < 8 chars', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({} as never)
|
||||
renderForm(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: '123' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when passwords do not match', async () => {
|
||||
renderForm(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
|
||||
fireEvent.change(screen.getByLabelText(/Подтверждение пароля/i), { target: { value: 'different' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Пароли не совпадают')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSuccess after successful login', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
const { tokenSet } = await import('@/shared/model/auth')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
|
||||
renderForm(false)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tokenSet).toHaveBeenCalledWith('test-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Lock, Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isRegister: boolean
|
||||
onRegisterChange: (v: boolean) => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const password = watch('password')
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
displayName: values.displayName || undefined,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const apiError = loginMutation.error || registerMutation.error
|
||||
const apiErrorMessage = apiError ? getApiErrorMessage(apiError) : null
|
||||
|
||||
const onSubmit = isRegister
|
||||
? handleSubmit((values) => registerMutation.mutate(values))
|
||||
: handleSubmit((values) => loginMutation.mutate(values))
|
||||
|
||||
const isPending = loginMutation.isPending || registerMutation.isPending
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={onSubmit} noValidate>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: !isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: !isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => onRegisterChange(false)}
|
||||
>
|
||||
Вход
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => onRegisterChange(true)}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email', {
|
||||
required: 'Введите email',
|
||||
pattern: {
|
||||
value: EMAIL_PATTERN,
|
||||
message: 'Некорректный email',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
autoComplete="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Имя (необязательно)"
|
||||
{...register('displayName')}
|
||||
fullWidth
|
||||
helperText="Если не указать, будет использована часть email до @"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
{...register('password', {
|
||||
required: 'Введите пароль',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Пароль должен быть не менее 8 символов',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('passwordConfirm', {
|
||||
required: 'Подтвердите пароль',
|
||||
validate: (value) => value === password || 'Пароли не совпадают',
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(errors.passwordConfirm)}
|
||||
helperText={errors.passwordConfirm?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="outlined"
|
||||
onClose={() => {
|
||||
loginMutation.reset()
|
||||
registerMutation.reset()
|
||||
}}
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isRegister ? (
|
||||
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||
Войти
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
|
||||
<Link component={RouterLink} to="/terms" underline="hover">
|
||||
пользовательское соглашение
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||
политику конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AddToCartButton } from './ui/AddToCartButton'
|
||||
@@ -0,0 +1,46 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import type { ButtonProps } from '@mui/material/Button'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { addNotification } from '@/shared/model/notification'
|
||||
|
||||
type Props = {
|
||||
productId: string
|
||||
qty?: number
|
||||
loggedOutLabel?: string
|
||||
} & Omit<ButtonProps, 'onClick'>
|
||||
|
||||
export function AddToCartButton(props: Props) {
|
||||
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
|
||||
const qc = useQueryClient()
|
||||
const user = useUnit($user)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={Boolean(disabled) || !user || addMut.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
addMut.mutate()
|
||||
}}
|
||||
>
|
||||
{user ? (children ?? 'В корзину') : loggedOutLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as notifications from '@/shared/model/notification'
|
||||
import { AddToCartButton } from '../AddToCartButton'
|
||||
|
||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||
addToCart: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', async () => {
|
||||
const actual = await vi.importActual('effector-react')
|
||||
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
|
||||
})
|
||||
|
||||
describe('AddToCartButton', () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
qc.clear()
|
||||
})
|
||||
|
||||
it('calls addNotification after successful add', async () => {
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AddToCartButton productId="test-product" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { CartBadge } from './ui/CartBadge'
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
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={user ? `Корзина (${cartCount})` : 'Авторизуйтесь для совершения покупок'}
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCart />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ToggleCartIcon } from './ui/ToggleCartIcon'
|
||||
@@ -0,0 +1,77 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { addToCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
||||
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { addNotification } from '@/shared/model/notification'
|
||||
|
||||
export function ToggleCartIcon(props: {
|
||||
productId: string
|
||||
size?: 'small' | 'medium'
|
||||
disabledReason?: string | null
|
||||
}) {
|
||||
const { productId, size = 'small', disabledReason = null } = props
|
||||
const user = useUnit($user)
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const cartQuery = useCartQuery()
|
||||
|
||||
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
||||
const inCart = Boolean(existing)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: () => removeCartItem(existing!.id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
})
|
||||
|
||||
const disabled = Boolean(disabledReason)
|
||||
const busy = addMut.isPending || removeMut.isPending
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (disabledReason) return
|
||||
if (!user) {
|
||||
navigate('/auth')
|
||||
return
|
||||
}
|
||||
if (inCart) removeMut.mutate()
|
||||
else addMut.mutate()
|
||||
}
|
||||
|
||||
const tooltip = disabledReason
|
||||
? disabledReason
|
||||
: !user
|
||||
? 'Авторизуйтесь для совершения покупок'
|
||||
: inCart
|
||||
? 'Убрать из корзины'
|
||||
: 'В корзину'
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
|
||||
{user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as api from '@/entities/cart/api/cart-api'
|
||||
import * as notifications from '@/shared/model/notification'
|
||||
import { ToggleCartIcon } from '../ToggleCartIcon'
|
||||
|
||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||
addToCart: vi.fn(() => Promise.resolve()),
|
||||
fetchMyCart: vi.fn(() => Promise.resolve({ items: [] })),
|
||||
removeCartItem: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', async () => {
|
||||
const actual = await vi.importActual('effector-react')
|
||||
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
|
||||
})
|
||||
|
||||
describe('ToggleCartIcon', () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
qc.clear()
|
||||
})
|
||||
|
||||
it('calls addNotification after successful add', async () => {
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ToggleCartIcon productId="test-product" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call addNotification on remove', async () => {
|
||||
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
|
||||
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
|
||||
})
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ToggleCartIcon productId="test-product" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /убрать из корзины/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /убрать из корзины/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { OrderChat } from './ui/OrderChat'
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
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 { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
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 currentUser = useUnit($user)
|
||||
|
||||
const adminAvatarQuery = useQuery({
|
||||
queryKey: ['admin', 'avatar'],
|
||||
queryFn: fetchAdminAvatar,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
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) => {
|
||||
const isAdminMsg = m.authorType === 'admin'
|
||||
const adminAv = adminAvatarQuery.data
|
||||
const avatarNode = isAdminMsg ? (
|
||||
<UserAvatar userId="admin" avatarUrl={adminAv?.avatar} avatarStyle={adminAv?.avatarStyle} size={24} />
|
||||
) : currentUser ? (
|
||||
<UserAvatar
|
||||
userId={currentUser.id}
|
||||
avatarUrl={currentUser.avatar}
|
||||
avatarStyle={currentUser.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
) : null
|
||||
return (
|
||||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{isAdminMsg ? 'Админ' : 'Вы'} · {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>
|
||||
)
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm'
|
||||
export { OrderDetailContent } from './ui/OrderDetailContent'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
export function DeliveryFeeAdjustmentForm({
|
||||
orderId,
|
||||
deliveryFeeCents,
|
||||
}: {
|
||||
orderId: string
|
||||
deliveryFeeCents: number
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
||||
|
||||
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: (next: string) => setAdminOrderStatus(orderId, next),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postAdminOrderMessage(orderId, msg.trim()),
|
||||
onSuccess: async () => {
|
||||
setMsg('')
|
||||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||||
},
|
||||
})
|
||||
|
||||
const deliverySnapshot = useMemo(
|
||||
() => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const nextStatuses = useMemo(
|
||||
() => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
const currentUser = useUnit($user)
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {ORDER_STATUS_MAP[detail.status] ?? detail.status} ·{' '}
|
||||
{formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{detail.deliveryType === 'delivery' && (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Адрес и получатель (на момент заказа)
|
||||
</Typography>
|
||||
{deliverySnapshot ? (
|
||||
<Stack spacing={0.75}>
|
||||
{deliverySnapshot.label?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Метка: {deliverySnapshot.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Получатель:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientName ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Телефон:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientPhone ?? '—'}
|
||||
</Typography>
|
||||
{deliverySnapshot.comment?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Комментарий к адресу: {deliverySnapshot.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Данные адреса в заказе отсутствуют или не распознаны.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
Товары в заказе
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{detail.items.map((item) => (
|
||||
<Stack
|
||||
key={item.id}
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{ alignItems: 'center', py: 0.5, px: 1, borderRadius: 1, bgcolor: 'action.hover' }}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Link component={RouterLink} to={`/products/${item.productId}`} underline="hover" color="primary">
|
||||
{item.titleSnapshot}
|
||||
</Link>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{item.qty} × {formatPriceRub(item.priceCentsSnapshot)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(item.priceCentsSnapshot * item.qty)}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
Быстрый переход статуса
|
||||
</Typography>
|
||||
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
|
||||
{nextStatuses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Статус финальный, смена недоступна
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||
{nextStatuses.map((nextStatus) => {
|
||||
const isCancelled = nextStatus === 'CANCELLED'
|
||||
return (
|
||||
<Button
|
||||
key={nextStatus}
|
||||
variant={isCancelled ? 'outlined' : 'contained'}
|
||||
color={isCancelled ? 'error' : 'primary'}
|
||||
disabled={statusMut.isPending}
|
||||
onClick={() => statusMut.mutate(nextStatus)}
|
||||
>
|
||||
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => {
|
||||
const isAdminMsg = m.authorType === 'admin'
|
||||
const avatarNode = isAdminMsg ? (
|
||||
currentUser && (
|
||||
<UserAvatar
|
||||
userId={currentUser.id}
|
||||
avatarUrl={currentUser.avatar}
|
||||
avatarStyle={currentUser.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UserAvatar
|
||||
userId={detail.user.id}
|
||||
avatarUrl={detail.user.avatar}
|
||||
avatarStyle={detail.user.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
)
|
||||
})}
|
||||
{detail.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={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !canSendMessage}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||
import { OrderDetailContent } from '../OrderDetailContent'
|
||||
|
||||
vi.mock('@/entities/order/api/admin-order-api', () => ({
|
||||
setAdminOrderStatus: vi.fn(),
|
||||
postAdminOrderMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', () => ({
|
||||
useUnit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/RichTextMessageEditor.lazy', () => ({
|
||||
RichTextMessageEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/ChatMessageBubble', () => ({
|
||||
ChatMessageBubble: ({ authorType, children }: { authorType: 'admin' | 'user'; children: ReactNode }) => (
|
||||
<div data-testid={`chat-message-${authorType}`}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
|
||||
|
||||
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
||||
return {
|
||||
id: 'order-12345678',
|
||||
status: 'PENDING_PAYMENT',
|
||||
deliveryType: 'delivery',
|
||||
deliveryCarrier: null,
|
||||
paymentMethod: 'online',
|
||||
itemsSubtotalCents: 3000,
|
||||
deliveryFeeCents: 300,
|
||||
deliveryFeeLocked: true,
|
||||
totalCents: 3300,
|
||||
currency: 'RUB',
|
||||
addressSnapshotJson: null,
|
||||
comment: null,
|
||||
createdAt: '2026-05-28T10:00:00.000Z',
|
||||
updatedAt: '2026-05-28T10:00:00.000Z',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'buyer@example.com',
|
||||
displayName: 'Покупатель',
|
||||
avatar: null,
|
||||
avatarStyle: null,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
productId: 'product-1',
|
||||
qty: 1,
|
||||
titleSnapshot: 'Тестовый товар',
|
||||
priceCentsSnapshot: 3000,
|
||||
},
|
||||
],
|
||||
messages: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function renderComponent(detail: AdminOrderDetailResponse['item'], orderId = 'order-12345678') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OrderDetailContent detail={detail} orderId={orderId} />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('OrderDetailContent quick status transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setAdminOrderStatusMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('рендерит кнопки доступных переходов статуса', async () => {
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
expect(screen.getByText('Быстрый переход статуса')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeInTheDocument()
|
||||
const cancelledButton = screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })
|
||||
expect(cancelledButton).toBeInTheDocument()
|
||||
expect(cancelledButton).toHaveClass('MuiButton-outlined')
|
||||
expect(cancelledButton).toHaveClass('MuiButton-colorError')
|
||||
})
|
||||
|
||||
it('по клику вызывает setAdminOrderStatus(orderId, статус)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }), 'order-click-test')
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||
|
||||
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('order-click-test', 'PAID')
|
||||
})
|
||||
|
||||
it('в pending состоянии дизейблит кнопки перехода', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferred = createDeferred<void>()
|
||||
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise)
|
||||
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).toBeDisabled()
|
||||
})
|
||||
|
||||
deferred.resolve(undefined)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('для финального статуса показывает сообщение без кнопок перехода', () => {
|
||||
renderComponent(createDetail({ status: 'CANCELLED' }))
|
||||
|
||||
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('показывает ошибку мутации и после завершения запроса снова даёт кликнуть', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferred = createDeferred<void>()
|
||||
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise).mockResolvedValueOnce(undefined)
|
||||
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
const paidButton = await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })
|
||||
await user.click(paidButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paidButton).toBeDisabled()
|
||||
})
|
||||
|
||||
deferred.reject(new Error('request failed'))
|
||||
|
||||
const errorAlert = await screen.findByText('Не удалось сменить статус')
|
||||
expect(errorAlert).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paidButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
await user.click(paidButton)
|
||||
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('передает фактический authorType в пузырь сообщения', () => {
|
||||
renderComponent(
|
||||
createDetail({
|
||||
messages: [
|
||||
{
|
||||
id: 'message-admin',
|
||||
authorType: 'admin',
|
||||
text: 'Ответ администратора',
|
||||
attachmentUrl: null,
|
||||
createdAt: '2026-05-28T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'message-user',
|
||||
authorType: 'user',
|
||||
text: 'Сообщение покупателя',
|
||||
attachmentUrl: null,
|
||||
createdAt: '2026-05-28T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('chat-message-admin')).toHaveTextContent('Админ (вы)')
|
||||
expect(screen.getByTestId('chat-message-user')).toHaveTextContent('Пользователь')
|
||||
})
|
||||
})
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
||||
@@ -0,0 +1,65 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||
|
||||
type Props = {
|
||||
status: string
|
||||
deliveryFeeLocked: boolean
|
||||
paymentMethod: string | null
|
||||
totalCents: number
|
||||
isPayPending: boolean
|
||||
payError: unknown
|
||||
onPay: () => void
|
||||
}
|
||||
|
||||
export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod, isPayPending, onPay }: Props) {
|
||||
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||
|
||||
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 === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
|
||||
</Typography>
|
||||
)}
|
||||
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
|
||||
{ORDER_STATUS_MAP['PAID'] ?? 'PAID'}».
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={onPay} disabled={isPayPending}>
|
||||
{isPayPending ? 'Создание платежа…' : 'Оплатить'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'PAID' && (
|
||||
<Typography color="success.main" variant="body1">
|
||||
Оплачено. Спасибо!
|
||||
</Typography>
|
||||
)}
|
||||
{status !== 'PENDING_PAYMENT' && status !== 'PAID' && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате не требуется.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export type { FormState } from './model/types'
|
||||
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidProductPriceRub, isValidProductQuantity } from '../use-product-form-helpers'
|
||||
|
||||
describe('product form helpers', () => {
|
||||
it('принимает корректную цену в рублях', () => {
|
||||
expect(isValidProductPriceRub('1200')).toBe(true)
|
||||
expect(isValidProductPriceRub('1200,50')).toBe(true)
|
||||
})
|
||||
|
||||
it('отклоняет пустую или некорректную цену', () => {
|
||||
expect(isValidProductPriceRub('')).toBe(false)
|
||||
expect(isValidProductPriceRub('0')).toBe(false)
|
||||
expect(isValidProductPriceRub('1200,555')).toBe(false)
|
||||
expect(isValidProductPriceRub('1,2,3')).toBe(false)
|
||||
})
|
||||
|
||||
it('принимает только целое количество от 0 до 10', () => {
|
||||
expect(isValidProductQuantity('0')).toBe(true)
|
||||
expect(isValidProductQuantity('10')).toBe(true)
|
||||
expect(isValidProductQuantity('')).toBe(false)
|
||||
expect(isValidProductQuantity('11')).toBe(false)
|
||||
expect(isValidProductQuantity('1.5')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '0',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
export function isValidProductPriceRub(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!/^\d+([,.]\d{1,2})?$/.test(trimmed)) return false
|
||||
|
||||
const priceRub = Number(trimmed.replace(',', '.'))
|
||||
return Number.isFinite(priceRub) && priceRub > 0 && priceRub <= 10_000
|
||||
}
|
||||
|
||||
export function isValidProductQuantity(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
|
||||
const quantity = Number(trimmed)
|
||||
return Number.isInteger(quantity) && quantity >= 0 && quantity <= 10
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
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 FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
|
||||
export function GalleryImagePicker({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentUrls,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (urls: string[]) => void
|
||||
currentUrls: string[]
|
||||
}) {
|
||||
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
const [hideUsed, setHideUsed] = useState(false)
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const toggleUrl = (url: string) => {
|
||||
setSelectedUrls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(url)) {
|
||||
next.delete(url)
|
||||
} else {
|
||||
next.add(url)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onSelect([...selectedUrls])
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
|
||||
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
{galleryQuery.data &&
|
||||
galleryQuery.data.items.length > 0 &&
|
||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее пока нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hideUsed} onChange={(_, v) => setHideUsed(v)} />}
|
||||
label="Скрыть уже прикреплённые"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{galleryQuery.data &&
|
||||
galleryQuery.data.items.length > 0 &&
|
||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.filter((item) => !hideUsed || !item.inUse)
|
||||
.map((item) => {
|
||||
const alreadyInCard = currentUrls.includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || selectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
widths={[320, 640]}
|
||||
sizes="120px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleApply}
|
||||
disabled={![...selectedUrls].some((u) => !currentUrls.includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import FormHelperText from '@mui/material/FormHelperText'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export function ProductFormFields({
|
||||
form,
|
||||
categories,
|
||||
onRemoveImage,
|
||||
onPickFromGallery,
|
||||
}: {
|
||||
form: UseFormReturn<FormState>
|
||||
categories: Category[]
|
||||
onRemoveImage: (url: string) => void
|
||||
onPickFromGallery: () => void
|
||||
}) {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="title"
|
||||
rules={{ required: 'Укажите название' }}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Название"
|
||||
fullWidth
|
||||
required
|
||||
{...field}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Описание
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>Стилизованный текст: жирный, курсив, список</FormHelperText>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<RichTextMessageEditor value={field.value} onChange={field.onChange} placeholder="Описание товара" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="materials"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Материалы"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="quantity"
|
||||
rules={{
|
||||
required: 'Укажите количество',
|
||||
validate: (v) => isValidProductQuantity(v) || 'Целое число от 0 до 10',
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="priceRub"
|
||||
rules={{
|
||||
required: 'Укажите цену',
|
||||
validate: (v) => isValidProductPriceRub(v) || 'Цена должна быть от 0,01 до 10 000 ₽, максимум 2 знака',
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Цена, ₽"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9.,]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (из галереи)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся
|
||||
на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={onPickFromGallery}>
|
||||
Из галереи
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{form.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{form.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
widths={[320, 640]}
|
||||
sizes="80px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => onRemoveImage(url)}
|
||||
aria-label="Убрать из карточки"
|
||||
title="Убрать из карточки"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 0,
|
||||
px: 0.75,
|
||||
py: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
rules={{ required: 'Выберите категорию' }}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!field.value}>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
{categories.map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export { ReviewSection } from './ui/ReviewSection'
|
||||
export { ReviewDialog } from './ui/ReviewDialog'
|
||||
export { ProductReviewsList } from './ui/ProductReviewsList'
|
||||
@@ -0,0 +1,110 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Rating from '@mui/material/Rating'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Star } from 'lucide-react'
|
||||
import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api'
|
||||
import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api'
|
||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
||||
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<UserAvatar userId={rv.authorId} avatarUrl={rv.authorAvatar} avatarStyle={rv.authorAvatarStyle} size={32} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Rating
|
||||
value={rv.rating}
|
||||
readOnly
|
||||
size="small"
|
||||
icon={<Star fontSize="inherit" />}
|
||||
emptyIcon={<Star fontSize="inherit" />}
|
||||
/>
|
||||
{body ? (
|
||||
<Box sx={{ color: 'text.secondary' }}>
|
||||
<RichTextMessageContent value={body} tone="review" />
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Без текстового комментария.
|
||||
</Typography>
|
||||
)}
|
||||
{rv.imageUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 1.5,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={rv.imageUrl}
|
||||
alt="Фото к отзыву"
|
||||
widths={[320, 640]}
|
||||
sizes="140px"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductReviewsList({ productId }: { productId: string }) {
|
||||
const reviewsQuery = useQuery({
|
||||
queryKey: ['products', 'public', productId, 'reviews', { page: 1, pageSize: 30 }],
|
||||
queryFn: () => fetchPublicProductReviews(productId, { page: 1, pageSize: 30 }),
|
||||
enabled: Boolean(productId),
|
||||
})
|
||||
|
||||
if (reviewsQuery.isLoading) return <Typography color="text.secondary">Загрузка отзывов…</Typography>
|
||||
if (reviewsQuery.isError) return <Alert severity="warning">Не удалось загрузить отзывы.</Alert>
|
||||
if (reviewsQuery.data && reviewsQuery.data.total === 0) {
|
||||
return (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Отзывов пока нет
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
||||
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
if (!reviewsQuery.data || reviewsQuery.data.items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
{reviewsQuery.data.items.map((rv) => (
|
||||
<ReviewItem key={rv.id} rv={rv} />
|
||||
))}
|
||||
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Всего {reviewsCountRu(reviewsQuery.data.total)} — ниже показаны последние {reviewsQuery.data.items.length}.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
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.lazy'
|
||||
|
||||
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 }) => Promise<void>
|
||||
onUploadImage: (file: File) => Promise<{ url: string }>
|
||||
}
|
||||
|
||||
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 [localUploadError, setLocalUploadError] = useState<string | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
setRating(5)
|
||||
setText('')
|
||||
setImageUrl(null)
|
||||
setLocalUploadError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isPending) return
|
||||
await 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={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.currentTarget.value = ''
|
||||
setLocalUploadError(null)
|
||||
try {
|
||||
const result = await onUploadImage(file)
|
||||
setImageUrl(result.url)
|
||||
} catch (err) {
|
||||
setLocalUploadError(
|
||||
err instanceof Error ? err.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.',
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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 || localUploadError ? (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{localUploadError
|
||||
? localUploadError
|
||||
: uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
|
||||
</Alert>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(error)}
|
||||
</Alert>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={isPending} onClick={handleSubmit}>
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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
|
||||
}) => Promise<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={async (params) => {
|
||||
if (!target) return
|
||||
await onSubmitReview({
|
||||
productId: target.productId,
|
||||
...params,
|
||||
imageUrl: uploadedImageUrl,
|
||||
})
|
||||
setTarget(null)
|
||||
setUploadedImageUrl(null)
|
||||
}}
|
||||
onUploadImage={async (file) => {
|
||||
const result = await onUploadImage(file)
|
||||
setUploadedImageUrl(result.url)
|
||||
return result
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
export { UserMenu } from './ui/UserMenu'
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import PersonIcon from '@mui/icons-material/Person'
|
||||
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'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
type Props = {
|
||||
user: AuthUser | null
|
||||
isAdmin?: boolean
|
||||
onNavigate: (to: string) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function UserMenu({ user, isAdmin = false, 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={user ? openMenu : () => go('/auth')}
|
||||
sx={{ ml: 1 }}
|
||||
aria-label="Пользователь"
|
||||
>
|
||||
{user ? (
|
||||
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
|
||||
) : (
|
||||
<PersonIcon sx={{ fontSize: 28 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={closeMenu}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItem onClick={() => go(isAdmin ? '/admin/settings' : '/me')}>
|
||||
<ListItemText
|
||||
primary={(user.displayName && user.displayName.trim()) || user.email}
|
||||
secondary={isAdmin ? 'Настройки' : 'Профиль'}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user