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

This commit is contained in:
Bivekich
2025-07-17 19:36:41 +03:00
parent f377fbab5f
commit 3d28051bde
12 changed files with 1074 additions and 141 deletions

View File

@ -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")
}

View File

@ -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>
))} ))}

View 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>
)
}

View 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>
)
}

View File

@ -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>
{/* Категории */} {/* Категории */}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}
}
}
`

View File

@ -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
}
}
}
}
`

View File

@ -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: 'Ошибка при удалении из избранного'
}
}
} }
}, },

View File

@ -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
` `