This commit is contained in:
Kirill
2026-05-26 12:10:38 +05:00
parent 4b8b86e1b8
commit e092299a11
37 changed files with 39573 additions and 214 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

+8 -3
View File
@@ -87,7 +87,7 @@ export const AppHeader = React.memo(function AppHeader() {
sx={{ sx={{
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: 'divider',
bgcolor: alpha(theme.palette.primary.main, 0.88), bgcolor: alpha(theme.palette.primary.main, 0.95),
backdropFilter: 'blur(8px)', backdropFilter: 'blur(8px)',
transition: 'box-shadow 0.2s ease, background-color 0.2s ease', transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
}} }}
@@ -118,7 +118,7 @@ export const AppHeader = React.memo(function AppHeader() {
gap: 1, gap: 1,
}} }}
> >
<BearLogo sx={{ width: 28, height: 28 }} /> <BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{STORE_NAME} {STORE_NAME}
</Typography> </Typography>
@@ -135,7 +135,12 @@ export const AppHeader = React.memo(function AppHeader() {
<> <>
{user && ( {user && (
<Tooltip title="Заказы"> <Tooltip title="Заказы">
<IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы"> <IconButton
color="inherit"
sx={{ ml: 1 }}
onClick={() => navigate('/me/orders')}
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
>
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}> <Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
<Package /> <Package />
</Badge> </Badge>
+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
} }
@@ -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,40 +38,55 @@ 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', {
email: values.email,
password: values.password,
})
tokenSet(data.token) tokenSet(data.token)
}, },
onSuccess, onSuccess,
}) })
const registerMutation = useMutation({ const registerMutation = useMutation({
mutationFn: async () => { mutationFn: async (values: FormValues) => {
const { data } = await apiClient.post<AuthResponse>('auth/register', { const { data } = await apiClient.post<AuthResponse>('auth/register', {
email, email: values.email,
password, password: values.password,
displayName: displayName || undefined, displayName: values.displayName || undefined,
}) })
tokenSet(data.token) tokenSet(data.token)
}, },
onSuccess, onSuccess,
}) })
const passwordError = isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null 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 (
<Box component="form" onSubmit={onSubmit} noValidate>
<Stack spacing={2}> <Stack spacing={2}>
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}> <Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
<Button <Button
@@ -106,8 +123,17 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
<TextField <TextField
label="Email" label="Email"
{...register('email')} {...register('email', {
required: 'Введите email',
pattern: {
value: EMAIL_PATTERN,
message: 'Некорректный email',
},
})}
fullWidth fullWidth
autoComplete="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@@ -131,8 +157,17 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
<TextField <TextField
label="Пароль" label="Пароль"
type="password" type="password"
{...register('password')} autoComplete={isRegister ? 'new-password' : 'current-password'}
{...register('password', {
required: 'Введите пароль',
minLength: {
value: 8,
message: 'Пароль должен быть не менее 8 символов',
},
})}
fullWidth fullWidth
error={Boolean(errors.password)}
helperText={errors.password?.message}
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@@ -148,47 +183,40 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
<TextField <TextField
label="Подтверждение пароля" label="Подтверждение пароля"
type="password" type="password"
{...register('passwordConfirm')} autoComplete="new-password"
{...register('passwordConfirm', {
required: 'Подтвердите пароль',
validate: (value) => value === password || 'Пароли не совпадают',
})}
fullWidth fullWidth
error={Boolean(passwordError)} error={Boolean(errors.passwordConfirm)}
helperText={passwordError} helperText={errors.passwordConfirm?.message}
/> />
)} )}
{apiErrorMessage && (
<Alert
severity="error"
variant="outlined"
onClose={() => {
loginMutation.reset()
registerMutation.reset()
}}
>
{apiErrorMessage}
</Alert>
)}
{isRegister ? ( {isRegister ? (
<Button <Button variant="contained" size="large" type="submit" disabled={isPending}>
variant="contained"
size="large"
disabled={
!email ||
!password ||
password.length < 8 ||
(isRegister && password !== passwordConfirm) ||
registerMutation.isPending
}
onClick={() => registerMutation.mutate()}
>
Зарегистрироваться Зарегистрироваться
</Button> </Button>
) : ( ) : (
<Button <Button variant="contained" size="large" type="submit" disabled={isPending}>
variant="contained"
size="large"
disabled={!email || !password || loginMutation.isPending}
onClick={() => loginMutation.mutate()}
>
Войти Войти
</Button> </Button>
)} )}
{(loginMutation.error || registerMutation.error) && (
<TextField
error
helperText={getApiErrorMessage(loginMutation.error) || getApiErrorMessage(registerMutation.error)}
sx={{ display: 'none' }}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}> <Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '} Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
<Link component={RouterLink} to="/terms" underline="hover"> <Link component={RouterLink} to="/terms" underline="hover">
@@ -201,5 +229,6 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr
. .
</Typography> </Typography>
</Stack> </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 (
@@ -1,11 +1,18 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider' import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
import { import {
deleteGalleryImage, deleteGalleryImage,
fetchAdminGallery, fetchAdminGallery,
@@ -13,10 +20,11 @@ import {
resizeGalleryImage, resizeGalleryImage,
uploadGalleryImages, uploadGalleryImages,
} from '@/entities/gallery' } from '@/entities/gallery'
import type { GalleryImageItem } from '@/entities/gallery'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { GallerySliderSection } from './GallerySliderSection'
import type { AxiosError } from 'axios' import type { AxiosError } from 'axios'
import { Grid3x3, List } from 'lucide-react'
function getApiErrorMessage(error: unknown): string | null { function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }> const e = error as AxiosError<{ error?: string }>
@@ -24,15 +32,102 @@ function getApiErrorMessage(error: unknown): string | null {
return msg ? String(msg) : null return msg ? String(msg) : null
} }
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return ''
}
}
function fileNameFromUrl(url: string): string {
const parts = url.split('/')
return parts[parts.length - 1] || url
}
function GalleryTable({
items,
deleting,
resizing,
onDelete,
onResize,
}: {
items: GalleryImageItem[]
deleting: boolean
resizing: string | null
onDelete: (id: string) => void
onResize: (id: string) => void
}) {
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Миниатюра</TableCell>
<TableCell>Имя файла</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Дата</TableCell>
<TableCell>Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1, display: 'block' }}
/>
</TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fileNameFromUrl(item.url)}
</TableCell>
<TableCell>
<Chip
label={item.isResized ? 'Готово' : 'Не обработано'}
size="small"
color={item.isResized ? 'success' : 'warning'}
/>
</TableCell>
<TableCell>{formatDate(item.createdAt)}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5}>
{!item.isResized && (
<Button
size="small"
variant="outlined"
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
Resize
</Button>
)}
<Button
size="small"
variant="outlined"
color="error"
disabled={deleting}
onClick={() => onDelete(item.id)}
>
Удалить
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
export function AdminGalleryPage() { export function AdminGalleryPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [resizingId, setResizingId] = useState<string | null>(null) const [resizingId, setResizingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'],
queryFn: fetchAdminCatalogSlider,
})
const galleryQuery = useQuery({ const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'], queryKey: ['admin', 'gallery'],
@@ -82,29 +177,6 @@ export function AdminGalleryPage() {
изображения доступны для добавления в карточку товара и слайдер. изображения доступны для добавления в карточку товара и слайдер.
</Typography> </Typography>
{sliderQuery.isError && (
<Typography color="error" sx={{ mb: 2 }}>
Не удалось загрузить настройки слайдера.
</Typography>
)}
{sliderQuery.isLoading && (
<Typography color="text.secondary" sx={{ mb: 2 }}>
Загрузка настроек слайдера
</Typography>
)}
{sliderQuery.isSuccess && (
<GallerySliderSection
key={sliderQuery.dataUpdatedAt}
initialSlides={sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
}))}
galleryItems={items}
/>
)}
<Divider sx={{ mb: 3 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Форматы: PNG, JPEG, WebP. На один файл до {formatAdminImageMaxSizeHint()}. Форматы: PNG, JPEG, WebP. На один файл до {formatAdminImageMaxSizeHint()}.
</Typography> </Typography>
@@ -125,6 +197,19 @@ export function AdminGalleryPage() {
}} }}
/> />
</Button> </Button>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, v) => v && setViewMode(v)}
size="small"
>
<ToggleButton value="grid" aria-label="Сетка">
<Grid3x3 size={16} />
</ToggleButton>
<ToggleButton value="table" aria-label="Таблица">
<List size={16} />
</ToggleButton>
</ToggleButtonGroup>
{uploadMut.isPending && <Typography color="text.secondary">Загрузка</Typography>} {uploadMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadMut.isError && ( {uploadMut.isError && (
<Typography color="error"> <Typography color="error">
@@ -145,6 +230,7 @@ export function AdminGalleryPage() {
</Typography> </Typography>
)} )}
{viewMode === 'grid' ? (
<GalleryGrid <GalleryGrid
items={items} items={items}
deleting={deleteMut.isPending} deleting={deleteMut.isPending}
@@ -152,6 +238,15 @@ export function AdminGalleryPage() {
onDelete={(id) => deleteMut.mutate(id)} onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.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,241 @@
import { useEffect, useState } from 'react'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminCatalogSlider, putAdminCatalogSlider } from '@/entities/catalog-slider'
import { fetchAdminGallery } from '@/entities/gallery'
import type { GalleryImageItem } from '@/entities/gallery'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
type SlideDraft = { galleryImageId: string; caption: string; textColor: string }
export function AdminSliderPage() {
const queryClient = useQueryClient()
const sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'],
queryFn: fetchAdminCatalogSlider,
})
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
})
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>([])
const [pickOpen, setPickOpen] = useState(false)
useEffect(() => {
if (sliderQuery.isSuccess && sliderDraft.length === 0) {
setSliderDraft(
sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
textColor: s.textColor || '#ffffff',
})),
)
}
}, [sliderQuery.isSuccess, sliderQuery.dataUpdatedAt])
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
const saveSliderMut = useMutation({
mutationFn: () =>
putAdminCatalogSlider({
slides: sliderDraft.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
textColor: s.textColor,
})),
}),
onSuccess: async () => {
await invalidateQueryKeys(queryClient, [['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const moveSlide = (idx: number, dir: -1 | 1) => {
const next = idx + dir
if (next < 0 || next >= sliderDraft.length) return
setSliderDraft((prev) => {
const copy = [...prev]
const t = copy[idx]!
copy[idx] = copy[next]!
copy[next] = t
return copy
})
}
const updateDraft = (idx: number, patch: Partial<SlideDraft>) => {
setSliderDraft((prev) => {
const copy = [...prev]
copy[idx] = { ...copy[idx]!, ...patch }
return copy
})
}
if (sliderQuery.isLoading || galleryQuery.isLoading) {
return <Typography color="text.secondary">Загрузка</Typography>
}
if (sliderQuery.isError) {
return <Typography color="error">Не удалось загрузить слайдер.</Typography>
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Слайдер
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения для карусели на главной странице. Сначала загрузите фото в Галерею и обработайте их (Resize), затем
добавьте в слайдер. Порядок строк = порядок показа.
</Typography>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{sliderDraft.length === 0 && (
<Typography color="text.secondary">Нет слайдов. Добавьте изображения из галереи.</Typography>
)}
{sliderDraft.map((row, idx) => {
const img = galleryItems.find((g) => g.id === row.galleryImageId)
return (
<Stack
key={`${row.galleryImageId}-${idx}`}
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
sx={{ alignItems: { sm: 'flex-start' } }}
>
<Box
sx={{
width: 100,
height: 100,
flexShrink: 0,
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
}}
>
<Box
component="img"
src={img?.url ?? ''}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
<Stack spacing={1.5} sx={{ flex: 1, minWidth: 0 }}>
<TextField
label="Подпись на слайде"
fullWidth
multiline
minRows={2}
value={row.caption}
onChange={(e) => updateDraft(idx, { caption: e.target.value })}
/>
<TextField
label="Цвет текста"
type="color"
value={row.textColor}
onChange={(e) => updateDraft(idx, { textColor: e.target.value })}
sx={{ width: 80 }}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
<Stack direction="row" spacing={0.5} sx={{ alignSelf: 'flex-start' }}>
<IconButton size="small" aria-label="Выше" onClick={() => moveSlide(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Ниже"
onClick={() => moveSlide(idx, 1)}
disabled={idx >= sliderDraft.length - 1}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="Убрать из слайдера"
onClick={() => setSliderDraft((prev) => prev.filter((_, i) => i !== idx))}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
)
})}
</Stack>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Button variant="outlined" disabled={pickCandidates.length === 0} onClick={() => setPickOpen(true)}>
Добавить слайд из галереи
</Button>
<Button variant="contained" disabled={saveSliderMut.isPending} onClick={() => saveSliderMut.mutate()}>
Сохранить слайдер
</Button>
{saveSliderMut.isError && (
<Typography color="error">
{saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'}
</Typography>
)}
</Stack>
</Paper>
<Dialog open={pickOpen} onClose={() => setPickOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Выберите изображение</DialogTitle>
<DialogContent dividers>
{pickCandidates.length === 0 ? (
<Typography color="text.secondary">Нет доступных файлов (все уже в слайдере или галерея пуста).</Typography>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{pickCandidates.map((item) => (
<Button
key={item.id}
sx={{ p: 0, minWidth: 0, display: 'block', borderRadius: 1, overflow: 'hidden' }}
onClick={() => {
setSliderDraft((prev) => [...prev, { galleryImageId: item.id, caption: '', textColor: '#ffffff' }])
setPickOpen(false)
}}
>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }}
/>
</Button>
))}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setPickOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Box>
)
}
+4 -2
View File
@@ -13,10 +13,12 @@ import { AuthForgotForm } from '@/features/auth-forgot'
import { OAuthButtons } from '@/features/auth-oauth' import { OAuthButtons } from '@/features/auth-oauth'
import { AuthPasswordForm } from '@/features/auth-password' import { AuthPasswordForm } from '@/features/auth-password'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { useThemeController } from '@/app/providers/theme-controller'
import { BearLogo } from '@/shared/ui/BearLogo' import { BearLogo } from '@/shared/ui/BearLogo'
export function AuthPage() { export function AuthPage() {
const theme = useTheme() const theme = useTheme()
const { scheme } = useThemeController()
const [message, setMessage] = useState<string | null>(null) const [message, setMessage] = useState<string | null>(null)
const [oauthError, setOauthError] = useState<string | null>(null) const [oauthError, setOauthError] = useState<string | null>(null)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
@@ -54,7 +56,7 @@ export function AuthPage() {
> >
<Box sx={{ width: '100%', maxWidth: 440 }}> <Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ width: 72, height: 72 }} /> <BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom> <Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
@@ -88,7 +90,7 @@ export function AuthPage() {
> >
<Box sx={{ width: '100%', maxWidth: 440 }}> <Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ width: 72, height: 72 }} /> <BearLogo scheme={scheme} sx={{ width: 72, height: 72 }} />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom> <Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
+12
View File
@@ -68,7 +68,19 @@ export function HomePage() {
return ( return (
<Box> <Box>
<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 /> <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' }}
/> />
+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: '50%',
bgcolor: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: i === index ? 22 : 10,
height: 10,
borderRadius: 99, borderRadius: 99,
bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)', bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)',
cursor: 'pointer',
transition: 'width 0.25s, background-color 0.25s', 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) {