Добавлена новая модель HeroBanner с соответствующими мутациями и запросами для управления баннерами героя. Обновлены типы GraphQL и резолверы для обработки данных баннеров, что улучшает функциональность приложения. В боковое меню добавлен новый элемент для навигации по баннерам героя, что повышает удобство работы с интерфейсом.

This commit is contained in:
Bivekich
2025-07-15 09:03:34 +03:00
parent ac122411e0
commit 70bcb48b92
7 changed files with 720 additions and 1 deletions

View File

@ -710,6 +710,20 @@ model TopSalesProduct {
@@map("top_sales_products")
}
model HeroBanner {
id String @id @default(cuid())
title String
subtitle String?
imageUrl String
linkUrl String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("hero_banners")
}
enum UserRole {
ADMIN
MODERATOR

View File

@ -0,0 +1,453 @@
"use client"
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { FileUpload } from '@/components/ui/file-upload'
import { Plus, Edit, Trash2, Image, ExternalLink } from 'lucide-react'
import { GET_HERO_BANNERS } from '@/lib/graphql/queries'
import { CREATE_HERO_BANNER, UPDATE_HERO_BANNER, DELETE_HERO_BANNER } from '@/lib/graphql/mutations'
import { toast } from 'sonner'
interface HeroBanner {
id: string
title: string
subtitle?: string
imageUrl: string
linkUrl?: string
isActive: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
interface BannerFormData {
title: string
subtitle: string
imageUrl: string
linkUrl: string
isActive: boolean
sortOrder: number
}
const defaultFormData: BannerFormData = {
title: '',
subtitle: '',
imageUrl: '',
linkUrl: '',
isActive: true,
sortOrder: 0
}
export default function HeroBannersPage() {
const [showDialog, setShowDialog] = useState(false)
const [editingBanner, setEditingBanner] = useState<HeroBanner | null>(null)
const [formData, setFormData] = useState<BannerFormData>(defaultFormData)
const [uploading, setUploading] = useState(false)
const { data, loading, error, refetch } = useQuery(GET_HERO_BANNERS, {
fetchPolicy: 'cache-and-network'
})
const [createBanner] = useMutation(CREATE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно создан')
setShowDialog(false)
setFormData(defaultFormData)
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка создания баннера')
}
})
const [updateBanner] = useMutation(UPDATE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно обновлен')
setShowDialog(false)
setEditingBanner(null)
setFormData(defaultFormData)
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка обновления баннера')
}
})
const [deleteBanner] = useMutation(DELETE_HERO_BANNER, {
onCompleted: () => {
toast.success('Баннер успешно удален')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Ошибка удаления баннера')
}
})
const banners: HeroBanner[] = data?.heroBanners || []
const handleOpenDialog = (banner?: HeroBanner) => {
if (banner) {
setEditingBanner(banner)
setFormData({
title: banner.title,
subtitle: banner.subtitle || '',
imageUrl: banner.imageUrl,
linkUrl: banner.linkUrl || '',
isActive: banner.isActive,
sortOrder: banner.sortOrder
})
} else {
setEditingBanner(null)
setFormData(defaultFormData)
}
setShowDialog(true)
}
const handleCloseDialog = () => {
setShowDialog(false)
setEditingBanner(null)
setFormData(defaultFormData)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title.trim()) {
toast.error('Заголовок обязателен')
return
}
if (!formData.imageUrl.trim()) {
toast.error('Изображение обязательно')
return
}
if (editingBanner) {
updateBanner({
variables: {
id: editingBanner.id,
input: formData
}
})
} else {
createBanner({
variables: {
input: formData
}
})
}
}
const handleDelete = (banner: HeroBanner) => {
if (confirm(`Вы уверены, что хотите удалить баннер "${banner.title}"?`)) {
deleteBanner({
variables: { id: banner.id }
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm mb-4">{error.message}</div>
<Button onClick={() => refetch()}>Повторить</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Баннеры героя</h1>
<p className="text-gray-600">
Управление баннерами на главной странице
</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={() => handleOpenDialog()}>
<Plus className="w-4 h-4 mr-2" />
Добавить баннер
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingBanner ? 'Редактировать баннер' : 'Создать баннер'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="title">Заголовок *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Введите заголовок баннера"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Порядок сортировки</Label>
<Input
id="sortOrder"
type="number"
value={formData.sortOrder}
onChange={(e) => setFormData(prev => ({ ...prev, sortOrder: parseInt(e.target.value) || 0 }))}
placeholder="0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle">Подзаголовок</Label>
<Textarea
id="subtitle"
value={formData.subtitle}
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: e.target.value }))}
placeholder="Введите подзаголовок баннера (необязательно)"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkUrl">Ссылка</Label>
<Input
id="linkUrl"
type="url"
value={formData.linkUrl}
onChange={(e) => setFormData(prev => ({ ...prev, linkUrl: e.target.value }))}
placeholder="https://example.com (необязательно)"
/>
</div>
<div className="space-y-2">
<Label>Изображение *</Label>
<div className="space-y-2">
{formData.imageUrl && (
<div className="relative">
<img
src={formData.imageUrl}
alt="Превью"
className="w-full h-32 object-cover rounded-lg border"
/>
</div>
)}
<FileUpload
onUpload={(url) => setFormData(prev => ({ ...prev, imageUrl: url }))}
accept="image/*"
maxSize={5 * 1024 * 1024}
disabled={uploading}
/>
{uploading && (
<div className="text-sm text-gray-500">Загрузка изображения...</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: checked }))}
/>
<Label htmlFor="isActive">Активен</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCloseDialog}>
Отмена
</Button>
<Button type="submit" disabled={uploading}>
{editingBanner ? 'Обновить' : 'Создать'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{/* Статистика */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Всего баннеров</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banners.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Активные</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{banners.filter(b => b.isActive).length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Неактивные</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">
{banners.filter(b => !b.isActive).length}
</div>
</CardContent>
</Card>
</div>
{/* Таблица баннеров */}
<Card>
<CardHeader>
<CardTitle>Список баннеров ({banners.length})</CardTitle>
<CardDescription>
Управление баннерами на главной странице сайта
</CardDescription>
</CardHeader>
<CardContent>
{banners.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Image className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p>Нет созданных баннеров</p>
<Button
className="mt-4"
onClick={() => handleOpenDialog()}
>
Создать первый баннер
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Изображение</TableHead>
<TableHead>Заголовок</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Порядок</TableHead>
<TableHead>Ссылка</TableHead>
<TableHead>Дата создания</TableHead>
<TableHead>Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...banners]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((banner) => (
<TableRow key={banner.id}>
<TableCell>
<img
src={banner.imageUrl}
alt={banner.title}
className="w-16 h-10 object-cover rounded border"
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{banner.title}</div>
{banner.subtitle && (
<div className="text-sm text-gray-500 truncate max-w-xs">
{banner.subtitle}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={banner.isActive ? 'default' : 'secondary'}>
{banner.isActive ? 'Активен' : 'Неактивен'}
</Badge>
</TableCell>
<TableCell>{banner.sortOrder}</TableCell>
<TableCell>
{banner.linkUrl ? (
<div className="flex items-center">
<ExternalLink className="w-4 h-4 mr-1 text-gray-400" />
<span className="text-sm text-blue-600 truncate max-w-xs">
{banner.linkUrl}
</span>
</div>
) : (
<span className="text-gray-400"></span>
)}
</TableCell>
<TableCell>
{new Date(banner.createdAt).toLocaleDateString('ru-RU')}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenDialog(banner)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(banner)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -14,7 +14,8 @@ import {
ShoppingCart,
Receipt,
Palette,
Star
Star,
Image
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -44,6 +45,11 @@ const navigationItems = [
href: '/dashboard/homepage-products',
icon: Star,
},
{
title: 'Баннеры героя',
href: '/dashboard/hero-banners',
icon: Image,
},
{
title: 'Заказы',
href: '/dashboard/orders',

View File

@ -1403,4 +1403,43 @@ export const DELETE_TOP_SALES_PRODUCT = gql`
mutation DeleteTopSalesProduct($id: ID!) {
deleteTopSalesProduct(id: $id)
}
`
// Hero Banners mutations
export const CREATE_HERO_BANNER = gql`
mutation CreateHeroBanner($input: HeroBannerInput!) {
createHeroBanner(input: $input) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const UPDATE_HERO_BANNER = gql`
mutation UpdateHeroBanner($id: String!, $input: HeroBannerUpdateInput!) {
updateHeroBanner(id: $id, input: $input) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const DELETE_HERO_BANNER = gql`
mutation DeleteHeroBanner($id: String!) {
deleteHeroBanner(id: $id)
}
`

View File

@ -1467,4 +1467,37 @@ export const GET_PARTSINDEX_CATEGORIES = gql`
}
`;
// Hero Banners queries
export const GET_HERO_BANNERS = gql`
query GetHeroBanners {
heroBanners {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`
export const GET_HERO_BANNER = gql`
query GetHeroBanner($id: String!) {
heroBanner(id: $id) {
id
title
subtitle
imageUrl
linkUrl
isActive
sortOrder
createdAt
updatedAt
}
}
`

View File

@ -456,6 +456,24 @@ interface TopSalesProductUpdateInput {
sortOrder?: number
}
interface HeroBannerInput {
title: string
subtitle?: string
imageUrl: string
linkUrl?: string
isActive?: boolean
sortOrder?: number
}
interface HeroBannerUpdateInput {
title?: string
subtitle?: string
imageUrl?: string
linkUrl?: string
isActive?: boolean
sortOrder?: number
}
// Утилиты
const createSlug = (text: string): string => {
return text
@ -4004,6 +4022,30 @@ export const resolvers = {
console.error('Ошибка получения новых поступлений:', error)
throw new Error('Не удалось получить новые поступления')
}
},
// Hero Banners queries
heroBanners: async () => {
try {
return await prisma.heroBanner.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' }
})
} catch (error) {
console.error('Ошибка получения баннеров героя:', error)
throw new Error('Не удалось получить баннеры героя')
}
},
heroBanner: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.heroBanner.findUnique({
where: { id }
})
} catch (error) {
console.error('Ошибка получения баннера героя:', error)
throw new Error('Не удалось получить баннер героя')
}
}
},
@ -9436,6 +9478,98 @@ export const resolvers = {
}
throw new Error('Не удалось удалить товар из топ продаж')
}
},
// Hero Banner mutations
createHeroBanner: async (_: unknown, { input }: { input: HeroBannerInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const heroBanner = await prisma.heroBanner.create({
data: {
title: input.title,
subtitle: input.subtitle,
imageUrl: input.imageUrl,
linkUrl: input.linkUrl,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
}
})
return heroBanner
} catch (error) {
console.error('Ошибка создания баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать баннер героя')
}
},
updateHeroBanner: async (_: unknown, { id, input }: { id: string; input: HeroBannerUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const existingBanner = await prisma.heroBanner.findUnique({
where: { id }
})
if (!existingBanner) {
throw new Error('Баннер героя не найден')
}
const heroBanner = await prisma.heroBanner.update({
where: { id },
data: {
...(input.title !== undefined && { title: input.title }),
...(input.subtitle !== undefined && { subtitle: input.subtitle }),
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
...(input.linkUrl !== undefined && { linkUrl: input.linkUrl }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
}
})
return heroBanner
} catch (error) {
console.error('Ошибка обновления баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить баннер героя')
}
},
deleteHeroBanner: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const existingBanner = await prisma.heroBanner.findUnique({
where: { id }
})
if (!existingBanner) {
throw new Error('Баннер героя не найден')
}
await prisma.heroBanner.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления баннера героя:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить баннер героя')
}
}
}
}

