Добавлены модели и функциональность для работы с избранными товарами, включая мутации и запросы в GraphQL. Обновлены компоненты для отображения и управления избранным, улучшен интерфейс взаимодействия с пользователем. Реализована логика добавления и удаления товаров из избранного.
This commit is contained in:
@ -124,6 +124,9 @@ model Organization {
|
|||||||
// Корзины
|
// Корзины
|
||||||
carts Cart[]
|
carts Cart[]
|
||||||
|
|
||||||
|
// Избранные товары
|
||||||
|
favorites Favorites[]
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,6 +340,9 @@ model Product {
|
|||||||
// Связь с элементами корзины
|
// Связь с элементами корзины
|
||||||
cartItems CartItem[]
|
cartItems CartItem[]
|
||||||
|
|
||||||
|
// Избранные товары
|
||||||
|
favorites Favorites[]
|
||||||
|
|
||||||
// Уникальность артикула в рамках организации
|
// Уникальность артикула в рамках организации
|
||||||
@@unique([organizationId, article])
|
@@unique([organizationId, article])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
@ -383,3 +389,24 @@ model CartItem {
|
|||||||
@@unique([cartId, productId])
|
@@unique([cartId, productId])
|
||||||
@@map("cart_items")
|
@@map("cart_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Модель избранных товаров
|
||||||
|
model Favorites {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Связь с организацией (пользователь может добавлять товары в избранное)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
|
||||||
|
// Связь с товаром
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
productId String
|
||||||
|
|
||||||
|
// Временные метки
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Уникальность: один товар может быть только один раз в избранном у организации
|
||||||
|
@@unique([organizationId, productId])
|
||||||
|
@@map("favorites")
|
||||||
|
}
|
||||||
|
@ -55,6 +55,7 @@ interface CartItemsProps {
|
|||||||
|
|
||||||
export function CartItems({ cart }: CartItemsProps) {
|
export function CartItems({ cart }: CartItemsProps) {
|
||||||
const [loadingItems, setLoadingItems] = useState<Set<string>>(new Set())
|
const [loadingItems, setLoadingItems] = useState<Set<string>>(new Set())
|
||||||
|
const [quantities, setQuantities] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
const [updateCartItem] = useMutation(UPDATE_CART_ITEM, {
|
const [updateCartItem] = useMutation(UPDATE_CART_ITEM, {
|
||||||
refetchQueries: [{ query: GET_MY_CART }],
|
refetchQueries: [{ query: GET_MY_CART }],
|
||||||
@ -97,6 +98,12 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getQuantity = (productId: string, defaultQuantity: number) => quantities[productId] || defaultQuantity
|
||||||
|
|
||||||
|
const setQuantity = (productId: string, quantity: number) => {
|
||||||
|
setQuantities(prev => ({ ...prev, [productId]: quantity }))
|
||||||
|
}
|
||||||
|
|
||||||
const updateQuantity = async (productId: string, newQuantity: number) => {
|
const updateQuantity = async (productId: string, newQuantity: number) => {
|
||||||
if (newQuantity <= 0) return
|
if (newQuantity <= 0) return
|
||||||
|
|
||||||
@ -182,9 +189,8 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
{cart.items.length > 0 && (
|
{cart.items.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClearCart}
|
onClick={handleClearCart}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
className="bg-gradient-to-r from-red-500/20 to-pink-500/20 hover:from-red-500/30 hover:to-pink-500/30 border border-red-500/30 text-red-400 hover:text-white transition-all"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Очистить заявки
|
Очистить заявки
|
||||||
@ -213,10 +219,10 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<OrganizationAvatar organization={group.organization} size="md" />
|
<OrganizationAvatar organization={group.organization} size="md" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
{group.organization.name || group.organization.fullName || `ИНН ${group.organization.inn}`}
|
{group.organization.name || group.organization.fullName || `ИНН ${group.organization.inn}`}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-3 text-sm text-white/60">
|
<div className="flex items-center space-x-3 text-sm text-white/60">
|
||||||
<span className="flex items-center space-x-1">
|
<span className="flex items-center space-x-1">
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
@ -241,16 +247,16 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
{/* Товары этого поставщика */}
|
{/* Товары этого поставщика */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isLoading = loadingItems.has(item.product.id)
|
const isLoading = loadingItems.has(item.product.id)
|
||||||
const mainImage = item.product.images?.[0] || item.product.mainImage
|
const mainImage = item.product.images?.[0] || item.product.mainImage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`bg-white/5 backdrop-blur border border-white/10 rounded-xl transition-all hover:bg-white/8 hover:border-white/20 ${
|
className={`bg-white/5 backdrop-blur border border-white/10 rounded-xl transition-all hover:bg-white/8 hover:border-white/20 ${
|
||||||
!item.isAvailable ? 'opacity-60' : ''
|
!item.isAvailable ? 'opacity-60' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Информация о поставщике в карточке товара */}
|
{/* Информация о поставщике в карточке товара */}
|
||||||
<div className="px-4 py-2 bg-white/5 border-b border-white/10 rounded-t-xl">
|
<div className="px-4 py-2 bg-white/5 border-b border-white/10 rounded-t-xl">
|
||||||
<div className="flex items-center space-x-2 text-xs text-white/60">
|
<div className="flex items-center space-x-2 text-xs text-white/60">
|
||||||
@ -262,23 +268,23 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Основное содержимое карточки */}
|
{/* Основное содержимое карточки */}
|
||||||
<div className="p-5">
|
<div className="p-4">
|
||||||
<div className="flex space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{/* Изображение товара */}
|
{/* Изображение товара */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-20 h-20 bg-white/5 rounded-xl overflow-hidden border border-white/10 shadow-lg">
|
<div className="w-16 h-16 bg-white/5 rounded-lg overflow-hidden border border-white/10 shadow-lg">
|
||||||
{mainImage ? (
|
{mainImage ? (
|
||||||
<Image
|
<Image
|
||||||
src={mainImage}
|
src={mainImage}
|
||||||
alt={item.product.name}
|
alt={item.product.name}
|
||||||
width={80}
|
width={64}
|
||||||
height={80}
|
height={64}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<Package className="h-8 w-8 text-white/20" />
|
<Package className="h-6 w-6 text-white/20" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -287,99 +293,134 @@ export function CartItems({ cart }: CartItemsProps) {
|
|||||||
{/* Информация о товаре */}
|
{/* Информация о товаре */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Название и артикул */}
|
{/* Название и артикул */}
|
||||||
<div className="mb-3">
|
<h4 className="text-sm font-semibold text-white mb-1 line-clamp-1">
|
||||||
<h4 className="text-base font-semibold text-white mb-1 line-clamp-2">
|
{item.product.name}
|
||||||
{item.product.name}
|
</h4>
|
||||||
</h4>
|
<p className="text-xs text-white/50 mb-2">
|
||||||
<p className="text-sm text-white/50">
|
Арт: {item.product.article}
|
||||||
Артикул: {item.product.article}
|
</p>
|
||||||
</p>
|
|
||||||
|
{/* Статус и наличие */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
{item.availableQuantity} шт.
|
||||||
|
</span>
|
||||||
|
{item.isAvailable ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-300 text-xs border border-green-500/30 px-1 py-0">
|
||||||
|
В наличии
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/20 text-red-300 text-xs border border-red-500/30 px-1 py-0">
|
||||||
|
Недоступно
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Статус доступности */}
|
{/* Управление количеством */}
|
||||||
{!item.isAvailable && (
|
<div className="flex-shrink-0">
|
||||||
<Badge className="bg-red-500/20 text-red-300 text-xs mb-3 border border-red-500/30">
|
<div className="flex items-center space-x-1 mb-2">
|
||||||
Недоступно
|
<Button
|
||||||
</Badge>
|
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
||||||
)}
|
disabled={isLoading || !item.isAvailable || item.quantity <= 1}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 text-purple-300 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Нижняя секция: управление количеством и цена */}
|
<Input
|
||||||
<div className="flex items-center justify-between">
|
type="text"
|
||||||
{/* Управление количеством */}
|
value={getQuantity(item.product.id, item.quantity)}
|
||||||
<div className="flex items-center space-x-3">
|
onChange={(e) => {
|
||||||
<div className="flex items-center space-x-2">
|
const value = e.target.value
|
||||||
<span className="text-sm text-white/60 font-medium">Количество:</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
|
||||||
disabled={isLoading || !item.isAvailable || item.quantity <= 1}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 border-white/20 text-white/70 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Input
|
// Разрешаем только цифры и пустое поле
|
||||||
type="number"
|
if (value === '' || /^\d+$/.test(value)) {
|
||||||
min="1"
|
const numValue = value === '' ? 0 : parseInt(value)
|
||||||
max={item.availableQuantity}
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value) || 1
|
|
||||||
if (value >= 1 && value <= item.availableQuantity && !isLoading && item.isAvailable) {
|
|
||||||
updateQuantity(item.product.id, value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isLoading || !item.isAvailable}
|
|
||||||
className="w-16 h-8 text-sm text-center bg-white/5 border border-white/20 rounded-lg text-white focus:border-purple-400/50 focus:bg-white/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
// Временно сохраняем даже если 0 или больше лимита для удобства ввода
|
||||||
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
if (value === '' || (numValue >= 0 && numValue <= 99999)) {
|
||||||
disabled={isLoading || !item.isAvailable || item.quantity >= item.availableQuantity}
|
setQuantity(item.product.id, numValue || 1)
|
||||||
variant="outline"
|
}
|
||||||
size="sm"
|
}
|
||||||
className="h-8 w-8 p-0 border-white/20 text-white/70 hover:bg-white/10"
|
}}
|
||||||
>
|
onFocus={(e) => {
|
||||||
<Plus className="h-3 w-3" />
|
// При фокусе выделяем весь текст для удобного редактирования
|
||||||
</Button>
|
e.target.select()
|
||||||
</div>
|
}}
|
||||||
</div>
|
onBlur={(e) => {
|
||||||
|
// При потере фокуса проверяем и корректируем значение, отправляем запрос
|
||||||
|
let value = parseInt(e.target.value)
|
||||||
|
if (isNaN(value) || value < 1) {
|
||||||
|
value = 1
|
||||||
|
} else if (value > item.availableQuantity) {
|
||||||
|
value = item.availableQuantity
|
||||||
|
}
|
||||||
|
setQuantity(item.product.id, value)
|
||||||
|
updateQuantity(item.product.id, value)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter для быстрого обновления
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
let value = parseInt(e.currentTarget.value)
|
||||||
|
if (isNaN(value) || value < 1) {
|
||||||
|
value = 1
|
||||||
|
} else if (value > item.availableQuantity) {
|
||||||
|
value = item.availableQuantity
|
||||||
|
}
|
||||||
|
setQuantity(item.product.id, value)
|
||||||
|
updateQuantity(item.product.id, value)
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !item.isAvailable}
|
||||||
|
className="w-16 h-7 text-xs text-center glass-input text-white border-white/20 bg-white/5"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
|
||||||
<span className="text-sm text-white/40">
|
<Button
|
||||||
из {item.availableQuantity} доступно
|
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
||||||
</span>
|
disabled={isLoading || !item.isAvailable || item.quantity >= item.availableQuantity}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 text-purple-300 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-white/40 text-center">
|
||||||
|
до {item.availableQuantity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая часть: цена и кнопка удаления */}
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
{/* Цена */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-base font-bold text-purple-300">
|
||||||
|
{formatPrice(item.totalPrice)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
{/* Цена и кнопка удаления */}
|
{formatPrice(item.product.price)} за шт.
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-lg font-bold text-purple-300 mb-1">
|
|
||||||
{formatPrice(item.totalPrice)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-white/50">
|
|
||||||
{formatPrice(item.product.price)} за шт.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => removeItem(item.product.id)}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-400 border-red-500/30 hover:bg-red-500/10 hover:text-red-300 h-9 w-9 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка удаления */}
|
||||||
|
<Button
|
||||||
|
onClick={() => removeItem(item.product.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 bg-gradient-to-r from-red-500/20 to-pink-500/20 hover:from-red-500/30 hover:to-pink-500/30 border border-red-500/30 text-red-400 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
46
src/components/favorites/favorites-dashboard.tsx
Normal file
46
src/components/favorites/favorites-dashboard.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { FavoritesItems } from './favorites-items'
|
||||||
|
import { GET_MY_FAVORITES } from '@/graphql/queries'
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
|
||||||
|
interface FavoritesDashboardProps {
|
||||||
|
onBackToCategories?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoritesDashboard({ onBackToCategories }: FavoritesDashboardProps) {
|
||||||
|
const { data, loading, error } = useQuery(GET_MY_FAVORITES)
|
||||||
|
|
||||||
|
const favorites = data?.myFavorites || []
|
||||||
|
|
||||||
|
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-red-400 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">
|
||||||
|
<Heart 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 (
|
||||||
|
<Card className="glass-card h-full overflow-hidden">
|
||||||
|
<FavoritesItems favorites={favorites} onBackToCategories={onBackToCategories} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
387
src/components/favorites/favorites-items.tsx
Normal file
387
src/components/favorites/favorites-items.tsx
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation } from '@apollo/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Heart,
|
||||||
|
Package,
|
||||||
|
Store,
|
||||||
|
ShoppingCart,
|
||||||
|
Plus,
|
||||||
|
ArrowLeft
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { REMOVE_FROM_FAVORITES, ADD_TO_CART } from '@/graphql/mutations'
|
||||||
|
import { GET_MY_FAVORITES, GET_MY_CART } from '@/graphql/queries'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
article: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
images: string[]
|
||||||
|
mainImage?: string
|
||||||
|
organization: {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
inn: string
|
||||||
|
}
|
||||||
|
category?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FavoritesItemsProps {
|
||||||
|
favorites: Product[]
|
||||||
|
onBackToCategories?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoritesItems({ favorites, onBackToCategories }: FavoritesItemsProps) {
|
||||||
|
const [loadingItems, setLoadingItems] = useState<Set<string>>(new Set())
|
||||||
|
const [quantities, setQuantities] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
const [removeFromFavorites] = useMutation(REMOVE_FROM_FAVORITES, {
|
||||||
|
refetchQueries: [{ query: GET_MY_FAVORITES }],
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.removeFromFavorites.success) {
|
||||||
|
toast.success(data.removeFromFavorites.message)
|
||||||
|
} else {
|
||||||
|
toast.error(data.removeFromFavorites.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Ошибка при удалении из избранного')
|
||||||
|
console.error('Error removing from favorites:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [addToCart] = useMutation(ADD_TO_CART, {
|
||||||
|
refetchQueries: [{ query: GET_MY_CART }],
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.addToCart.success) {
|
||||||
|
toast.success(data.addToCart.message)
|
||||||
|
} else {
|
||||||
|
toast.error(data.addToCart.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Ошибка при добавлении в корзину')
|
||||||
|
console.error('Error adding to cart:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeFromFavoritesList = async (productId: string) => {
|
||||||
|
setLoadingItems(prev => new Set(prev).add(productId))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeFromFavorites({
|
||||||
|
variables: { productId }
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoadingItems(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(productId)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getQuantity = (productId: string) => quantities[productId] || 1
|
||||||
|
|
||||||
|
const setQuantity = (productId: string, quantity: number) => {
|
||||||
|
setQuantities(prev => ({ ...prev, [productId]: quantity }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProductToCart = async (productId: string) => {
|
||||||
|
setLoadingItems(prev => new Set(prev).add(productId))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quantity = getQuantity(productId)
|
||||||
|
await addToCart({
|
||||||
|
variables: { productId, quantity }
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoadingItems(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(productId)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB'
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группировка товаров по поставщикам
|
||||||
|
const groupedItems = favorites.reduce((groups, product) => {
|
||||||
|
const orgId = product.organization.id
|
||||||
|
if (!groups[orgId]) {
|
||||||
|
groups[orgId] = {
|
||||||
|
organization: product.organization,
|
||||||
|
products: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups[orgId].products.push(product)
|
||||||
|
return groups
|
||||||
|
}, {} as Record<string, {
|
||||||
|
organization: Product['organization'],
|
||||||
|
products: Product[]
|
||||||
|
}>)
|
||||||
|
|
||||||
|
const supplierGroups = Object.values(groupedItems)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 h-full flex flex-col">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center space-x-3 mb-6">
|
||||||
|
{onBackToCategories && (
|
||||||
|
<Button
|
||||||
|
onClick={onBackToCategories}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-white/70 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="p-3 rounded-xl bg-gradient-to-r from-red-500/20 to-pink-500/20 border border-red-500/30">
|
||||||
|
<Heart className="h-8 w-8 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-1">
|
||||||
|
Избранные товары
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60">
|
||||||
|
{favorites.length > 0
|
||||||
|
? `${favorites.length} товаров в избранном`
|
||||||
|
: 'Ваш список избранного пуст'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Содержимое */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{favorites.length === 0 ? (
|
||||||
|
<div className="glass-card p-8 h-full">
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<Heart 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 className="space-y-8">
|
||||||
|
{supplierGroups.map((group) => (
|
||||||
|
<div key={group.organization.id} className="space-y-4">
|
||||||
|
{/* Заголовок поставщика */}
|
||||||
|
<div className="bg-gradient-to-r from-red-600/20 via-red-500/10 to-transparent border border-red-500/20 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<OrganizationAvatar organization={group.organization} size="md" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
{group.organization.name || group.organization.fullName || `ИНН ${group.organization.inn}`}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-3 text-sm text-white/60">
|
||||||
|
<span className="flex items-center space-x-1">
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
<span>{group.products.length} товаров</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-red-500/20 text-red-300 px-3 py-1 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{group.products.length} в избранном
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Товары этого поставщика */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.products.map((product) => {
|
||||||
|
const isLoading = loadingItems.has(product.id)
|
||||||
|
const mainImage = product.images?.[0] || product.mainImage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className="bg-white/5 backdrop-blur border border-white/10 rounded-xl transition-all hover:bg-white/8 hover:border-white/20"
|
||||||
|
>
|
||||||
|
{/* Информация о поставщике в карточке товара */}
|
||||||
|
<div className="px-4 py-2 bg-white/5 border-b border-white/10 rounded-t-xl">
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-white/60">
|
||||||
|
<Store className="h-3 w-3" />
|
||||||
|
<span>Поставщик:</span>
|
||||||
|
<span className="text-white/80 font-medium">
|
||||||
|
{product.organization.name || product.organization.fullName || `ИНН ${product.organization.inn}`}
|
||||||
|
</span>
|
||||||
|
{product.category && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-white/60">
|
||||||
|
{product.category.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основное содержимое карточки */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Изображение товара */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-16 h-16 bg-white/5 rounded-lg overflow-hidden border border-white/10 shadow-lg">
|
||||||
|
{mainImage ? (
|
||||||
|
<Image
|
||||||
|
src={mainImage}
|
||||||
|
alt={product.name}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Package className="h-6 w-6 text-white/20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о товаре */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Название и артикул */}
|
||||||
|
<h4 className="text-sm font-semibold text-white mb-1 line-clamp-1">
|
||||||
|
{product.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-white/50 mb-2">
|
||||||
|
Арт: {product.article}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Статус наличия */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-xs text-white/60">
|
||||||
|
{product.quantity} шт.
|
||||||
|
</span>
|
||||||
|
{product.quantity > 0 ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-300 text-xs border border-green-500/30 px-1 py-0">
|
||||||
|
В наличии
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/20 text-red-300 text-xs border border-red-500/30 px-1 py-0">
|
||||||
|
Нет в наличии
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая часть: цена и действия */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{/* Цена */}
|
||||||
|
<div className="text-right mb-2">
|
||||||
|
<div className="text-base font-bold text-red-300">
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Количество и кнопки */}
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{/* Инпут количества */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={getQuantity(product.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
// Разрешаем только цифры и пустое поле
|
||||||
|
if (value === '' || /^\d+$/.test(value)) {
|
||||||
|
const numValue = value === '' ? 0 : parseInt(value)
|
||||||
|
|
||||||
|
// Временно сохраняем даже если 0 или больше лимита для удобства ввода
|
||||||
|
if (value === '' || (numValue >= 0 && numValue <= 99999)) {
|
||||||
|
setQuantity(product.id, numValue || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
// При фокусе выделяем весь текст для удобного редактирования
|
||||||
|
e.target.select()
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// При потере фокуса проверяем и корректируем значение
|
||||||
|
let value = parseInt(e.target.value)
|
||||||
|
if (isNaN(value) || value < 1) {
|
||||||
|
value = 1
|
||||||
|
} else if (value > product.quantity) {
|
||||||
|
value = product.quantity
|
||||||
|
}
|
||||||
|
setQuantity(product.id, value)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter для быстрого добавления в корзину
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addProductToCart(product.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 h-7 text-xs text-center glass-input text-white border-white/20 bg-white/5"
|
||||||
|
disabled={product.quantity === 0}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Кнопка добавления в корзину */}
|
||||||
|
<Button
|
||||||
|
onClick={() => addProductToCart(product.id)}
|
||||||
|
disabled={isLoading || product.quantity === 0}
|
||||||
|
size="sm"
|
||||||
|
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 hover:border-purple-400/50 h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Кнопка удаления из избранного */}
|
||||||
|
<Button
|
||||||
|
onClick={() => removeFromFavoritesList(product.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-400 border-red-500/30 hover:bg-red-500/10 hover:text-red-300 h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<Heart className="h-3 w-3 fill-current" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -4,7 +4,7 @@ import { useQuery } from '@apollo/client'
|
|||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { GET_CATEGORIES, GET_MY_CART } from '@/graphql/queries'
|
import { GET_CATEGORIES, GET_MY_CART } from '@/graphql/queries'
|
||||||
import { Package2, ArrowRight, Sparkles, ShoppingCart } from 'lucide-react'
|
import { Package2, ArrowRight, Sparkles, ShoppingCart, Heart } from 'lucide-react'
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string
|
id: string
|
||||||
@ -16,9 +16,10 @@ interface Category {
|
|||||||
interface MarketCategoriesProps {
|
interface MarketCategoriesProps {
|
||||||
onSelectCategory: (categoryId: string, categoryName: string) => void
|
onSelectCategory: (categoryId: string, categoryName: string) => void
|
||||||
onShowCart?: () => void
|
onShowCart?: () => void
|
||||||
|
onShowFavorites?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketCategories({ onSelectCategory, onShowCart }: MarketCategoriesProps) {
|
export function MarketCategories({ onSelectCategory, onShowCart, onShowFavorites }: MarketCategoriesProps) {
|
||||||
const { data, loading, error } = useQuery(GET_CATEGORIES)
|
const { data, loading, error } = useQuery(GET_CATEGORIES)
|
||||||
const { data: cartData } = useQuery(GET_MY_CART)
|
const { data: cartData } = useQuery(GET_MY_CART)
|
||||||
|
|
||||||
@ -67,16 +68,29 @@ export function MarketCategories({ onSelectCategory, onShowCart }: MarketCategor
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка корзины */}
|
{/* Кнопки корзины и избранного */}
|
||||||
{onShowCart && (
|
<div className="flex items-center space-x-3">
|
||||||
<Button
|
{onShowFavorites && (
|
||||||
onClick={onShowCart}
|
<Button
|
||||||
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"
|
onClick={onShowFavorites}
|
||||||
>
|
variant="outline"
|
||||||
<ShoppingCart className="h-5 w-5 mr-2" />
|
className="bg-gradient-to-r from-red-500/20 to-pink-500/20 hover:from-red-500/30 hover:to-pink-500/30 text-white border-red-500/30 hover:border-red-400/50 transition-all duration-200 shadow-lg px-6 py-3"
|
||||||
Корзина {uniqueItemsCount > 0 && `(${uniqueItemsCount})`}
|
>
|
||||||
</Button>
|
<Heart className="h-5 w-5 mr-2" />
|
||||||
)}
|
Избранное
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Категории */}
|
{/* Категории */}
|
||||||
|
@ -12,9 +12,10 @@ import { MarketWholesale } from './market-wholesale'
|
|||||||
import { MarketProducts } from './market-products'
|
import { MarketProducts } from './market-products'
|
||||||
import { MarketCategories } from './market-categories'
|
import { MarketCategories } from './market-categories'
|
||||||
import { MarketRequests } from './market-requests'
|
import { MarketRequests } from './market-requests'
|
||||||
|
import { FavoritesDashboard } from '../favorites/favorites-dashboard'
|
||||||
|
|
||||||
export function MarketDashboard() {
|
export function MarketDashboard() {
|
||||||
const [productsView, setProductsView] = useState<'categories' | 'products' | 'cart'>('categories')
|
const [productsView, setProductsView] = useState<'categories' | 'products' | 'cart' | 'favorites'>('categories')
|
||||||
const [selectedCategory, setSelectedCategory] = useState<{ id: string; name: string } | null>(null)
|
const [selectedCategory, setSelectedCategory] = useState<{ id: string; name: string } | null>(null)
|
||||||
|
|
||||||
const handleSelectCategory = (categoryId: string, categoryName: string) => {
|
const handleSelectCategory = (categoryId: string, categoryName: string) => {
|
||||||
@ -32,6 +33,11 @@ export function MarketDashboard() {
|
|||||||
setSelectedCategory(null)
|
setSelectedCategory(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleShowFavorites = () => {
|
||||||
|
setProductsView('favorites')
|
||||||
|
setSelectedCategory(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@ -122,15 +128,17 @@ export function MarketDashboard() {
|
|||||||
<TabsContent value="products" className="flex-1 overflow-hidden mt-6">
|
<TabsContent value="products" className="flex-1 overflow-hidden mt-6">
|
||||||
<Card className="glass-card h-full overflow-hidden p-0">
|
<Card className="glass-card h-full overflow-hidden p-0">
|
||||||
{productsView === 'categories' ? (
|
{productsView === 'categories' ? (
|
||||||
<MarketCategories onSelectCategory={handleSelectCategory} onShowCart={handleShowCart} />
|
<MarketCategories onSelectCategory={handleSelectCategory} onShowCart={handleShowCart} onShowFavorites={handleShowFavorites} />
|
||||||
) : productsView === 'products' ? (
|
) : productsView === 'products' ? (
|
||||||
<MarketProducts
|
<MarketProducts
|
||||||
selectedCategoryId={selectedCategory?.id}
|
selectedCategoryId={selectedCategory?.id}
|
||||||
selectedCategoryName={selectedCategory?.name}
|
selectedCategoryName={selectedCategory?.name}
|
||||||
onBackToCategories={handleBackToCategories}
|
onBackToCategories={handleBackToCategories}
|
||||||
/>
|
/>
|
||||||
|
) : productsView === 'cart' ? (
|
||||||
|
<MarketRequests onBackToCategories={handleBackToCategories} />
|
||||||
) : (
|
) : (
|
||||||
<MarketRequests />
|
<FavoritesDashboard onBackToCategories={handleBackToCategories} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
@ -4,9 +4,14 @@ import { useQuery } from '@apollo/client'
|
|||||||
import { CartItems } from '../cart/cart-items'
|
import { CartItems } from '../cart/cart-items'
|
||||||
import { CartSummary } from '../cart/cart-summary'
|
import { CartSummary } from '../cart/cart-summary'
|
||||||
import { GET_MY_CART } from '@/graphql/queries'
|
import { GET_MY_CART } from '@/graphql/queries'
|
||||||
import { ShoppingCart, Package } from 'lucide-react'
|
import { ShoppingCart, Package, ArrowLeft } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export function MarketRequests() {
|
interface MarketRequestsProps {
|
||||||
|
onBackToCategories?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketRequests({ onBackToCategories }: MarketRequestsProps) {
|
||||||
const { data, loading, error } = useQuery(GET_MY_CART)
|
const { data, loading, error } = useQuery(GET_MY_CART)
|
||||||
|
|
||||||
const cart = data?.myCart
|
const cart = data?.myCart
|
||||||
@ -39,6 +44,16 @@ export function MarketRequests() {
|
|||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex items-center space-x-3 p-6 border-b border-white/10">
|
<div className="flex items-center space-x-3 p-6 border-b border-white/10">
|
||||||
|
{onBackToCategories && (
|
||||||
|
<Button
|
||||||
|
onClick={onBackToCategories}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-white/70 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<ShoppingCart className="h-6 w-6 text-purple-400" />
|
<ShoppingCart className="h-6 w-6 text-purple-400" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white">Мои заявки</h1>
|
<h1 className="text-xl font-bold text-white">Мои заявки</h1>
|
||||||
|
@ -8,14 +8,15 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Eye,
|
Eye,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Heart
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { OrganizationAvatar } from './organization-avatar'
|
import { OrganizationAvatar } from './organization-avatar'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useMutation } from '@apollo/client'
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
import { ADD_TO_CART } from '@/graphql/mutations'
|
import { ADD_TO_CART, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES } from '@/graphql/mutations'
|
||||||
import { GET_MY_CART } from '@/graphql/queries'
|
import { GET_MY_CART, GET_MY_FAVORITES } from '@/graphql/queries'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
@ -58,6 +59,11 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||||||
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false)
|
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false)
|
||||||
const [quantity, setQuantity] = useState(1)
|
const [quantity, setQuantity] = useState(1)
|
||||||
|
|
||||||
|
// Запрос избранного для проверки статуса
|
||||||
|
const { data: favoritesData } = useQuery(GET_MY_FAVORITES)
|
||||||
|
const favorites = favoritesData?.myFavorites || []
|
||||||
|
const isFavorite = favorites.some((fav: Product) => fav.id === product.id)
|
||||||
|
|
||||||
const [addToCart, { loading: addingToCart }] = useMutation(ADD_TO_CART, {
|
const [addToCart, { loading: addingToCart }] = useMutation(ADD_TO_CART, {
|
||||||
refetchQueries: [{ query: GET_MY_CART }],
|
refetchQueries: [{ query: GET_MY_CART }],
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
@ -74,6 +80,36 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [addToFavorites, { loading: addingToFavorites }] = useMutation(ADD_TO_FAVORITES, {
|
||||||
|
refetchQueries: [{ query: GET_MY_FAVORITES }],
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.addToFavorites.success) {
|
||||||
|
toast.success(data.addToFavorites.message)
|
||||||
|
} else {
|
||||||
|
toast.error(data.addToFavorites.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Ошибка при добавлении в избранное')
|
||||||
|
console.error('Error adding to favorites:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [removeFromFavorites, { loading: removingFromFavorites }] = useMutation(REMOVE_FROM_FAVORITES, {
|
||||||
|
refetchQueries: [{ query: GET_MY_FAVORITES }],
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.removeFromFavorites.success) {
|
||||||
|
toast.success(data.removeFromFavorites.message)
|
||||||
|
} else {
|
||||||
|
toast.error(data.removeFromFavorites.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Ошибка при удалении из избранного')
|
||||||
|
console.error('Error removing from favorites:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const displayPrice = new Intl.NumberFormat('ru-RU', {
|
const displayPrice = new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'RUB'
|
currency: 'RUB'
|
||||||
@ -96,6 +132,22 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleFavorite = async () => {
|
||||||
|
try {
|
||||||
|
if (isFavorite) {
|
||||||
|
await removeFromFavorites({
|
||||||
|
variables: { productId: product.id }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await addToFavorites({
|
||||||
|
variables: { productId: product.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextImage = () => {
|
const nextImage = () => {
|
||||||
setCurrentImageIndex((prev) => (prev + 1) % images.length)
|
setCurrentImageIndex((prev) => (prev + 1) % images.length)
|
||||||
}
|
}
|
||||||
@ -225,24 +277,59 @@ export function ProductCard({ product }: ProductCardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка добавления в заявки */}
|
{/* Кнопки действий */}
|
||||||
<Button
|
<div className="flex items-center space-x-2">
|
||||||
onClick={handleAddToCart}
|
{/* Кнопка добавления в заявки */}
|
||||||
disabled={addingToCart}
|
<Button
|
||||||
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"
|
onClick={handleAddToCart}
|
||||||
>
|
disabled={addingToCart}
|
||||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
className="flex-1 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"
|
||||||
{addingToCart ? 'Добавление...' : 'В заявки'}
|
>
|
||||||
</Button>
|
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||||
|
{addingToCart ? 'Добавление...' : 'В заявки'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Кнопка избранного */}
|
||||||
|
<Button
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
disabled={addingToFavorites || removingFromFavorites}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`h-8 w-8 p-0 transition-all ${
|
||||||
|
isFavorite
|
||||||
|
? 'bg-red-500/20 border-red-500/30 text-red-400 hover:bg-red-500/30'
|
||||||
|
: 'bg-white/5 border-white/20 text-white/60 hover:bg-red-500/20 hover:border-red-500/30 hover:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div className="flex items-center space-x-2">
|
||||||
disabled
|
<Button
|
||||||
className="w-full h-8 bg-gray-500/20 text-gray-400 border-0 text-xs cursor-not-allowed"
|
disabled
|
||||||
>
|
className="flex-1 h-8 bg-gray-500/20 text-gray-400 border-0 text-xs cursor-not-allowed"
|
||||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
>
|
||||||
Недоступно
|
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||||
</Button>
|
Недоступно
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Кнопка избранного (всегда доступна) */}
|
||||||
|
<Button
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
disabled={addingToFavorites || removingFromFavorites}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`h-8 w-8 p-0 transition-all ${
|
||||||
|
isFavorite
|
||||||
|
? 'bg-red-500/20 border-red-500/30 text-red-400 hover:bg-red-500/30'
|
||||||
|
: 'bg-white/5 border-white/20 text-white/60 hover:bg-red-500/20 hover:border-red-500/30 hover:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -818,3 +818,60 @@ export const CLEAR_CART = gql`
|
|||||||
clearCart
|
clearCart
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Мутации для избранного
|
||||||
|
export const ADD_TO_FAVORITES = gql`
|
||||||
|
mutation AddToFavorites($productId: ID!) {
|
||||||
|
addToFavorites(productId: $productId) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
favorites {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
images
|
||||||
|
mainImage
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const REMOVE_FROM_FAVORITES = gql`
|
||||||
|
mutation RemoveFromFavorites($productId: ID!) {
|
||||||
|
removeFromFavorites(productId: $productId) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
favorites {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
images
|
||||||
|
mainImage
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
@ -437,3 +437,43 @@ export const GET_MY_CART = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const GET_MY_FAVORITES = gql`
|
||||||
|
query GetMyFavorites {
|
||||||
|
myFavorites {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
brand
|
||||||
|
color
|
||||||
|
size
|
||||||
|
images
|
||||||
|
mainImage
|
||||||
|
isActive
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
category {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
inn
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
address
|
||||||
|
phones
|
||||||
|
emails
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
@ -638,6 +638,44 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return cart
|
return cart
|
||||||
|
},
|
||||||
|
|
||||||
|
// Избранные товары пользователя
|
||||||
|
myFavorites: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем избранные товары
|
||||||
|
const favorites = await prisma.favorites.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
organization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return favorites.map(favorite => favorite.product)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -2844,6 +2882,165 @@ export const resolvers = {
|
|||||||
console.error('Error clearing cart:', error)
|
console.error('Error clearing cart:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Добавить товар в избранное
|
||||||
|
addToFavorites: async (_: unknown, args: { productId: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что товар существует и активен
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.productId,
|
||||||
|
isActive: true
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organization: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Товар не найден или неактивен'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь не пытается добавить свой собственный товар
|
||||||
|
if (product.organizationId === currentUser.organization.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Нельзя добавлять собственные товары в избранное'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем, есть ли уже такой товар в избранном
|
||||||
|
const existingFavorite = await prisma.favorites.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationId_productId: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
productId: args.productId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingFavorite) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Товар уже в избранном'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем товар в избранное
|
||||||
|
await prisma.favorites.create({
|
||||||
|
data: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
productId: args.productId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем обновленный список избранного
|
||||||
|
const favorites = await prisma.favorites.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
organization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Товар добавлен в избранное',
|
||||||
|
favorites: favorites.map(favorite => favorite.product)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding to favorites:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при добавлении в избранное'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить товар из избранного
|
||||||
|
removeFromFavorites: async (_: unknown, args: { productId: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем товар из избранного
|
||||||
|
await prisma.favorites.deleteMany({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
productId: args.productId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем обновленный список избранного
|
||||||
|
const favorites = await prisma.favorites.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
organization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Товар удален из избранного',
|
||||||
|
favorites: favorites.map(favorite => favorite.product)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing from favorites:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при удалении из избранного'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -40,6 +40,9 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Корзина пользователя
|
# Корзина пользователя
|
||||||
myCart: Cart
|
myCart: Cart
|
||||||
|
|
||||||
|
# Избранные товары пользователя
|
||||||
|
myFavorites: [Product!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -100,6 +103,10 @@ export const typeDefs = gql`
|
|||||||
updateCartItem(productId: ID!, quantity: Int!): CartResponse!
|
updateCartItem(productId: ID!, quantity: Int!): CartResponse!
|
||||||
removeFromCart(productId: ID!): CartResponse!
|
removeFromCart(productId: ID!): CartResponse!
|
||||||
clearCart: Boolean!
|
clearCart: Boolean!
|
||||||
|
|
||||||
|
# Работа с избранным
|
||||||
|
addToFavorites(productId: ID!): FavoritesResponse!
|
||||||
|
removeFromFavorites(productId: ID!): FavoritesResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Типы данных
|
# Типы данных
|
||||||
@ -456,6 +463,13 @@ export const typeDefs = gql`
|
|||||||
cart: Cart
|
cart: Cart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Типы для избранного
|
||||||
|
type FavoritesResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
favorites: [Product!]
|
||||||
|
}
|
||||||
|
|
||||||
# JSON скаляр
|
# JSON скаляр
|
||||||
scalar JSON
|
scalar JSON
|
||||||
`
|
`
|
Reference in New Issue
Block a user