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

This commit is contained in:
Bivekich
2025-06-29 03:36:21 +03:00
parent d268bb3359
commit 7f91da525f
23 changed files with 685 additions and 780 deletions

View File

@ -1,117 +0,0 @@
# Интеграция Parts Index API
## Описание
В проект добавлена интеграция с Parts Index API для отображения детальной информации о деталях, включая:
- Главную фотографию детали
- Штрих-коды
- Технические характеристики
- Категории
- Дополнительные изображения
- Логотип "powered by Parts Index"
## Реализованные компоненты
### 1. Типы (`src/types/partsindex.ts`)
- `PartsIndexEntityInfo` - информация о детали
- `PartsIndexEntityInfoResponse` - ответ API
- `PartsIndexEntityInfoVariables` - параметры запроса
### 2. Сервис (`src/lib/partsindex-service.ts`)
- `getEntityInfo(code, brand?, lang?)` - получение информации о детали
### 3. Хук (`src/hooks/usePartsIndex.ts`)
- `usePartsIndexEntityInfo(code, brand)` - хук для получения данных
### 4. Компонент (`src/components/PartsIndexCard.tsx`)
- Отображение карточки с информацией о детали
- Поддержка состояния загрузки
- Адаптивный дизайн
## Интеграция в страницу поиска
В файле `src/pages/search-result.tsx` добавлено:
```tsx
import PartsIndexCard from "@/components/PartsIndexCard";
import { usePartsIndexEntityInfo } from "@/hooks/usePartsIndex";
// В компоненте:
const { entityInfo, loading: partsIndexLoading } = usePartsIndexEntityInfo(
searchQuery || null,
brandQuery || null
);
// В JSX:
{partsIndexLoading && (
<PartsIndexCard
entityInfo={null as any}
loading={true}
/>
)}
{entityInfo && !partsIndexLoading && (
<PartsIndexCard
entityInfo={entityInfo}
loading={false}
/>
)}
```
## API Parts Index
### Endpoint
```
GET https://api.parts-index.com/v1/entities
```
### Параметры
- `code` (обязательный) - артикул детали
- `brand` (опциональный) - бренд
- `lang` (опциональный) - язык (по умолчанию 'ru')
### Заголовки
```
Authorization: PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE
Accept: application/json
```
### Пример запроса
```bash
curl -H "Authorization: PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE" \
"https://api.parts-index.com/v1/entities?code=059198405B&brand=VAG&lang=ru"
```
## Тестирование
### URL для тестирования
```
http://localhost:3002/search-result?article=059198405B&brand=VAG
```
### Тестовая HTML страница
Создана страница `test-parts-index.html` для демонстрации работы API без React.
## Функциональность
1. **Автоматическая загрузка** - при переходе на страницу результатов поиска
2. **Главная фотография** - отображается первое изображение из массива
3. **Логотип Parts Index** - в правом верхнем углу карточки
4. **Характеристики** - первые 6 параметров из API
5. **Штрих-коды** - все доступные штрих-коды
6. **Дополнительные изображения** - до 4 дополнительных фото
7. **Обработка ошибок** - скрытие изображений при ошибке загрузки
## Стили
Компонент использует Tailwind CSS классы для стилизации:
- Адаптивная сетка для характеристик
- Скроллинг для дополнительных изображений
- Состояние загрузки с анимацией
- Обработка ошибок изображений
## Производительность
- Загрузка данных только при наличии артикула
- Кэширование на уровне React Query (через Apollo Client)
- Ленивая загрузка изображений
- Обработка ошибок сети

View File

