ыввы
@@ -2,11 +2,11 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon-32.ico" sizes="32x32" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon-48.ico" sizes="48x48" />
|
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon-64.ico" sizes="64x64" />
|
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon-128.ico" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="apple-touch-icon" href="/favicon-128.ico" />
|
<link rel="preconnect" href="https://xn--80abekoceifm0c0a5irb.xn--p1ai" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -17,8 +17,10 @@
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
|
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
|
||||||
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
|
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
|
||||||
<meta property="og:image" content="/favicon-128.ico" />
|
<meta property="og:image" content="/favicon-128.png" />
|
||||||
<meta property="og:locale" content="ru_RU" />
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<link rel="preload" href="/fonts/Outfit-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<link rel="preload" href="/fonts/Outfit-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
<link rel="canonical" href="https://любимыйкреатив.рф/" />
|
<link rel="canonical" href="https://любимыйкреатив.рф/" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 516 B |
|
Before Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 66 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#546E7A"/><text x="16" y="23" text-anchor="middle" font-size="22" fill="white" font-family="sans-serif" font-weight="bold">К</text></svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
@@ -87,7 +87,7 @@ export const AppHeader = React.memo(function AppHeader() {
|
|||||||
sx={{
|
sx={{
|
||||||
borderBottom: 1,
|
borderBottom: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
bgcolor: alpha(theme.palette.primary.main, 0.88),
|
bgcolor: alpha(theme.palette.primary.main, 0.95),
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
||||||
}}
|
}}
|
||||||
@@ -118,7 +118,7 @@ export const AppHeader = React.memo(function AppHeader() {
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BearLogo sx={{ width: 28, height: 28 }} />
|
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
|
||||||
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{STORE_NAME}
|
{STORE_NAME}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -135,7 +135,12 @@ export const AppHeader = React.memo(function AppHeader() {
|
|||||||
<>
|
<>
|
||||||
{user && (
|
{user && (
|
||||||
<Tooltip title="Заказы">
|
<Tooltip title="Заказы">
|
||||||
<IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы">
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
onClick={() => navigate('/me/orders')}
|
||||||
|
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
|
||||||
|
>
|
||||||
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
|
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
|
||||||
<Package />
|
<Package />
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||||
<Grid container spacing={5}>
|
<Grid container spacing={5}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}>
|
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}>
|
||||||
{STORE_NAME}
|
{STORE_NAME}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
||||||
@@ -50,7 +50,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
||||||
Покупателям
|
Покупателям
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
@@ -66,7 +66,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
||||||
Контакты
|
Контакты
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
@@ -89,13 +89,13 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
||||||
>
|
>
|
||||||
<Box component="img" src={vkLogoSrc} alt="VK" sx={{ width: 20, height: 20 }} />
|
<Box component="img" src={vkLogoSrc} alt="" sx={{ width: 20, height: 20 }} />
|
||||||
VK
|
VK
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
<Typography variant="subtitle1" component="h4" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
||||||
Юридическая информация
|
Юридическая информация
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogS
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function putAdminCatalogSlider(body: {
|
export async function putAdminCatalogSlider(body: {
|
||||||
slides: Array<{ galleryImageId: string; caption: string }>
|
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
|
||||||
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||||
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type CatalogSliderSlide = {
|
|||||||
id: string
|
id: string
|
||||||
url: string
|
url: string
|
||||||
caption: string
|
caption: string
|
||||||
|
textColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export type GalleryImageItem = {
|
|||||||
url: string
|
url: string
|
||||||
isResized: boolean
|
isResized: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
inUse?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
|
component="h2"
|
||||||
className="product-card__title"
|
className="product-card__title"
|
||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -240,6 +241,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
|
component="p"
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
|
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Alert from '@mui/material/Alert'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
import InputAdornment from '@mui/material/InputAdornment'
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
@@ -36,170 +38,197 @@ type Props = {
|
|||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Props) {
|
export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Props) {
|
||||||
const { register, watch } = useForm<FormValues>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormValues>({
|
||||||
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
|
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
const email = watch('email')
|
|
||||||
const password = watch('password')
|
const password = watch('password')
|
||||||
const passwordConfirm = watch('passwordConfirm')
|
|
||||||
const displayName = watch('displayName')
|
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (values: FormValues) => {
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
const { data } = await apiClient.post<AuthResponse>('auth/login', {
|
||||||
tokenSet(data.token)
|
email: values.email,
|
||||||
},
|
password: values.password,
|
||||||
onSuccess,
|
|
||||||
})
|
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
displayName: displayName || undefined,
|
|
||||||
})
|
})
|
||||||
tokenSet(data.token)
|
tokenSet(data.token)
|
||||||
},
|
},
|
||||||
onSuccess,
|
onSuccess,
|
||||||
})
|
})
|
||||||
|
|
||||||
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null
|
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 (
|
return (
|
||||||
<Stack spacing={2}>
|
<Box component="form" onSubmit={onSubmit} noValidate>
|
||||||
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
<Stack spacing={2}>
|
||||||
<Button
|
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
||||||
variant="text"
|
<Button
|
||||||
size="small"
|
variant="text"
|
||||||
sx={{
|
size="small"
|
||||||
color: !isRegister ? 'primary.main' : 'text.secondary',
|
sx={{
|
||||||
borderBottom: !isRegister ? 2 : 0,
|
color: !isRegister ? 'primary.main' : 'text.secondary',
|
||||||
borderColor: 'primary.main',
|
borderBottom: !isRegister ? 2 : 0,
|
||||||
borderRadius: 0,
|
borderColor: 'primary.main',
|
||||||
pb: 0.5,
|
borderRadius: 0,
|
||||||
textTransform: 'none',
|
pb: 0.5,
|
||||||
}}
|
textTransform: 'none',
|
||||||
onClick={() => onRegisterChange(false)}
|
}}
|
||||||
>
|
onClick={() => onRegisterChange(false)}
|
||||||
Вход
|
>
|
||||||
</Button>
|
Вход
|
||||||
<Button
|
</Button>
|
||||||
variant="text"
|
<Button
|
||||||
size="small"
|
variant="text"
|
||||||
sx={{
|
size="small"
|
||||||
color: isRegister ? 'primary.main' : 'text.secondary',
|
sx={{
|
||||||
borderBottom: isRegister ? 2 : 0,
|
color: isRegister ? 'primary.main' : 'text.secondary',
|
||||||
borderColor: 'primary.main',
|
borderBottom: isRegister ? 2 : 0,
|
||||||
borderRadius: 0,
|
borderColor: 'primary.main',
|
||||||
pb: 0.5,
|
borderRadius: 0,
|
||||||
textTransform: 'none',
|
pb: 0.5,
|
||||||
}}
|
textTransform: 'none',
|
||||||
onClick={() => onRegisterChange(true)}
|
}}
|
||||||
>
|
onClick={() => onRegisterChange(true)}
|
||||||
Регистрация
|
>
|
||||||
</Button>
|
Регистрация
|
||||||
</Stack>
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Email"
|
|
||||||
{...register('email')}
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Mail size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isRegister && (
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Имя (необязательно)"
|
label="Email"
|
||||||
{...register('displayName')}
|
{...register('email', {
|
||||||
|
required: 'Введите email',
|
||||||
|
pattern: {
|
||||||
|
value: EMAIL_PATTERN,
|
||||||
|
message: 'Некорректный email',
|
||||||
|
},
|
||||||
|
})}
|
||||||
fullWidth
|
fullWidth
|
||||||
helperText="Если не указать, будет использована часть email до @"
|
autoComplete="email"
|
||||||
|
error={Boolean(errors.email)}
|
||||||
|
helperText={errors.email?.message}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Mail size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<TextField
|
{isRegister && (
|
||||||
label="Пароль"
|
<TextField
|
||||||
type="password"
|
label="Имя (необязательно)"
|
||||||
{...register('password')}
|
{...register('displayName')}
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
helperText="Если не указать, будет использована часть email до @"
|
||||||
input: {
|
/>
|
||||||
startAdornment: (
|
)}
|
||||||
<InputAdornment position="start">
|
|
||||||
<Lock size={18} />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isRegister && (
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Подтверждение пароля"
|
label="Пароль"
|
||||||
type="password"
|
type="password"
|
||||||
{...register('passwordConfirm')}
|
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||||
|
{...register('password', {
|
||||||
|
required: 'Введите пароль',
|
||||||
|
minLength: {
|
||||||
|
value: 8,
|
||||||
|
message: 'Пароль должен быть не менее 8 символов',
|
||||||
|
},
|
||||||
|
})}
|
||||||
fullWidth
|
fullWidth
|
||||||
error={Boolean(passwordError)}
|
error={Boolean(errors.password)}
|
||||||
helperText={passwordError}
|
helperText={errors.password?.message}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Lock size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{isRegister ? (
|
{isRegister && (
|
||||||
<Button
|
<TextField
|
||||||
variant="contained"
|
label="Подтверждение пароля"
|
||||||
size="large"
|
type="password"
|
||||||
disabled={
|
autoComplete="new-password"
|
||||||
!email ||
|
{...register('passwordConfirm', {
|
||||||
!password ||
|
required: 'Подтвердите пароль',
|
||||||
password.length < 8 ||
|
validate: (value) => value === password || 'Пароли не совпадают',
|
||||||
(isRegister && password !== passwordConfirm) ||
|
})}
|
||||||
registerMutation.isPending
|
fullWidth
|
||||||
}
|
error={Boolean(errors.passwordConfirm)}
|
||||||
onClick={() => registerMutation.mutate()}
|
helperText={errors.passwordConfirm?.message}
|
||||||
>
|
/>
|
||||||
Зарегистрироваться
|
)}
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
disabled={!email || !password || loginMutation.isPending}
|
|
||||||
onClick={() => loginMutation.mutate()}
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(loginMutation.error || registerMutation.error) && (
|
{apiErrorMessage && (
|
||||||
<TextField
|
<Alert
|
||||||
error
|
severity="error"
|
||||||
helperText={getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error)}
|
variant="outlined"
|
||||||
sx={{ display: 'none' }}
|
onClose={() => {
|
||||||
/>
|
loginMutation.reset()
|
||||||
)}
|
registerMutation.reset()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{apiErrorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
{isRegister ? (
|
||||||
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
|
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||||
<Link component={RouterLink} to="/terms" underline="hover">
|
Зарегистрироваться
|
||||||
пользовательское соглашение
|
</Button>
|
||||||
</Link>{' '}
|
) : (
|
||||||
и{' '}
|
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
Войти
|
||||||
политику конфиденциальности
|
</Button>
|
||||||
</Link>
|
)}
|
||||||
.
|
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
</Stack>
|
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
|
||||||
|
<Link component={RouterLink} to="/terms" underline="hover">
|
||||||
|
пользовательское соглашение
|
||||||
|
</Link>{' '}
|
||||||
|
и{' '}
|
||||||
|
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||||
|
политику конфиденциальности
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function CartBadge({ user, cartCount, onNavigate }: Props) {
|
|||||||
if (!user) onNavigate('/auth')
|
if (!user) onNavigate('/auth')
|
||||||
else onNavigate('/cart')
|
else onNavigate('/cart')
|
||||||
}}
|
}}
|
||||||
aria-label="Корзина"
|
aria-label={user ? `Корзина (${cartCount})` : 'Авторизуйтесь для совершения покупок'}
|
||||||
>
|
>
|
||||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||||
<ShoppingCart />
|
<ShoppingCart />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function GalleryImagePicker({
|
|||||||
currentUrls: string[]
|
currentUrls: string[]
|
||||||
}) {
|
}) {
|
||||||
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
||||||
|
const [hideUsed, setHideUsed] = useState(false)
|
||||||
|
|
||||||
const galleryQuery = useQuery({
|
const galleryQuery = useQuery({
|
||||||
queryKey: ['admin', 'gallery'],
|
queryKey: ['admin', 'gallery'],
|
||||||
@@ -58,12 +59,26 @@ export function GalleryImagePicker({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||||
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
|
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
|
||||||
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
|
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
|
||||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
<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 &&
|
||||||
galleryQuery.data.items.length > 0 &&
|
galleryQuery.data.items.length > 0 &&
|
||||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||||
@@ -82,6 +97,7 @@ export function GalleryImagePicker({
|
|||||||
>
|
>
|
||||||
{(galleryQuery.data?.items ?? [])
|
{(galleryQuery.data?.items ?? [])
|
||||||
.filter((item) => item.isResized)
|
.filter((item) => item.isResized)
|
||||||
|
.filter((item) => !hideUsed || !item.inUse)
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const alreadyInCard = currentUrls.includes(item.url)
|
const alreadyInCard = currentUrls.includes(item.url)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Divider from '@mui/material/Divider'
|
import Chip from '@mui/material/Chip'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Table from '@mui/material/Table'
|
||||||
|
import TableBody from '@mui/material/TableBody'
|
||||||
|
import TableCell from '@mui/material/TableCell'
|
||||||
|
import TableContainer from '@mui/material/TableContainer'
|
||||||
|
import TableHead from '@mui/material/TableHead'
|
||||||
|
import TableRow from '@mui/material/TableRow'
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton'
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
|
||||||
import {
|
import {
|
||||||
deleteGalleryImage,
|
deleteGalleryImage,
|
||||||
fetchAdminGallery,
|
fetchAdminGallery,
|
||||||
@@ -13,10 +20,11 @@ import {
|
|||||||
resizeGalleryImage,
|
resizeGalleryImage,
|
||||||
uploadGalleryImages,
|
uploadGalleryImages,
|
||||||
} from '@/entities/gallery'
|
} from '@/entities/gallery'
|
||||||
|
import type { GalleryImageItem } from '@/entities/gallery'
|
||||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { GallerySliderSection } from './GallerySliderSection'
|
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
|
import { Grid3x3, List } from 'lucide-react'
|
||||||
|
|
||||||
function getApiErrorMessage(error: unknown): string | null {
|
function getApiErrorMessage(error: unknown): string | null {
|
||||||
const e = error as AxiosError<{ error?: string }>
|
const e = error as AxiosError<{ error?: string }>
|
||||||
@@ -24,15 +32,102 @@ function getApiErrorMessage(error: unknown): string | null {
|
|||||||
return msg ? String(msg) : null
|
return msg ? String(msg) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameFromUrl(url: string): string {
|
||||||
|
const parts = url.split('/')
|
||||||
|
return parts[parts.length - 1] || url
|
||||||
|
}
|
||||||
|
|
||||||
|
function GalleryTable({
|
||||||
|
items,
|
||||||
|
deleting,
|
||||||
|
resizing,
|
||||||
|
onDelete,
|
||||||
|
onResize,
|
||||||
|
}: {
|
||||||
|
items: GalleryImageItem[]
|
||||||
|
deleting: boolean
|
||||||
|
resizing: string | null
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onResize: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Миниатюра</TableCell>
|
||||||
|
<TableCell>Имя файла</TableCell>
|
||||||
|
<TableCell>Статус</TableCell>
|
||||||
|
<TableCell>Дата</TableCell>
|
||||||
|
<TableCell>Действия</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={item.url}
|
||||||
|
alt=""
|
||||||
|
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{fileNameFromUrl(item.url)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={item.isResized ? 'Готово' : 'Не обработано'}
|
||||||
|
size="small"
|
||||||
|
color={item.isResized ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(item.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
{!item.isResized && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={resizing === item.id}
|
||||||
|
onClick={() => onResize(item.id)}
|
||||||
|
>
|
||||||
|
Resize
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminGalleryPage() {
|
export function AdminGalleryPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [resizingId, setResizingId] = useState<string | null>(null)
|
const [resizingId, setResizingId] = useState<string | null>(null)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
|
||||||
const sliderQuery = useQuery({
|
|
||||||
queryKey: ['admin', 'catalog-slider'],
|
|
||||||
queryFn: fetchAdminCatalogSlider,
|
|
||||||
})
|
|
||||||
|
|
||||||
const galleryQuery = useQuery({
|
const galleryQuery = useQuery({
|
||||||
queryKey: ['admin', 'gallery'],
|
queryKey: ['admin', 'gallery'],
|
||||||
@@ -82,29 +177,6 @@ export function AdminGalleryPage() {
|
|||||||
изображения доступны для добавления в карточку товара и слайдер.
|
изображения доступны для добавления в карточку товара и слайдер.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{sliderQuery.isError && (
|
|
||||||
<Typography color="error" sx={{ mb: 2 }}>
|
|
||||||
Не удалось загрузить настройки слайдера.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{sliderQuery.isLoading && (
|
|
||||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
Загрузка настроек слайдера…
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{sliderQuery.isSuccess && (
|
|
||||||
<GallerySliderSection
|
|
||||||
key={sliderQuery.dataUpdatedAt}
|
|
||||||
initialSlides={sliderQuery.data.slides.map((s) => ({
|
|
||||||
galleryImageId: s.galleryImageId,
|
|
||||||
caption: s.caption,
|
|
||||||
}))}
|
|
||||||
galleryItems={items}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}.
|
Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -125,6 +197,19 @@ export function AdminGalleryPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={viewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, v) => v && setViewMode(v)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ToggleButton value="grid" aria-label="Сетка">
|
||||||
|
<Grid3x3 size={16} />
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="table" aria-label="Таблица">
|
||||||
|
<List size={16} />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
{uploadMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
{uploadMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||||
{uploadMut.isError && (
|
{uploadMut.isError && (
|
||||||
<Typography color="error">
|
<Typography color="error">
|
||||||
@@ -145,13 +230,23 @@ export function AdminGalleryPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<GalleryGrid
|
{viewMode === 'grid' ? (
|
||||||
items={items}
|
<GalleryGrid
|
||||||
deleting={deleteMut.isPending}
|
items={items}
|
||||||
resizing={resizingId}
|
deleting={deleteMut.isPending}
|
||||||
onDelete={(id) => deleteMut.mutate(id)}
|
resizing={resizingId}
|
||||||
onResize={(id) => resizeMut.mutate(id)}
|
onDelete={(id) => deleteMut.mutate(id)}
|
||||||
/>
|
onResize={(id) => resizeMut.mutate(id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GalleryTable
|
||||||
|
items={items}
|
||||||
|
deleting={deleteMut.isPending}
|
||||||
|
resizing={resizingId}
|
||||||
|
onDelete={(id) => deleteMut.mutate(id)}
|
||||||
|
onResize={(id) => resizeMut.mutate(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!galleryQuery.isLoading && items.length === 0 && (
|
{!galleryQuery.isLoading && items.length === 0 && (
|
||||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ListOrdered,
|
ListOrdered,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Settings,
|
Settings,
|
||||||
|
SlidersHorizontal,
|
||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -34,6 +35,7 @@ import { AdminOrdersPage } from '@/pages/admin-orders'
|
|||||||
import { AdminProductsPage } from '@/pages/admin-products'
|
import { AdminProductsPage } from '@/pages/admin-products'
|
||||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||||
import { AdminSettingsPage } from '@/pages/admin-settings'
|
import { AdminSettingsPage } from '@/pages/admin-settings'
|
||||||
|
import { AdminSliderPage } from '@/pages/admin-slider'
|
||||||
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
|
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
|
||||||
import { AdminUsersPage } from '@/pages/admin-users'
|
import { AdminUsersPage } from '@/pages/admin-users'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
@@ -69,6 +71,7 @@ export function AdminLayoutPage() {
|
|||||||
{ to: '/admin', label: 'Товары', icon: <Store /> },
|
{ to: '/admin', label: 'Товары', icon: <Store /> },
|
||||||
{ to: '/admin/categories', label: 'Категории', icon: <LayoutGrid /> },
|
{ to: '/admin/categories', label: 'Категории', icon: <LayoutGrid /> },
|
||||||
{ to: '/admin/gallery', label: 'Галерея', icon: <Image /> },
|
{ to: '/admin/gallery', label: 'Галерея', icon: <Image /> },
|
||||||
|
{ to: '/admin/slider', label: 'Слайдер', icon: <SlidersHorizontal /> },
|
||||||
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
|
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
|
||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||||
@@ -200,6 +203,7 @@ export function AdminLayoutPage() {
|
|||||||
<Route index element={<AdminProductsPage />} />
|
<Route index element={<AdminProductsPage />} />
|
||||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||||
|
<Route path="slider" element={<AdminSliderPage />} />
|
||||||
<Route path="orders" element={<AdminOrdersPage />} />
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AdminSliderPage } from './ui/AdminSliderPage'
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
|
||||||
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||||
|
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 IconButton from '@mui/material/IconButton'
|
||||||
|
import Paper from '@mui/material/Paper'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { fetchAdminCatalogSlider, putAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||||
|
import { fetchAdminGallery } from '@/entities/gallery'
|
||||||
|
import type { GalleryImageItem } from '@/entities/gallery'
|
||||||
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
|
|
||||||
|
type SlideDraft = { galleryImageId: string; caption: string; textColor: string }
|
||||||
|
|
||||||
|
export function AdminSliderPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const sliderQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'catalog-slider'],
|
||||||
|
queryFn: fetchAdminCatalogSlider,
|
||||||
|
})
|
||||||
|
|
||||||
|
const galleryQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'gallery'],
|
||||||
|
queryFn: fetchAdminGallery,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>([])
|
||||||
|
const [pickOpen, setPickOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sliderQuery.isSuccess && sliderDraft.length === 0) {
|
||||||
|
setSliderDraft(
|
||||||
|
sliderQuery.data.slides.map((s) => ({
|
||||||
|
galleryImageId: s.galleryImageId,
|
||||||
|
caption: s.caption,
|
||||||
|
textColor: s.textColor || '#ffffff',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [sliderQuery.isSuccess, sliderQuery.dataUpdatedAt])
|
||||||
|
|
||||||
|
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
||||||
|
|
||||||
|
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
|
||||||
|
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
|
||||||
|
|
||||||
|
const saveSliderMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
putAdminCatalogSlider({
|
||||||
|
slides: sliderDraft.map((s) => ({
|
||||||
|
galleryImageId: s.galleryImageId,
|
||||||
|
caption: s.caption,
|
||||||
|
textColor: s.textColor,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateQueryKeys(queryClient, [['admin', 'catalog-slider'], ['catalog-slider']])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveSlide = (idx: number, dir: -1 | 1) => {
|
||||||
|
const next = idx + dir
|
||||||
|
if (next < 0 || next >= sliderDraft.length) return
|
||||||
|
setSliderDraft((prev) => {
|
||||||
|
const copy = [...prev]
|
||||||
|
const t = copy[idx]!
|
||||||
|
copy[idx] = copy[next]!
|
||||||
|
copy[next] = t
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDraft = (idx: number, patch: Partial<SlideDraft>) => {
|
||||||
|
setSliderDraft((prev) => {
|
||||||
|
const copy = [...prev]
|
||||||
|
copy[idx] = { ...copy[idx]!, ...patch }
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sliderQuery.isLoading || galleryQuery.isLoading) {
|
||||||
|
return <Typography color="text.secondary">Загрузка…</Typography>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sliderQuery.isError) {
|
||||||
|
return <Typography color="error">Не удалось загрузить слайдер.</Typography>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Слайдер
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Изображения для карусели на главной странице. Сначала загрузите фото в Галерею и обработайте их (Resize), затем
|
||||||
|
добавьте в слайдер. Порядок строк = порядок показа.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 2 }}>
|
||||||
|
{sliderDraft.length === 0 && (
|
||||||
|
<Typography color="text.secondary">Нет слайдов. Добавьте изображения из галереи.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sliderDraft.map((row, idx) => {
|
||||||
|
const img = galleryItems.find((g) => g.id === row.galleryImageId)
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={`${row.galleryImageId}-${idx}`}
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
sx={{ alignItems: { sm: 'flex-start' } }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={img?.url ?? ''}
|
||||||
|
alt=""
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Stack spacing={1.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TextField
|
||||||
|
label="Подпись на слайде"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={row.caption}
|
||||||
|
onChange={(e) => updateDraft(idx, { caption: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Цвет текста"
|
||||||
|
type="color"
|
||||||
|
value={row.textColor}
|
||||||
|
onChange={(e) => updateDraft(idx, { textColor: e.target.value })}
|
||||||
|
sx={{ width: 80 }}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
<IconButton size="small" aria-label="Выше" onClick={() => moveSlide(idx, -1)} disabled={idx === 0}>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label="Ниже"
|
||||||
|
onClick={() => moveSlide(idx, 1)}
|
||||||
|
disabled={idx >= sliderDraft.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
aria-label="Убрать из слайдера"
|
||||||
|
onClick={() => setSliderDraft((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Button variant="outlined" disabled={pickCandidates.length === 0} onClick={() => setPickOpen(true)}>
|
||||||
|
Добавить слайд из галереи
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" disabled={saveSliderMut.isPending} onClick={() => saveSliderMut.mutate()}>
|
||||||
|
Сохранить слайдер
|
||||||
|
</Button>
|
||||||
|
{saveSliderMut.isError && (
|
||||||
|
<Typography color="error">
|
||||||
|
{saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={pickOpen} onClose={() => setPickOpen(false)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Выберите изображение</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{pickCandidates.length === 0 ? (
|
||||||
|
<Typography color="text.secondary">Нет доступных файлов (все уже в слайдере или галерея пуста).</Typography>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: 1.5,
|
||||||
|
pt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pickCandidates.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
sx={{ p: 0, minWidth: 0, display: 'block', borderRadius: 1, overflow: 'hidden' }}
|
||||||
|
onClick={() => {
|
||||||
|
setSliderDraft((prev) => [...prev, { galleryImageId: item.id, caption: '', textColor: '#ffffff' }])
|
||||||
|
setPickOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={item.url}
|
||||||
|
alt=""
|
||||||
|
sx={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setPickOpen(false)}>Закрыть</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ import { AuthForgotForm } from '@/features/auth-forgot'
|
|||||||
import { OAuthButtons } from '@/features/auth-oauth'
|
import { OAuthButtons } from '@/features/auth-oauth'
|
||||||
import { AuthPasswordForm } from '@/features/auth-password'
|
import { AuthPasswordForm } from '@/features/auth-password'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { useThemeController } from '@/app/providers/theme-controller'
|
||||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const { scheme } = useThemeController()
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||||
const [tab, setTab] = useState(0)
|
const [tab, setTab] = useState(0)
|
||||||
@@ -54,7 +56,7 @@ export function AuthPage() {
|
|||||||
>
|
>
|
||||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
<BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||||
@@ -88,7 +90,7 @@ export function AuthPage() {
|
|||||||
>
|
>
|
||||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
<BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||||
|
|||||||
@@ -68,7 +68,19 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<CatalogSlider />
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
mb: 3,
|
||||||
|
aspectRatio: { xs: '4/3', sm: '21/9' },
|
||||||
|
maxHeight: { xs: 320, sm: 400 },
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CatalogSlider />
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
|
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import type { SxProps, Theme } from '@mui/material/styles'
|
import type { SxProps, Theme } from '@mui/material/styles'
|
||||||
|
import type { ColorScheme } from '@/shared/model/theme'
|
||||||
|
|
||||||
|
const HUE_ROTATE: Record<ColorScheme, string> = {
|
||||||
|
craft: 'none',
|
||||||
|
forest: 'hue-rotate(-65deg) saturate(1.3)',
|
||||||
|
ocean: 'hue-rotate(-25deg) saturate(1.15)',
|
||||||
|
berry: 'hue-rotate(80deg) saturate(1.1)',
|
||||||
|
}
|
||||||
|
|
||||||
type BearLogoProps = {
|
type BearLogoProps = {
|
||||||
|
scheme?: ColorScheme
|
||||||
sx?: SxProps<Theme>
|
sx?: SxProps<Theme>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BearLogo({ sx }: BearLogoProps) {
|
export function BearLogo({ scheme, sx }: BearLogoProps) {
|
||||||
|
const filter = scheme ? HUE_ROTATE[scheme] : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="/logo.webp"
|
src="/logo.webp"
|
||||||
alt="Любимый Креатив"
|
alt=""
|
||||||
|
width={35}
|
||||||
|
height={35}
|
||||||
sx={{
|
sx={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
transform: 'scale(1.25)',
|
...(filter && filter !== 'none' ? { filter } : {}),
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,7 +49,17 @@ export const OptimizedImage = React.memo(function OptimizedImage({
|
|||||||
|
|
||||||
// If src is not an upload URL, render a plain img
|
// If src is not an upload URL, render a plain img
|
||||||
if (!srcSet) {
|
if (!srcSet) {
|
||||||
return <Box component="img" src={src} alt={alt} loading={priority ? 'eager' : 'lazy'} decoding="async" sx={sx} />
|
return (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
fetchPriority={priority ? 'high' : undefined}
|
||||||
|
decoding="async"
|
||||||
|
sx={sx}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px'
|
const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px'
|
||||||
@@ -63,6 +73,7 @@ export const OptimizedImage = React.memo(function OptimizedImage({
|
|||||||
src={fallbackSrc}
|
src={fallbackSrc}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
loading={priority ? 'eager' : 'lazy'}
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
fetchPriority={priority ? 'high' : undefined}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const UserAvatar = React.memo(function UserAvatar({
|
|||||||
const src = avatarUrl || generatedSrc || ''
|
const src = avatarUrl || generatedSrc || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
|
<Avatar src={src} alt="" sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
|
||||||
{!src && '?'}
|
{!src && '?'}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,22 +87,22 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 4,
|
zIndex: 4,
|
||||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.75))',
|
background: 'linear-gradient(rgba(0,0,0,0.75), transparent)',
|
||||||
px: 3,
|
px: 3,
|
||||||
py: 2.5,
|
py: 2.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
color="common.white"
|
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: { xs: '1.5rem', sm: '2rem', md: '2.5rem' },
|
fontSize: { xs: '1.5rem', sm: '2rem', md: '2.5rem' },
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
color: slide.textColor || '#ffffff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{captionText}
|
{captionText}
|
||||||
@@ -138,16 +138,28 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
|
|||||||
aria-current={i === index ? 'true' : undefined}
|
aria-current={i === index ? 'true' : undefined}
|
||||||
onClick={() => setIndex(i)}
|
onClick={() => setIndex(i)}
|
||||||
sx={{
|
sx={{
|
||||||
width: i === index ? 22 : 8,
|
width: 48,
|
||||||
height: 8,
|
height: 48,
|
||||||
p: 0,
|
p: 0,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 99,
|
borderRadius: '50%',
|
||||||
bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)',
|
bgcolor: 'transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'width 0.25s, background-color 0.25s',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: i === index ? 22 : 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 99,
|
||||||
|
bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)',
|
||||||
|
transition: 'width 0.25s, background-color 0.25s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -168,7 +180,7 @@ export function CatalogSlider() {
|
|||||||
if (!isSuccess || slides.length === 0) return null
|
if (!isSuccess || slides.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%', mb: 3 }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<CatalogSliderInner key={slideKey} slides={slides} />
|
<CatalogSliderInner key={slideKey} slides={slides} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function NavigationDrawer({
|
|||||||
>
|
>
|
||||||
<Box sx={{ p: 2 }}>
|
<Box sx={{ p: 2 }}>
|
||||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<BearLogo sx={{ width: 28, height: 28 }} />
|
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
|
||||||
<Typography variant="h6">{STORE_NAME}</Typography>
|
<Typography variant="h6">{STORE_NAME}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function ReviewsBlock() {
|
|||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
|
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
|
||||||
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
||||||
<Typography variant="h5">Отзывы</Typography>
|
<Typography variant="h5" component="h3">Отзывы</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Последние отзывы о товарах
|
Последние отзывы о товарах
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -48,7 +48,7 @@ export function ReviewsBlock() {
|
|||||||
{q.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
|
{q.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
|
||||||
{!q.isLoading && !q.isError && q.data && items.length === 0 && (
|
{!q.isLoading && !q.isError && q.data && items.length === 0 && (
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="h6" component="p" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
Отзывов пока нет
|
Отзывов пока нет
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_CatalogSliderSlide" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sortOrder" INTEGER NOT NULL,
|
||||||
|
"caption" TEXT NOT NULL DEFAULT '',
|
||||||
|
"textColor" TEXT NOT NULL DEFAULT '#ffffff',
|
||||||
|
"galleryImageId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "CatalogSliderSlide_galleryImageId_fkey" FOREIGN KEY ("galleryImageId") REFERENCES "GalleryImage" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_CatalogSliderSlide" ("caption", "galleryImageId", "id", "sortOrder") SELECT "caption", "galleryImageId", "id", "sortOrder" FROM "CatalogSliderSlide";
|
||||||
|
DROP TABLE "CatalogSliderSlide";
|
||||||
|
ALTER TABLE "new_CatalogSliderSlide" RENAME TO "CatalogSliderSlide";
|
||||||
|
CREATE INDEX "CatalogSliderSlide_sortOrder_idx" ON "CatalogSliderSlide"("sortOrder");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -68,6 +68,7 @@ model CatalogSliderSlide {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sortOrder Int
|
sortOrder Int
|
||||||
caption String @default("")
|
caption String @default("")
|
||||||
|
textColor String @default("#ffffff")
|
||||||
galleryImageId String
|
galleryImageId String
|
||||||
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
|
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,35 @@ export async function registerAdminGalleryRoutes(fastify) {
|
|||||||
const items = await prisma.galleryImage.findMany({
|
const items = await prisma.galleryImage.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
return { items }
|
|
||||||
|
const urls = items.map((i) => i.url)
|
||||||
|
const usedUrls = new Set()
|
||||||
|
|
||||||
|
const productImages = await prisma.productImage.findMany({
|
||||||
|
where: { url: { in: urls } },
|
||||||
|
select: { url: true },
|
||||||
|
})
|
||||||
|
for (const pi of productImages) {
|
||||||
|
usedUrls.add(pi.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyProducts = await prisma.product.findMany({
|
||||||
|
where: { imageUrl: { in: urls } },
|
||||||
|
select: { imageUrl: true },
|
||||||
|
})
|
||||||
|
for (const p of legacyProducts) {
|
||||||
|
if (p.imageUrl) usedUrls.add(p.imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
url: i.url,
|
||||||
|
isResized: i.isResized,
|
||||||
|
createdAt: i.createdAt,
|
||||||
|
inUse: usedUrls.has(i.url),
|
||||||
|
})),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
url: s.galleryImage.url,
|
url: s.galleryImage.url,
|
||||||
caption: s.caption,
|
caption: s.caption,
|
||||||
|
textColor: s.textColor,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -34,6 +35,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
galleryImageId: s.galleryImageId,
|
galleryImageId: s.galleryImageId,
|
||||||
url: s.galleryImage.url,
|
url: s.galleryImage.url,
|
||||||
caption: s.caption,
|
caption: s.caption,
|
||||||
|
textColor: s.textColor,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -70,7 +72,8 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
||||||
}
|
}
|
||||||
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
|
||||||
normalized.push({ galleryImageId, caption, sortOrder: i })
|
const textColor = String(row?.textColor || '#ffffff').trim()
|
||||||
|
normalized.push({ galleryImageId, caption, textColor, sortOrder: i })
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -80,6 +83,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
data: {
|
data: {
|
||||||
sortOrder: n.sortOrder,
|
sortOrder: n.sortOrder,
|
||||||
caption: n.caption,
|
caption: n.caption,
|
||||||
|
textColor: n.textColor,
|
||||||
galleryImageId: n.galleryImageId,
|
galleryImageId: n.galleryImageId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -96,6 +100,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
|||||||
galleryImageId: s.galleryImageId,
|
galleryImageId: s.galleryImageId,
|
||||||
url: s.galleryImage.url,
|
url: s.galleryImage.url,
|
||||||
caption: s.caption,
|
caption: s.caption,
|
||||||
|
textColor: s.textColor,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||