Добавлены модели товаров и корзины для оптовиков, реализованы соответствующие мутации и запросы в GraphQL. Обновлен API для загрузки файлов с учетом новых типов данных. Улучшена обработка ошибок и добавлены новые функции для работы с категориями товаров.
This commit is contained in:
205
src/components/market/market-categories.tsx
Normal file
205
src/components/market/market-categories.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GET_CATEGORIES, GET_MY_CART } from '@/graphql/queries'
|
||||
import { Package2, ArrowRight, Sparkles, ShoppingCart } from 'lucide-react'
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface MarketCategoriesProps {
|
||||
onSelectCategory: (categoryId: string, categoryName: string) => void
|
||||
onShowCart?: () => void
|
||||
}
|
||||
|
||||
export function MarketCategories({ onSelectCategory, onShowCart }: MarketCategoriesProps) {
|
||||
const { data, loading, error } = useQuery(GET_CATEGORIES)
|
||||
const { data: cartData } = useQuery(GET_MY_CART)
|
||||
|
||||
const categories: Category[] = data?.categories || []
|
||||
const cart = cartData?.myCart
|
||||
const uniqueItemsCount = cart?.items?.length || 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-white border-t-transparent mx-auto mb-4"></div>
|
||||
<p className="text-white/70">Загружаем категории...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Package2 className="h-16 w-16 text-red-400/40 mx-auto mb-4" />
|
||||
<p className="text-red-400">Ошибка загрузки категорий</p>
|
||||
<p className="text-white/40 text-sm mt-2">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-6">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30">
|
||||
<Package2 className="h-8 w-8 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">
|
||||
Каталог товаров
|
||||
</h1>
|
||||
<p className="text-white/60">
|
||||
Выберите категорию для просмотра товаров от оптовиков
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка корзины */}
|
||||
{onShowCart && (
|
||||
<Button
|
||||
onClick={onShowCart}
|
||||
className="bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 text-white border-purple-500/30 hover:border-purple-400/50 transition-all duration-200 shadow-lg px-6 py-3"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5 mr-2" />
|
||||
Корзина {uniqueItemsCount > 0 && `(${uniqueItemsCount})`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Категории */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{categories.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<Package2 className="h-16 w-16 text-white/20 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Категории отсутствуют
|
||||
</h3>
|
||||
<p className="text-white/60">
|
||||
Пока нет доступных категорий товаров
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{/* Карточка "Все товары" */}
|
||||
<Card
|
||||
onClick={() => onSelectCategory('', 'Все товары')}
|
||||
className="group relative overflow-hidden bg-gradient-to-br from-indigo-500/10 via-purple-500/10 to-pink-500/10 backdrop-blur border-white/10 hover:border-white/20 transition-all duration-300 cursor-pointer hover:scale-105"
|
||||
>
|
||||
<div className="p-6 h-32 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-r from-indigo-500/20 to-purple-500/20 border border-indigo-500/30">
|
||||
<Sparkles className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-white/40 group-hover:text-white/80 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-white transition-colors">
|
||||
Все товары
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
Просмотреть весь каталог
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Эффект при наведении */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</Card>
|
||||
|
||||
{/* Карточки категорий */}
|
||||
{categories.map((category, index) => {
|
||||
// Разные градиенты для разных категорий
|
||||
const gradients = [
|
||||
'from-purple-500/10 via-pink-500/10 to-red-500/10',
|
||||
'from-blue-500/10 via-cyan-500/10 to-teal-500/10',
|
||||
'from-green-500/10 via-emerald-500/10 to-lime-500/10',
|
||||
'from-yellow-500/10 via-orange-500/10 to-red-500/10',
|
||||
'from-pink-500/10 via-rose-500/10 to-purple-500/10',
|
||||
'from-indigo-500/10 via-blue-500/10 to-cyan-500/10',
|
||||
'from-teal-500/10 via-green-500/10 to-emerald-500/10'
|
||||
]
|
||||
|
||||
const borderColors = [
|
||||
'border-purple-500/30',
|
||||
'border-blue-500/30',
|
||||
'border-green-500/30',
|
||||
'border-orange-500/30',
|
||||
'border-pink-500/30',
|
||||
'border-indigo-500/30',
|
||||
'border-teal-500/30'
|
||||
]
|
||||
|
||||
const iconColors = [
|
||||
'text-purple-400',
|
||||
'text-blue-400',
|
||||
'text-green-400',
|
||||
'text-orange-400',
|
||||
'text-pink-400',
|
||||
'text-indigo-400',
|
||||
'text-teal-400'
|
||||
]
|
||||
|
||||
const bgColors = [
|
||||
'from-purple-500/20 to-pink-500/20',
|
||||
'from-blue-500/20 to-cyan-500/20',
|
||||
'from-green-500/20 to-emerald-500/20',
|
||||
'from-yellow-500/20 to-orange-500/20',
|
||||
'from-pink-500/20 to-rose-500/20',
|
||||
'from-indigo-500/20 to-blue-500/20',
|
||||
'from-teal-500/20 to-green-500/20'
|
||||
]
|
||||
|
||||
const gradient = gradients[index % gradients.length]
|
||||
const borderColor = borderColors[index % borderColors.length]
|
||||
const iconColor = iconColors[index % iconColors.length]
|
||||
const bgColor = bgColors[index % bgColors.length]
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={category.id}
|
||||
onClick={() => onSelectCategory(category.id, category.name)}
|
||||
className={`group relative overflow-hidden bg-gradient-to-br ${gradient} backdrop-blur border-white/10 hover:${borderColor} transition-all duration-300 cursor-pointer hover:scale-105`}
|
||||
>
|
||||
<div className="p-6 h-32 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`p-3 rounded-lg bg-gradient-to-r ${bgColor} border ${borderColor}`}>
|
||||
<Package2 className={`h-6 w-6 ${iconColor}`} />
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-white/40 group-hover:text-white/80 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-white transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
Товары категории
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Эффект при наведении */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-r ${gradient} opacity-0 group-hover:opacity-50 transition-opacity duration-300`} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
@ -8,8 +9,29 @@ import { MarketFulfillment } from './market-fulfillment'
|
||||
import { MarketSellers } from './market-sellers'
|
||||
import { MarketLogistics } from './market-logistics'
|
||||
import { MarketWholesale } from './market-wholesale'
|
||||
import { MarketProducts } from './market-products'
|
||||
import { MarketCategories } from './market-categories'
|
||||
import { MarketRequests } from './market-requests'
|
||||
|
||||
export function MarketDashboard() {
|
||||
const [productsView, setProductsView] = useState<'categories' | 'products' | 'cart'>('categories')
|
||||
const [selectedCategory, setSelectedCategory] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const handleSelectCategory = (categoryId: string, categoryName: string) => {
|
||||
setSelectedCategory({ id: categoryId, name: categoryName })
|
||||
setProductsView('products')
|
||||
}
|
||||
|
||||
const handleBackToCategories = () => {
|
||||
setProductsView('categories')
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
const handleShowCart = () => {
|
||||
setProductsView('cart')
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||
<Sidebar />
|
||||
@ -17,8 +39,18 @@ export function MarketDashboard() {
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="counterparties" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0">
|
||||
<Tabs
|
||||
defaultValue="counterparties"
|
||||
className="h-full flex flex-col"
|
||||
onValueChange={(value) => {
|
||||
if (value === 'products') {
|
||||
// Сбрасываем состояние когда переходим на вкладку товаров
|
||||
setProductsView('categories')
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-6 bg-white/5 backdrop-blur border-white/10 flex-shrink-0">
|
||||
<TabsTrigger
|
||||
value="counterparties"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
@ -49,6 +81,12 @@ export function MarketDashboard() {
|
||||
>
|
||||
Оптовик
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="products"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Товары
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="counterparties" className="flex-1 overflow-hidden mt-6">
|
||||
@ -80,6 +118,22 @@ export function MarketDashboard() {
|
||||
<MarketWholesale />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-0">
|
||||
{productsView === 'categories' ? (
|
||||
<MarketCategories onSelectCategory={handleSelectCategory} onShowCart={handleShowCart} />
|
||||
) : productsView === 'products' ? (
|
||||
<MarketProducts
|
||||
selectedCategoryId={selectedCategory?.id}
|
||||
selectedCategoryName={selectedCategory?.name}
|
||||
onBackToCategories={handleBackToCategories}
|
||||
/>
|
||||
) : (
|
||||
<MarketRequests />
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
243
src/components/market/market-products.tsx
Normal file
243
src/components/market/market-products.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, ShoppingBag, Package2, ArrowLeft } from 'lucide-react'
|
||||
import { ProductCard } from './product-card'
|
||||
import { GET_ALL_PRODUCTS } from '@/graphql/queries'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
article: string
|
||||
description?: string
|
||||
price: number
|
||||
quantity: number
|
||||
category?: { id: string; name: string }
|
||||
brand?: string
|
||||
color?: string
|
||||
size?: string
|
||||
weight?: number
|
||||
dimensions?: string
|
||||
material?: string
|
||||
images: string[]
|
||||
mainImage?: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
organization: {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: string
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface MarketProductsProps {
|
||||
selectedCategoryId?: string
|
||||
selectedCategoryName?: string
|
||||
onBackToCategories?: () => void
|
||||
}
|
||||
|
||||
export function MarketProducts({ selectedCategoryId, selectedCategoryName, onBackToCategories }: MarketProductsProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('')
|
||||
const [localSearch, setLocalSearch] = useState('')
|
||||
|
||||
const { data, loading, refetch } = useQuery(GET_ALL_PRODUCTS, {
|
||||
variables: {
|
||||
search: searchTerm || null,
|
||||
category: selectedCategoryId || selectedCategory || null
|
||||
}
|
||||
})
|
||||
|
||||
const products: Product[] = data?.allProducts || []
|
||||
|
||||
// Получаем уникальные категории из товаров
|
||||
const categories = useMemo(() => {
|
||||
const allCategories = products
|
||||
.map(product => product.category?.name)
|
||||
.filter(Boolean)
|
||||
.filter((category, index, arr) => arr.indexOf(category) === index)
|
||||
.sort()
|
||||
|
||||
return allCategories
|
||||
}, [products])
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchTerm(localSearch.trim())
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Фильтруем товары по доступности
|
||||
const availableProducts = products.filter(product => product.isActive && product.quantity > 0)
|
||||
const totalProducts = products.length
|
||||
const availableCount = availableProducts.length
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden p-6">
|
||||
{/* Кнопка назад и заголовок */}
|
||||
{selectedCategoryName && onBackToCategories && (
|
||||
<div className="flex items-center space-x-3 flex-shrink-0">
|
||||
<Button
|
||||
onClick={onBackToCategories}
|
||||
variant="outline"
|
||||
className="glass-secondary text-white hover:text-white border-white/20 hover:border-white/40"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад к категориям
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{selectedCategoryName}</h2>
|
||||
<p className="text-white/60 text-sm">Товары выбранной категории</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="flex space-x-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск товаров по названию, артикулу, бренду..."
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 cursor-pointer h-10"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Категории */}
|
||||
<div className="flex-shrink-0">
|
||||
<h4 className="text-white font-medium mb-3 flex items-center">
|
||||
<Package2 className="h-4 w-4 mr-2" />
|
||||
Категории
|
||||
</h4>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setSelectedCategory('')}
|
||||
variant={selectedCategory === '' ? 'default' : 'outline'}
|
||||
className={`h-8 px-3 text-sm transition-all ${
|
||||
selectedCategory === ''
|
||||
? 'bg-purple-500 hover:bg-purple-600 text-white'
|
||||
: 'bg-white/5 hover:bg-white/10 text-white/70 border-white/20'
|
||||
}`}
|
||||
>
|
||||
Все категории
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category!)}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
className={`h-8 px-3 text-sm transition-all ${
|
||||
selectedCategory === category
|
||||
? 'bg-purple-500 hover:bg-purple-600 text-white'
|
||||
: 'bg-white/5 hover:bg-white/10 text-white/70 border-white/20'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заголовок с статистикой */}
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<ShoppingBag className="h-6 w-6 text-purple-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Товары оптовиков</h3>
|
||||
<p className="text-white/60 text-sm">
|
||||
Найдено {totalProducts} товаров, доступно {availableCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные фильтры */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{searchTerm && (
|
||||
<div className="bg-purple-500/20 px-3 py-1 rounded-full text-purple-300 text-sm flex items-center">
|
||||
<Search className="h-3 w-3 mr-1" />
|
||||
{searchTerm}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('')
|
||||
setLocalSearch('')
|
||||
}}
|
||||
className="ml-2 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
<div className="bg-purple-500/20 px-3 py-1 rounded-full text-purple-300 text-sm flex items-center">
|
||||
<Package2 className="h-3 w-3 mr-1" />
|
||||
{selectedCategory}
|
||||
<button
|
||||
onClick={() => setSelectedCategory('')}
|
||||
className="ml-2 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список товаров */}
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Загружаем товары...</div>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<ShoppingBag className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm || selectedCategory ? 'Товары не найдены' : 'Пока нет товаров для отображения'}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
{searchTerm || selectedCategory
|
||||
? 'Попробуйте изменить условия поиска или фильтры'
|
||||
: 'Оптовики еще не добавили свои товары'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Пагинация будет добавлена позже если понадобится */}
|
||||
</div>
|
||||
)
|
||||
}
|
84
src/components/market/market-requests.tsx
Normal file
84
src/components/market/market-requests.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { CartItems } from '../cart/cart-items'
|
||||
import { CartSummary } from '../cart/cart-summary'
|
||||
import { GET_MY_CART } from '@/graphql/queries'
|
||||
import { ShoppingCart, Package } from 'lucide-react'
|
||||
|
||||
export function MarketRequests() {
|
||||
const { data, loading, error } = useQuery(GET_MY_CART)
|
||||
|
||||
const cart = data?.myCart
|
||||
const hasItems = cart?.items && cart.items.length > 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-white border-t-transparent mx-auto mb-4"></div>
|
||||
<p className="text-white/70">Загружаем заявки...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ShoppingCart className="h-16 w-16 text-red-400/40 mx-auto mb-4" />
|
||||
<p className="text-red-400">Ошибка загрузки заявок</p>
|
||||
<p className="text-white/40 text-sm mt-2">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center space-x-3 p-6 border-b border-white/10">
|
||||
<ShoppingCart className="h-6 w-6 text-purple-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Мои заявки</h1>
|
||||
<p className="text-white/60">
|
||||
{hasItems
|
||||
? `${cart.totalItems} заявок на сумму ${new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(cart.totalPrice)}`
|
||||
: 'У вас пока нет заявок'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{hasItems ? (
|
||||
<div className="h-full grid grid-cols-1 lg:grid-cols-3 gap-6 p-6">
|
||||
{/* Заявки */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="glass-card h-full overflow-hidden">
|
||||
<CartItems cart={cart} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сводка заявок */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="glass-card h-fit">
|
||||
<CartSummary cart={cart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||
<Package className="h-24 w-24 text-white/20 mb-6" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Нет заявок</h2>
|
||||
<p className="text-white/60 mb-6 max-w-md">
|
||||
Добавьте товары в заявки из раздела "Товары", чтобы создать заявку для оптовика
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
292
src/components/market/product-card.tsx
Normal file
292
src/components/market/product-card.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import Image from 'next/image'
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { ADD_TO_CART } from '@/graphql/mutations'
|
||||
import { GET_MY_CART } from '@/graphql/queries'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
article: string
|
||||
description?: string
|
||||
price: number
|
||||
quantity: number
|
||||
category?: { id: string; name: string }
|
||||
brand?: string
|
||||
color?: string
|
||||
size?: string
|
||||
weight?: number
|
||||
dimensions?: string
|
||||
material?: string
|
||||
images: string[]
|
||||
mainImage?: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
organization: {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: string
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: ProductCardProps) {
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
|
||||
const [addToCart, { loading: addingToCart }] = useMutation(ADD_TO_CART, {
|
||||
refetchQueries: [{ query: GET_MY_CART }],
|
||||
onCompleted: (data) => {
|
||||
if (data.addToCart.success) {
|
||||
toast.success(data.addToCart.message)
|
||||
setQuantity(1) // Сбрасываем количество после добавления
|
||||
} else {
|
||||
toast.error(data.addToCart.message)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Ошибка при добавлении в заявки')
|
||||
console.error('Error adding to cart:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const displayPrice = new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB'
|
||||
}).format(product.price)
|
||||
|
||||
const displayName = product.organization.name || product.organization.fullName || 'Неизвестная организация'
|
||||
const images = product.images.length > 0 ? product.images : [product.mainImage].filter(Boolean)
|
||||
const hasMultipleImages = images.length > 1
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
try {
|
||||
await addToCart({
|
||||
variables: {
|
||||
productId: product.id,
|
||||
quantity: quantity
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error adding to cart:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const nextImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev + 1) % images.length)
|
||||
}
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length)
|
||||
}
|
||||
|
||||
const getStockStatus = () => {
|
||||
if (product.quantity === 0) return { text: 'Нет в наличии', color: 'bg-red-500/20 text-red-300' }
|
||||
if (product.quantity < 10) return { text: 'Мало', color: 'bg-orange-500/20 text-orange-300' }
|
||||
return { text: 'В наличии', color: 'bg-green-500/20 text-green-300' }
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus()
|
||||
const canAddToCart = product.quantity > 0 && product.isActive
|
||||
|
||||
return (
|
||||
<div className="glass-card p-3 hover:bg-white/10 transition-all duration-300 group">
|
||||
{/* Изображения товара */}
|
||||
<div className="relative mb-3 aspect-video bg-white/5 rounded-lg overflow-hidden">
|
||||
{images.length > 0 ? (
|
||||
<>
|
||||
<Image
|
||||
src={images[currentImageIndex] || '/placeholder-product.png'}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover cursor-pointer"
|
||||
onClick={() => setIsImageDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Навигация по изображениям */}
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Индикаторы изображений */}
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex space-x-1">
|
||||
{images.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
index === currentImageIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Кнопка увеличения */}
|
||||
<button
|
||||
onClick={() => setIsImageDialogOpen(true)}
|
||||
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ShoppingCart className="h-8 w-8 text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="space-y-2">
|
||||
{/* Название и цена */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-white text-sm mb-1 line-clamp-1">{product.name}</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-bold text-purple-300">{displayPrice}</span>
|
||||
<Badge className={`${stockStatus.color} text-xs`}>
|
||||
{stockStatus.text}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Краткая информация */}
|
||||
<div className="flex items-center justify-between text-xs text-white/60">
|
||||
<span>Арт: {product.article}</span>
|
||||
{product.category && (
|
||||
<span className="bg-white/10 px-2 py-1 rounded text-xs">{product.category.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о продавце */}
|
||||
<div className="border-t border-white/10 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<OrganizationAvatar organization={product.organization} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white text-xs truncate">{displayName}</p>
|
||||
<p className="text-xs text-white/50">ИНН: {product.organization.inn}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор количества и добавление в заявки */}
|
||||
{canAddToCart ? (
|
||||
<div className="space-y-2">
|
||||
{/* Выбор количества */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/70">Количество:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={product.quantity}
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 1
|
||||
if (value >= 1 && value <= product.quantity) {
|
||||
setQuantity(value)
|
||||
}
|
||||
}}
|
||||
className="w-16 h-6 text-xs text-center glass-input text-white border-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления в заявки */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={addingToCart}
|
||||
className="w-full h-8 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white border-0 text-xs"
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
{addingToCart ? 'Добавление...' : 'В заявки'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
className="w-full h-8 bg-gray-500/20 text-gray-400 border-0 text-xs cursor-not-allowed"
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
Недоступно
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Диалог просмотра изображений */}
|
||||
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] bg-black/90 backdrop-blur-xl border border-white/20 p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Просмотр изображения товара {product.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
{images.length > 0 && (
|
||||
<div className="relative aspect-square">
|
||||
<Image
|
||||
src={images[currentImageIndex] || '/placeholder-product.png'}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/50 px-3 py-1 rounded-full text-white text-sm">
|
||||
{currentImageIndex + 1} из {images.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user