@ -1,4 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import toast from "react-hot-toast";
interface BestPriceCardProps { interface BestPriceCardProps {
bestOfferType: string; bestOfferType: string;
@ -7,9 +9,20 @@ interface BestPriceCardProps {
price: string; price: string;
delivery: string; delivery: string;
stock: string; stock: string;
offer?: any; // Добавляем полный объект предложения для корзины
} }
const BestPriceCard: React.FC<BestPriceCardProps> = ({ bestOfferType, title, description, price, delivery, stock }) => { const BestPriceCard: React.FC<BestPriceCardProps> = ({
bestOfferType,
title,
description,
price,
delivery,
stock,
offer
}) => {
const { addItem } = useCart();
// Парсим stock в число, если возможно // Парсим stock в число, если возможно
const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10); const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10);
const maxCount = isNaN(parsedStock) ? undefined : parsedStock; const maxCount = isNaN(parsedStock) ? undefined : parsedStock;
@ -28,12 +41,75 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({ bestOfferType, title, des
let value = parseInt(e.target.value, 10); let value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) value = 1; if (isNaN(value) || value < 1) value = 1;
if (maxCount !== undefined && value > maxCount) { if (maxCount !== undefined && value > maxCount) {
window.alert(`Максимум ${maxCount} шт.`); toast.error(`Максимум ${maxCount} шт.`);
return; return;
} }
setCount(value); setCount(value);
}; };
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
return parseFloat(cleanPrice) || 0;
};
// Обработчик добавления в корзину
const handleAddToCart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!offer) {
toast.error('Информация о товаре недоступна');
return;
}
const numericPrice = parsePrice(price);
if (numericPrice <= 0) {
toast.error('Цена товара не найдена');
return;
}
// Проверяем наличие
if (maxCount !== undefined && count > maxCount) {
toast.error(`Недостаточно товара в наличии. Доступно: ${maxCount} шт.`);
return;
}
try {
addItem({
productId: offer.productId,
offerKey: offer.offerKey,
name: description,
description: `${offer.brand} ${offer.articleNumber} - ${description}`,
brand: offer.brand,
article: offer.articleNumber,
price: numericPrice,
currency: offer.currency || 'RUB',
quantity: count,
deliveryTime: delivery,
warehouse: offer.warehouse || 'Склад',
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
isExternal: offer.isExternal || false,
image: offer.image,
});
// Показываем тоастер об успешном добавлении
toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка добавления товара в корзину');
}
};
return ( return (
<div className="w-layout-vflex flex-block-44"> <div className="w-layout-vflex flex-block-44">
<h3 className="heading-8-copy line-clamp-2 md:line-clamp-1 min-h-[2.5em] md:min-h-0">{bestOfferType}</h3> <h3 className="heading-8-copy line-clamp-2 md:line-clamp-1 min-h-[2.5em] md:min-h-0">{bestOfferType}</h3>
@ -80,11 +156,17 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({ bestOfferType, title, des
</div> </div>
</div> </div>
<div className="w-layout-hflex flex-block-42"> <div className="w-layout-hflex flex-block-42">
<a href="#" className="button-icon w-inline-block"> <button
type="button"
onClick={handleAddToCart}
className="button-icon w-inline-block"
style={{ cursor: 'pointer', textDecoration: 'none' }}
aria-label="Добавить в корзину"
>
<div className="div-block-26"> <div className="div-block-26">
<div className="icon-setting w-embed"><svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"/></svg></div> <div className="icon-setting w-embed"><svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"/></svg></div>
</div> </div>
</a> </button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { useFavorites } from "@/contexts/FavoritesContext";
const CartList: React.FC = () => { const CartList: React.FC = () => {
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity } = useCart(); const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const { items } = state; const { items } = state;
const allSelected = items.length > 0 && items.every((item) => item.selected); const allSelected = items.length > 0 && items.every((item) => item.selected);
@ -29,9 +29,18 @@ const CartList: React.FC = () => {
const isInFavorites = isFavorite(item.productId, item.offerKey, item.article, item.brand); const isInFavorites = isFavorite(item.productId, item.offerKey, item.article, item.brand);
if (isInFavorites) { if (isInFavorites) {
// Удаляем из избранного // Находим товар в избранном по правильному ID
const favoriteId = `${item.productId || item.offerKey || ''}:${item.article}:${item.brand}`; const favoriteItem = favorites.find((fav: any) => {
removeFromFavorites(favoriteId); // Проверяем по разным комбинациям идентификаторов
if (item.productId && fav.productId === item.productId) return true;
if (item.offerKey && fav.offerKey === item.offerKey) return true;
if (fav.article === item.article && fav.brand === item.brand) return true;
return false;
});
if (favoriteItem) {
removeFromFavorites(favoriteItem.id);
}
} else { } else {
// Добавляем в избранное // Добавляем в избранное
addToFavorites({ addToFavorites({

View File

@ -1,88 +1,79 @@
import React, { useState } from "react"; import React from "react";
import { useCart } from "@/contexts/CartContext";
const initialItems = [
{
id: 1,
name: "Ganz GIE37312",
description: "Ролик ремня ГРМ VW AD GANZ GIE37312",
delivery: "Послезавтра, курьером",
deliveryDate: "пт, 7 февраля",
price: "18 763 ₽",
pricePerItem: "18 763 ₽/шт",
count: 1,
comment: "",
},
{
id: 2,
name: "Ganz GIE37312",
description: "Ролик ремня ГРМ VW AD GANZ GIE37312",
delivery: "Послезавтра, курьером",
deliveryDate: "пт, 7 февраля",
price: "18 763 ₽",
pricePerItem: "18 763 ₽/шт",
count: 1,
comment: "",
},
// ...ещё товары
];
const CartList2: React.FC = () => { const CartList2: React.FC = () => {
const [items, setItems] = useState(initialItems); const { state, updateComment } = useCart();
const { items } = state;
const handleComment = (id: number, comment: string) => { const handleComment = (id: string, comment: string) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, comment } : item)); updateComment(id, comment);
}; };
// Функция для форматирования цены
const formatPrice = (price: number, currency: string = 'RUB') => {
return `${price.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
// Показываем только выбранные товары на втором этапе
const selectedItems = items.filter(item => item.selected);
return ( return (
<div className="w-layout-vflex flex-block-48"> <div className="w-layout-vflex flex-block-48">
<div className="w-layout-vflex product-list-cart-check"> <div className="w-layout-vflex product-list-cart-check">
{items.map((item) => ( {selectedItems.length === 0 ? (
<div className="div-block-21-copy" key={item.id}> <div className="empty-cart-message" style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
<div className="w-layout-hflex cart-item-check"> <p>Не выбрано товаров для заказа</p>
<div className="w-layout-hflex info-block-search"> <p>Вернитесь на предыдущий шаг и выберите товары</p>
<div className="text-block-35">{item.count}</div> </div>
<div className="w-layout-hflex block-name"> ) : (
<h4 className="heading-9-copy">{item.name}</h4> selectedItems.map((item) => (
<div className="text-block-21-copy">{item.description}</div> <div className="div-block-21-copy" key={item.id}>
</div> <div className="w-layout-hflex cart-item-check">
<div className="form-block-copy w-form"> <div className="w-layout-hflex info-block-search">
<form className="form-copy" onSubmit={e => e.preventDefault()}> <div className="text-block-35">{item.quantity}</div>
<input <div className="w-layout-hflex block-name">
className="text-field-copy w-input" <h4 className="heading-9-copy">{item.name}</h4>
maxLength={256} <div className="text-block-21-copy">{item.description}</div>
name="Search-5"
data-name="Search 5"
placeholder="Комментарий"
type="text"
id="Search-5"
value={item.comment}
onChange={e => handleComment(item.id, e.target.value)}
/>
</form>
<div className="success-message w-form-done">
<div>Thank you! Your submission has been received!</div>
</div> </div>
<div className="error-message w-form-fail"> <div className="form-block-copy w-form">
<div>Oops! Something went wrong while submitting the form.</div> <form className="form-copy" onSubmit={e => e.preventDefault()}>
<input
className="text-field-copy w-input"
maxLength={256}
name="Search-5"
data-name="Search 5"
placeholder="Комментарий"
type="text"
id={`Search-${item.id}`}
value={item.comment || ''}
onChange={e => handleComment(item.id, e.target.value)}
/>
</form>
<div className="success-message w-form-done">
<div>Thank you! Your submission has been received!</div>
</div>
<div className="error-message w-form-fail">
<div>Oops! Something went wrong while submitting the form.</div>
</div>
</div> </div>
</div> </div>
</div> <div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex add-to-cart-block"> <div className="w-layout-hflex flex-block-39-copy">
<div className="w-layout-hflex flex-block-39-copy"> <h4 className="heading-9-copy">{item.deliveryTime || 'Уточняется'}</h4>
<h4 className="heading-9-copy">{item.delivery}</h4> <div className="text-block-21-copy">{item.deliveryDate || ''}</div>
<div className="text-block-21-copy">{item.deliveryDate}</div> </div>
</div> <div className="w-layout-hflex pcs">
<div className="w-layout-hflex pcs"> <div className="pcs-text">{item.quantity} шт.</div>
<div className="pcs-text">{item.count} шт.</div> </div>
</div> <div className="w-layout-hflex flex-block-39-copy-copy">
<div className="w-layout-hflex flex-block-39-copy-copy"> <h4 className="heading-9-copy-copy">{formatPrice(item.price * item.quantity, item.currency)}</h4>
<h4 className="heading-9-copy-copy">{item.price}</h4> <div className="text-block-21-copy-copy">{formatPrice(item.price, item.currency)}/шт</div>
<div className="text-block-21-copy-copy">{item.pricePerItem}</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> ))
))} )}
</div> </div>
</div> </div>
); );

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useCart } from "@/contexts/CartContext"; import { useCart } from "@/contexts/CartContext";
import { useMutation, useQuery } from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import { CREATE_ORDER, CREATE_PAYMENT, GET_CLIENT_ME, GET_CLIENT_DELIVERY_ADDRESSES, GET_DELIVERY_OFFERS } from "@/lib/graphql"; import { CREATE_ORDER, CREATE_PAYMENT, GET_CLIENT_ME, GET_CLIENT_DELIVERY_ADDRESSES } from "@/lib/graphql";
import toast from "react-hot-toast";
const CartSummary: React.FC = () => { const CartSummary: React.FC = () => {
const { state, updateDelivery, updateOrderComment, clearCart } = useCart(); const { state, updateDelivery, updateOrderComment, clearCart } = useCart();
@ -15,7 +16,7 @@ const CartSummary: React.FC = () => {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [showAuthWarning, setShowAuthWarning] = useState(false); const [showAuthWarning, setShowAuthWarning] = useState(false);
const [currentStep, setCurrentStep] = useState(1); // 1 - первый шаг, 2 - второй шаг const [step, setStep] = useState(1);
// Новые состояния для первого шага // Новые состояния для первого шага
const [selectedLegalEntity, setSelectedLegalEntity] = useState<string>(""); const [selectedLegalEntity, setSelectedLegalEntity] = useState<string>("");
@ -32,22 +33,17 @@ const CartSummary: React.FC = () => {
const [paymentMethod, setPaymentMethod] = useState<string>("yookassa"); const [paymentMethod, setPaymentMethod] = useState<string>("yookassa");
const [showPaymentDropdown, setShowPaymentDropdown] = useState(false); const [showPaymentDropdown, setShowPaymentDropdown] = useState(false);
// Состояния для офферов доставки // Упрощенный тип доставки - только курьер или самовывоз
const [deliveryOffers, setDeliveryOffers] = useState<any[]>([]); // const [deliveryType, setDeliveryType] = useState<'courier' | 'pickup'>('courier');
const [selectedDeliveryOffer, setSelectedDeliveryOffer] = useState<any>(null);
const [loadingOffers, setLoadingOffers] = useState(false);
const [offersError, setOffersError] = useState<string>("");
const [createOrder] = useMutation(CREATE_ORDER); const [createOrder] = useMutation(CREATE_ORDER);
const [createPayment] = useMutation(CREATE_PAYMENT); const [createPayment] = useMutation(CREATE_PAYMENT);
const [getDeliveryOffers] = useMutation(GET_DELIVERY_OFFERS); // Убираем useMutation для GET_DELIVERY_OFFERS
// Получаем данные клиента // Получаем данные клиента
const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME); const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME);
const { data: addressesData, loading: addressesLoading } = useQuery(GET_CLIENT_DELIVERY_ADDRESSES); const { data: addressesData, loading: addressesLoading } = useQuery(GET_CLIENT_DELIVERY_ADDRESSES);
// Получаем пользователя из localStorage для проверки авторизации // Получаем пользователя из localStorage для проверки авторизации
const [userData, setUserData] = useState<any>(null); const [userData, setUserData] = useState<any>(null);
@ -67,7 +63,7 @@ const CartSummary: React.FC = () => {
if (savedCartSummaryState) { if (savedCartSummaryState) {
try { try {
const state = JSON.parse(savedCartSummaryState); const state = JSON.parse(savedCartSummaryState);
setCurrentStep(state.currentStep || 1); setStep(state.step || 1);
setSelectedLegalEntity(state.selectedLegalEntity || ''); setSelectedLegalEntity(state.selectedLegalEntity || '');
setSelectedLegalEntityId(state.selectedLegalEntityId || ''); setSelectedLegalEntityId(state.selectedLegalEntityId || '');
setIsIndividual(state.isIndividual ?? true); setIsIndividual(state.isIndividual ?? true);
@ -87,7 +83,7 @@ const CartSummary: React.FC = () => {
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stateToSave = { const stateToSave = {
currentStep, step,
selectedLegalEntity, selectedLegalEntity,
selectedLegalEntityId, selectedLegalEntityId,
isIndividual, isIndividual,
@ -99,7 +95,7 @@ const CartSummary: React.FC = () => {
}; };
localStorage.setItem('cartSummaryState', JSON.stringify(stateToSave)); localStorage.setItem('cartSummaryState', JSON.stringify(stateToSave));
} }
}, [currentStep, selectedLegalEntity, selectedLegalEntityId, isIndividual, selectedDeliveryAddress, recipientName, recipientPhone, paymentMethod, consent]); }, [step, selectedLegalEntity, selectedLegalEntityId, isIndividual, selectedDeliveryAddress, recipientName, recipientPhone, paymentMethod, consent]);
// Инициализация данных получателя // Инициализация данных получателя
useEffect(() => { useEffect(() => {
@ -134,176 +130,35 @@ const CartSummary: React.FC = () => {
}; };
}, []); }, []);
// Функция для загрузки офферов доставки
const loadDeliveryOffers = async () => {
if (!selectedDeliveryAddress || !recipientName || !recipientPhone || items.length === 0) {
return;
}
setLoadingOffers(true);
setOffersError("");
try {
// Подготавливаем данные для API
const deliveryOffersInput = {
items: items.map(item => {
// Извлекаем срок поставки из deliveryTime товара
let deliveryDays = 0;
if (item.deliveryTime) {
const match = item.deliveryTime.match(/(\d+)/);
if (match) {
deliveryDays = parseInt(match[0]);
}
}
return {
name: item.name,
article: item.article || '',
brand: item.brand || '',
price: item.price,
quantity: item.quantity,
weight: item.weight || 500, // Примерный вес в граммах
dimensions: "10x10x5", // Примерные размеры
deliveryTime: deliveryDays, // Срок поставки товара в днях
offerKey: item.offerKey,
isExternal: item.isExternal
};
}),
deliveryAddress: selectedDeliveryAddress,
recipientName,
recipientPhone
};
const { data } = await getDeliveryOffers({
variables: { input: deliveryOffersInput }
});
if (data?.getDeliveryOffers?.success && data.getDeliveryOffers.offers && Array.isArray(data.getDeliveryOffers.offers) && data.getDeliveryOffers.offers.length > 0) {
setDeliveryOffers(data.getDeliveryOffers.offers);
setOffersError('');
// Автоматически выбираем первый оффер
const firstOffer = data.getDeliveryOffers.offers[0];
setSelectedDeliveryOffer(firstOffer);
// Обновляем стоимость доставки в корзине
updateDelivery({
address: selectedDeliveryAddress,
cost: firstOffer.cost,
date: firstOffer.deliveryDate,
time: firstOffer.deliveryTime
});
} else {
const errorMessage = data?.getDeliveryOffers?.error || 'Не удалось получить варианты доставки';
setOffersError(errorMessage);
// Добавляем стандартные варианты доставки как fallback
const standardOffers = data?.getDeliveryOffers?.offers || [
{
id: 'standard',
name: 'Стандартная доставка',
description: 'Доставка в течение 3-5 рабочих дней',
deliveryDate: 'в течение 3-5 рабочих дней',
deliveryTime: '',
cost: 500
},
{
id: 'express',
name: 'Экспресс доставка',
description: 'Доставка на следующий день',
deliveryDate: 'завтра',
deliveryTime: '10:00-18:00',
cost: 1000
}
];
setDeliveryOffers(standardOffers);
setSelectedDeliveryOffer(standardOffers[0]);
updateDelivery({
address: selectedDeliveryAddress,
cost: standardOffers[0].cost,
date: standardOffers[0].deliveryDate,
time: standardOffers[0].deliveryTime
});
}
} catch (error) {
setOffersError('Ошибка загрузки вариантов доставки');
// Добавляем стандартные варианты доставки как fallback при ошибке
const standardOffers = [
{
id: 'standard',
name: 'Стандартная доставка',
description: 'Доставка в течение 3-5 рабочих дней',
deliveryDate: 'в течение 3-5 рабочих дней',
deliveryTime: '',
cost: 500
}
];
setDeliveryOffers(standardOffers);
setSelectedDeliveryOffer(standardOffers[0]);
updateDelivery({
address: selectedDeliveryAddress,
cost: standardOffers[0].cost,
date: standardOffers[0].deliveryDate,
time: standardOffers[0].deliveryTime
});
} finally {
setLoadingOffers(false);
}
};
// Автоматическая загрузка офферов при изменении ключевых данных
useEffect(() => {
if (selectedDeliveryAddress && recipientName && recipientPhone && items.length > 0) {
// Загружаем офферы с небольшой задержкой для избежания множественных запросов
const timeoutId = setTimeout(() => {
loadDeliveryOffers();
}, 500);
return () => clearTimeout(timeoutId);
}
}, [selectedDeliveryAddress, recipientName, recipientPhone, items.length]);
const handleProceedToStep2 = () => { const handleProceedToStep2 = () => {
if (!selectedDeliveryAddress) { if (!recipientName.trim()) {
setError("Пожалуйста, выберите адрес доставки."); toast.error('Пожалуйста, введите имя получателя');
return; return;
} }
if (summary.totalItems === 0) { if (!recipientPhone.trim()) {
setError("Корзина пуста. Добавьте товары для оформления заказа."); toast.error('Пожалуйста, введите телефон получателя');
return;
}
if (!selectedDeliveryAddress.trim()) {
toast.error('Пожалуйста, выберите адрес доставки');
return; return;
} }
if (!selectedDeliveryOffer) { // Обновляем данные доставки без стоимости
setError("Пожалуйста, выберите способ доставки."); updateDelivery({
return; address: selectedDeliveryAddress,
} cost: 0, // Стоимость включена в товары
date: 'Включена в стоимость товаров',
time: 'Способ доставки указан в адресе'
});
// Проверяем достаточность средств для оплаты с баланса setStep(2);
if (paymentMethod === 'balance' && !isIndividual) {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive);
const finalAmount = summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice);
const availableBalance = (defaultContract?.balance || 0) + (defaultContract?.creditLimit || 0);
if (availableBalance < finalAmount) {
setError("Недостаточно средств на балансе для оплаты заказа. Выберите другой способ оплаты.");
return;
}
}
setError("");
setCurrentStep(2);
}; };
const handleBackToStep1 = () => { const handleBackToStep1 = () => {
setCurrentStep(1); setStep(1);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -339,7 +194,7 @@ const CartSummary: React.FC = () => {
deliveryAddress: selectedDeliveryAddress || delivery.address, deliveryAddress: selectedDeliveryAddress || delivery.address,
legalEntityId: !isIndividual ? selectedLegalEntityId : null, legalEntityId: !isIndividual ? selectedLegalEntityId : null,
paymentMethod: paymentMethod, paymentMethod: paymentMethod,
comment: orderComment || `Адрес доставки: ${selectedDeliveryAddress}. ${!isIndividual && selectedLegalEntity ? `Юридическое лицо: ${selectedLegalEntity}.` : 'Физическое лицо.'} Способ оплаты: ${getPaymentMethodName(paymentMethod)}. Доставка: ${selectedDeliveryOffer?.name || 'Стандартная доставка'} (${selectedDeliveryOffer?.deliveryDate || ''} ${selectedDeliveryOffer?.deliveryTime || ''}).`, comment: orderComment || `Адрес доставки: ${selectedDeliveryAddress}. ${!isIndividual && selectedLegalEntity ? `Юридическое лицо: ${selectedLegalEntity}.` : 'Физическое лицо.'} Способ оплаты: ${getPaymentMethodName(paymentMethod)}. Доставка: ${selectedDeliveryAddress}.`,
items: selectedItems.map(item => ({ items: selectedItems.map(item => ({
productId: item.productId, productId: item.productId,
externalId: item.offerKey, externalId: item.offerKey,
@ -367,14 +222,6 @@ const CartSummary: React.FC = () => {
localStorage.removeItem('cartSummaryState'); localStorage.removeItem('cartSummaryState');
} }
window.location.href = `/payment/success?orderId=${order.id}&orderNumber=${order.orderNumber}&paymentMethod=balance`; window.location.href = `/payment/success?orderId=${order.id}&orderNumber=${order.orderNumber}&paymentMethod=balance`;
} else if (paymentMethod === 'invoice') {
// Для оплаты по реквизитам - переходим на страницу с реквизитами
clearCart();
// Очищаем сохраненное состояние оформления заказа
if (typeof window !== 'undefined') {
localStorage.removeItem('cartSummaryState');
}
window.location.href = `/payment/invoice?orderId=${order.id}&orderNumber=${order.orderNumber}`;
} else { } else {
// Для ЮКассы - создаем платеж и переходим на оплату // Для ЮКассы - создаем платеж и переходим на оплату
const paymentResult = await createPayment({ const paymentResult = await createPayment({
@ -421,14 +268,12 @@ const CartSummary: React.FC = () => {
return 'ЮКасса (банковские карты)'; return 'ЮКасса (банковские карты)';
case 'balance': case 'balance':
return 'Оплата с баланса'; return 'Оплата с баланса';
case 'invoice':
return 'Оплата по реквизитам';
default: default:
return 'Выберите способ оплаты'; return 'ЮКасса (банковские карты)';
} }
}; };
if (currentStep === 1) { if (step === 1) {
// Первый шаг - настройка доставки // Первый шаг - настройка доставки
return ( return (
<div className="w-layout-vflex cart-ditail"> <div className="w-layout-vflex cart-ditail">
@ -650,107 +495,6 @@ const CartSummary: React.FC = () => {
)} )}
</div> </div>
{/* Варианты доставки */}
<div className="w-layout-vflex flex-block-66">
<div className="text-block-31" style={{ marginBottom: '12px' }}>Варианты доставки</div>
{loadingOffers && (
<div style={{
padding: '16px',
textAlign: 'center',
fontSize: '14px',
color: '#666'
}}>
Загружаем варианты доставки...
</div>
)}
{offersError && (
<div style={{
padding: '12px',
backgroundColor: '#FEF3C7',
border: '1px solid #F59E0B',
borderRadius: '4px',
fontSize: '12px',
color: '#92400E',
marginBottom: '12px'
}}>
{offersError}
</div>
)}
{deliveryOffers.length > 0 && !loadingOffers && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{deliveryOffers.map((offer, index) => (
<div
key={offer.id}
onClick={() => {
setSelectedDeliveryOffer(offer);
updateDelivery({
address: selectedDeliveryAddress,
cost: offer.cost,
date: offer.deliveryDate,
time: offer.deliveryTime
});
}}
style={{
padding: '12px',
border: selectedDeliveryOffer?.id === offer.id ? '2px solid #007bff' : '1px solid #dee2e6',
borderRadius: '8px',
cursor: 'pointer',
backgroundColor: selectedDeliveryOffer?.id === offer.id ? '#f8f9fa' : 'white',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (selectedDeliveryOffer?.id !== offer.id) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedDeliveryOffer?.id !== offer.id) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: '14px', marginBottom: '4px' }}>
{offer.name}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
{offer.description}
</div>
<div style={{ fontSize: '12px', color: '#007bff' }}>
{offer.deliveryDate} {offer.deliveryTime}
</div>
</div>
<div style={{
fontWeight: 500,
fontSize: '14px',
color: offer.cost === 0 ? '#28a745' : '#333'
}}>
{offer.cost === 0 ? 'Бесплатно' : `${offer.cost}`}
</div>
</div>
</div>
))}
</div>
)}
{deliveryOffers.length === 0 && !loadingOffers && selectedDeliveryAddress && (
<div style={{
padding: '16px',
textAlign: 'center',
fontSize: '14px',
color: '#666',
border: '1px dashed #dee2e6',
borderRadius: '8px'
}}>
Выберите адрес доставки для просмотра вариантов
</div>
)}
</div>
{/* Способ оплаты */} {/* Способ оплаты */}
<div className="w-layout-vflex flex-block-58" style={{ position: 'relative' }} ref={paymentDropdownRef}> <div className="w-layout-vflex flex-block-58" style={{ position: 'relative' }} ref={paymentDropdownRef}>
<div className="text-block-31">Способ оплаты</div> <div className="text-block-31">Способ оплаты</div>
@ -856,24 +600,19 @@ const CartSummary: React.FC = () => {
); );
} }
const contracts = clientData?.clientMe?.contracts || []; const activeContracts = clientData?.clientMe?.contracts?.filter((contract: any) => contract.isActive) || [];
const defaultContract = contracts.find((contract: any) => contract.isDefault && contract.isActive); const defaultContract = activeContracts.find((contract: any) => contract.isDefault) || activeContracts[0];
if (!defaultContract) { if (!defaultContract) {
const anyActiveContract = contracts.find((contract: any) => contract.isActive); return (
<span style={{ color: '#EF4444', fontWeight: 500 }}>
if (!anyActiveContract) { Активный договор не найден
return ( </span>
<span style={{ fontWeight: 500, color: '#e74c3c' }}> );
Нет активных контрактов
</span>
);
}
} }
const contract = defaultContract || contracts.find((contract: any) => contract.isActive); const balance = defaultContract.balance || 0;
const balance = contract?.balance || 0; const creditLimit = defaultContract.creditLimit || 0;
const creditLimit = contract?.creditLimit || 0;
const totalAvailable = balance + creditLimit; const totalAvailable = balance + creditLimit;
return ( return (
@ -884,59 +623,10 @@ const CartSummary: React.FC = () => {
})()} })()}
</div> </div>
</div> </div>
<div
onClick={() => {
setPaymentMethod('invoice');
setShowPaymentDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: paymentMethod === 'invoice' ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (paymentMethod !== 'invoice') {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (paymentMethod !== 'invoice') {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
Оплата по реквизитам
</div>
</> </>
)} )}
</div> </div>
)} )}
{/* Показываем предупреждение для оплаты с баланса если недостаточно средств */}
{paymentMethod === 'balance' && !isIndividual && (
(() => {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive);
const availableBalance = (defaultContract?.balance || 0) + (defaultContract?.creditLimit || 0);
const finalAmount = summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice);
const isInsufficientFunds = availableBalance < finalAmount;
return isInsufficientFunds ? (
<div style={{
marginTop: '8px',
padding: '8px 12px',
backgroundColor: '#FEF3C7',
border: '1px solid #F59E0B',
borderRadius: '4px',
fontSize: '12px',
color: '#92400E'
}}>
Недостаточно средств на балансе для оплаты заказа
</div>
) : null;
})()
)}
</div> </div>
<div className="px-line"></div> <div className="px-line"></div>
@ -958,10 +648,7 @@ const CartSummary: React.FC = () => {
<div className="w-layout-hflex flex-block-59"> <div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Доставка</div> <div className="text-block-21-copy-copy">Доставка</div>
<div className="text-block-33"> <div className="text-block-33">
{selectedDeliveryOffer?.cost === 0 Включена в стоимость товаров
? 'Бесплатно'
: formatPrice(selectedDeliveryOffer?.cost || summary.deliveryPrice)
}
</div> </div>
</div> </div>
</div> </div>
@ -972,7 +659,7 @@ const CartSummary: React.FC = () => {
<div className="text-block-32">Итого</div> <div className="text-block-32">Итого</div>
<h4 className="heading-9-copy-copy"> <h4 className="heading-9-copy-copy">
{formatPrice( {formatPrice(
summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice) summary.totalPrice - summary.totalDiscount + (selectedDeliveryAddress ? 0 : summary.deliveryPrice)
)} )}
</h4> </h4>
</div> </div>
@ -1075,9 +762,19 @@ const CartSummary: React.FC = () => {
{paymentMethod === 'balance' && !isIndividual && ( {paymentMethod === 'balance' && !isIndividual && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}> <div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
{(() => { {(() => {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive); const activeContracts = clientData?.clientMe?.contracts?.filter((contract: any) => contract.isActive) || [];
const balance = defaultContract?.balance || 0; const defaultContract = activeContracts.find((contract: any) => contract.isDefault) || activeContracts[0];
const creditLimit = defaultContract?.creditLimit || 0;
if (!defaultContract) {
return (
<span style={{ color: '#EF4444', fontWeight: 500 }}>
Активный договор не найден
</span>
);
}
const balance = defaultContract.balance || 0;
const creditLimit = defaultContract.creditLimit || 0;
const totalAvailable = balance + creditLimit; const totalAvailable = balance + creditLimit;
return ( return (
@ -1131,10 +828,7 @@ const CartSummary: React.FC = () => {
<div className="w-layout-hflex flex-block-59"> <div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Доставка</div> <div className="text-block-21-copy-copy">Доставка</div>
<div className="text-block-33"> <div className="text-block-33">
{selectedDeliveryOffer?.cost === 0 Включена в стоимость товаров
? 'Бесплатно'
: formatPrice(selectedDeliveryOffer?.cost || summary.deliveryPrice)
}
</div> </div>
</div> </div>
</div> </div>
@ -1145,7 +839,7 @@ const CartSummary: React.FC = () => {
<div className="text-block-32">Итого</div> <div className="text-block-32">Итого</div>
<h4 className="heading-9-copy-copy"> <h4 className="heading-9-copy-copy">
{formatPrice( {formatPrice(
summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice) summary.totalPrice - summary.totalDiscount + (selectedDeliveryAddress ? 0 : summary.deliveryPrice)
)} )}
</h4> </h4>
</div> </div>
@ -1196,7 +890,6 @@ const CartSummary: React.FC = () => {
> >
{isProcessing ? 'Оформляем заказ...' : {isProcessing ? 'Оформляем заказ...' :
paymentMethod === 'balance' ? 'Оплатить с баланса' : paymentMethod === 'balance' ? 'Оплатить с баланса' :
paymentMethod === 'invoice' ? 'Выставить счёт' :
'Оплатить'} 'Оплатить'}
</button> </button>

View File

@ -35,7 +35,7 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
priceElement, priceElement,
onAddToCart, onAddToCart,
}) => { }) => {
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
// Обрабатываем пустое изображение - используем SVG-заглушку вместо мокап-фотки // Обрабатываем пустое изображение - используем SVG-заглушку вместо мокап-фотки
const displayImage = image || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjE5MCIgdmlld0JveD0iMCAwIDIxMCAxOTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMTAiIGhlaWdodD0iMTkwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04NSA5NUw5NSA4NUwxMjUgMTE1TDE0MCA5NUwxNjUgMTIwSDE2NVY5MEg0NVY5MEw4NSA5NVoiIGZpbGw9IiNEMUQ1REIiLz4KPGNpcmNsZSBjeD0iNzUiIGN5PSI3NSIgcj0iMTAiIGZpbGw9IiNEMUQ1REIiLz4KPHRleHQgeD0iMTA1IiB5PSIxNTAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMiIgZmlsbD0iIzlDQTNBRiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+Tm8gaW1hZ2U8L3RleHQ+Cjwvc3ZnPgo='; const displayImage = image || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjE5MCIgdmlld0JveD0iMCAwIDIxMCAxOTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMTAiIGhlaWdodD0iMTkwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04NSA5NUw5NSA4NUwxMjUgMTE1TDE0MCA5NUwxNjUgMTIwSDE2NVY5MEg0NVY5MEw4NSA5NVoiIGZpbGw9IiNEMUQ1REIiLz4KPGNpcmNsZSBjeD0iNzUiIGN5PSI3NSIgcj0iMTAiIGZpbGw9IiNEMUQ1REIiLz4KPHRleHQgeD0iMTA1IiB5PSIxNTAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMiIgZmlsbD0iIzlDQTNBRiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+Tm8gaW1hZ2U8L3RleHQ+Cjwvc3ZnPgo=';
@ -57,9 +57,18 @@ const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
const numericPrice = parseFloat(price.replace(/[^\d.,]/g, '').replace(',', '.')) || 0; const numericPrice = parseFloat(price.replace(/[^\d.,]/g, '').replace(',', '.')) || 0;
if (isItemFavorite) { if (isItemFavorite) {
// Создаем ID для удаления // Находим товар в избранном по правильному ID
const id = `${productId || offerKey || ''}:${articleNumber}:${brandName || brand}`; const favoriteItem = favorites.find((fav: any) => {
removeFromFavorites(id); // Проверяем по разным комбинациям идентификаторов
if (productId && fav.productId === productId) return true;
if (offerKey && fav.offerKey === offerKey) return true;
if (fav.article === articleNumber && fav.brand === (brandName || brand)) return true;
return false;
});
if (favoriteItem) {
removeFromFavorites(favoriteItem.id);
}
} else { } else {
// Добавляем в избранное // Добавляем в избранное
addToFavorites({ addToFavorites({

View File

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext"; import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext"; import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast";
const INITIAL_OFFERS_LIMIT = 5; const INITIAL_OFFERS_LIMIT = 5;
@ -46,7 +47,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
partsIndexPowered = false partsIndexPowered = false
}) => { }) => {
const { addItem } = useCart(); const { addItem } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT); const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
const [quantities, setQuantities] = useState<{ [key: number]: number }>( const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}) offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
@ -88,7 +89,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
let num = parseInt(value, 10); let num = parseInt(value, 10);
if (isNaN(num) || num < 1) num = 1; if (isNaN(num) || num < 1) num = 1;
if (num > availableStock) { if (num > availableStock) {
window.alert(`Максимум ${availableStock} шт.`); toast.error(`Максимум ${availableStock} шт.`);
return; return;
} }
setQuantities(prev => ({ ...prev, [index]: num })); setQuantities(prev => ({ ...prev, [index]: num }));
@ -100,7 +101,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
// Проверяем наличие // Проверяем наличие
if (quantity > availableStock) { if (quantity > availableStock) {
alert(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`); toast.error(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return; return;
} }
@ -123,8 +124,17 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
image: image, image: image,
}); });
// Показываем уведомление о добавлении // Показываем тоастер вместо alert
alert(`Товар "${brand} ${article}" добавлен в корзину (${quantity} шт.)`); toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{`${brand} ${article} (${quantity} шт.)`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
}; };
// Обработчик клика по сердечку // Обработчик клика по сердечку
@ -133,9 +143,18 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
e.stopPropagation(); e.stopPropagation();
if (isItemFavorite) { if (isItemFavorite) {
// Создаем ID для удаления // Находим товар в избранном и удаляем по правильному ID
const id = `${offers[0]?.productId || offers[0]?.offerKey || ''}:${article}:${brand}`; const favoriteItem = favorites.find((item: any) => {
removeFromFavorites(id); // Проверяем по разным комбинациям идентификаторов
if (offers[0]?.productId && item.productId === offers[0].productId) return true;
if (offers[0]?.offerKey && item.offerKey === offers[0].offerKey) return true;
if (item.article === article && item.brand === brand) return true;
return false;
});
if (favoriteItem) {
removeFromFavorites(favoriteItem.id);
}
} else { } else {
// Добавляем в избранное // Добавляем в избранное
const bestOffer = offers[0]; // Берем первое предложение как лучшее const bestOffer = offers[0]; // Берем первое предложение как лучшее

View File

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router";
import Filters, { FilterConfig } from "./Filters"; import Filters, { FilterConfig } from "./Filters";
import { useFavorites } from "@/contexts/FavoritesContext"; import { useFavorites } from "@/contexts/FavoritesContext";
@ -8,9 +9,9 @@ interface FavoriteListProps {
onFilterChange?: (type: string, value: any) => void; onFilterChange?: (type: string, value: any) => void;
searchQuery?: string; searchQuery?: string;
onSearchChange?: (value: string) => void; onSearchChange?: (value: string) => void;
sortBy?: 'name' | 'brand' | 'price' | 'date'; sortBy?: 'name' | 'brand' | 'date';
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
onSortChange?: (sortBy: 'name' | 'brand' | 'price' | 'date') => void; onSortChange?: (sortBy: 'name' | 'brand' | 'date') => void;
onSortOrderChange?: (sortOrder: 'asc' | 'desc') => void; onSortOrderChange?: (sortOrder: 'asc' | 'desc') => void;
} }
@ -25,6 +26,7 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
onSortChange, onSortChange,
onSortOrderChange onSortOrderChange
}) => { }) => {
const router = useRouter();
const { favorites, removeFromFavorites, clearFavorites } = useFavorites(); const { favorites, removeFromFavorites, clearFavorites } = useFavorites();
const handleRemove = (id: string) => { const handleRemove = (id: string) => {
@ -35,6 +37,14 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
clearFavorites(); clearFavorites();
}; };
// Функция для поиска товара
const handleSearchItem = (item: any) => {
// Формируем поисковый запрос из бренда и артикула
const searchQuery = `${item.brand} ${item.article}`;
// Переходим на страницу поиска с результатами
router.push(`/search-result?article=${encodeURIComponent(item.article)}&brand=${encodeURIComponent(item.brand)}`);
};
// Состояние для hover на иконке удаления всех // Состояние для hover на иконке удаления всех
const [removeAllHover, setRemoveAllHover] = useState(false); const [removeAllHover, setRemoveAllHover] = useState(false);
// Состояние для hover на корзине отдельного товара // Состояние для hover на корзине отдельного товара
@ -82,11 +92,6 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
case 'brand': case 'brand':
comparison = a.brand.localeCompare(b.brand); comparison = a.brand.localeCompare(b.brand);
break; break;
case 'price':
const priceA = a.price || 0;
const priceB = b.price || 0;
comparison = priceA - priceB;
break;
case 'date': case 'date':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break; break;
@ -97,29 +102,19 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
return sortOrder === 'asc' ? comparison : -comparison; return sortOrder === 'asc' ? comparison : -comparison;
}); });
const formatPrice = (price?: number, currency?: string) => { const handleSortClick = (newSortBy: 'name' | 'brand' | 'date') => {
if (!price) {
return 'Цена не указана';
}
if (currency === 'RUB') {
return `от ${price.toLocaleString('ru-RU')}`;
}
return `от ${price} ${currency || ''}`;
};
const handleSortClick = (newSortBy: 'name' | 'brand' | 'price' | 'date') => {
if (sortBy === newSortBy) { if (sortBy === newSortBy) {
// Если тот же столбец, меняем порядок // Если тот же столбец, меняем порядок
onSortOrderChange?.(sortOrder === 'asc' ? 'desc' : 'asc'); onSortOrderChange?.(sortOrder === 'asc' ? 'desc' : 'asc');
} else { } else {
// Если новый столбец, устанавливаем его и порядок по умолчанию // Если новый столбец, устанавливаем его и порядок по умолчанию
onSortChange?.(newSortBy); onSortChange?.(newSortBy);
onSortOrderChange?.(newSortBy === 'price' ? 'asc' : 'desc'); onSortOrderChange?.(newSortBy === 'date' ? 'desc' : 'asc');
} }
}; };
// SVG-галочки для сортировки — всегда видны у всех колонок // SVG-галочки для сортировки — всегда видны у всех колонок
const getSortIcon = (columnSort: 'name' | 'brand' | 'price' | 'date') => { const getSortIcon = (columnSort: 'name' | 'brand' | 'date') => {
const isActive = sortBy === columnSort; const isActive = sortBy === columnSort;
const isAsc = sortOrder === 'asc'; const isAsc = sortOrder === 'asc';
const color = isActive ? 'var(--_button---primary)' : '#94a3b8'; const color = isActive ? 'var(--_button---primary)' : '#94a3b8';
@ -176,6 +171,9 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
</div> </div>
<div className="sort-item-comments">Комментарий</div> <div className="sort-item-comments">Комментарий</div>
</div> </div>
<div className="w-layout-hflex add-to-cart-block-copy">
<div className="text-sm font-medium text-gray-600">Действия</div>
</div>
{favorites.length > 0 && ( {favorites.length > 0 && (
<div <div
className="w-layout-hflex select-all-block" className="w-layout-hflex select-all-block"
@ -233,12 +231,22 @@ const FavoriteList: React.FC<FavoriteListProps> = ({
</div> </div>
</div> </div>
<div className="w-layout-hflex add-to-cart-block-copy"> <div className="w-layout-hflex add-to-cart-block-copy">
<h4 <button
className="heading-9-copy-copy cursor-pointer hover:text-blue-600 flex items-center gap-1" onClick={() => handleSearchItem(item)}
onClick={() => handleSortClick('price')} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200 flex items-center gap-2 text-sm font-medium"
> >
{formatPrice(item.price, item.currency)} {getSortIcon('price')} <svg
</h4> width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Поиск
</button>
<div className="w-layout-hflex control-element-copy"> <div className="w-layout-hflex control-element-copy">
{/* Корзина с hover-эффектом для удаления товара */} {/* Корзина с hover-эффектом для удаления товара */}
<span <span

View File

@ -58,6 +58,15 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
setSearchQuery(q); setSearchQuery(q);
} }
} }
// Если мы находимся на странице деталей автомобиля, восстанавливаем VIN из URL
else if (router.pathname === '/vehicle-search/[brand]/[vehicleId]') {
const { vin } = router.query;
if (vin && typeof vin === 'string') {
setSearchQuery(vin);
} else {
setSearchQuery('');
}
}
// Для других страниц очищаем поисковый запрос // Для других страниц очищаем поисковый запрос
else { else {
setSearchQuery(''); setSearchQuery('');
@ -321,11 +330,31 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
const catalogCode = (vehicle as any).catalog || vehicle.brand.toLowerCase(); const catalogCode = (vehicle as any).catalog || vehicle.brand.toLowerCase();
console.log('🚗 Переход на страницу автомобиля:', { catalogCode, vehicleId: vehicle.vehicleid, ssd: vehicle.ssd }); console.log('🚗 Переход на страницу автомобиля:', { catalogCode, vehicleId: vehicle.vehicleid, ssd: vehicle.ssd });
// Создаем параметры URL
const urlParams = new URLSearchParams();
// Добавляем SSD если есть
if (vehicle.ssd) {
urlParams.set('ssd', vehicle.ssd);
}
// Добавляем VIN-номер в URL, если поиск был по VIN
if (searchType === 'vin' && searchQuery) {
urlParams.set('vin', searchQuery);
}
// Если переход происходит из поиска автомобилей по артикулу, передаем артикул для автоматического поиска // Если переход происходит из поиска автомобилей по артикулу, передаем артикул для автоматического поиска
const currentOEMNumber = oemSearchMode === 'vehicles' ? searchQuery.trim().toUpperCase() : ''; const currentOEMNumber = oemSearchMode === 'vehicles' ? searchQuery.trim().toUpperCase() : '';
const url = `/vehicle-search/${catalogCode}/${vehicle.vehicleid}?ssd=${vehicle.ssd || ''}${currentOEMNumber ? `&oemNumber=${encodeURIComponent(currentOEMNumber)}` : ''}`; if (currentOEMNumber) {
urlParams.set('oemNumber', currentOEMNumber);
}
setSearchQuery(''); // Формируем URL
const baseUrl = `/vehicle-search/${catalogCode}/${vehicle.vehicleid}`;
const url = urlParams.toString() ? `${baseUrl}?${urlParams.toString()}` : baseUrl;
// НЕ очищаем поисковый запрос, чтобы он остался в строке поиска
// setSearchQuery('');
router.push(url); router.push(url);
}; };

View File

@ -1,7 +1,9 @@
import * as React from "react"; import * as React from "react";
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useQuery } from '@apollo/client';
import { useIsClient } from '@/lib/useIsomorphicLayoutEffect'; import { useIsClient } from '@/lib/useIsomorphicLayoutEffect';
import { GET_CLIENT_ME } from '@/lib/graphql';
const menuItems = [ const menuItems = [
{ label: 'Заказы', href: '/profile-orders', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/22ecd7e6251abe04521d03f0ac09f73018a8c2c8?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' }, { label: 'Заказы', href: '/profile-orders', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/22ecd7e6251abe04521d03f0ac09f73018a8c2c8?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
@ -28,6 +30,15 @@ const LKMenu = React.forwardRef<HTMLDivElement>((props, ref) => {
const router = useRouter(); const router = useRouter();
const isClient = useIsClient(); const isClient = useIsClient();
// Получаем данные клиента для проверки наличия юридических лиц
const { data: clientData } = useQuery(GET_CLIENT_ME, {
skip: !isClient,
errorPolicy: 'ignore' // Игнорируем ошибки, чтобы не ломать интерфейс
});
// Проверяем есть ли у клиента юридические лица
const hasLegalEntities = clientData?.clientMe?.legalEntities && clientData.clientMe.legalEntities.length > 0;
const handleLogout = () => { const handleLogout = () => {
if (isClient) { if (isClient) {
localStorage.removeItem('authToken'); localStorage.removeItem('authToken');
@ -66,31 +77,37 @@ const LKMenu = React.forwardRef<HTMLDivElement>((props, ref) => {
); );
})} })}
</div> </div>
<div className="gap-2.5 self-start px-2.5 pt-2.5 mt-3 whitespace-nowrap text-gray-950">
Финансы {/* Раздел "Финансы" показываем только если есть юридические лица */}
</div> {hasLegalEntities && (
<div className="flex flex-col mt-3 w-full text-base leading-snug text-gray-600"> <>
{financeItems.map((item) => { <div className="gap-2.5 self-start px-2.5 pt-2.5 mt-3 whitespace-nowrap text-gray-950">
const isActive = normalizePath(router.asPath) === normalizePath(item.href); Финансы
return ( </div>
<Link href={item.href} key={item.href}> <div className="flex flex-col mt-3 w-full text-base leading-snug text-gray-600">
<div {financeItems.map((item) => {
className={`flex gap-2.5 items-center px-2.5 py-2 w-full whitespace-nowrap rounded-lg ${ const isActive = normalizePath(router.asPath) === normalizePath(item.href);
isActive ? 'bg-slate-200' : 'bg-white' return (
}`} <Link href={item.href} key={item.href}>
> <div
<img className={`flex gap-2.5 items-center px-2.5 py-2 w-full whitespace-nowrap rounded-lg ${
loading="lazy" isActive ? 'bg-slate-200' : 'bg-white'
src={item.icon} }`}
className="object-contain shrink-0 self-stretch my-auto w-5 aspect-square" >
alt={item.label} <img
/> loading="lazy"
<div className="self-stretch my-auto text-gray-600">{item.label}</div> src={item.icon}
</div> className="object-contain shrink-0 self-stretch my-auto w-5 aspect-square"
</Link> alt={item.label}
); />
})} <div className="self-stretch my-auto text-gray-600">{item.label}</div>
</div> </div>
</Link>
);
})}
</div>
</>
)}
{/* Кнопка выхода */} {/* Кнопка выхода */}
<div className="mt-3"> <div className="mt-3">

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import { useQuery } from '@apollo/client';
import { useIsClient } from '@/lib/useIsomorphicLayoutEffect'; import { useIsClient } from '@/lib/useIsomorphicLayoutEffect';
import { GET_CLIENT_ME } from '@/lib/graphql';
interface ProfileSidebarProps { interface ProfileSidebarProps {
activeItem: string; activeItem: string;
@ -8,6 +10,15 @@ interface ProfileSidebarProps {
const ProfileSidebar: React.FC<ProfileSidebarProps> = ({ activeItem }) => { const ProfileSidebar: React.FC<ProfileSidebarProps> = ({ activeItem }) => {
const isClient = useIsClient(); const isClient = useIsClient();
// Получаем данные клиента для проверки наличия юридических лиц
const { data: clientData } = useQuery(GET_CLIENT_ME, {
skip: !isClient,
errorPolicy: 'ignore' // Игнорируем ошибки, чтобы не ломать интерфейс
});
// Проверяем есть ли у клиента юридические лица
const hasLegalEntities = clientData?.clientMe?.legalEntities && clientData.clientMe.legalEntities.length > 0;
const menuItems = [ const menuItems = [
{ id: 'orders', icon: 'order', label: 'Заказы', href: '/profile-orders' }, { id: 'orders', icon: 'order', label: 'Заказы', href: '/profile-orders' },
{ id: 'history', icon: 'history', label: 'История поиска', href: '/profile-history' }, { id: 'history', icon: 'history', label: 'История поиска', href: '/profile-history' },
@ -133,25 +144,28 @@ const ProfileSidebar: React.FC<ProfileSidebarProps> = ({ activeItem }) => {
</div> </div>
</div> </div>
<div className="sidebar-section"> {/* Раздел "Финансы" показываем только если есть юридические лица */}
<div className="sidebar-header"> {hasLegalEntities && (
<h3 className="sidebar-title">Финансы</h3> <div className="sidebar-section">
<div className="sidebar-header">
<h3 className="sidebar-title">Финансы</h3>
</div>
<div className="sidebar-menu">
{financeItems.map((item) => (
<a
key={item.id}
href={item.href}
className={`sidebar-item ${activeItem === item.id ? 'active' : ''}`}
>
<div className="sidebar-icon">
{renderIcon(item.icon, activeItem === item.id)}
</div>
<span className="sidebar-label">{item.label}</span>
</a>
))}
</div>
</div> </div>
<div className="sidebar-menu"> )}
{financeItems.map((item) => (
<a
key={item.id}
href={item.href}
className={`sidebar-item ${activeItem === item.id ? 'active' : ''}`}
>
<div className="sidebar-icon">
{renderIcon(item.icon, activeItem === item.id)}
</div>
<span className="sidebar-label">{item.label}</span>
</a>
))}
</div>
</div>
{/* Кнопка выхода */} {/* Кнопка выхода */}
<div className="logout-section"> <div className="logout-section">

View File

@ -23,7 +23,7 @@ export default function InfoCard({
currency = 'RUB', currency = 'RUB',
image image
}: InfoCardProps) { }: InfoCardProps) {
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites(); const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
// Проверяем, есть ли товар в избранном // Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(productId, offerKey, articleNumber, brand); const isItemFavorite = isFavorite(productId, offerKey, articleNumber, brand);
@ -34,9 +34,18 @@ export default function InfoCard({
e.stopPropagation(); e.stopPropagation();
if (isItemFavorite) { if (isItemFavorite) {
// Создаем ID для удаления // Находим товар в избранном по правильному ID
const id = `${productId || offerKey || ''}:${articleNumber}:${brand}`; const favoriteItem = favorites.find((fav: any) => {
removeFromFavorites(id); // Проверяем по разным комбинациям идентификаторов
if (productId && fav.productId === productId) return true;
if (offerKey && fav.offerKey === offerKey) return true;
if (fav.article === articleNumber && fav.brand === brand) return true;
return false;
});
if (favoriteItem) {
removeFromFavorites(favoriteItem.id);
}
} else { } else {
// Добавляем в избранное // Добавляем в избранное
addToFavorites({ addToFavorites({

View File

@ -1,96 +1,47 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { useQuery } from "@apollo/client";
import { PARTS_INDEX_SEARCH_BY_ARTICLE } from "@/lib/graphql";
interface ProductCharacteristicsProps { interface ProductCharacteristicsProps {
result?: any; result?: any;
} }
const ProductCharacteristics = ({ result }: ProductCharacteristicsProps) => { const ProductCharacteristics = ({ result }: ProductCharacteristicsProps) => {
const [partsIndexData, setPartsIndexData] = useState<any>(null);
// Функция для рендеринга характеристик из нашей базы данных // Запрос к Parts Index для получения дополнительных характеристик
const renderInternalCharacteristics = () => { const { data: partsIndexResult, loading: partsIndexLoading } = useQuery(PARTS_INDEX_SEARCH_BY_ARTICLE, {
if (!result?.characteristics || result.characteristics.length === 0) return null; variables: {
articleNumber: result?.articleNumber || '',
brandName: result?.brand || '',
lang: 'ru'
},
skip: !result?.articleNumber || !result?.brand,
errorPolicy: 'ignore'
});
return ( useEffect(() => {
<div className="w-layout-vflex flex-block-53"> if (partsIndexResult?.partsIndexSearchByArticle) {
<div className="w-layout-hflex flex-block-55"> setPartsIndexData(partsIndexResult.partsIndexSearchByArticle);
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}> }
Характеристики товара: }, [partsIndexResult]);
</span>
</div> // Функция для рендеринга параметров из Parts Index
{result.characteristics.map((char: any, index: number) => ( const renderPartsIndexParameters = () => {
<div key={index} className="w-layout-hflex flex-block-55"> if (!partsIndexData?.parameters) return null;
<span className="text-block-29">{char.characteristic.name}:</span>
<span className="text-block-28">{char.value}</span> return partsIndexData.parameters.map((paramGroup: any, groupIndex: number) => (
<div key={groupIndex} className="w-layout-vflex flex-block-53">
{paramGroup.params?.map((param: any, paramIndex: number) => (
<div key={paramIndex} className="w-layout-hflex flex-block-55">
<span className="text-block-29">{param.title}:</span>
<span className="text-block-28">
{param.values?.map((value: any) => value.value).join(', ') || 'Нет данных'}
</span>
</div> </div>
))} ))}
</div> </div>
); ));
};
// Функция для рендеринга характеристик из Parts Index
const renderPartsIndexCharacteristics = () => {
if (!result?.partsIndexCharacteristics || result.partsIndexCharacteristics.length === 0) return null;
return (
<div className="w-layout-vflex flex-block-53">
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
Дополнительные характеристики:
</span>
</div>
{result.partsIndexCharacteristics.map((char: any, index: number) => (
<div key={index} className="w-layout-hflex flex-block-55">
<span className="text-block-29">{char.name}:</span>
<span className="text-block-28">{char.value}</span>
</div>
))}
</div>
);
};
// Функция для рендеринга изображений из нашей базы данных
const renderInternalImages = () => {
if (!result?.images || result.images.length === 0) return null;
return (
<div className="w-layout-vflex flex-block-53" style={{ marginTop: '20px' }}>
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
Изображения товара:
</span>
</div>
<div className="w-layout-hflex" style={{ flexWrap: 'wrap', gap: '10px', marginTop: '10px' }}>
{result.images.slice(0, 6).map((image: any, index: number) => (
<div key={image.id || index} style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
width: '120px',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9f9f9'
}}>
<img
src={image.url}
alt={image.alt || `${result?.brand} ${result?.articleNumber} - изображение ${index + 1}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
cursor: 'pointer'
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onClick={() => window.open(image.url, '_blank')}
/>
</div>
))}
</div>
</div>
);
}; };
return ( return (
@ -111,27 +62,33 @@ const ProductCharacteristics = ({ result }: ProductCharacteristicsProps) => {
<span className="text-block-29">Название:</span> <span className="text-block-29">Название:</span>
<span className="text-block-28">{result.name}</span> <span className="text-block-28">{result.name}</span>
</div> </div>
{result?.description && ( {partsIndexData?.originalName && (
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Оригинальное название:</span>
<span className="text-block-28">{partsIndexData.originalName}</span>
</div>
)}
{partsIndexData?.description && (
<div className="w-layout-hflex flex-block-55"> <div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Описание:</span> <span className="text-block-29">Описание:</span>
<span className="text-block-28" style={{ maxWidth: '400px', wordWrap: 'break-word' }}> <span className="text-block-28">{partsIndexData.description}</span>
{result.description}
</span>
</div> </div>
)} )}
</div> </div>
{/* Характеристики из нашей базы данных */}
{renderInternalCharacteristics()}
{/* Дополнительные характеристики из Parts Index */} {/* Дополнительные характеристики из Parts Index */}
{renderPartsIndexCharacteristics()} {partsIndexLoading ? (
<div className="w-layout-vflex flex-block-53">
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Загрузка характеристик...</span>
</div>
</div>
) : (
renderPartsIndexParameters()
)}
</> </>
)} )}
</div> </div>
{/* Изображения из нашей базы данных */}
{renderInternalImages()}
</> </>
); );
}; };

View File

@ -15,6 +15,7 @@ import {
const ProfileHistoryMain = () => { const ProfileHistoryMain = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("Все"); const [activeTab, setActiveTab] = useState("Все");
const [selectedManufacturer, setSelectedManufacturer] = useState("Все");
const [sortField, setSortField] = useState<"date" | "manufacturer" | "name">("date"); const [sortField, setSortField] = useState<"date" | "manufacturer" | "name">("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [filteredItems, setFilteredItems] = useState<PartsSearchHistoryItem[]>([]); const [filteredItems, setFilteredItems] = useState<PartsSearchHistoryItem[]>([]);
@ -105,6 +106,14 @@ const ProfileHistoryMain = () => {
useEffect(() => { useEffect(() => {
let filtered = [...getFilteredByTime(historyItems, activeTab)]; let filtered = [...getFilteredByTime(historyItems, activeTab)];
// Фильтрация по производителю
if (selectedManufacturer !== "Все") {
filtered = filtered.filter(item =>
item.brand === selectedManufacturer ||
item.vehicleInfo?.brand === selectedManufacturer
);
}
// Поиск // Поиск
if (search.trim()) { if (search.trim()) {
const searchLower = search.toLowerCase(); const searchLower = search.toLowerCase();
@ -152,7 +161,7 @@ const ProfileHistoryMain = () => {
} }
setFilteredItems(filtered); setFilteredItems(filtered);
}, [historyItems, search, activeTab, sortField, sortOrder]); }, [historyItems, search, activeTab, selectedManufacturer, sortField, sortOrder]);
const handleSort = (field: "date" | "manufacturer" | "name") => { const handleSort = (field: "date" | "manufacturer" | "name") => {
if (sortField === field) { if (sortField === field) {
@ -272,6 +281,18 @@ const ProfileHistoryMain = () => {
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{(selectedManufacturer !== "Все" || search.trim() || activeTab !== "Все") && (
<button
onClick={() => {
setSelectedManufacturer("Все");
setSearch("");
setActiveTab("Все");
}}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Сбросить фильтры
</button>
)}
{historyItems.length === 0 && ( {historyItems.length === 0 && (
<button <button
onClick={handleCreateTestData} onClick={handleCreateTestData}
@ -297,7 +318,14 @@ const ProfileHistoryMain = () => {
</div> </div>
<div className="flex flex-col mt-5 w-full text-lg font-medium leading-tight whitespace-nowrap text-gray-950 max-md:max-w-full"> <div className="flex flex-col mt-5 w-full text-lg font-medium leading-tight whitespace-nowrap text-gray-950 max-md:max-w-full">
<ProfileHistoryTabs tabs={tabOptions} activeTab={activeTab} onTabChange={setActiveTab} /> <ProfileHistoryTabs
tabs={tabOptions}
activeTab={activeTab}
onTabChange={setActiveTab}
historyItems={historyItems}
selectedManufacturer={selectedManufacturer}
onManufacturerChange={setSelectedManufacturer}
/>
</div> </div>
<div className="flex flex-col mt-5 w-full text-gray-400 max-md:max-w-full flex-1 h-full"> <div className="flex flex-col mt-5 w-full text-gray-400 max-md:max-w-full flex-1 h-full">
@ -421,6 +449,11 @@ const ProfileHistoryMain = () => {
{filteredItems.length > 0 && ( {filteredItems.length > 0 && (
<div className="mt-4 text-center text-sm text-gray-500"> <div className="mt-4 text-center text-sm text-gray-500">
Показано {filteredItems.length} из {historyItems.length} записей Показано {filteredItems.length} из {historyItems.length} записей
{(selectedManufacturer !== "Все" || search.trim() || activeTab !== "Все") && (
<span className="ml-2 text-blue-600">
(применены фильтры)
</span>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -1,22 +1,47 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef } from "react";
import { PartsSearchHistoryItem } from '@/lib/graphql/search-history';
interface ProfileHistoryTabsProps { interface ProfileHistoryTabsProps {
tabs: string[]; tabs: string[];
activeTab: string; activeTab: string;
onTabChange: (tab: string) => void; onTabChange: (tab: string) => void;
historyItems: PartsSearchHistoryItem[];
selectedManufacturer: string;
onManufacturerChange: (manufacturer: string) => void;
} }
const manufacturers = ["Все", "VAG", "Toyota", "Ford", "BMW"];
const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
tabs, tabs,
activeTab, activeTab,
onTabChange, onTabChange,
historyItems,
selectedManufacturer,
onManufacturerChange,
}) => { }) => {
const [selectedManufacturer, setSelectedManufacturer] = useState(manufacturers[0]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Получаем уникальных производителей из истории поиска
const getUniqueManufacturers = () => {
const manufacturersSet = new Set<string>();
historyItems.forEach(item => {
// Добавляем бренд из поля brand
if (item.brand) {
manufacturersSet.add(item.brand);
}
// Добавляем бренд из информации об автомобиле
if (item.vehicleInfo?.brand) {
manufacturersSet.add(item.vehicleInfo.brand);
}
});
const uniqueManufacturers = Array.from(manufacturersSet).sort();
return ["Все", ...uniqueManufacturers];
};
const manufacturers = getUniqueManufacturers();
// Закрытие дропдауна при клике вне // Закрытие дропдауна при клике вне
React.useEffect(() => { React.useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@ -34,6 +59,11 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
}; };
}, [isDropdownOpen]); }, [isDropdownOpen]);
const handleManufacturerSelect = (manufacturer: string) => {
onManufacturerChange(manufacturer);
setIsDropdownOpen(false);
};
return ( return (
<div className="flex flex-wrap gap-5 w-full max-md:max-w-full"> <div className="flex flex-wrap gap-5 w-full max-md:max-w-full">
{tabs.map((tab) => ( {tabs.map((tab) => (
@ -69,20 +99,41 @@ const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
> >
<span className="truncate">{selectedManufacturer}</span> <span className="truncate">{selectedManufacturer}</span>
<span className="ml-2 flex-shrink-0 flex items-center"> <span className="ml-2 flex-shrink-0 flex items-center">
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg> <svg
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
className={`transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
>
<path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span> </span>
</div> </div>
{isDropdownOpen && ( {isDropdownOpen && (
<ul className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full"> <ul className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full max-h-60 overflow-y-auto">
{manufacturers.map((option) => ( {manufacturers.length === 0 ? (
<li <li className="px-6 py-4 text-gray-400 text-center">
key={option} Нет данных
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === selectedManufacturer ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setSelectedManufacturer(option); setIsDropdownOpen(false); }}
>
{option}
</li> </li>
))} ) : (
manufacturers.map((manufacturer) => (
<li
key={manufacturer}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 transition-colors ${manufacturer === selectedManufacturer ? 'bg-blue-50 font-semibold text-blue-600' : ''}`}
onMouseDown={() => handleManufacturerSelect(manufacturer)}
>
{manufacturer}
{manufacturer !== "Все" && (
<span className="ml-2 text-xs text-gray-400">
({historyItems.filter(item =>
item.brand === manufacturer || item.vehicleInfo?.brand === manufacturer
).length})
</span>
)}
</li>
))
)}
</ul> </ul>
)} )}
</div> </div>

View File

@ -18,7 +18,7 @@ export const useArticleImage = (
artId: string | undefined | null, artId: string | undefined | null,
options: UseArticleImageOptions = {} options: UseArticleImageOptions = {}
): UseArticleImageReturn => { ): UseArticleImageReturn => {
const { enabled = true, fallbackImage = '' } = options; const { enabled = true, fallbackImage = '/images/image-10.png' } = options;
const [imageUrl, setImageUrl] = useState<string>(fallbackImage); const [imageUrl, setImageUrl] = useState<string>(fallbackImage);
// Проверяем что artId валидный // Проверяем что artId валидный

View File

@ -7,7 +7,6 @@ import CartSummary from "@/components/CartSummary";
import CartRecommended from "../components/CartRecommended"; import CartRecommended from "../components/CartRecommended";
import CatalogSubscribe from "@/components/CatalogSubscribe"; import CatalogSubscribe from "@/components/CatalogSubscribe";
import MobileMenuBottomSection from "@/components/MobileMenuBottomSection"; import MobileMenuBottomSection from "@/components/MobileMenuBottomSection";
import CartDebug from "@/components/CartDebug";
export default function CartPage() { export default function CartPage() {
@ -39,7 +38,6 @@ export default function CartPage() {
</section> </section>
<Footer /> <Footer />
<MobileMenuBottomSection /> <MobileMenuBottomSection />
<CartDebug />
</> </>
); );
} }

View File

@ -16,7 +16,7 @@ export default function Favorite() {
const [showFiltersMobile, setShowFiltersMobile] = useState(false); const [showFiltersMobile, setShowFiltersMobile] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterValues, setFilterValues] = useState<{[key: string]: any}>({}); const [filterValues, setFilterValues] = useState<{[key: string]: any}>({});
const [sortBy, setSortBy] = useState<'name' | 'brand' | 'price' | 'date'>('date'); const [sortBy, setSortBy] = useState<'name' | 'brand' | 'date'>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Создаем динамические фильтры на основе данных избранного // Создаем динамические фильтры на основе данных избранного

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Head from "next/head"; import Head from "next/head";
import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMutation, ApolloProvider } from "@apollo/client"; import { useMutation, ApolloProvider } from "@apollo/client";
import { gql } from "@apollo/client"; import { gql } from "@apollo/client";
import { apolloClient } from "@/lib/apollo"; import { apolloClient } from "@/lib/apollo";
import toast from "react-hot-toast";
const CONFIRM_PAYMENT = gql` const CONFIRM_PAYMENT = gql`
mutation ConfirmPayment($orderId: ID!) { mutation ConfirmPayment($orderId: ID!) {
@ -51,8 +51,15 @@ function PaymentSuccessContent() {
} }
}).then(() => { }).then(() => {
console.log('Оплата заказа подтверждена'); console.log('Оплата заказа подтверждена');
toast.success('Оплата успешно подтверждена!', {
duration: 3000,
icon: '✅',
});
}).catch((error: any) => { }).catch((error: any) => {
console.error('Ошибка подтверждения оплаты:', error); console.error('Ошибка подтверждения оплаты:', error);
toast.error('Ошибка подтверждения оплаты: ' + error.message, {
duration: 5000,
});
}); });
} }
}, [router.query, confirmPayment]); }, [router.query, confirmPayment]);
@ -76,7 +83,7 @@ function PaymentSuccessContent() {
<link href="/images/webclip.png" rel="apple-touch-icon" /> <link href="/images/webclip.png" rel="apple-touch-icon" />
</Head> </Head>
<Header />
<div className="w-layout-blockcontainer container info w-container"> <div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9"> <div className="w-layout-vflex flex-block-9">

View File

@ -1,4 +1,8 @@
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useQuery } from '@apollo/client';
import { GET_CLIENT_ME } from '@/lib/graphql';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import ProfileSidebar from '@/components/ProfileSidebar'; import ProfileSidebar from '@/components/ProfileSidebar';
@ -11,6 +15,48 @@ import NotificationMane from "@/components/profile/NotificationMane";
import Head from "next/head"; import Head from "next/head";
const ProfileActsPage = () => { const ProfileActsPage = () => {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME, {
skip: !isAuthenticated,
onCompleted: (data) => {
// Проверяем есть ли у клиента юридические лица
if (!data?.clientMe?.legalEntities?.length) {
// Если нет юридических лиц, перенаправляем на настройки
router.push('/profile-settings?tab=legal');
return;
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
// Если ошибка авторизации, перенаправляем на главную
router.push('/');
}
});
useEffect(() => {
// Проверяем авторизацию
const token = localStorage.getItem('authToken');
if (!token) {
router.push('/');
return;
}
setIsAuthenticated(true);
}, [router]);
// Показываем загрузку пока проверяем авторизацию и данные
if (!isAuthenticated || clientLoading) {
return (
<div className="page-wrapper">
<div className="flex flex-col justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка...</div>
</div>
<Footer />
</div>
);
}
return ( return (
<div className="page-wrapper"> <div className="page-wrapper">
<Head> <Head>

View File

@ -1,4 +1,8 @@
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useQuery } from '@apollo/client';
import { GET_CLIENT_ME } from '@/lib/graphql';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import CatalogSubscribe from '@/components/CatalogSubscribe'; import CatalogSubscribe from '@/components/CatalogSubscribe';
@ -8,9 +12,49 @@ import ProfileRequisitiesMain from '@/components/profile/ProfileRequisitiesMain'
import ProfileInfo from '@/components/profile/ProfileInfo'; import ProfileInfo from '@/components/profile/ProfileInfo';
import Head from "next/head"; import Head from "next/head";
const ProfileRequisitiesPage = () => { const ProfileRequisitiesPage = () => {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME, {
skip: !isAuthenticated,
onCompleted: (data) => {
// Проверяем есть ли у клиента юридические лица
if (!data?.clientMe?.legalEntities?.length) {
// Если нет юридических лиц, перенаправляем на настройки
router.push('/profile-settings?tab=legal');
return;
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
// Если ошибка авторизации, перенаправляем на главную
router.push('/');
}
});
useEffect(() => {
// Проверяем авторизацию
const token = localStorage.getItem('authToken');
if (!token) {
router.push('/');
return;
}
setIsAuthenticated(true);
}, [router]);
// Показываем загрузку пока проверяем авторизацию и данные
if (!isAuthenticated || clientLoading) {
return (
<div className="page-wrapper">
<div className="flex flex-col justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка...</div>
</div>
<Footer />
</div>
);
}
return ( return (
<div className="page-wrapper"> <div className="page-wrapper">
<Head> <Head>

View File

@ -502,6 +502,7 @@ export default function SearchResult() {
price={`${offer.price.toLocaleString()}`} price={`${offer.price.toLocaleString()}`}
delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`} delivery={`${offer.deliveryDuration} ${offer.deliveryDuration === 1 ? 'день' : 'дней'}`}
stock={`${offer.quantity} шт.`} stock={`${offer.quantity} шт.`}
offer={offer}
/> />
))} ))}
</div> </div>

View File

@ -105,6 +105,14 @@ const VehicleSearchResultsPage: React.FC<VehicleSearchResultsPageProps> = () =>
ssdLength: ssd.length ssdLength: ssd.length
}); });
// Создаем базовые параметры URL
const urlParams = new URLSearchParams();
// Добавляем VIN-номер в URL, если он есть
if (searchQuery && searchType === 'vin') {
urlParams.set('vin', searchQuery);
}
// Если есть SSD, сохраняем его в localStorage для безопасной передачи // Если есть SSD, сохраняем его в localStorage для безопасной передачи
if (ssd && ssd.trim() !== '') { if (ssd && ssd.trim() !== '') {
const vehicleKey = `vehicle_ssd_${catalogCode}_${vehicleId}`; const vehicleKey = `vehicle_ssd_${catalogCode}_${vehicleId}`;
@ -121,25 +129,22 @@ const VehicleSearchResultsPage: React.FC<VehicleSearchResultsPageProps> = () =>
localStorage.setItem(vehicleKey, ssd); localStorage.setItem(vehicleKey, ssd);
// Выбираем URL в зависимости от того, нужно ли пропустить промежуточную страницу urlParams.set('use_storage', '1');
const url = skipToCategories urlParams.set('ssd_length', ssd.length.toString());
? `/vehicle-search/${catalogCode}/${vehicleId}?use_storage=1&ssd_length=${ssd.length}&searchType=categories`
: `/vehicle-search/${catalogCode}/${vehicleId}?use_storage=1&ssd_length=${ssd.length}`;
console.log('🔗 Переходим на URL с localStorage:', url);
// Используем replace вместо push для моментального перехода
router.replace(url);
} else {
// Выбираем URL в зависимости от того, нужно ли пропустить промежуточную страницу
const url = skipToCategories
? `/vehicle-search/${catalogCode}/${vehicleId}?searchType=categories`
: `/vehicle-search/${catalogCode}/${vehicleId}`;
console.log('🔗 Переходим на URL без SSD:', url);
// Используем replace вместо push для моментального перехода
router.replace(url);
} }
}, [router]);
if (skipToCategories) {
urlParams.set('searchType', 'categories');
}
// Формируем URL с параметрами
const baseUrl = `/vehicle-search/${catalogCode}/${vehicleId}`;
const url = urlParams.toString() ? `${baseUrl}?${urlParams.toString()}` : baseUrl;
console.log('🔗 Переходим на URL:', url);
// Используем replace вместо push для моментального перехода
router.replace(url);
}, [router, searchQuery, searchType]);
// Предзагрузка и автоматический переход при поиске по VIN, если найден только один автомобиль // Предзагрузка и автоматический переход при поиске по VIN, если найден только один автомобиль
useEffect(() => { useEffect(() => {