initial: client

This commit is contained in:
Shop Deploy
2026-06-11 13:48:08 +05:00
commit a36f96c290
271 changed files with 28009 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
+127
View File
@@ -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
View File
@@ -0,0 +1 @@
export { AddressMapPicker } from './ui/AddressMapPicker'
+3
View File
@@ -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
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthCodeForm } from './ui/AuthCodeForm'
+67
View File
@@ -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
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthForgotForm } from './ui/AuthForgotForm'
+145
View File
@@ -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')
})
})
+1
View File
@@ -0,0 +1 @@
export { OAuthButtons } from './ui/OAuthButtons'
+24
View File
@@ -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
View File
@@ -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>
)
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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',
})
})
})
})
+1
View File
@@ -0,0 +1 @@
export { CartBadge } from './ui/CartBadge'
+31
View File
@@ -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
View File
@@ -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>
)
}
@@ -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()
})
})
})
+1
View File
@@ -0,0 +1 @@
export { OrderChat } from './ui/OrderChat'
+87
View File
@@ -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>
)
}
+2
View File
@@ -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
View File
@@ -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('Пользователь')
})
})
+1
View File
@@ -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>
)
}
+2
View File
@@ -0,0 +1,2 @@
export type { FormState } from './model/types'
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { UserMenu } from './ui/UserMenu'
+72
View File
@@ -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>
</>
)
}