Добавлены модели и функциональность для работы с избранными товарами, включая мутации и запросы в GraphQL. Обновлены компоненты для отображения и управления избранным, улучшен интерфейс взаимодействия с пользователем. Реализована логика добавления и удаления товаров из избранного.
This commit is contained in:
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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user