Merge branch 'perf2'

This commit is contained in:
Kirill
2026-05-27 16:31:54 +05:00
39 changed files with 39605 additions and 219 deletions
+8 -6
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

+1
View File
@@ -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

+14 -4
View File
@@ -87,12 +87,17 @@ 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',
}} }}
> >
<Toolbar> <Toolbar
sx={{
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
}}
>
{isMobile && ( {isMobile && (
<IconButton <IconButton
color="inherit" color="inherit"
@@ -118,7 +123,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 +140,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>
+5 -5
View File
@@ -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
} }
@@ -81,7 +81,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}> <Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
<Swiper <Swiper
slidesPerView={1} slidesPerView={1}
spaceBetween={0} spaceBetween={16}
allowTouchMove={!isMobile} allowTouchMove={!isMobile}
onSwiper={(s) => { onSwiper={(s) => {
swiperRef.current = s swiperRef.current = s
@@ -106,7 +106,7 @@ const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
alt={product.title} alt={product.title}
sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`} sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
sx={{ sx={{
width: '100%', width: '101%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: 'cover',
}} }}
@@ -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'],
@@ -64,6 +65,20 @@ export function GalleryImagePicker({
{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 (
@@ -33,7 +33,12 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props)
return ( 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 ? ( {user ? (
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} /> <UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
) : ( ) : (
@@ -1,11 +1,19 @@
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 { Grid3x3, List } from 'lucide-react'
import { import {
deleteGalleryImage, deleteGalleryImage,
fetchAdminGallery, fetchAdminGallery,
@@ -13,9 +21,9 @@ 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'
function getApiErrorMessage(error: unknown): string | null { function getApiErrorMessage(error: unknown): string | null {
@@ -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,14 @@ 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 +225,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 />} />
+1
View File
@@ -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>
)
}
+17 -3
View File
@@ -13,10 +13,24 @@ 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 type { ColorScheme } from '@/shared/model/theme'
import { BearLogo } from '@/shared/ui/BearLogo' 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() { export function AuthPage() {
const theme = useTheme() const theme = useTheme()
const scheme = readStoredScheme()
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)
@@ -38,7 +52,7 @@ export function AuthPage() {
setSearchParams({}, { replace: true }) setSearchParams({}, { replace: true })
}, 0) }, 0)
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, []) }, [searchParams, setSearchParams])
if (showForgot) { if (showForgot) {
return ( return (
@@ -54,7 +68,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 +102,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>
+13 -1
View File
@@ -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}
+16 -3
View File
@@ -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,
}} }}
/> />
+12 -1
View File
@@ -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' }}
/> />
@@ -12,7 +12,7 @@ type Props = {
const SCHEMES: { key: ColorScheme; color: string; label: string; icon: React.ReactNode }[] = [ const SCHEMES: { key: ColorScheme; color: string; label: string; icon: React.ReactNode }[] = [
{ key: 'craft', color: '#546E7A', label: 'Крафт', icon: <Hammer size={16} /> }, { key: 'craft', color: '#546E7A', label: 'Крафт', icon: <Hammer size={16} /> },
{ key: 'forest', color: '#2E8B57', label: 'Лес', icon: <Trees 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} /> }, { key: 'berry', color: '#8A2BE2', label: 'Ягоды', icon: <Cherry size={16} /> },
] ]
+1 -1
View File
@@ -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 }}>
File diff suppressed because one or more lines are too long
@@ -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;
Binary file not shown.
+1
View File
@@ -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)
+29 -1
View File
@@ -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) => {
+6 -1
View File
@@ -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) {