Обновлены компоненты интерфейса для работы с карточками товаров Wildberries: добавлены новые функции для загрузки и поиска карточек, улучшен интерфейс отображения товаров и их деталей. Исправлены проблемы с отображением текста и добавлены новые поля в GraphQL для работы с API ключами. Реализована логика обработки ошибок при взаимодействии с API.

This commit is contained in:
Bivekich
2025-07-21 13:51:12 +03:00
parent d964b9b6d4
commit d3fb590c6e
10 changed files with 836 additions and 254 deletions

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ params: string[] }> }
) {
try {
const resolvedParams = await params
const [width, height] = resolvedParams.params[0]?.split('/') || ['400', '400']
const searchParams = request.nextUrl.searchParams
const text = searchParams.get('text') || 'Image'
// Создаем простое SVG изображение
const svg = `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-family="Arial, sans-serif" font-size="16" fill="#666">
${text} ${width}x${height}
</text>
</svg>
`
return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=31536000'
}
})
} catch (error) {
console.error('Placeholder API error:', error)
// Возвращаем простое SVG в случае ошибки
const svg = `
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-family="Arial, sans-serif" font-size="16" fill="#666">
No Image
</text>
</svg>
`
return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml'
}
})
}
}

View File

@ -1447,7 +1447,7 @@ export function NavigationDemo() {
{/* Load More Pattern */}
<div>
<h4 className="text-white/90 text-sm font-medium mb-3">Паттерн "Загрузить еще"</h4>
<h4 className="text-white/90 text-sm font-medium mb-3">Паттерн &quot;Загрузить еще&quot;</h4>
<div className="glass-card p-4 rounded-xl border border-white/10 text-center">
<div className="space-y-3">
<div className="text-white/70 text-sm">

View File

@ -526,7 +526,7 @@ export function MaterialsOrderForm() {
: "Партнеры не найдены"}
</p>
<p className="text-white/40 text-sm mt-2">
Добавьте партнеров в разделе "Партнеры"
Добавьте партнеров в разделе &quot;Партнеры&quot;
</p>
</div>
</div>

View File

@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { WBProductCards } from './wb-product-cards'
interface WholesalerForCreation {
id: string
@ -72,6 +73,34 @@ interface SelectedProduct extends WholesalerProduct {
wholesalerName: string
}
interface WildberriesCard {
nmID: number
vendorCode: string
title: string
description: string
brand: string
mediaFiles: string[]
sizes: Array<{
chrtID: number
techSize: string
wbSize: string
price: number
discountedPrice: number
quantity: number
}>
}
interface SelectedCard {
card: WildberriesCard
selectedQuantity: number
selectedMarket: string
selectedPlace: string
sellerName: string
sellerPhone: string
deliveryDate: string
selectedServices: string[]
}
// Моковые данные оптовиков
const mockWholesalers: WholesalerForCreation[] = [
{
@ -229,6 +258,7 @@ export function CreateSupplyPage() {
const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null)
const [selectedWholesaler, setSelectedWholesaler] = useState<WholesalerForCreation | null>(null)
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
const [showSummary, setShowSummary] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@ -329,6 +359,13 @@ export function CreateSupplyPage() {
return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0)
}
const handleCardsComplete = (cards: SelectedCard[]) => {
setSelectedCards(cards)
console.log('Карточки товаров выбраны:', cards)
// TODO: Здесь будет создание поставки с данными карточек
router.push('/supplies')
}
const handleCreateSupply = () => {
if (selectedVariant === 'cards') {
console.log('Создание поставки с карточками Wildberries')
@ -706,6 +743,16 @@ export function CreateSupplyPage() {
)
}
// Рендер карточек Wildberries
if (selectedVariant === 'cards') {
return (
<WBProductCards
onBack={() => setSelectedVariant(null)}
onComplete={handleCardsComplete}
/>
)
}
// Рендер выбора оптовиков
if (selectedVariant === 'wholesaler') {
return (

View File

@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Search,
Plus,
@ -20,7 +21,9 @@ import {
Wrench,
ArrowLeft,
Check,
X
Eye,
ChevronLeft,
ChevronRight
} from 'lucide-react'
import { WildberriesService } from '@/services/wildberries-service'
import { useAuth } from '@/hooks/useAuth'
@ -82,6 +85,176 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
const [showSummary, setShowSummary] = useState(false)
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
const [selectedCardForDetails, setSelectedCardForDetails] = useState<WildberriesCard | null>(null)
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// Моковые товары для демонстрации
const getMockCards = (): WildberriesCard[] => [
{
nmID: 123456789,
vendorCode: 'SKU001',
title: 'Смартфон Samsung Galaxy A54',
description: 'Современный смартфон с отличной камерой и долгим временем автономной работы',
brand: 'Samsung',
object: 'Смартфоны',
parent: 'Электроника',
countryProduction: 'Корея',
supplierVendorCode: 'SUPPLIER-001',
mediaFiles: ['/api/placeholder/400/400', '/api/placeholder/400/401', '/api/placeholder/400/402'],
sizes: [
{
chrtID: 123456,
techSize: '128GB',
wbSize: '128GB Черный',
price: 25990,
discountedPrice: 22990,
quantity: 15
}
]
},
{
nmID: 987654321,
vendorCode: 'SKU002',
title: 'Наушники Apple AirPods Pro',
description: 'Беспроводные наушники с активным шумоподавлением и пространственным звуком',
brand: 'Apple',
object: 'Наушники',
parent: 'Электроника',
countryProduction: 'Китай',
supplierVendorCode: 'SUPPLIER-002',
mediaFiles: ['/api/placeholder/400/403', '/api/placeholder/400/404'],
sizes: [
{
chrtID: 987654,
techSize: 'Standard',
wbSize: 'Белый',
price: 24990,
discountedPrice: 19990,
quantity: 8
}
]
},
{
nmID: 555666777,
vendorCode: 'SKU003',
title: 'Кроссовки Nike Air Max 270',
description: 'Спортивные кроссовки с современным дизайном и комфортной посадкой',
brand: 'Nike',
object: 'Кроссовки',
parent: 'Обувь',
countryProduction: 'Вьетнам',
supplierVendorCode: 'SUPPLIER-003',
mediaFiles: ['/api/placeholder/400/405', '/api/placeholder/400/406', '/api/placeholder/400/407'],
sizes: [
{
chrtID: 555666,
techSize: '42',
wbSize: '42 EU',
price: 12990,
discountedPrice: 9990,
quantity: 25
},
{
chrtID: 555667,
techSize: '43',
wbSize: '43 EU',
price: 12990,
discountedPrice: 9990,
quantity: 20
}
]
},
{
nmID: 444333222,
vendorCode: 'SKU004',
title: 'Футболка Adidas Originals',
description: 'Классическая футболка из органического хлопка с логотипом бренда',
brand: 'Adidas',
object: 'Футболки',
parent: 'Одежда',
countryProduction: 'Бангладеш',
supplierVendorCode: 'SUPPLIER-004',
mediaFiles: ['/api/placeholder/400/408', '/api/placeholder/400/409'],
sizes: [
{
chrtID: 444333,
techSize: 'M',
wbSize: 'M',
price: 2990,
discountedPrice: 2490,
quantity: 50
},
{
chrtID: 444334,
techSize: 'L',
wbSize: 'L',
price: 2990,
discountedPrice: 2490,
quantity: 45
},
{
chrtID: 444335,
techSize: 'XL',
wbSize: 'XL',
price: 2990,
discountedPrice: 2490,
quantity: 30
}
]
},
{
nmID: 111222333,
vendorCode: 'SKU005',
title: 'Рюкзак для ноутбука Xiaomi',
description: 'Стильный и функциональный рюкзак для ноутбука до 15.6 дюймов',
brand: 'Xiaomi',
object: 'Рюкзаки',
parent: 'Аксессуары',
countryProduction: 'Китай',
supplierVendorCode: 'SUPPLIER-005',
mediaFiles: ['/api/placeholder/400/410'],
sizes: [
{
chrtID: 111222,
techSize: '15.6"',
wbSize: 'Черный',
price: 4990,
discountedPrice: 3990,
quantity: 35
}
]
},
{
nmID: 777888999,
vendorCode: 'SKU006',
title: 'Умные часы Apple Watch Series 9',
description: 'Новейшие умные часы с передовыми функциями здоровья и фитнеса',
brand: 'Apple',
object: 'Умные часы',
parent: 'Электроника',
countryProduction: 'Китай',
supplierVendorCode: 'SUPPLIER-006',
mediaFiles: ['/api/placeholder/400/411', '/api/placeholder/400/412', '/api/placeholder/400/413'],
sizes: [
{
chrtID: 777888,
techSize: '41mm',
wbSize: '41mm GPS',
price: 39990,
discountedPrice: 35990,
quantity: 12
},
{
chrtID: 777889,
techSize: '45mm',
wbSize: '45mm GPS',
price: 42990,
discountedPrice: 38990,
quantity: 8
}
]
}
]
// Загружаем контрагентов-фулфилментов
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
@ -113,12 +286,19 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
useEffect(() => {
// Загружаем услуги фулфилмента из контрагентов
if (counterpartiesData?.myCounterparties) {
interface Organization {
id: string
name?: string
fullName?: string
type: string
}
const fulfillmentOrganizations = counterpartiesData.myCounterparties.filter(
(org: any) => org.type === 'FULFILLMENT'
(org: Organization) => org.type === 'FULFILLMENT'
)
// В реальном приложении здесь был бы запрос услуг для каждой организации
const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: any) => [
const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: Organization) => [
{
id: `${org.id}-packaging`,
name: 'Упаковка товаров',
@ -146,83 +326,155 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
}
}, [counterpartiesData])
const searchCards = async () => {
if (!searchTerm.trim()) return
// Автоматически загружаем товары при открытии компонента
useEffect(() => {
const loadCards = async () => {
setLoading(true)
try {
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
console.log('WB API Key found:', !!wbApiKey)
console.log('WB API Key active:', wbApiKey?.isActive)
console.log('WB API Key validationData:', wbApiKey?.validationData)
if (wbApiKey?.isActive) {
// Попытка загрузить реальные данные из API Wildberries
const validationData = wbApiKey.validationData as Record<string, string>
// API ключ может храниться в разных местах
const apiToken = validationData?.token ||
validationData?.apiKey ||
validationData?.key ||
(wbApiKey as { apiKey?: string }).apiKey // Прямое поле apiKey из базы
console.log('API Token extracted:', !!apiToken)
console.log('API Token length:', apiToken?.length)
if (apiToken) {
console.log('Загружаем карточки из WB API...')
const cards = await WildberriesService.getAllCards(apiToken, 50)
setWbCards(cards)
console.log('Загружено карточек из WB API:', cards.length)
return
}
}
// Если API ключ не настроен, оставляем пустое состояние
console.log('API ключ WB не настроен, показываем пустое состояние')
setWbCards([])
} catch (error) {
console.error('Ошибка загрузки карточек WB:', error)
// При ошибке API показываем пустое состояние
setWbCards([])
} finally {
setLoading(false)
}
}
loadCards()
}, [user])
const loadAllCards = async () => {
setLoading(true)
try {
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
if (!wbApiKey?.isActive) {
throw new Error('API ключ Wildberries не настроен')
}
const validationData = wbApiKey.validationData as Record<string, string>
const apiToken = validationData?.token || validationData?.apiKey
if (!apiToken) {
throw new Error('API токен не найден')
}
const cards = await WildberriesService.searchCards(apiToken, searchTerm)
setWbCards(cards)
} catch (error) {
console.error('Ошибка поиска карточек:', error)
// Для демо загрузим моковые данные
setWbCards([
{
nmID: 123456789,
vendorCode: 'SKU001',
title: 'Смартфон Samsung Galaxy A54',
description: 'Современный смартфон с отличной камерой',
brand: 'Samsung',
object: 'Смартфоны',
parent: 'Электроника',
countryProduction: 'Корея',
supplierVendorCode: 'SUPPLIER-001',
mediaFiles: ['/api/placeholder/300/300'],
sizes: [
{
chrtID: 123456,
techSize: '128GB',
wbSize: '128GB Черный',
price: 25990,
discountedPrice: 22990,
quantity: 10
}
]
},
{
nmID: 987654321,
vendorCode: 'SKU002',
title: 'Наушники Apple AirPods Pro',
description: 'Беспроводные наушники с шумоподавлением',
brand: 'Apple',
object: 'Наушники',
parent: 'Электроника',
countryProduction: 'Китай',
supplierVendorCode: 'SUPPLIER-002',
mediaFiles: ['/api/placeholder/300/300'],
sizes: [
{
chrtID: 987654,
techSize: 'Standart',
wbSize: 'Белый',
price: 24990,
discountedPrice: 19990,
quantity: 5
}
]
if (wbApiKey?.isActive) {
// Попытка загрузить реальные данные из API Wildberries
const validationData = wbApiKey.validationData as Record<string, string>
const apiToken = validationData?.token ||
validationData?.apiKey ||
validationData?.key ||
(wbApiKey as { apiKey?: string }).apiKey
if (apiToken) {
console.log('Загружаем все карточки из WB API...')
const cards = await WildberriesService.getAllCards(apiToken, 100)
setWbCards(cards)
console.log('Загружено карточек из WB API:', cards.length)
return
}
])
}
// Если API ключ не настроен, загружаем моковые данные
console.log('API ключ WB не настроен, загружаем моковые данные')
const allCards = getMockCards()
setWbCards(allCards)
console.log('Загружены моковые товары:', allCards.length)
} catch (error) {
console.error('Ошибка загрузки всех карточек WB:', error)
// При ошибке загружаем моковые данные
const allCards = getMockCards()
setWbCards(allCards)
console.log('Загружены моковые товары (fallback):', allCards.length)
} finally {
setLoading(false)
}
}
const updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: any) => {
const searchCards = async () => {
if (!searchTerm.trim()) {
loadAllCards()
return
}
setLoading(true)
try {
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
if (wbApiKey?.isActive) {
// Попытка поиска в реальном API Wildberries
const validationData = wbApiKey.validationData as Record<string, string>
const apiToken = validationData?.token ||
validationData?.apiKey ||
validationData?.key ||
(wbApiKey as { apiKey?: string }).apiKey
if (apiToken) {
console.log('Поиск в WB API:', searchTerm)
const cards = await WildberriesService.searchCards(apiToken, searchTerm, 50)
setWbCards(cards)
console.log('Найдено карточек в WB API:', cards.length)
return
}
}
// Если API ключ не настроен, ищем в моковых данных
console.log('API ключ WB не настроен, поиск в моковых данных:', searchTerm)
const mockCards = getMockCards()
// Фильтруем товары по поисковому запросу
const filteredCards = mockCards.filter(card =>
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.object.toLowerCase().includes(searchTerm.toLowerCase())
)
setWbCards(filteredCards)
console.log('Найдено моковых товаров:', filteredCards.length)
} catch (error) {
console.error('Ошибка поиска карточек WB:', error)
// При ошибке ищем в моковых данных
const mockCards = getMockCards()
const filteredCards = mockCards.filter(card =>
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.object.toLowerCase().includes(searchTerm.toLowerCase())
)
setWbCards(filteredCards)
console.log('Найдено моковых товаров (fallback):', filteredCards.length)
} finally {
setLoading(false)
}
}
const updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: string | number | string[]) => {
setSelectedCards(prev => {
const existing = prev.find(sc => sc.card.nmID === card.nmID)
if (field === 'selectedQuantity' && value === 0) {
if (field === 'selectedQuantity' && typeof value === 'number' && value === 0) {
return prev.filter(sc => sc.card.nmID !== card.nmID)
}
@ -232,10 +484,10 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
? { ...sc, [field]: value }
: sc
)
} else if (field === 'selectedQuantity' && value > 0) {
} else if (field === 'selectedQuantity' && typeof value === 'number' && value > 0) {
const newSelectedCard: SelectedCard = {
card,
selectedQuantity: value,
selectedQuantity: value as number,
selectedMarket: '',
selectedPlace: '',
sellerName: '',
@ -284,6 +536,28 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
)
}
const handleCardClick = (card: WildberriesCard) => {
setSelectedCardForDetails(card)
setCurrentImageIndex(0)
}
const closeDetailsModal = () => {
setSelectedCardForDetails(null)
setCurrentImageIndex(0)
}
const nextImage = () => {
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) {
setCurrentImageIndex((prev) => (prev + 1) % selectedCardForDetails.mediaFiles.length)
}
}
const prevImage = () => {
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) {
setCurrentImageIndex((prev) => (prev - 1 + selectedCardForDetails.mediaFiles.length) % selectedCardForDetails.mediaFiles.length)
}
}
const handleCreateSupply = async () => {
try {
const supplyInput = {
@ -356,7 +630,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex space-x-4">
<img
src={sc.card.mediaFiles[0] || '/api/placeholder/120/120'}
src={sc.card.mediaFiles?.[0] || '/api/placeholder/120/120'}
alt={sc.card.title}
className="w-20 h-20 rounded-lg object-cover"
/>
@ -504,8 +778,26 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
</div>
</Card>
{/* Состояние загрузки */}
{loading && (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i} className="bg-white/10 backdrop-blur border-white/20 p-4 animate-pulse">
<div className="space-y-4">
<div className="bg-white/20 rounded-lg h-48 w-full"></div>
<div className="space-y-2">
<div className="bg-white/20 rounded h-4 w-3/4"></div>
<div className="bg-white/20 rounded h-4 w-1/2"></div>
</div>
<div className="bg-white/20 rounded h-10 w-full"></div>
</div>
</Card>
))}
</div>
)}
{/* Карточки товаров */}
{wbCards.length > 0 && (
{!loading && wbCards.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{wbCards.map((card) => {
const selectedQuantity = getSelectedQuantity(card)
@ -520,13 +812,28 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<div className="p-4 space-y-4">
{/* Изображение и основная информация */}
<div className="space-y-3">
<img
src={card.mediaFiles[0] || '/api/placeholder/300/200'}
alt={card.title}
className="w-full h-48 rounded-lg object-cover"
/>
<div className="relative group">
<img
src={card.mediaFiles?.[0] || '/api/placeholder/300/200'}
alt={card.title}
className="w-full h-48 rounded-lg object-cover cursor-pointer"
onClick={() => handleCardClick(card)}
/>
{/* Кнопка "Подробнее" при наведении */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center rounded-lg">
<Button
size="sm"
variant="outline"
onClick={() => handleCardClick(card)}
className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30"
>
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
</div>
<div>
<h3 className="text-white font-medium text-lg mb-1">{card.title}</h3>
<h3 className="text-white font-medium text-lg mb-1 cursor-pointer hover:text-purple-300 transition-colors" onClick={() => handleCardClick(card)}>{card.title}</h3>
<p className="text-white/60 text-sm mb-2">{card.vendorCode}</p>
<div className="flex items-center justify-between">
<span className="text-white font-bold text-xl">{formatCurrency(price)}</span>
@ -718,13 +1025,183 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<Card className="bg-white/10 backdrop-blur border-white/20 p-12">
<div className="text-center">
<Package className="h-16 w-16 text-white/20 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">Поиск товаров</h3>
<p className="text-white/60 mb-4">
Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries
</p>
<h3 className="text-xl font-semibold text-white mb-2">Карточки товаров Wildberries</h3>
{user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive ? (
<>
<p className="text-white/60 mb-6">
Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries, или загрузите все доступные карточки
</p>
<Button
onClick={loadAllCards}
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
>
<Package className="h-4 w-4 mr-2" />
Загрузить карточки из WB API
</Button>
</>
) : (
<>
<p className="text-white/60 mb-4">
Для работы с реальными карточками товаров необходимо настроить API ключ Wildberries в настройках организации
</p>
<p className="text-white/40 text-sm mb-6">
Сейчас показаны демонстрационные товары. Для тестирования используйте поиск или загрузите все.
</p>
<Button
onClick={loadAllCards}
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
>
<Package className="h-4 w-4 mr-2" />
Показать демо товары
</Button>
</>
)}
</div>
</Card>
)}
{/* Модальное окно с детальной информацией о товаре */}
<Dialog open={!!selectedCardForDetails} onOpenChange={(open) => !open && closeDetailsModal()}>
<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>Детальная информация о товаре</DialogTitle>
</DialogHeader>
{selectedCardForDetails && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
{/* Изображения */}
<div className="space-y-4">
<div className="relative">
<img
src={selectedCardForDetails.mediaFiles?.[currentImageIndex] || '/api/placeholder/400/400'}
alt={selectedCardForDetails.title}
className="w-full aspect-square rounded-lg object-cover"
/>
{/* Навигация по изображениям */}
{selectedCardForDetails.mediaFiles?.length > 1 && (
<>
<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} из {selectedCardForDetails.mediaFiles?.length || 0}
</div>
</>
)}
</div>
{/* Миниатюры изображений */}
{selectedCardForDetails.mediaFiles?.length > 1 && (
<div className="flex space-x-2 overflow-x-auto">
{selectedCardForDetails.mediaFiles?.map((image, index) => (
<img
key={index}
src={image}
alt={`${selectedCardForDetails.title} ${index + 1}`}
className={`w-16 h-16 rounded-lg object-cover cursor-pointer flex-shrink-0 ${
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
}`}
onClick={() => setCurrentImageIndex(index)}
/>
))}
</div>
)}
</div>
{/* Информация о товаре */}
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{selectedCardForDetails.title}</h2>
<p className="text-white/60 mb-4">Артикул: {selectedCardForDetails.vendorCode}</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-white/60">Бренд:</span>
<span className="text-white ml-2">{selectedCardForDetails.brand}</span>
</div>
<div>
<span className="text-white/60">Категория:</span>
<span className="text-white ml-2">{selectedCardForDetails.object}</span>
</div>
<div>
<span className="text-white/60">Родительская категория:</span>
<span className="text-white ml-2">{selectedCardForDetails.parent}</span>
</div>
<div>
<span className="text-white/60">Страна производства:</span>
<span className="text-white ml-2">{selectedCardForDetails.countryProduction}</span>
</div>
</div>
</div>
{selectedCardForDetails.description && (
<div>
<h3 className="text-white font-semibold mb-2">Описание</h3>
<p className="text-white/80 text-sm leading-relaxed">{selectedCardForDetails.description}</p>
</div>
)}
{/* Размеры и цены */}
<div>
<h3 className="text-white font-semibold mb-3">Доступные варианты</h3>
<div className="space-y-2">
{selectedCardForDetails.sizes.map((size) => (
<div key={size.chrtID} className="bg-white/5 rounded-lg p-3">
<div className="flex justify-between items-center mb-2">
<span className="text-white font-medium">{size.wbSize}</span>
<Badge className={`${size.quantity > 10 ? 'bg-green-500/20 text-green-300' : size.quantity > 0 ? 'bg-yellow-500/20 text-yellow-300' : 'bg-red-500/20 text-red-300'}`}>
{size.quantity} шт.
</Badge>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-white/60">Размер: {size.techSize}</span>
<div className="text-right">
<div className="text-white font-bold">{formatCurrency(size.discountedPrice || size.price)}</div>
{size.discountedPrice && size.discountedPrice < size.price && (
<div className="text-white/40 line-through text-xs">{formatCurrency(size.price)}</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Кнопки действий в модальном окне */}
<div className="flex gap-3 pt-4 border-t border-white/20">
<Button
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
onClick={() => {
const currentQuantity = getSelectedQuantity(selectedCardForDetails)
updateCardSelection(selectedCardForDetails, 'selectedQuantity', currentQuantity + 1)
}}
>
<Plus className="h-4 w-4 mr-2" />
Добавить в корзину
</Button>
<Button
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
onClick={closeDetailsModal}
>
Закрыть
</Button>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -177,6 +177,7 @@ export const ADD_MARKETPLACE_API_KEY = gql`
apiKey {
id
marketplace
apiKey
isActive
validationData
}

View File

@ -40,8 +40,11 @@ export const GET_ME = gql`
apiKeys {
id
marketplace
apiKey
isActive
validationData
createdAt
updatedAt
}
}
}
@ -253,6 +256,7 @@ export const GET_ORGANIZATION = gql`
apiKeys {
id
marketplace
apiKey
isActive
validationData
createdAt

View File

@ -96,7 +96,7 @@ const generateToken = (payload: AuthTokenPayload): string => {
const verifyToken = (token: string): AuthTokenPayload => {
try {
return jwt.verify(token, process.env.JWT_SECRET!) as AuthTokenPayload;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
throw new GraphQLError("Недействительный токен", {
extensions: { code: "UNAUTHENTICATED" },
@ -168,7 +168,7 @@ function parseLiteral(ast: unknown): unknown {
fields?: unknown[];
values?: unknown[];
};
switch (astNode.kind) {
case Kind.STRING:
case Kind.BOOLEAN:
@ -290,7 +290,7 @@ export const resolvers = {
// Получаем исходящие заявки для добавления флага hasOutgoingRequest
const outgoingRequests = await prisma.counterpartyRequest.findMany({
where: {
where: {
senderId: currentUser.organization.id,
status: "PENDING",
},
@ -301,7 +301,7 @@ export const resolvers = {
// Получаем входящие заявки для добавления флага hasIncomingRequest
const incomingRequests = await prisma.counterpartyRequest.findMany({
where: {
where: {
receiverId: currentUser.organization.id,
status: "PENDING",
},
@ -365,7 +365,7 @@ export const resolvers = {
const counterparties = await prisma.counterparty.findMany({
where: { organizationId: currentUser.organization.id },
include: {
include: {
counterparty: {
include: {
users: true,
@ -396,7 +396,7 @@ export const resolvers = {
}
return await prisma.counterpartyRequest.findMany({
where: {
where: {
receiverId: currentUser.organization.id,
status: "PENDING",
},
@ -436,7 +436,7 @@ export const resolvers = {
}
return await prisma.counterpartyRequest.findMany({
where: {
where: {
senderId: currentUser.organization.id,
status: { in: ["PENDING", "REJECTED"] },
},
@ -505,7 +505,7 @@ export const resolvers = {
receiverOrganization: {
include: {
users: true,
},
},
},
},
orderBy: { createdAt: "asc" },
@ -670,7 +670,7 @@ export const resolvers = {
return await prisma.product.findMany({
where: { organizationId: currentUser.organization.id },
include: {
include: {
category: true,
organization: true,
},
@ -712,7 +712,7 @@ export const resolvers = {
return await prisma.product.findMany({
where,
include: {
include: {
category: true,
organization: {
include: {
@ -897,7 +897,7 @@ export const resolvers = {
}
const employee = await prisma.employee.findFirst({
where: {
where: {
id: args.id,
organizationId: currentUser.organization.id,
},
@ -984,7 +984,7 @@ export const resolvers = {
args.phone,
args.code
);
if (!verificationResult.success) {
return {
success: false,
@ -1043,7 +1043,7 @@ export const resolvers = {
};
console.log("verifySmsCode - Returning result:", {
success: result.success,
success: result.success,
hasToken: !!result.token,
hasUser: !!result.user,
message: result.message,
@ -1147,28 +1147,28 @@ export const resolvers = {
addressFull: organizationData.addressFull,
ogrn: organizationData.ogrn,
ogrnDate: organizationData.ogrnDate,
// Статус организации
status: organizationData.status,
actualityDate: organizationData.actualityDate,
registrationDate: organizationData.registrationDate,
liquidationDate: organizationData.liquidationDate,
// Руководитель
managementName: organizationData.managementName,
managementPost: organizationData.managementPost,
// ОПФ
opfCode: organizationData.opfCode,
opfFull: organizationData.opfFull,
opfShort: organizationData.opfShort,
// Коды статистики
okato: organizationData.okato,
oktmo: organizationData.oktmo,
okpo: organizationData.okpo,
okved: organizationData.okved,
// Контакты
phones: organizationData.phones
? JSON.parse(JSON.stringify(organizationData.phones))
@ -1176,12 +1176,12 @@ export const resolvers = {
emails: organizationData.emails
? JSON.parse(JSON.stringify(organizationData.emails))
: null,
// Финансовые данные
employeeCount: organizationData.employeeCount,
revenue: organizationData.revenue,
taxSystem: organizationData.taxSystem,
type: type,
dadataData: JSON.parse(JSON.stringify(organizationData.rawData)),
},
@ -1284,7 +1284,7 @@ export const resolvers = {
const tradeMark = validationResults[0]?.data?.tradeMark;
const sellerName = validationResults[0]?.data?.sellerName;
const shopName = tradeMark || sellerName || "Магазин";
const organization = await prisma.organization.create({
data: {
inn:
@ -1427,7 +1427,7 @@ export const resolvers = {
where: { id: existingKey.id },
data: {
apiKey,
validationData: JSON.parse(JSON.stringify(validationResult.data)),
validationData: JSON.parse(JSON.stringify(validationResult.data)),
isActive: true,
},
});
@ -1453,7 +1453,7 @@ export const resolvers = {
message: "API ключ успешно добавлен",
apiKey: newKey,
};
}
}
} catch (error) {
console.error("Error adding marketplace API key:", error);
return {
@ -1526,7 +1526,7 @@ export const resolvers = {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
include: {
organization: {
include: {
apiKeys: true,
@ -1541,7 +1541,7 @@ export const resolvers = {
try {
const { input } = args;
// Обновляем данные пользователя (аватар, имя управляющего)
const userUpdateData: { avatar?: string; managerName?: string } = {};
if (input.avatar) {
@ -1550,14 +1550,14 @@ export const resolvers = {
if (input.managerName) {
userUpdateData.managerName = input.managerName;
}
if (Object.keys(userUpdateData).length > 0) {
await prisma.user.update({
where: { id: context.user.id },
data: userUpdateData,
});
}
// Подготавливаем данные для обновления организации
const updateData: {
phones?: object;
@ -1565,20 +1565,20 @@ export const resolvers = {
managementName?: string;
managementPost?: string;
} = {};
// Название организации больше не обновляется через профиль
// Для селлеров устанавливается при регистрации, для остальных - при смене ИНН
// Обновляем контактные данные в JSON поле phones
if (input.orgPhone) {
updateData.phones = [{ value: input.orgPhone, type: "main" }];
}
// Обновляем email в JSON поле emails
// Обновляем email в JSON поле emails
if (input.email) {
updateData.emails = [{ value: input.email, type: "main" }];
}
// Сохраняем дополнительные контакты в custom полях
// Пока добавим их как дополнительные JSON поля
const customContacts: {
@ -1592,13 +1592,13 @@ export const resolvers = {
corrAccount?: string;
};
} = {};
// managerName теперь сохраняется в поле пользователя, а не в JSON
if (input.telegram) {
customContacts.telegram = input.telegram;
}
if (input.whatsapp) {
customContacts.whatsapp = input.whatsapp;
}
@ -1616,7 +1616,7 @@ export const resolvers = {
corrAccount: input.corrAccount,
};
}
// Если есть дополнительные контакты, сохраним их в поле managementPost временно
// В идеале нужно добавить отдельную таблицу для контактов
if (Object.keys(customContacts).length > 0) {
@ -1635,7 +1635,7 @@ export const resolvers = {
// Получаем обновленного пользователя
const updatedUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
include: {
organization: {
include: {
apiKeys: true,
@ -1671,7 +1671,7 @@ export const resolvers = {
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
include: {
organization: {
include: {
apiKeys: true,
@ -1769,7 +1769,7 @@ export const resolvers = {
// Получаем обновленного пользователя
const updatedUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
include: {
organization: {
include: {
apiKeys: true,
@ -2930,22 +2930,22 @@ export const resolvers = {
createProduct: async (
_: unknown,
args: {
input: {
name: string;
article: string;
description?: string;
price: number;
quantity: number;
categoryId?: string;
brand?: string;
color?: string;
size?: string;
weight?: number;
dimensions?: string;
material?: string;
images?: string[];
mainImage?: string;
isActive?: boolean;
input: {
name: string;
article: string;
description?: string;
price: number;
quantity: number;
categoryId?: string;
brand?: string;
color?: string;
size?: string;
weight?: number;
dimensions?: string;
material?: string;
images?: string[];
mainImage?: string;
isActive?: boolean;
};
},
context: Context
@ -3005,7 +3005,7 @@ export const resolvers = {
isActive: args.input.isActive ?? true,
organizationId: currentUser.organization.id,
},
include: {
include: {
category: true,
organization: true,
},
@ -3029,23 +3029,23 @@ export const resolvers = {
updateProduct: async (
_: unknown,
args: {
id: string;
input: {
name: string;
article: string;
description?: string;
price: number;
quantity: number;
categoryId?: string;
brand?: string;
color?: string;
size?: string;
weight?: number;
dimensions?: string;
material?: string;
images?: string[];
mainImage?: string;
isActive?: boolean;
id: string;
input: {
name: string;
article: string;
description?: string;
price: number;
quantity: number;
categoryId?: string;
brand?: string;
color?: string;
size?: string;
weight?: number;
dimensions?: string;
material?: string;
images?: string[];
mainImage?: string;
isActive?: boolean;
};
},
context: Context
@ -3115,7 +3115,7 @@ export const resolvers = {
mainImage: args.input.mainImage,
isActive: args.input.isActive ?? true,
},
include: {
include: {
category: true,
organization: true,
},
@ -3400,7 +3400,7 @@ export const resolvers = {
if (existingCartItem) {
// Обновляем количество
const newQuantity = existingCartItem.quantity + args.quantity;
if (newQuantity > product.quantity) {
return {
success: false,
@ -3940,7 +3940,7 @@ export const resolvers = {
try {
const employee = await prisma.employee.update({
where: {
where: {
id: args.id,
organizationId: currentUser.organization.id,
},
@ -4002,7 +4002,7 @@ export const resolvers = {
try {
await prisma.employee.delete({
where: {
where: {
id: args.id,
organizationId: currentUser.organization.id,
},
@ -4150,7 +4150,7 @@ export const resolvers = {
if (parent.users) {
return parent.users;
}
// Иначе загружаем отдельно
return await prisma.user.findMany({
where: { organizationId: parent.id },
@ -4197,7 +4197,7 @@ export const resolvers = {
if (parent.organization) {
return parent.organization;
}
// Иначе загружаем отдельно если есть organizationId
if (parent.organizationId) {
return await prisma.organization.findUnique({
@ -4514,14 +4514,14 @@ const adminQueries = {
const limit = args.limit || 50;
const offset = args.offset || 0;
// Строим условие поиска
const whereCondition: Prisma.UserWhereInput = args.search
? {
OR: [
{ phone: { contains: args.search, mode: "insensitive" } },
{ managerName: { contains: args.search, mode: "insensitive" } },
{
{
organization: {
OR: [
{ name: { contains: args.search, mode: "insensitive" } },
@ -4589,7 +4589,7 @@ const adminMutations = {
args.password,
admin.password
);
if (!isPasswordValid) {
return {
success: false,
@ -4605,7 +4605,7 @@ const adminMutations = {
// Создать токен
const token = jwt.sign(
{
{
adminId: admin.id,
username: admin.username,
type: "admin",

View File

@ -246,6 +246,7 @@ export const typeDefs = gql`
type ApiKey {
id: ID!
marketplace: MarketplaceType!
apiKey: String!
isActive: Boolean!
validationData: JSON
createdAt: DateTime!

View File

@ -43,7 +43,7 @@ interface WildberriesCardsResponse {
}
interface WildberriesCardFilter {
sort?: {
settings?: {
cursor?: {
limit?: number
nmID?: number
@ -55,61 +55,55 @@ interface WildberriesCardFilter {
objectIDs?: number[]
tagIDs?: number[]
brandIDs?: number[]
colorIDs?: number[]
sizeIDs?: number[]
}
}
}
export class WildberriesService {
private static baseUrl = 'https://marketplace-api.wildberries.ru'
private static contentUrl = 'https://content-api.wildberries.ru'
private static publicUrl = 'https://public-api.wildberries.ru'
private static supplierUrl = 'https://suppliers-api.wildberries.ru'
/**
* Получить список складов WB
*/
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
try {
const response = await fetch(`${this.baseUrl}/api/v2/warehouses`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
const data: WildberriesWarehousesResponse = await response.json()
return data.data || []
} catch (error) {
console.error('Error fetching WB warehouses:', error)
throw new Error('Ошибка получения складов Wildberries')
}
}
/**
* Получить карточки товаров
* Получение карточек товаров через Content API v2
*/
static async getCards(apiKey: string, filter?: WildberriesCardFilter): Promise<WildberriesCard[]> {
try {
const response = await fetch(`${this.contentUrl}/content/v1/cards/cursor/list`, {
console.log('Calling WB Content API v2 with filter:', filter)
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(filter || {
sort: {
settings: {
cursor: {
limit: 100
},
filter: {
withPhoto: -1
}
}
})
})
console.log(`${this.contentUrl}/content/v2/get/cards/list`, response.status, response.statusText)
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
const errorText = await response.text()
let errorData
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { message: errorText }
}
console.log('WB API Error Response:', errorData)
throw new Error(`WB API Error: ${response.status} - ${response.statusText}`)
}
const data: WildberriesCardsResponse = await response.json()
@ -121,16 +115,17 @@ export class WildberriesService {
}
/**
* Поиск карточек товаров по тексту
* Поиск карточек товаров
*/
static async searchCards(apiKey: string, searchText: string, limit: number = 100): Promise<WildberriesCard[]> {
static async searchCards(apiKey: string, searchTerm: string, limit = 50): Promise<WildberriesCard[]> {
const filter: WildberriesCardFilter = {
sort: {
settings: {
cursor: {
limit
},
filter: {
textSearch: searchText
textSearch: searchTerm,
withPhoto: -1
}
}
}
@ -139,63 +134,70 @@ export class WildberriesService {
}
/**
* Валидация API ключа WB
* Получение всех карточек товаров с пагинацией
*/
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
const filter: WildberriesCardFilter = {
settings: {
cursor: {
limit
},
filter: {
withPhoto: -1
}
}
}
return this.getCards(apiKey, filter)
}
/**
* Получение складов WB
*/
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
try {
const response = await fetch(`${this.supplierUrl}/api/v3/warehouses`, {
headers: {
'Authorization': apiKey,
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: WildberriesWarehousesResponse = await response.json()
return data.data || []
} catch (error) {
console.error('Error fetching warehouses:', error)
return []
}
}
/**
* Проверка валидности API ключа
*/
static async validateApiKey(apiKey: string): Promise<boolean> {
try {
await this.getWarehouses(apiKey)
return true
const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
settings: {
cursor: {
limit: 1
}
}
})
})
return response.ok
} catch (error) {
console.error('WB API key validation failed:', error)
console.error('Error validating API key:', error)
return false
}
}
/**
* Получить информацию о поставке
*/
static async getSupplyInfo(apiKey: string, supplyId: string): Promise<unknown> {
try {
const response = await fetch(`${this.baseUrl}/api/v3/supplies/${supplyId}`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching WB supply info:', error)
throw new Error('Ошибка получения информации о поставке')
}
}
/**
* Получить список поставок
*/
static async getSupplies(apiKey: string, limit: number = 1000, next: number = 0): Promise<unknown> {
try {
const response = await fetch(`${this.baseUrl}/api/v3/supplies?limit=${limit}&next=${next}`, {
method: 'GET',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('Error fetching WB supplies:', error)
throw new Error('Ошибка получения списка поставок')
}
}
}