Files
shop-server/client/src/pages/admin-settings/ui/AdminSettingsPage.tsx
T
2026-05-24 19:42:17 +05:00

217 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
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 TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { createAvatar } from '@dicebear/core'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles'
import { $user, updateProfileFx } from '@/shared/model/auth'
import type { UpdateProfileParams } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
function getApiErrorMessage(error: unknown): string | null {
const e = error as { response?: { data?: { error?: string } } }
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
export function AdminSettingsPage() {
const user = useUnit($user)
const qc = useQueryClient()
const pendingProfile = useUnit(updateProfileFx.pending)
const {
data: profile,
isLoading,
isError,
} = useQuery({
queryKey: ['admin', 'profile'],
queryFn: async () => {
const { data } = await apiClient.get<{
id: string
email: string
displayName: string | null
avatar: string | null
avatarStyle: string | null
}>('admin/profile')
return data
},
})
const profileForm = useForm<{ displayName: string }>({
defaultValues: { displayName: profile?.displayName ?? '' },
values: { displayName: profile?.displayName ?? '' },
mode: 'onChange',
})
const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
const [previewSrc, setPreviewSrc] = useState<string | null>(null)
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
const hasUnsavedPreview = previewSrc !== null
const profileSaveMut = useMutation({
mutationFn: (params: { displayName: string | null; avatar?: string | null; avatarStyle?: string | null }) =>
apiClient.patch('admin/profile', params),
onSuccess: (_data, variables) => {
const p: UpdateProfileParams = { displayName: variables.displayName ?? null }
if (variables.avatar !== undefined) {
p.avatar = variables.avatar
p.avatarStyle = variables.avatarStyle ?? null
}
updateProfileFx(p)
void qc.invalidateQueries({ queryKey: ['admin', 'profile'] })
},
})
const profileErrorMsg = getApiErrorMessage(profileSaveMut.error)
if (isLoading) return <Typography>Загрузка настроек</Typography>
if (isError) return <Alert severity="error">Не удалось загрузить настройки.</Alert>
if (!user) return <Alert severity="info">Нужно войти.</Alert>
return (
<Box>
<Typography variant="h4" gutterBottom>
Настройки
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Текущая почта: <b>{String(user.email)}</b>
</Typography>
{profileErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{profileErrorMsg}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
<Box>
<Typography variant="h6" gutterBottom>
Профиль
</Typography>
<Stack spacing={2}>
<TextField
label="Имя или ник"
helperText="До 40 символов"
slotProps={{ htmlInput: { maxLength: 40 } }}
{...profileForm.register('displayName')}
/>
<Button
variant="contained"
disabled={pendingProfile || profileSaveMut.isPending}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({ displayName: name.length ? name : null })
}}
>
Сохранить
</Button>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Аватар
</Typography>
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={String(user.id)}
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
size={80}
sx={{
border: 2,
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
</Typography>
</Box>
{hasUnsavedPreview && (
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={String(user.id)}
avatarUrl={user.avatar}
avatarStyle={user.avatarStyle}
size={80}
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Текущий
</Typography>
</Box>
)}
</Stack>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Стиль</InputLabel>
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
{AVATAR_STYLE_LOADERS.map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
onClick={async () => {
const seed = `${String(user.id)}_${Date.now()}`
const style = await loadAvatarStyle(selectedStyle)
const avatar = createAvatar(style, { seed })
setPreviewSrc(avatar.toDataUri())
setPreviewStyle(selectedStyle)
}}
>
Сгенерировать
</Button>
</Stack>
{hasUnsavedPreview && (
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
<Button
variant="contained"
disabled={pendingProfile || profileSaveMut.isPending}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({
displayName: name.length ? name : null,
avatar: previewSrc,
avatarStyle: previewStyle,
})
setPreviewSrc(null)
}}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setPreviewSrc(null)}>
Отмена
</Button>
</Stack>
)}
</Box>
</Stack>
</Box>
)
}