diff --git a/docker-compose.yml b/docker-compose.yml index 662262f..cfd1612 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: NEXT_PUBLIC_UPLOAD_URL: ${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload} NEXT_PUBLIC_MAINTENANCE_MODE: ${NEXT_PUBLIC_MAINTENANCE_MODE:-false} NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${NEXT_PUBLIC_YANDEX_MAPS_API_KEY} + PARTSAPI_URL: ${PARTSAPI_URL:-https://api.parts-index.com} FRONTEND_PORT: ${FRONTEND_PORT:-3000} NODE_ENV: ${NODE_ENV:-production} container_name: protekauto-frontend @@ -26,7 +27,8 @@ services: - NEXT_PUBLIC_CMS_GRAPHQL_URL=${NEXT_PUBLIC_CMS_GRAPHQL_URL:-http://localhost:4000/graphql} - NEXT_PUBLIC_UPLOAD_URL=${NEXT_PUBLIC_UPLOAD_URL:-http://localhost:4000/upload} - NEXT_PUBLIC_MAINTENANCE_MODE=${NEXT_PUBLIC_MAINTENANCE_MODE:-false} - + - PARTSAPI_URL=${PARTSAPI_URL:-https://api.parts-index.com} + # Yandex Maps API - NEXT_PUBLIC_YANDEX_MAPS_API_KEY=${NEXT_PUBLIC_YANDEX_MAPS_API_KEY} diff --git a/src/components/BestPriceCard.tsx b/src/components/BestPriceCard.tsx index 21d02d6..d900e8c 100644 --- a/src/components/BestPriceCard.tsx +++ b/src/components/BestPriceCard.tsx @@ -72,6 +72,10 @@ const BestPriceCard: React.FC = ({ return parseFloat(cleanPrice) || 0; }; + // Note: BestPriceCard doesn't receive isInCart flags from backend + // Since it's a summary component, we'll remove cart state checking for now + const inCart = false; // Disabled for BestPriceCard + // Обработчик добавления в корзину const handleAddToCart = async (e: React.MouseEvent) => { e.preventDefault(); @@ -108,10 +112,14 @@ const BestPriceCard: React.FC = ({ }); if (result.success) { - // Показываем тоастер об успешном добавлении + // Показываем тоастер с разным текстом в зависимости от того, был ли товар уже в корзине + const toastMessage = inCart + ? `Количество увеличено (+${count} шт.)` + : 'Товар добавлен в корзину!'; + toast.success(
-
Товар добавлен в корзину!
+
{toastMessage}
{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}
, { @@ -176,17 +184,55 @@ const BestPriceCard: React.FC = ({
- +
+ + {inCart && ( +
+ ✓ +
+ )} +
diff --git a/src/components/BestPriceItem.tsx b/src/components/BestPriceItem.tsx index 829dcc8..6821f09 100644 --- a/src/components/BestPriceItem.tsx +++ b/src/components/BestPriceItem.tsx @@ -122,7 +122,6 @@ const BestPriceItem: React.FC = ({ currency: 'RUB', image: image }); - toast.success('Товар добавлен в избранное'); } }; diff --git a/src/components/CartDebug.tsx b/src/components/CartDebug.tsx index 14ec234..7e4512c 100644 --- a/src/components/CartDebug.tsx +++ b/src/components/CartDebug.tsx @@ -1,79 +1,54 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useCart } from '@/contexts/CartContext'; const CartDebug: React.FC = () => { - const { state, addItem, clearCart } = useCart(); - const [debugInfo, setDebugInfo] = useState({}); + const { state, isInCart } = useCart(); - useEffect(() => { - if (typeof window !== 'undefined') { - const cartState = localStorage.getItem('cartState'); - const cartSummaryState = localStorage.getItem('cartSummaryState'); - const oldCart = localStorage.getItem('cart'); - - setDebugInfo({ - cartState: cartState ? JSON.parse(cartState) : null, - cartSummaryState: cartSummaryState ? JSON.parse(cartSummaryState) : null, - oldCart: oldCart ? JSON.parse(oldCart) : null, - currentItems: state.items.length - }); - } - }, [state.items]); + if (process.env.NODE_ENV !== 'development') { + return null; + } - const addTestItem = () => { - addItem({ - name: 'Тестовый товар', - description: 'Описание тестового товара', - article: 'TEST123', - brand: 'TestBrand', - price: 1000, - currency: 'RUB', - quantity: 1, - image: '', - productId: 'test-product', - offerKey: 'test-offer', - isExternal: false - }); - }; - - const clearStorage = () => { - if (typeof window !== 'undefined') { - localStorage.removeItem('cartState'); - localStorage.removeItem('cartSummaryState'); - localStorage.removeItem('cart'); - window.location.reload(); - } - }; + // Test the isInCart function with some example values from the cart + const testItem = state.items[0]; + const testResult = testItem ? isInCart(testItem.productId, testItem.offerKey, testItem.article, testItem.brand) : false; return ( -
-

Cart Debug

- - - -
- Товаров в корзине: {state.items.length} -
-
-        {JSON.stringify(debugInfo, null, 2)}
-      
+
+
🛒 Cart Debug: {state.items.length} items
+ {testItem && ( +
+
Testing isInCart for first item:
+
Brand: {testItem.brand}, Article: {testItem.article}
+
Result: {testResult ? '✅ Found' : '❌ Not found'}
+
+ )} + {state.items.slice(0, 6).map((item, idx) => ( +
+ {item.brand} {item.article} + {item.productId &&
PID: {item.productId.substring(0, 8)}...
} + {item.offerKey &&
OK: {item.offerKey.substring(0, 15)}...
} +
+ ))} + {state.items.length > 6 && ( +
+ ...и еще {state.items.length - 6} товаров +
+ )}
); }; diff --git a/src/components/CatalogProductCard.tsx b/src/components/CatalogProductCard.tsx index 9c69bbb..b778f0e 100644 --- a/src/components/CatalogProductCard.tsx +++ b/src/components/CatalogProductCard.tsx @@ -17,6 +17,7 @@ interface CatalogProductCardProps { currency?: string; priceElement?: React.ReactNode; // Элемент для отображения цены (например, скелетон) onAddToCart?: (e: React.MouseEvent) => void | Promise; + isInCart?: boolean; } const CatalogProductCard: React.FC = ({ @@ -34,6 +35,7 @@ const CatalogProductCard: React.FC = ({ currency = 'RUB', priceElement, onAddToCart, + isInCart = false, }) => { const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites(); @@ -150,7 +152,15 @@ const CatalogProductCard: React.FC = ({ {/* Обновляем кнопку купить */} -
+
@@ -158,7 +168,7 @@ const CatalogProductCard: React.FC = ({
-
Купить
+
{isInCart ? 'В корзине' : 'Купить'}
); diff --git a/src/components/CoreProductCard.tsx b/src/components/CoreProductCard.tsx index 2a8f7ee..b894852 100644 --- a/src/components/CoreProductCard.tsx +++ b/src/components/CoreProductCard.tsx @@ -21,6 +21,8 @@ interface CoreProductCardOffer { warehouse?: string; supplier?: string; deliveryTime?: number; + hasStock?: boolean; + isInCart?: boolean; } interface CoreProductCardProps { @@ -34,6 +36,7 @@ interface CoreProductCardProps { isLoadingOffers?: boolean; onLoadOffers?: () => void; partsIndexPowered?: boolean; + hasStock?: boolean; } const CoreProductCard: React.FC = ({ @@ -46,7 +49,8 @@ const CoreProductCard: React.FC = ({ isAnalog = false, isLoadingOffers = false, onLoadOffers, - partsIndexPowered = false + partsIndexPowered = false, + hasStock = true }) => { const { addItem } = useCart(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites(); @@ -120,6 +124,8 @@ const CoreProductCard: React.FC = ({ brand ); + // Теперь используем isInCart флаг из backend вместо frontend проверки + const handleInputChange = (idx: number, val: string) => { setInputValues(prev => ({ ...prev, [idx]: val })); if (val === "") return; @@ -154,6 +160,7 @@ const CoreProductCard: React.FC = ({ const handleAddToCart = async (offer: CoreProductCardOffer, index: number) => { const quantity = quantities[index] || 1; const availableStock = parseStock(offer.pcs); + const inCart = offer.isInCart || false; // Use backend flag const numericPrice = parsePrice(offer.price); @@ -176,10 +183,14 @@ const CoreProductCard: React.FC = ({ }); if (result.success) { - // Показываем тоастер вместо alert + // Показываем тоастер с разным текстом в зависимости от того, был ли товар уже в корзине + const toastMessage = inCart + ? `Количество увеличено (+${quantity} шт.)` + : 'Товар добавлен в корзину!'; + toast.success(
-
Товар добавлен в корзину!
+
{toastMessage}
{`${brand} ${article} (${quantity} шт.)`}
, { @@ -256,7 +267,7 @@ const CoreProductCard: React.FC = ({ if (!offers || offers.length === 0) { return ( -
+
@@ -266,6 +277,19 @@ const CoreProductCard: React.FC = ({

{brand}

{article}

+ {!hasStock && ( + + Нет в наличии + + )}
{name}
@@ -299,7 +323,7 @@ const CoreProductCard: React.FC = ({ return ( <> -
+
@@ -315,6 +339,19 @@ const CoreProductCard: React.FC = ({

{brand}

{article}

+ {!hasStock && ( + + Нет в наличии + + )}
= ({ {displayedOffers.map((offer, idx) => { const isLast = idx === displayedOffers.length - 1; const maxCount = parseStock(offer.pcs); + const inCart = offer.isInCart || false; // Use backend flag + + // Backend now provides isInCart flag directly + return (
= ({
- +
+ + {inCart && ( +
+ ✓ +
+ )} + +
diff --git a/src/components/TopSalesItem.tsx b/src/components/TopSalesItem.tsx index d009c83..3c17e33 100644 --- a/src/components/TopSalesItem.tsx +++ b/src/components/TopSalesItem.tsx @@ -89,7 +89,6 @@ const TopSalesItem: React.FC = ({ currency: 'RUB', image: image }); - toast.success('Товар добавлен в избранное'); } }; diff --git a/src/components/profile/LegalEntityFormBlock.tsx b/src/components/profile/LegalEntityFormBlock.tsx index e47e3ee..b5f0251 100644 --- a/src/components/profile/LegalEntityFormBlock.tsx +++ b/src/components/profile/LegalEntityFormBlock.tsx @@ -112,6 +112,19 @@ const LegalEntityFormBlock: React.FC = ({ onAdd, onCancel, }) => { + // Состояния для отображения ошибок валидации + const [validationErrors, setValidationErrors] = React.useState({ + inn: false, + shortName: false, + jurAddress: false, + form: false, + taxSystem: false, + }); + + // Функция для очистки ошибки при изменении поля + const clearError = (field: keyof typeof validationErrors) => { + setValidationErrors(prev => ({ ...prev, [field]: false })); + }; const [createLegalEntity, { loading: createLoading }] = useMutation(CREATE_CLIENT_LEGAL_ENTITY, { onCompleted: () => { console.log('Юридическое лицо создано'); @@ -137,29 +150,27 @@ const LegalEntityFormBlock: React.FC = ({ const loading = createLoading || updateLoading; const handleSave = async () => { - // Валидация - if (!inn || inn.length < 10) { - alert('Введите корректный ИНН'); - return; - } + // Сброс предыдущих ошибок + setValidationErrors({ + inn: false, + shortName: false, + jurAddress: false, + form: false, + taxSystem: false, + }); - if (!shortName.trim()) { - alert('Введите краткое наименование'); - return; - } + // Валидация с установкой ошибок + const errors = { + inn: !inn || inn.length < 10, + shortName: !shortName.trim(), + jurAddress: !jurAddress.trim(), + form: form === 'Выбрать', + taxSystem: taxSystem === 'Выбрать', + }; - if (!jurAddress.trim()) { - alert('Введите юридический адрес'); - return; - } - - if (form === 'Выбрать') { - alert('Выберите форму организации'); - return; - } - - if (taxSystem === 'Выбрать') { - alert('Выберите систему налогообложения'); + // Если есть ошибки, устанавливаем их и прерываем выполнение + if (Object.values(errors).some(error => error)) { + setValidationErrors(errors); return; } @@ -238,13 +249,18 @@ const LegalEntityFormBlock: React.FC = ({
ИНН
-
+
setInn(e.target.value)} + onChange={e => { + setInn(e.target.value); + clearError('inn'); + }} />
@@ -252,7 +268,9 @@ const LegalEntityFormBlock: React.FC = ({
Форма
setIsFormOpen((prev: boolean) => !prev)} tabIndex={0} onBlur={() => setIsFormOpen(false)} @@ -266,7 +284,11 @@ const LegalEntityFormBlock: React.FC = ({
  • { setForm(option); setIsFormOpen(false); }} + onMouseDown={() => { + setForm(option); + setIsFormOpen(false); + clearError('form'); + }} > {option}
  • @@ -303,25 +325,35 @@ const LegalEntityFormBlock: React.FC = ({
    Юридический адрес
    -
    +
    setJurAddress(e.target.value)} + onChange={e => { + setJurAddress(e.target.value); + clearError('jurAddress'); + }} />
    Краткое наименование
    -
    +
    setShortName(e.target.value)} + onChange={e => { + setShortName(e.target.value); + clearError('shortName'); + }} />
    @@ -355,7 +387,9 @@ const LegalEntityFormBlock: React.FC = ({
    Система налогоблажения
    setIsTaxSystemOpen((prev: boolean) => !prev)} tabIndex={0} onBlur={() => setIsTaxSystemOpen(false)} @@ -369,7 +403,11 @@ const LegalEntityFormBlock: React.FC = ({
  • { setTaxSystem(option); setIsTaxSystemOpen(false); }} + onMouseDown={() => { + setTaxSystem(option); + setIsTaxSystemOpen(false); + clearError('taxSystem'); + }} > {option}
  • diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx index ba67f2a..9971473 100644 --- a/src/contexts/CartContext.tsx +++ b/src/contexts/CartContext.tsx @@ -1,7 +1,10 @@ 'use client' -import React, { createContext, useContext, useReducer, useEffect, useState } from 'react' +import React, { createContext, useContext, useState, useEffect } from 'react' +import { useMutation, useQuery } from '@apollo/client' import { CartState, CartContextType, CartItem, DeliveryInfo } from '@/types/cart' +import { ADD_TO_CART, REMOVE_FROM_CART, UPDATE_CART_ITEM_QUANTITY, CLEAR_CART, GET_CART } from '@/lib/graphql' +import { toast } from 'react-hot-toast' // Начальное состояние корзины const initialState: CartState = { @@ -22,51 +25,53 @@ const initialState: CartState = { isLoading: false } -// Типы действий -type CartAction = - | { type: 'ADD_ITEM'; payload: Omit } - | { type: 'ADD_ITEM_SUCCESS'; payload: { items: CartItem[]; summary: any } } - | { type: 'ADD_ITEM_ERROR'; payload: string } - | { type: 'REMOVE_ITEM'; payload: string } - | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } } - | { type: 'TOGGLE_SELECT'; payload: string } - | { type: 'TOGGLE_FAVORITE'; payload: string } - | { type: 'UPDATE_COMMENT'; payload: { id: string; comment: string } } - | { type: 'UPDATE_ORDER_COMMENT'; payload: string } - | { type: 'SELECT_ALL' } - | { type: 'REMOVE_ALL' } - | { type: 'REMOVE_SELECTED' } - | { type: 'UPDATE_DELIVERY'; payload: Partial } - | { type: 'CLEAR_CART' } - | { type: 'LOAD_CART'; payload: CartItem[] } - | { type: 'LOAD_FULL_STATE'; payload: { items: CartItem[]; delivery: DeliveryInfo; orderComment: string } } - | { type: 'SET_LOADING'; payload: boolean } - | { type: 'SET_ERROR'; payload: string } - -// Функция для генерации ID -const generateId = () => Math.random().toString(36).substr(2, 9) +// Создаем контекст +const CartContext = createContext(undefined) // Утилитарная функция для парсинга количества в наличии const parseStock = (stockStr: string | number | undefined): number => { - if (typeof stockStr === 'number') return stockStr; + if (stockStr === undefined || stockStr === null) return 0 + if (typeof stockStr === 'number') return stockStr if (typeof stockStr === 'string') { - const match = stockStr.match(/\d+/); - return match ? parseInt(match[0]) : 0; + // Извлекаем числа из строки типа "10 шт" или "В наличии: 5" + const match = stockStr.match(/\d+/) + return match ? parseInt(match[0], 10) : 0 } - return 0; -}; + return 0 +} -// Функция для расчета итогов -const calculateSummary = (items: CartItem[], deliveryPrice: number) => { - const selectedItems = items.filter(item => item.selected) - const totalItems = selectedItems.reduce((sum, item) => sum + item.quantity, 0) - const totalPrice = selectedItems.reduce((sum, item) => sum + (item.price * item.quantity), 0) - const totalDiscount = selectedItems.reduce((sum, item) => { - const discount = item.originalPrice ? (item.originalPrice - item.price) * item.quantity : 0 - return sum + discount - }, 0) - // Доставка включена в стоимость товаров, поэтому добавляем её только если есть товары - const finalPrice = totalPrice + (totalPrice > 0 ? 0 : 0) // Доставка всегда включена в цену товаров +// Функция для преобразования backend cart items в frontend format +const transformBackendItems = (backendItems: any[]): CartItem[] => { + return backendItems.map(item => ({ + id: item.id, + productId: item.productId, + offerKey: item.offerKey, + name: item.name, + description: item.description, + brand: item.brand, + article: item.article, + price: item.price, + currency: item.currency || 'RUB', + quantity: item.quantity, + stock: item.stock, + deliveryTime: item.deliveryTime, + warehouse: item.warehouse, + supplier: item.supplier, + isExternal: item.isExternal, + image: item.image, + selected: true, + favorite: false, + comment: '' + })) +} + +// Функция для подсчета статистики корзины +const calculateSummary = (items: CartItem[]) => { + const totalItems = items.reduce((sum, item) => sum + item.quantity, 0) + const totalPrice = items.reduce((sum, item) => sum + (item.price * item.quantity), 0) + const totalDiscount = 0 // TODO: Implement discount logic + const deliveryPrice = 39 + const finalPrice = totalPrice + deliveryPrice - totalDiscount return { totalItems, @@ -77,373 +82,317 @@ const calculateSummary = (items: CartItem[], deliveryPrice: number) => { } } -// Редьюсер корзины -const cartReducer = (state: CartState, action: CartAction): CartState => { - switch (action.type) { - case 'ADD_ITEM': { - const existingItemIndex = state.items.findIndex( - item => - (item.productId && item.productId === action.payload.productId) || - (item.offerKey && item.offerKey === action.payload.offerKey) - ) - - let newItems: CartItem[] - - if (existingItemIndex >= 0) { - // Увеличиваем количество существующего товара - const existingItem = state.items[existingItemIndex]; - const totalQuantity = existingItem.quantity + action.payload.quantity; - - newItems = state.items.map((item, index) => - index === existingItemIndex - ? { ...item, quantity: totalQuantity } - : item - ) - } else { - // Добавляем новый товар - const newItem: CartItem = { - ...action.payload, - id: generateId(), - selected: true, - favorite: false - } - newItems = [...state.items, newItem] - } - - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'REMOVE_ITEM': { - const newItems = state.items.filter(item => item.id !== action.payload) - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'UPDATE_QUANTITY': { - const newItems = state.items.map(item => - item.id === action.payload.id - ? { ...item, quantity: Math.max(1, action.payload.quantity) } - : item - ) - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'TOGGLE_SELECT': { - const newItems = state.items.map(item => - item.id === action.payload - ? { ...item, selected: !item.selected } - : item - ) - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'TOGGLE_FAVORITE': { - const newItems = state.items.map(item => - item.id === action.payload - ? { ...item, favorite: !item.favorite } - : item - ) - - return { - ...state, - items: newItems - } - } - - case 'UPDATE_COMMENT': { - const newItems = state.items.map(item => - item.id === action.payload.id - ? { ...item, comment: action.payload.comment } - : item - ) - - return { - ...state, - items: newItems - } - } - - case 'UPDATE_ORDER_COMMENT': { - return { - ...state, - orderComment: action.payload - } - } - - case 'SELECT_ALL': { - const allSelected = state.items.every(item => item.selected) - const newItems = state.items.map(item => ({ - ...item, - selected: !allSelected - })) - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'REMOVE_ALL': { - const newSummary = calculateSummary([], state.delivery.price) - - return { - ...state, - items: [], - summary: newSummary - } - } - - case 'REMOVE_SELECTED': { - const newItems = state.items.filter(item => !item.selected) - const newSummary = calculateSummary(newItems, state.delivery.price) - - return { - ...state, - items: newItems, - summary: newSummary - } - } - - case 'UPDATE_DELIVERY': { - const newDelivery = { ...state.delivery, ...action.payload } - const newSummary = calculateSummary(state.items, newDelivery.price) - - return { - ...state, - delivery: newDelivery, - summary: newSummary - } - } - - case 'CLEAR_CART': { - const newSummary = calculateSummary([], state.delivery.price) - - return { - ...state, - items: [], - summary: newSummary - } - } - - case 'LOAD_CART': { - const newSummary = calculateSummary(action.payload, state.delivery.price) - - return { - ...state, - items: action.payload, - summary: newSummary - } - } - - case 'LOAD_FULL_STATE': { - const newSummary = calculateSummary(action.payload.items, action.payload.delivery.price || state.delivery.price) - - return { - ...state, - items: action.payload.items, - delivery: action.payload.delivery, - orderComment: action.payload.orderComment, - summary: newSummary - } - } - - case 'SET_LOADING': { - return { - ...state, - isLoading: action.payload - } - } - - case 'SET_ERROR': { - return { - ...state, - error: action.payload, - isLoading: false - } - } - - default: - return state - } -} - -// Создание контекста -const CartContext = createContext(undefined) - -// Провайдер корзины +// Провайдер контекста export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [state, dispatch] = useReducer(cartReducer, initialState) - const [isInitialized, setIsInitialized] = useState(false) + const [state, setState] = useState(initialState) + const [error, setError] = useState('') - // Загрузка корзины из localStorage при инициализации + // GraphQL operations + const { data: cartData, loading: cartLoading, refetch: refetchCart } = useQuery(GET_CART, { + errorPolicy: 'ignore' // Don't show errors for unauthenticated users + }) + + const [addToCartMutation] = useMutation(ADD_TO_CART) + const [removeFromCartMutation] = useMutation(REMOVE_FROM_CART) + const [updateQuantityMutation] = useMutation(UPDATE_CART_ITEM_QUANTITY) + const [clearCartMutation] = useMutation(CLEAR_CART) + + // Load cart from backend when component mounts or cart data changes useEffect(() => { - if (typeof window === 'undefined') return + if (cartData?.getCart) { + const backendItems = transformBackendItems(cartData.getCart.items) + const summary = calculateSummary(backendItems) + + setState(prev => ({ + ...prev, + items: backendItems, + summary, + isLoading: false + })) + } else { + setState(prev => ({ + ...prev, + items: [], + summary: calculateSummary([]), + isLoading: false + })) + } + }, [cartData]) - console.log('🔄 Загружаем состояние корзины из localStorage...') - - const savedCartState = localStorage.getItem('cartState') - if (savedCartState) { - try { - const cartState = JSON.parse(savedCartState) - console.log('✅ Найдено сохраненное состояние корзины:', cartState) - // Загружаем полное состояние корзины - dispatch({ type: 'LOAD_FULL_STATE', payload: cartState }) - } catch (error) { - console.error('❌ Ошибка загрузки корзины из localStorage:', error) - // Попытаемся загрузить старый формат (только товары) - const savedCart = localStorage.getItem('cart') - if (savedCart) { - try { - const cartItems = JSON.parse(savedCart) - console.log('✅ Найдены товары в старом формате:', cartItems) - dispatch({ type: 'LOAD_CART', payload: cartItems }) - } catch (error) { - console.error('❌ Ошибка загрузки старой корзины:', error) + // Set loading state + useEffect(() => { + setState(prev => ({ + ...prev, + isLoading: cartLoading + })) + }, [cartLoading]) + + // GraphQL-based cart operations + const addItem = async (item: Omit) => { + try { + setError('') + setState(prev => ({ ...prev, isLoading: true })) + + console.log('🛒 Adding item to backend cart:', item) + + const { data } = await addToCartMutation({ + variables: { + input: { + productId: item.productId || null, + offerKey: item.offerKey || null, + name: item.name, + description: item.description, + brand: item.brand, + article: item.article, + price: item.price, + currency: item.currency || 'RUB', + quantity: item.quantity, + stock: item.stock || null, + deliveryTime: item.deliveryTime || null, + warehouse: item.warehouse || null, + supplier: item.supplier || null, + isExternal: item.isExternal || false, + image: item.image || null } } + }) + + if (data?.addToCart?.success) { + // Update local state with backend response + if (data.addToCart.cart) { + const backendItems = transformBackendItems(data.addToCart.cart.items) + const summary = calculateSummary(backendItems) + + setState(prev => ({ + ...prev, + items: backendItems, + summary, + isLoading: false + })) + } + + + + // Refetch to ensure data consistency + refetchCart() + + return { success: true } + } else { + const errorMessage = data?.addToCart?.error || 'Ошибка добавления товара' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + return { success: false, error: errorMessage } } - } else { - console.log('ℹ️ Сохраненное состояние корзины не найдено') + } catch (error) { + console.error('❌ Error adding item to cart:', error) + const errorMessage = 'Ошибка добавления товара в корзину' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + return { success: false, error: errorMessage } } - - setIsInitialized(true) - }, []) - - // Сохранение полного состояния корзины в localStorage при изменении (только после инициализации) - useEffect(() => { - if (!isInitialized || typeof window === 'undefined') return - - const stateToSave = { - items: state.items, - delivery: state.delivery, - orderComment: state.orderComment - } - - console.log('💾 Сохраняем состояние корзины:', stateToSave) - localStorage.setItem('cartState', JSON.stringify(stateToSave)) - // Сохраняем также старый формат для совместимости - localStorage.setItem('cart', JSON.stringify(state.items)) - }, [state.items, state.delivery, state.orderComment, isInitialized]) - - // Функции для работы с корзиной - const addItem = async (item: Omit) => { - // Проверяем наличие товара на складе перед добавлением - const existingItemIndex = state.items.findIndex( - existingItem => - (existingItem.productId && existingItem.productId === item.productId) || - (existingItem.offerKey && existingItem.offerKey === item.offerKey) - ) - - let totalQuantity = item.quantity; - if (existingItemIndex >= 0) { - const existingItem = state.items[existingItemIndex]; - totalQuantity = existingItem.quantity + item.quantity; - } - - // Проверяем наличие товара на складе - const availableStock = parseStock(item.stock); - if (availableStock > 0 && totalQuantity > availableStock) { - const errorMessage = `Недостаточно товара в наличии. Доступно: ${availableStock} шт., запрошено: ${totalQuantity} шт.`; - dispatch({ type: 'SET_ERROR', payload: errorMessage }); - return { success: false, error: errorMessage }; - } - - // Если проверка прошла успешно, добавляем товар - dispatch({ type: 'ADD_ITEM', payload: item }) - return { success: true } } - const removeItem = (id: string) => { - dispatch({ type: 'REMOVE_ITEM', payload: id }) - } + const removeItem = async (id: string) => { + try { + setError('') + setState(prev => ({ ...prev, isLoading: true })) - const updateQuantity = (id: string, quantity: number) => { - // Найдем товар для проверки наличия - const item = state.items.find(item => item.id === id); - if (item) { - const availableStock = parseStock(item.stock); - if (availableStock > 0 && quantity > availableStock) { - // Показываем ошибку, но не изменяем количество - dispatch({ type: 'SET_ERROR', payload: `Недостаточно товара в наличии. Доступно: ${availableStock} шт.` }); - return; + console.log('🗑️ Removing item from backend cart:', id) + + const { data } = await removeFromCartMutation({ + variables: { itemId: id } + }) + + if (data?.removeFromCart?.success) { + // Update local state + if (data.removeFromCart.cart) { + const backendItems = transformBackendItems(data.removeFromCart.cart.items) + const summary = calculateSummary(backendItems) + + setState(prev => ({ + ...prev, + items: backendItems, + summary, + isLoading: false + })) + } + + toast.success(data.removeFromCart.message || 'Товар удален из корзины') + refetchCart() + } else { + const errorMessage = data?.removeFromCart?.error || 'Ошибка удаления товара' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) } + } catch (error) { + console.error('❌ Error removing item from cart:', error) + const errorMessage = 'Ошибка удаления товара из корзины' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) } - - dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }) } + const updateQuantity = async (id: string, quantity: number) => { + try { + if (quantity < 1) return + + setError('') + setState(prev => ({ ...prev, isLoading: true })) + + console.log('📝 Updating item quantity in backend cart:', id, quantity) + + const { data } = await updateQuantityMutation({ + variables: { itemId: id, quantity } + }) + + if (data?.updateCartItemQuantity?.success) { + // Update local state + if (data.updateCartItemQuantity.cart) { + const backendItems = transformBackendItems(data.updateCartItemQuantity.cart.items) + const summary = calculateSummary(backendItems) + + setState(prev => ({ + ...prev, + items: backendItems, + summary, + isLoading: false + })) + } + + toast.success(data.updateCartItemQuantity.message || 'Количество обновлено') + refetchCart() + } else { + const errorMessage = data?.updateCartItemQuantity?.error || 'Ошибка обновления количества' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + } + } catch (error) { + console.error('❌ Error updating item quantity:', error) + const errorMessage = 'Ошибка обновления количества товара' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + } + } + + const clearCart = async () => { + try { + setError('') + setState(prev => ({ ...prev, isLoading: true })) + + console.log('🧹 Clearing backend cart') + + const { data } = await clearCartMutation() + + if (data?.clearCart?.success) { + setState(prev => ({ + ...prev, + items: [], + summary: calculateSummary([]), + isLoading: false + })) + + toast.success(data.clearCart.message || 'Корзина очищена') + refetchCart() + } else { + const errorMessage = data?.clearCart?.error || 'Ошибка очистки корзины' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + } + } catch (error) { + console.error('❌ Error clearing cart:', error) + const errorMessage = 'Ошибка очистки корзины' + setError(errorMessage) + setState(prev => ({ ...prev, isLoading: false })) + toast.error(errorMessage) + } + } + + // Local-only operations (not synced with backend) const toggleSelect = (id: string) => { - dispatch({ type: 'TOGGLE_SELECT', payload: id }) + setState(prev => ({ + ...prev, + items: prev.items.map(item => + item.id === id ? { ...item, selected: !item.selected } : item + ) + })) } const toggleFavorite = (id: string) => { - dispatch({ type: 'TOGGLE_FAVORITE', payload: id }) + setState(prev => ({ + ...prev, + items: prev.items.map(item => + item.id === id ? { ...item, favorite: !item.favorite } : item + ) + })) } const updateComment = (id: string, comment: string) => { - dispatch({ type: 'UPDATE_COMMENT', payload: { id, comment } }) + setState(prev => ({ + ...prev, + items: prev.items.map(item => + item.id === id ? { ...item, comment } : item + ) + })) } const updateOrderComment = (comment: string) => { - dispatch({ type: 'UPDATE_ORDER_COMMENT', payload: comment }) + setState(prev => ({ + ...prev, + orderComment: comment + })) } const selectAll = () => { - dispatch({ type: 'SELECT_ALL' }) + setState(prev => ({ + ...prev, + items: prev.items.map(item => ({ ...item, selected: true })) + })) } const removeAll = () => { - dispatch({ type: 'REMOVE_ALL' }) + clearCart() } - const removeSelected = () => { - dispatch({ type: 'REMOVE_SELECTED' }) - } - - const updateDelivery = (delivery: Partial) => { - dispatch({ type: 'UPDATE_DELIVERY', payload: delivery }) - } - - const clearCart = () => { - dispatch({ type: 'CLEAR_CART' }) - // Очищаем localStorage при очистке корзины - if (typeof window !== 'undefined') { - localStorage.removeItem('cartState') - localStorage.removeItem('cart') + const removeSelected = async () => { + const selectedItems = state.items.filter(item => item.selected) + for (const item of selectedItems) { + await removeItem(item.id) } } + const updateDelivery = (delivery: Partial) => { + setState(prev => ({ + ...prev, + delivery: { ...prev.delivery, ...delivery } + })) + } + const clearError = () => { - dispatch({ type: 'SET_ERROR', payload: '' }) + setError('') + } + + // Check if item is in cart (using backend data) + const isInCart = (productId?: string, offerKey?: string, article?: string, brand?: string): boolean => { + return state.items.some(item => { + if (productId && item.productId === productId) return true + if (offerKey && item.offerKey === offerKey) return true + if (article && brand && item.article === article && item.brand === brand) return true + return false + }) } const contextValue: CartContextType = { - state, + state: { + ...state, + error + }, addItem, removeItem, updateQuantity, @@ -456,7 +405,8 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children removeSelected, updateDelivery, clearCart, - clearError + clearError, + isInCart } return ( @@ -466,7 +416,6 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children ) } - // Хук для использования контекста корзины export const useCart = (): CartContextType => { const context = useContext(CartContext) diff --git a/src/hooks/useProductPrices.ts b/src/hooks/useProductPrices.ts index e357405..06a1488 100644 --- a/src/hooks/useProductPrices.ts +++ b/src/hooks/useProductPrices.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { useLazyQuery } from '@apollo/client'; import { SEARCH_PRODUCT_OFFERS } from '@/lib/graphql'; +import { useCart } from '@/contexts/CartContext'; interface ProductOffer { offerKey: string; @@ -15,6 +16,7 @@ interface ProductOffer { warehouse: string; supplier: string; canPurchase: boolean; + isInCart: boolean; } interface ProductPriceData { @@ -25,12 +27,22 @@ interface ProductPriceData { externalOffers: ProductOffer[]; analogs: number; hasInternalStock: boolean; + isInCart: boolean; }; } +interface CartItemInput { + productId?: string; + offerKey?: string; + article: string; + brand: string; + quantity: number; +} + interface ProductPriceVariables { articleNumber: string; brand: string; + cartItems?: CartItemInput[]; } export const useProductPrices = () => { @@ -38,6 +50,7 @@ export const useProductPrices = () => { const [loadingPrices, setLoadingPrices] = useState>(new Set()); const [loadedPrices, setLoadedPrices] = useState>(new Set()); + const { state: cartState } = useCart(); const [searchOffers] = useLazyQuery(SEARCH_PRODUCT_OFFERS); const loadPrice = useCallback(async (product: { code: string; brand: string; id: string }) => { @@ -52,10 +65,22 @@ export const useProductPrices = () => { setLoadingPrices(prev => new Set([...prev, key])); try { + // Преобразуем товары корзины в формат для запроса + const cartItems: CartItemInput[] = cartState.items + .filter(item => item.article && item.brand) // Фильтруем товары с обязательными полями + .map(item => ({ + productId: item.productId, + offerKey: item.offerKey, + article: item.article!, + brand: item.brand!, + quantity: item.quantity + })); + const result = await searchOffers({ variables: { articleNumber: product.code, - brand: product.brand + brand: product.brand, + cartItems } }); diff --git a/src/lib/apollo.ts b/src/lib/apollo.ts index 15383a5..b73d250 100644 --- a/src/lib/apollo.ts +++ b/src/lib/apollo.ts @@ -20,16 +20,25 @@ const authLink = setContext((_, { headers }) => { const user = JSON.parse(userData); // Создаем токен в формате, который ожидает CMS token = `client_${user.id}`; - console.log('Apollo Client: создан токен:', token); - console.log('Apollo Client: user data:', user); - console.log('Apollo Client: заголовки:', { authorization: `Bearer ${token}` }); + console.log('Apollo Client: создан токен для авторизованного пользователя:', token); } catch (error) { console.error('Apollo Client: ошибка парсинга userData:', error); localStorage.removeItem('userData'); localStorage.removeItem('authToken'); } - } else { - console.log('Apollo Client: userData не найден в localStorage'); + } + + // Если нет авторизованного пользователя, создаем анонимную сессию для корзины + if (!token) { + let sessionId = localStorage.getItem('anonymousSessionId'); + if (!sessionId) { + // Генерируем уникальный ID сессии + sessionId = 'anon_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now(); + localStorage.setItem('anonymousSessionId', sessionId); + console.log('Apollo Client: создана новая анонимная сессия:', sessionId); + } + token = `client_${sessionId}`; + console.log('Apollo Client: используется анонимная сессия:', token); } } diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index 0620b73..5c28897 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -1125,14 +1125,25 @@ export const GET_LAXIMO_UNIT_IMAGE_MAP = gql` ` export const SEARCH_PRODUCT_OFFERS = gql` - query SearchProductOffers($articleNumber: String!, $brand: String!) { - searchProductOffers(articleNumber: $articleNumber, brand: $brand) { + query SearchProductOffers($articleNumber: String!, $brand: String!, $cartItems: [CartItemInput!]) { + searchProductOffers(articleNumber: $articleNumber, brand: $brand, cartItems: $cartItems) { articleNumber brand name description hasInternalStock totalOffers + isInCart + stockCalculation { + totalInternalStock + totalExternalStock + availableInternalOffers + availableExternalOffers + hasInternalStock + hasExternalStock + totalStock + hasAnyStock + } images { id url @@ -1168,6 +1179,7 @@ export const SEARCH_PRODUCT_OFFERS = gql` available rating supplier + isInCart } externalOffers { offerKey @@ -1185,6 +1197,7 @@ export const SEARCH_PRODUCT_OFFERS = gql` weight volume canPurchase + isInCart } analogs { brand @@ -1192,6 +1205,16 @@ export const SEARCH_PRODUCT_OFFERS = gql` name type } + stockCalculation { + totalInternalStock + totalExternalStock + availableInternalOffers + availableExternalOffers + hasInternalStock + hasExternalStock + totalStock + hasAnyStock + } } } ` @@ -1738,4 +1761,164 @@ export const GET_NEW_ARRIVALS = gql` } } } -` \ No newline at end of file +`; + +// Cart mutations and queries +export const GET_CART = gql` + query GetCart { + getCart { + id + clientId + items { + id + productId + offerKey + name + description + brand + article + price + currency + quantity + stock + deliveryTime + warehouse + supplier + isExternal + image + createdAt + updatedAt + } + createdAt + updatedAt + } + } +`; + +export const ADD_TO_CART = gql` + mutation AddToCart($input: AddToCartInput!) { + addToCart(input: $input) { + success + message + error + cart { + id + clientId + items { + id + productId + offerKey + name + description + brand + article + price + currency + quantity + stock + deliveryTime + warehouse + supplier + isExternal + image + createdAt + updatedAt + } + createdAt + updatedAt + } + } + } +`; + +export const REMOVE_FROM_CART = gql` + mutation RemoveFromCart($itemId: ID!) { + removeFromCart(itemId: $itemId) { + success + message + error + cart { + id + clientId + items { + id + productId + offerKey + name + description + brand + article + price + currency + quantity + stock + deliveryTime + warehouse + supplier + isExternal + image + } + createdAt + updatedAt + } + } + } +`; + +export const UPDATE_CART_ITEM_QUANTITY = gql` + mutation UpdateCartItemQuantity($itemId: ID!, $quantity: Int!) { + updateCartItemQuantity(itemId: $itemId, quantity: $quantity) { + success + message + error + cart { + id + clientId + items { + id + productId + offerKey + name + description + brand + article + price + currency + quantity + stock + deliveryTime + warehouse + supplier + isExternal + image + } + createdAt + updatedAt + } + } + } +`; + +export const CLEAR_CART = gql` + mutation ClearCart { + clearCart { + success + message + error + cart { + id + clientId + items { + id + name + brand + article + quantity + price + } + createdAt + updatedAt + } + } + } +`; \ No newline at end of file diff --git a/src/lib/partsindex-service.ts b/src/lib/partsindex-service.ts index d68740b..f5f59cc 100644 --- a/src/lib/partsindex-service.ts +++ b/src/lib/partsindex-service.ts @@ -1,8 +1,14 @@ import { PartsIndexCatalogsResponse, PartsIndexGroup, PartsIndexEntityInfoResponse } from '@/types/partsindex'; -const PARTS_INDEX_API_BASE = process.env.PARTSAPI_URL+"/v1" || 'https://api.parts-index.com/v1'; +const PARTS_INDEX_API_BASE = process.env.PARTSAPI_URL || 'https://api.parts-index.com'; const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; +// Debug logging for development +if (process.env.NODE_ENV === 'development') { + console.log('🔍 PartsIndex API Base URL:', PARTS_INDEX_API_BASE); + console.log('🔍 Environment variable NEXT_PUBLIC_PARTSAPI_URL:', process.env.NEXT_PUBLIC_PARTSAPI_URL); +} + class PartsIndexService { /** * Получить список каталогов diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 2d423e4..fe892d4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -58,14 +58,12 @@ export default function App({ Component, pageProps }: AppProps) { style: { background: '#363636', color: '#fff', - marginTop: '80px', // Отступ сверху, чтобы не закрывать кнопки меню }, success: { duration: 3000, style: { background: '#22c55e', // Зеленый фон для успешных уведомлений color: '#fff', // Белый текст - marginTop: '80px', // Отступ сверху для успешных уведомлений }, iconTheme: { primary: '#22c55e', @@ -75,7 +73,8 @@ export default function App({ Component, pageProps }: AppProps) { error: { duration: 5000, style: { - marginTop: '80px', // Отступ сверху для ошибок + background: '#ef4444', + color: '#fff', }, iconTheme: { primary: '#ef4444', @@ -83,6 +82,9 @@ export default function App({ Component, pageProps }: AppProps) { }, }, }} + containerStyle={{ + top: '80px', // Отступ для всего контейнера toast'ов + }} />