Merge branch 'perf2'
@@ -2,11 +2,11 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon-32.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon-48.ico" sizes="48x48" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon-64.ico" sizes="64x64" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon-128.ico" />
|
||||
<link rel="apple-touch-icon" href="/favicon-128.ico" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://xn--80abekoceifm0c0a5irb.xn--p1ai" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
@@ -17,8 +17,10 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" 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" />
|
||||
<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://любимыйкреатив.рф/" />
|
||||
</head>
|
||||
<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,12 +87,17 @@ export const AppHeader = React.memo(function AppHeader() {
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.88),
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.95),
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<Toolbar
|
||||
sx={{
|
||||
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
|
||||
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
|
||||
}}
|
||||
>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
@@ -118,7 +123,7 @@ export const AppHeader = React.memo(function AppHeader() {
|
||||
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' }}>
|
||||
{STORE_NAME}
|
||||
</Typography>
|
||||
@@ -135,7 +140,12 @@ export const AppHeader = React.memo(function AppHeader() {
|
||||
<>
|
||||
{user && (
|
||||
<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}>
|
||||
<Package />
|
||||
</Badge>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||
<Grid container spacing={5}>
|
||||
<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}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
||||
@@ -50,7 +50,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
</Typography>
|
||||
</Grid>
|
||||
<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>
|
||||
<Stack spacing={1.5}>
|
||||
@@ -66,7 +66,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
</Stack>
|
||||
</Grid>
|
||||
<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>
|
||||
<Stack spacing={1}>
|
||||
@@ -89,13 +89,13 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
color="text.secondary"
|
||||
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
|
||||
</Link>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<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>
|
||||
<Stack spacing={1.5}>
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogS
|
||||
}
|
||||
|
||||
export async function putAdminCatalogSlider(body: {
|
||||
slides: Array<{ galleryImageId: string; caption: string }>
|
||||
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
|
||||
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
||||
return data
|
||||
|
||||
@@ -2,6 +2,7 @@ export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
|
||||
@@ -3,4 +3,5 @@ export type GalleryImageItem = {
|
||||
url: string
|
||||
isResized: boolean
|
||||
createdAt: string
|
||||
inUse?: boolean
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
||||
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
|
||||
<Swiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={0}
|
||||
spaceBetween={16}
|
||||
allowTouchMove={!isMobile}
|
||||
onSwiper={(s) => {
|
||||
swiperRef.current = s
|
||||
@@ -106,7 +106,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
||||
alt={product.title}
|
||||
sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
width: '101%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
@@ -174,6 +174,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
||||
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="product-card__title"
|
||||
sx={{
|
||||
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 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="p"
|
||||
color="primary"
|
||||
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 InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
@@ -36,170 +38,197 @@ type Props = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
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: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const password = watch('password')
|
||||
const passwordConfirm = watch('passwordConfirm')
|
||||
const displayName = watch('displayName')
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||||
email,
|
||||
password,
|
||||
displayName: displayName || undefined,
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
},
|
||||
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 (
|
||||
<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>
|
||||
<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')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Имя (необязательно)"
|
||||
{...register('displayName')}
|
||||
label="Email"
|
||||
{...register('email', {
|
||||
required: 'Введите email',
|
||||
pattern: {
|
||||
value: EMAIL_PATTERN,
|
||||
message: 'Некорректный email',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
helperText="Если не указать, будет использована часть email до @"
|
||||
autoComplete="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Имя (необязательно)"
|
||||
{...register('displayName')}
|
||||
fullWidth
|
||||
helperText="Если не указать, будет использована часть email до @"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
label="Пароль"
|
||||
type="password"
|
||||
{...register('passwordConfirm')}
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
{...register('password', {
|
||||
required: 'Введите пароль',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Пароль должен быть не менее 8 символов',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(passwordError)}
|
||||
helperText={passwordError}
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRegister ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={
|
||||
!email ||
|
||||
!password ||
|
||||
password.length < 8 ||
|
||||
(isRegister && password !== passwordConfirm) ||
|
||||
registerMutation.isPending
|
||||
}
|
||||
onClick={() => registerMutation.mutate()}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!email || !password || loginMutation.isPending}
|
||||
onClick={() => loginMutation.mutate()}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
)}
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('passwordConfirm', {
|
||||
required: 'Подтвердите пароль',
|
||||
validate: (value) => value === password || 'Пароли не совпадают',
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(errors.passwordConfirm)}
|
||||
helperText={errors.passwordConfirm?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(loginMutation.error || registerMutation.error) && (
|
||||
<TextField
|
||||
error
|
||||
helperText={getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error)}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
)}
|
||||
{apiErrorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="outlined"
|
||||
onClose={() => {
|
||||
loginMutation.reset()
|
||||
registerMutation.reset()
|
||||
}}
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function CartBadge({ user, cartCount, onNavigate }: Props) {
|
||||
if (!user) onNavigate('/auth')
|
||||
else onNavigate('/cart')
|
||||
}}
|
||||
aria-label="Корзина"
|
||||
aria-label={user ? `Корзина (${cartCount})` : 'Авторизуйтесь для совершения покупок'}
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCart />
|
||||
|
||||
@@ -25,6 +25,7 @@ export function GalleryImagePicker({
|
||||
currentUrls: string[]
|
||||
}) {
|
||||
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
const [hideUsed, setHideUsed] = useState(false)
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
@@ -64,6 +65,20 @@ export function GalleryImagePicker({
|
||||
{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 &&
|
||||
@@ -82,6 +97,7 @@ export function GalleryImagePicker({
|
||||
>
|
||||
{(galleryQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.filter((item) => !hideUsed || !item.inUse)
|
||||
.map((item) => {
|
||||
const alreadyInCard = currentUrls.includes(item.url)
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,12 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={openMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<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} />
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
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 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||
import { Grid3x3, List } from 'lucide-react'
|
||||
import {
|
||||
deleteGalleryImage,
|
||||
fetchAdminGallery,
|
||||
@@ -13,9 +21,9 @@ import {
|
||||
resizeGalleryImage,
|
||||
uploadGalleryImages,
|
||||
} from '@/entities/gallery'
|
||||
import type { GalleryImageItem } from '@/entities/gallery'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { GallerySliderSection } from './GallerySliderSection'
|
||||
import type { AxiosError } from 'axios'
|
||||
|
||||
function getApiErrorMessage(error: unknown): string | null {
|
||||
@@ -24,15 +32,102 @@ function getApiErrorMessage(error: unknown): string | 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() {
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [resizingId, setResizingId] = useState<string | null>(null)
|
||||
|
||||
const sliderQuery = useQuery({
|
||||
queryKey: ['admin', 'catalog-slider'],
|
||||
queryFn: fetchAdminCatalogSlider,
|
||||
})
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
@@ -82,29 +177,6 @@ export function AdminGalleryPage() {
|
||||
изображения доступны для добавления в карточку товара и слайдер.
|
||||
</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 }}>
|
||||
Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}.
|
||||
</Typography>
|
||||
@@ -125,6 +197,14 @@ export function AdminGalleryPage() {
|
||||
}}
|
||||
/>
|
||||
</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.isError && (
|
||||
<Typography color="error">
|
||||
@@ -145,13 +225,23 @@ export function AdminGalleryPage() {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<GalleryGrid
|
||||
items={items}
|
||||
deleting={deleteMut.isPending}
|
||||
resizing={resizingId}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onResize={(id) => resizeMut.mutate(id)}
|
||||
/>
|
||||
{viewMode === 'grid' ? (
|
||||
<GalleryGrid
|
||||
items={items}
|
||||
deleting={deleteMut.isPending}
|
||||
resizing={resizingId}
|
||||
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 && (
|
||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ListOrdered,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Store,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
@@ -34,6 +35,7 @@ import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminProductsPage } from '@/pages/admin-products'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminSettingsPage } from '@/pages/admin-settings'
|
||||
import { AdminSliderPage } from '@/pages/admin-slider'
|
||||
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
@@ -69,6 +71,7 @@ export function AdminLayoutPage() {
|
||||
{ to: '/admin', label: 'Товары', icon: <Store /> },
|
||||
{ to: '/admin/categories', label: 'Категории', icon: <LayoutGrid /> },
|
||||
{ to: '/admin/gallery', label: 'Галерея', icon: <Image /> },
|
||||
{ to: '/admin/slider', label: 'Слайдер', icon: <SlidersHorizontal /> },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: <ListOrdered /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||
@@ -200,6 +203,7 @@ export function AdminLayoutPage() {
|
||||
<Route index element={<AdminProductsPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||
<Route path="slider" element={<AdminSliderPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminSliderPage } from './ui/AdminSliderPage'
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMemo, 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 }
|
||||
|
||||
function SliderEditor({
|
||||
initialSlides,
|
||||
galleryItems,
|
||||
}: {
|
||||
initialSlides: SlideDraft[]
|
||||
galleryItems: GalleryImageItem[]
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>(initialSlides)
|
||||
const [pickOpen, setPickOpen] = useState(false)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminSliderPage() {
|
||||
const sliderQuery = useQuery({
|
||||
queryKey: ['admin', 'catalog-slider'],
|
||||
queryFn: fetchAdminCatalogSlider,
|
||||
})
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
})
|
||||
|
||||
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
||||
|
||||
const initialSlides = useMemo<SlideDraft[]>(() => {
|
||||
if (!sliderQuery.isSuccess) return []
|
||||
return sliderQuery.data.slides.map((s) => ({
|
||||
galleryImageId: s.galleryImageId,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor || '#ffffff',
|
||||
}))
|
||||
}, [sliderQuery.isSuccess, sliderQuery.data?.slides])
|
||||
|
||||
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>
|
||||
|
||||
<SliderEditor key={sliderQuery.dataUpdatedAt} initialSlides={initialSlides} galleryItems={galleryItems} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,24 @@ import { AuthForgotForm } from '@/features/auth-forgot'
|
||||
import { OAuthButtons } from '@/features/auth-oauth'
|
||||
import { AuthPasswordForm } from '@/features/auth-password'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import type { ColorScheme } from '@/shared/model/theme'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
|
||||
function readStoredScheme(): ColorScheme {
|
||||
try {
|
||||
const raw = localStorage.getItem('craftshop_theme')
|
||||
if (!raw) return 'craft'
|
||||
const parsed = JSON.parse(raw)
|
||||
const scheme = parsed?.scheme
|
||||
return scheme === 'forest' || scheme === 'ocean' || scheme === 'berry' ? scheme : 'craft'
|
||||
} catch {
|
||||
return 'craft'
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthPage() {
|
||||
const theme = useTheme()
|
||||
const scheme = readStoredScheme()
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [oauthError, setOauthError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState(0)
|
||||
@@ -38,7 +52,7 @@ export function AuthPage() {
|
||||
setSearchParams({}, { replace: true })
|
||||
}, 0)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [])
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
if (showForgot) {
|
||||
return (
|
||||
@@ -54,7 +68,7 @@ export function AuthPage() {
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
||||
<BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||
@@ -88,7 +102,7 @@ export function AuthPage() {
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
||||
<BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||
|
||||
@@ -68,7 +68,19 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{title}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import Box from '@mui/material/Box'
|
||||
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 = {
|
||||
scheme?: ColorScheme
|
||||
sx?: SxProps<Theme>
|
||||
}
|
||||
|
||||
export function BearLogo({ sx }: BearLogoProps) {
|
||||
export function BearLogo({ scheme, sx }: BearLogoProps) {
|
||||
const filter = scheme ? HUE_ROTATE[scheme] : undefined
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.webp"
|
||||
alt="Любимый Креатив"
|
||||
alt=""
|
||||
width={35}
|
||||
height={35}
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
transform: 'scale(1.25)',
|
||||
...(filter && filter !== 'none' ? { filter } : {}),
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,17 @@ export const OptimizedImage = React.memo(function OptimizedImage({
|
||||
|
||||
// If src is not an upload URL, render a plain img
|
||||
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'
|
||||
@@ -63,6 +73,7 @@ export const OptimizedImage = React.memo(function OptimizedImage({
|
||||
src={fallbackSrc}
|
||||
alt={alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
fetchPriority={priority ? 'high' : undefined}
|
||||
decoding="async"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
const SCHEMES: { key: ColorScheme; color: string; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'craft', color: '#546E7A', label: 'Крафт', icon: <Hammer size={16} /> },
|
||||
{ key: 'forest', color: '#2E8B57', label: 'Лес', icon: <Trees size={16} /> },
|
||||
{ key: 'ocean', color: '#20B2AA', label: 'Океан', icon: <WavesHorizontal size={16} /> },
|
||||
{ key: 'ocean', color: '#0E8A82', label: 'Океан', icon: <WavesHorizontal size={16} /> },
|
||||
{ key: 'berry', color: '#8A2BE2', label: 'Ягоды', icon: <Cherry size={16} /> },
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const UserAvatar = React.memo(function UserAvatar({
|
||||
const src = avatarUrl || generatedSrc || ''
|
||||
|
||||
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 && '?'}
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
@@ -87,22 +87,22 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 4,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.75))',
|
||||
background: 'linear-gradient(rgba(0,0,0,0.75), transparent)',
|
||||
px: 3,
|
||||
py: 2.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color="common.white"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
textAlign: 'center',
|
||||
fontSize: { xs: '1.5rem', sm: '2rem', md: '2.5rem' },
|
||||
lineHeight: 1.2,
|
||||
color: slide.textColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{captionText}
|
||||
@@ -138,16 +138,28 @@ function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
|
||||
aria-current={i === index ? 'true' : undefined}
|
||||
onClick={() => setIndex(i)}
|
||||
sx={{
|
||||
width: i === index ? 22 : 8,
|
||||
height: 8,
|
||||
width: 48,
|
||||
height: 48,
|
||||
p: 0,
|
||||
border: 'none',
|
||||
borderRadius: 99,
|
||||
bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'transparent',
|
||||
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>
|
||||
)}
|
||||
@@ -168,7 +180,7 @@ export function CatalogSlider() {
|
||||
if (!isSuccess || slides.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', mb: 3 }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<CatalogSliderInner key={slideKey} slides={slides} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ export function NavigationDrawer({
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ReviewsBlock() {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 3 }, borderRadius: 2, bgcolor: 'background.paper' }}>
|
||||
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
||||
<Typography variant="h5">Отзывы</Typography>
|
||||
<Typography variant="h5" component="h3">Отзывы</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Последние отзывы о товарах
|
||||
</Typography>
|
||||
@@ -48,7 +48,7 @@ export function ReviewsBlock() {
|
||||
{q.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
|
||||
{!q.isLoading && !q.isError && q.data && items.length === 0 && (
|
||||
<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 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())
|
||||
sortOrder Int
|
||||
caption String @default("")
|
||||
textColor String @default("#ffffff")
|
||||
galleryImageId String
|
||||
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -13,7 +13,35 @@ export async function registerAdminGalleryRoutes(fastify) {
|
||||
const items = await prisma.galleryImage.findMany({
|
||||
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) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
id: s.id,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -34,6 +35,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -70,7 +72,8 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
|
||||
}
|
||||
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) => {
|
||||
@@ -80,6 +83,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
data: {
|
||||
sortOrder: n.sortOrder,
|
||||
caption: n.caption,
|
||||
textColor: n.textColor,
|
||||
galleryImageId: n.galleryImageId,
|
||||
},
|
||||
})
|
||||
@@ -96,6 +100,7 @@ export async function registerCatalogSliderRoutes(fastify) {
|
||||
galleryImageId: s.galleryImageId,
|
||||
url: s.galleryImage.url,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||