View File

@ -1054,6 +1054,10 @@ export const typeDefs = gql`
topSalesProducts: [TopSalesProduct!]!
topSalesProduct(id: ID!): TopSalesProduct
# Баннеры героя
heroBanners: [HeroBanner!]!
heroBanner(id: String!): HeroBanner
# Новые поступления
newArrivals(limit: Int = 8): [Product!]!
}
@ -1262,6 +1266,11 @@ export const typeDefs = gql`
createTopSalesProduct(input: TopSalesProductInput!): TopSalesProduct!
updateTopSalesProduct(id: ID!, input: TopSalesProductUpdateInput!): TopSalesProduct!
deleteTopSalesProduct(id: ID!): Boolean!
# Баннеры героя
createHeroBanner(input: HeroBannerInput!): HeroBanner!
updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner!
deleteHeroBanner(id: String!): Boolean!
}
input LoginInput {
@ -2302,4 +2311,35 @@ export const typeDefs = gql`
isActive: Boolean
sortOrder: Int
}
# Типы для баннеров героя
type HeroBanner {
id: ID!
title: String!
subtitle: String
imageUrl: String!
linkUrl: String
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input HeroBannerInput {
title: String!
subtitle: String
imageUrl: String!
linkUrl: String
isActive: Boolean
sortOrder: Int
}
input HeroBannerUpdateInput {
title: String
subtitle: String
imageUrl: String
linkUrl: String
isActive: Boolean
sortOrder: Int
}
`