9 Commits

Author SHA1 Message Date
7d9f611fe5 Merge pull request 'демо исправление карточки' (#14) from 1318 into main
Reviewed-on: #14
2025-07-06 13:20:06 +03:00
8820f4e835 демо исправление карточки 2025-07-06 13:18:53 +03:00
ac7b2de49f Обновлена логика добавления товаров в корзину во всех компонентах. Теперь добавление происходит асинхронно с обработкой успешных и ошибочных результатов. Добавлена информация о наличии товара при добавлении в корзину. Улучшены уведомления о добавлении товара с учетом статуса операции. 2025-07-06 02:21:33 +03:00
a8c8ae60bb Добавлен компонент CartIcon и обновлены уведомления о добавлении товара в корзину во всех соответствующих компонентах. Изменены стили текста и иконки в уведомлениях для улучшения визуального восприятия. 2025-07-05 18:38:12 +03:00
78e17a94ab Исправлены размеры SVG в компонентах CartRecommendedProductCard, Footer и Header. Обновлены атрибуты width и height для корректного отображения и устранения опечаток. В компоненте VinQuick улучшена уникальность ключей при отображении деталей. 2025-07-05 18:00:31 +03:00
36c5990921 Улучшена обработка SSD в компонентах QuickDetailSection, UnitDetailsSection и KnotIn. Добавлены отладочные логи для отслеживания значений SSD и состояния загрузки данных. Обновлены условия пропуска запросов в зависимости от наличия SSD. Исправлена логика передачи SSD в компонент KnotIn с использованием значения узла или родительского SSD. 2025-07-05 13:35:49 +03:00
e989d402a3 Добавлены отладочные логи в компонент WizardSearchForm для отслеживания обновлений и сброса параметров. Обновлена логика автовыбора параметров с учетом состояния загрузки. В компонент VinLeftbar добавлена загрузка единиц для категории при открытии. Улучшена обработка SSD при сбросе параметров. 2025-07-05 12:55:08 +03:00
65710a35be Merge pull request 'newpravki' (#13) from newpravki into main
Reviewed-on: #13
2025-07-04 21:52:14 +03:00
9a604b39b3 переделаны счетчки фильтр рэндж, настроены выборы категорий и подкатегорий 2025-07-04 21:51:28 +03:00
30 changed files with 1093 additions and 532 deletions

View File

@ -1,6 +1,7 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useCart } from "@/contexts/CartContext";
import toast from "react-hot-toast";
import CartIcon from "./CartIcon";
interface BestPriceCardProps {
bestOfferType: string;
@ -27,6 +28,11 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10);
const maxCount = isNaN(parsedStock) ? undefined : parsedStock;
const [count, setCount] = useState(1);
const [inputValue, setInputValue] = useState("1");
useEffect(() => {
setInputValue(count.toString());
}, [count]);
const handleMinus = () => setCount(prev => Math.max(1, prev - 1));
const handlePlus = () => {
@ -38,7 +44,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
};
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = parseInt(e.target.value, 10);
const val = e.target.value;
setInputValue(val);
if (val === "") {
// Не обновляем count, пока не будет blur
return;
}
let value = parseInt(val, 10);
if (isNaN(value) || value < 1) value = 1;
if (maxCount !== undefined && value > maxCount) {
toast.error(`Максимум ${maxCount} шт.`);
@ -47,6 +59,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
setCount(value);
};
const handleInputBlur = () => {
if (inputValue === "") {
setInputValue("1");
setCount(1);
}
};
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
@ -54,7 +73,7 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
};
// Обработчик добавления в корзину
const handleAddToCart = (e: React.MouseEvent) => {
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -69,14 +88,8 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
return;
}
// Проверяем наличие
if (maxCount !== undefined && count > maxCount) {
toast.error(`Недостаточно товара в наличии. Доступно: ${maxCount} шт.`);
return;
}
try {
addItem({
const result = await addItem({
productId: offer.productId,
offerKey: offer.offerKey,
name: description,
@ -86,6 +99,7 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
price: numericPrice,
currency: offer.currency || 'RUB',
quantity: count,
stock: maxCount, // передаем информацию о наличии
deliveryTime: delivery,
warehouse: offer.warehouse || 'Склад',
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
@ -93,17 +107,22 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
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: '🛒',
}
);
if (result.success) {
// Показываем тоастер об успешном добавлении
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${offer.brand} ${offer.articleNumber} (${count} шт.)`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
// Показываем ошибку
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка добавления товара в корзину');
@ -144,8 +163,9 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
type="number"
min={1}
max={maxCount}
value={count}
value={inputValue}
onChange={handleInput}
onBlur={handleInputBlur}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
/>

View File

@ -0,0 +1,25 @@
import React from 'react';
interface CartIconProps {
size?: number;
color?: string;
}
const CartIcon: React.FC<CartIconProps> = ({ size = 24, color = '#fff' }) => {
return (
<svg
width={size}
height={size}
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={color}
/>
</svg>
);
};
export default CartIcon;

View File

@ -38,160 +38,180 @@ const CartItem: React.FC<CartItemProps> = ({
onRemove,
isSummaryStep = false,
itemNumber,
}) => (
<div className="w-layout-hflex cart-item">
<div className="w-layout-hflex info-block-search-copy">
{isSummaryStep ? (
<div style={{ marginRight: 12, minWidth: 24, textAlign: 'center', fontWeight: 600, fontSize: 14 }}>{itemNumber}</div>
) : (
<div
className={"div-block-7" + (selected ? " active" : "")}
onClick={onSelect}
style={{ marginRight: 12, cursor: 'pointer' }}
>
{selected && (
<svg width="14" height="10" viewBox="0 0 14 10" fill="none">
<path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
)}
<div className="w-layout-hflex block-name">
<h4 className="heading-9-copy">{name}</h4>
<div
className={
"text-block-21-copy" +
(isSummaryStep && itemNumber === 1 ? " border-t-0" : "")
}
style={
isSummaryStep && itemNumber === 1
? { borderTop: 'none' }
: undefined
}
>
{description}
</div>
</div>
<div className="form-block-copy w-form">
<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-5"
value={comment}
onChange={e => onComment(e.target.value)}
disabled={isSummaryStep}
/>
</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 className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-39-copy">
<h4 className="delivery-cart-s1">{delivery}</h4>
<div className="text-block-21-copy-copy">{deliveryDate}</div>
</div>
<div className="w-layout-hflex pcs-cart-s1">
}) => {
// --- Фикс для input: можно стереть, при blur пустое = 1 ---
const [inputValue, setInputValue] = React.useState(count.toString());
React.useEffect(() => {
setInputValue(count.toString());
}, [count]);
return (
<div className="w-layout-hflex cart-item">
<div className="w-layout-hflex info-block-search-copy">
{isSummaryStep ? (
<div className="text-block-26" style={{ fontWeight: 600, fontSize: 14 }}>{count} шт.</div>
<div style={{ marginRight: 12, minWidth: 24, textAlign: 'center', fontWeight: 600, fontSize: 14 }}>{itemNumber}</div>
) : (
<>
<div
className="minus-plus"
onClick={() => onCountChange && onCountChange(count - 1)}
style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count - 1)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor"/>
</svg>
</div>
<div
className={"div-block-7" + (selected ? " active" : "")}
onClick={onSelect}
style={{ marginRight: 12, cursor: 'pointer' }}
>
{selected && (
<svg width="14" height="10" viewBox="0 0 14 10" fill="none">
<path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
<div className="input-pcs">
<input
type="number"
min={1}
value={count}
onChange={e => {
const value = Math.max(1, parseInt(e.target.value, 10) || 1);
onCountChange && onCountChange(value);
}}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
style={{ width: 40 }}
/>
</div>
<div
className="minus-plus"
onClick={() => onCountChange && onCountChange(count + 1)}
style={{ cursor: 'pointer' }}
aria-label="Увеличить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count + 1)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor"/>
</svg>
</div>
</div>
</>
)}
</div>
<div className="w-layout-hflex flex-block-39-copy-copy">
<h4 className="price-in-cart-s1">{price}</h4>
<div className="price-1-pcs-cart-s1">{pricePerItem}</div>
</div>
{!isSummaryStep && (
<div className="w-layout-hflex control-element">
<div className="favorite-icon w-embed" onClick={onFavorite} style={{ cursor: 'pointer', color: favorite ? '#e53935' : undefined }}>
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 16.5L7.84 15.4929C3.72 11.93 1 9.57248 1 6.69619C1 4.33869 2.936 2.5 5.4 2.5C6.792 2.5 8.128 3.11798 9 4.08692C9.872 3.11798 11.208 2.5 12.6 2.5C15.064 2.5 17 4.33869 17 6.69619C17 9.57248 14.28 11.93 10.16 15.4929L9 16.5Z" fill={favorite ? "#e53935" : "currentColor"} />
</svg>
</div>
<div className="w-layout-hflex block-name">
<h4 className="heading-9-copy">{name}</h4>
<div
className="bdel"
role="button"
tabIndex={0}
aria-label="Удалить из корзины"
onClick={onRemove}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onRemove && onRemove()}
style={{ display: 'inline-flex', cursor: 'pointer', transition: 'color 0.2s' }}
onMouseEnter={e => {
const path = e.currentTarget.querySelector('path');
if (path) path.setAttribute('fill', '#ec1c24');
}}
onMouseLeave={e => {
const path = e.currentTarget.querySelector('path');
if (path) path.setAttribute('fill', '#D0D0D0');
}}
className={
"text-block-21-copy" +
(isSummaryStep && itemNumber === 1 ? " border-t-0" : "")
}
style={
isSummaryStep && itemNumber === 1
? { borderTop: 'none' }
: undefined
}
>
{description}
</div>
</div>
<div className="form-block-copy w-form">
<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-5"
value={comment}
onChange={e => onComment(e.target.value)}
disabled={isSummaryStep}
/>
</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 className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-39-copy">
<h4 className="delivery-cart-s1">{delivery}</h4>
<div className="text-block-21-copy-copy">{deliveryDate}</div>
</div>
<div className="w-layout-hflex pcs-cart-s1">
{isSummaryStep ? (
<div className="text-block-26" style={{ fontWeight: 600, fontSize: 14 }}>{count} шт.</div>
) : (
<>
<div
className="minus-plus"
onClick={() => onCountChange && onCountChange(count - 1)}
style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count - 1)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor"/>
</svg>
</div>
</div>
<div className="input-pcs">
<input
type="number"
min={1}
value={inputValue}
onChange={e => {
const val = e.target.value;
setInputValue(val);
if (val === "") {
// Не обновляем count, пока не будет blur
return;
}
const valueNum = Math.max(1, parseInt(val, 10) || 1);
onCountChange && onCountChange(valueNum);
}}
onBlur={() => {
if (inputValue === "") {
setInputValue("1");
onCountChange && onCountChange(1);
}
}}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
style={{ width: 40 }}
/>
</div>
<div
className="minus-plus"
onClick={() => onCountChange && onCountChange(count + 1)}
style={{ cursor: 'pointer' }}
aria-label="Увеличить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count + 1)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor"/>
</svg>
</div>
</div>
</>
)}
</div>
<div className="w-layout-hflex flex-block-39-copy-copy">
<h4 className="price-in-cart-s1">{price}</h4>
<div className="price-1-pcs-cart-s1">{pricePerItem}</div>
</div>
{!isSummaryStep && (
<div className="w-layout-hflex control-element">
<div className="favorite-icon w-embed" onClick={onFavorite} style={{ cursor: 'pointer', color: favorite ? '#e53935' : undefined }}>
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.625 17.5C4.14375 17.5 3.73192 17.3261 3.3895 16.9782C3.04708 16.6304 2.87558 16.2117 2.875 15.7222V4.16667H2V2.38889H6.375V1.5H11.625V2.38889H16V4.16667H15.125V15.7222C15.125 16.2111 14.9538 16.6298 14.6114 16.9782C14.269 17.3267 13.8568 17.5006 13.375 17.5H4.625ZM6.375 13.9444H8.125V5.94444H6.375V13.9444ZM9.875 13.9444H11.625V5.94444H9.875V13.9444Z"
fill="#D0D0D0"
style={{ transition: 'fill 0.2s' }}
/>
<path d="M9 16.5L7.84 15.4929C3.72 11.93 1 9.57248 1 6.69619C1 4.33869 2.936 2.5 5.4 2.5C6.792 2.5 8.128 3.11798 9 4.08692C9.872 3.11798 11.208 2.5 12.6 2.5C15.064 2.5 17 4.33869 17 6.69619C17 9.57248 14.28 11.93 10.16 15.4929L9 16.5Z" fill={favorite ? "#e53935" : "currentColor"} />
</svg>
</div>
<div
className="bdel"
role="button"
tabIndex={0}
aria-label="Удалить из корзины"
onClick={onRemove}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onRemove && onRemove()}
style={{ display: 'inline-flex', cursor: 'pointer', transition: 'color 0.2s' }}
onMouseEnter={e => {
const path = e.currentTarget.querySelector('path');
if (path) path.setAttribute('fill', '#ec1c24');
}}
onMouseLeave={e => {
const path = e.currentTarget.querySelector('path');
if (path) path.setAttribute('fill', '#D0D0D0');
}}
>
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.625 17.5C4.14375 17.5 3.73192 17.3261 3.3895 16.9782C3.04708 16.6304 2.87558 16.2117 2.875 15.7222V4.16667H2V2.38889H6.375V1.5H11.625V2.38889H16V4.16667H15.125V15.7222C15.125 16.2111 14.9538 16.6298 14.6114 16.9782C14.269 17.3267 13.8568 17.5006 13.375 17.5H4.625ZM6.375 13.9444H8.125V5.94444H6.375V13.9444ZM9.875 13.9444H11.625V5.94444H9.875V13.9444Z"
fill="#D0D0D0"
style={{ transition: 'fill 0.2s' }}
/>
</svg>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
);
};
export default CartItem;

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import CartItem from "./CartItem";
import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
@ -8,7 +8,7 @@ interface CartListProps {
}
const CartList: React.FC<CartListProps> = ({ isSummaryStep = false }) => {
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity } = useCart();
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity, clearError } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite, favorites } = useFavorites();
const { items } = state;
@ -73,8 +73,40 @@ const CartList: React.FC<CartListProps> = ({ isSummaryStep = false }) => {
// На втором шаге показываем только выбранные товары
const displayItems = isSummaryStep ? items.filter(item => item.selected) : items;
// Автоматически очищаем ошибки через 5 секунд
useEffect(() => {
if (state.error) {
const timer = setTimeout(() => {
clearError();
}, 5000);
return () => clearTimeout(timer);
}
}, [state.error, clearError]);
return (
<div className="w-layout-vflex flex-block-48">
{/* Отображение ошибок корзины */}
{state.error && (
<div className="alert alert-error mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span>{state.error}</span>
</div>
<button
onClick={clearError}
className="ml-2 text-red-500 hover:text-red-700"
aria-label="Закрыть уведомление"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
<div className="w-layout-vflex product-list-cart">
{!isSummaryStep && (
<div className="w-layout-hflex multi-control">

View File

@ -3,6 +3,7 @@ import CatalogProductCard from "./CatalogProductCard";
import { useArticleImage } from "@/hooks/useArticleImage";
import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast";
import CartIcon from "./CartIcon";
interface CartRecommendedProps {
recommendedProducts?: any[];
@ -36,13 +37,14 @@ const RecommendedProductCard: React.FC<{
}
// Добавляем товар в корзину
addItem({
const result = await addItem({
productId: String(item.artId) || undefined,
name: item.name || `${item.brand} ${item.articleNumber}`,
description: item.name || `${item.brand} ${item.articleNumber}`,
price: numericPrice,
currency: 'RUB',
quantity: 1,
stock: undefined, // информация о наличии не доступна для рекомендуемых товаров
image: displayImage,
brand: item.brand,
article: item.articleNumber,
@ -51,17 +53,22 @@ const RecommendedProductCard: React.FC<{
isExternal: true
});
// Показываем успешный тоастер
toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{item.name || `${item.brand} ${item.articleNumber}`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
if (result.success) {
// Показываем успешный тоастер
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{item.name || `${item.brand} ${item.articleNumber}`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
// Показываем ошибку
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка при добавлении товара в корзину');

View File

@ -42,7 +42,7 @@ const CartRecommendedProductCard: React.FC<CartRecommendedProductCardProps> = ({
<Link href="/cart" className="link-block-4-copy w-inline-block">
<div className="div-block-25">
<span className="icon-setting w-embed">
<svg width="currentWidht" height="currentHight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="30" height="30" 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>
</span>

View File

@ -1,7 +1,8 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast";
import CartIcon from "./CartIcon";
const INITIAL_OFFERS_LIMIT = 5;
@ -52,8 +53,16 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
);
const [inputValues, setInputValues] = useState<{ [key: number]: string }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: "1" }), {})
);
const [quantityErrors, setQuantityErrors] = useState<{ [key: number]: string }>({});
useEffect(() => {
setInputValues(offers.reduce((acc, _, index) => ({ ...acc, [index]: "1" }), {}));
setQuantities(offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}));
}, [offers.length]);
const displayedOffers = offers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < offers.length;
@ -83,31 +92,44 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
return match ? parseInt(match[0]) : 0;
};
const handleQuantityInput = (index: number, value: string) => {
const offer = offers[index];
const availableStock = parseStock(offer.pcs);
let num = parseInt(value, 10);
if (isNaN(num) || num < 1) num = 1;
if (num > availableStock) {
toast.error(`Максимум ${availableStock} шт.`);
return;
}
setQuantities(prev => ({ ...prev, [index]: num }));
const handleInputChange = (idx: number, val: string) => {
setInputValues(prev => ({ ...prev, [idx]: val }));
if (val === "") return;
const valueNum = Math.max(1, parseInt(val, 10) || 1);
setQuantities(prev => ({ ...prev, [idx]: valueNum }));
};
const handleAddToCart = (offer: CoreProductCardOffer, index: number) => {
const handleInputBlur = (idx: number) => {
if (inputValues[idx] === "") {
setInputValues(prev => ({ ...prev, [idx]: "1" }));
setQuantities(prev => ({ ...prev, [idx]: 1 }));
}
};
const handleMinus = (idx: number) => {
setQuantities(prev => {
const newVal = Math.max(1, (prev[idx] || 1) - 1);
setInputValues(vals => ({ ...vals, [idx]: newVal.toString() }));
return { ...prev, [idx]: newVal };
});
};
const handlePlus = (idx: number, maxCount?: number) => {
setQuantities(prev => {
let newVal = (prev[idx] || 1) + 1;
if (maxCount !== undefined) newVal = Math.min(newVal, maxCount);
setInputValues(vals => ({ ...vals, [idx]: newVal.toString() }));
return { ...prev, [idx]: newVal };
});
};
const handleAddToCart = async (offer: CoreProductCardOffer, index: number) => {
const quantity = quantities[index] || 1;
const availableStock = parseStock(offer.pcs);
// Проверяем наличие
if (quantity > availableStock) {
toast.error(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return;
}
const numericPrice = parsePrice(offer.price);
addItem({
const result = await addItem({
productId: offer.productId,
offerKey: offer.offerKey,
name: name,
@ -117,6 +139,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
price: numericPrice,
currency: offer.currency || 'RUB',
quantity: quantity,
stock: availableStock, // передаем информацию о наличии
deliveryTime: parseDeliveryTime(offer.days),
warehouse: offer.warehouse || 'Склад',
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
@ -124,17 +147,22 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
image: image,
});
// Показываем тоастер вместо alert
toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{`${brand} ${article} (${quantity} шт.)`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
if (result.success) {
// Показываем тоастер вместо alert
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${brand} ${article} (${quantity} шт.)`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
// Показываем ошибку
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
};
// Обработчик клика по сердечку
@ -291,6 +319,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<div className="w-layout-vflex product-list-search-s1">
{displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1;
const maxCount = parseStock(offer.pcs);
return (
<div
className="w-layout-hflex product-item-search-s1"
@ -317,43 +346,48 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<div className="w-layout-hflex add-to-cart-block-s1">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-cart-s1">
<button
type="button"
<div
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) - 1).toString())}
onClick={() => handleMinus(idx)}
style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handleMinus(idx)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor" />
</svg>
</div>
</button>
</div>
<div className="input-pcs">
<input
type="number"
min={1}
max={parseStock(offer.pcs)}
value={quantities[idx] || 1}
onChange={e => handleQuantityInput(idx, e.target.value)}
max={maxCount}
value={inputValues[idx]}
onChange={e => handleInputChange(idx, e.target.value)}
onBlur={() => handleInputBlur(idx)}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
/>
</div>
<button
type="button"
<div
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) + 1).toString())}
onClick={() => handlePlus(idx, maxCount)}
style={{ cursor: 'pointer' }}
aria-label="Увеличить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handlePlus(idx, maxCount)}
role="button"
>
<div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor" />
</svg>
</div>
</button>
</div>
</div>
<button
type="button"

View File

@ -0,0 +1,25 @@
import React from 'react';
interface DeleteCartIconProps {
size?: number;
color?: string;
}
const DeleteCartIcon: React.FC<DeleteCartIconProps> = ({ size = 24, color = '#ec1c24' }) => {
return (
<svg
width={size}
height={size}
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={color}
/>
</svg>
);
};
export default DeleteCartIcon;

View File

@ -24,7 +24,7 @@ const Footer = () => (
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
<div className="dropdown-toggle-2 w-dropdown-toggle">
<div className="text-block-17">Покупателям</div>
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
</div>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Оплата</a>
@ -43,7 +43,7 @@ const Footer = () => (
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
<div className="dropdown-toggle-2 w-dropdown-toggle">
<div className="text-block-17">Сотрудничество</div>
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
</div>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
@ -62,7 +62,7 @@ const Footer = () => (
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
<div className="dropdown-toggle-2 w-dropdown-toggle">
<div className="text-block-17">PROTEK</div>
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
</div>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Вакансии</a>
@ -81,7 +81,7 @@ const Footer = () => (
<div data-hover="false" data-delay="0" className="dropdown-3 w-dropdown">
<div className="dropdown-toggle-2 w-dropdown-toggle">
<div className="text-block-17">Оферта</div>
<div className="code-embed-10 w-embed"><svg width="currentwight" height="currentheight" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentcolor"></path></svg></div>
<div className="code-embed-10 w-embed"><svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M2 6.74036L3.28446 5.5L9 11.0193L14.7155 5.5L16 6.74036L9 13.5L2 6.74036Z" fill="currentColor"></path></svg></div>
</div>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>

View File

@ -37,10 +37,8 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
// Если мы находимся на странице search-result, восстанавливаем поисковый запрос
if (router.pathname === '/search-result') {
const { article, brand } = router.query;
if (article && brand && typeof article === 'string' && typeof brand === 'string') {
// Формируем поисковый запрос из артикула и бренда
setSearchQuery(`${brand} ${article}`);
} else if (article && typeof article === 'string') {
if (article && typeof article === 'string') {
// Отображаем только артикул, без бренда
setSearchQuery(article);
}
}
@ -375,7 +373,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
<div className="w-layout-hflex flex-block-2">
<div className="w-layout-hflex flex-block-3">
<div className="w-layout-hflex flex-block-77-copy">
<div className="code-embed-4 w-embed"><svg width="currentwidth" height="currenthight" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.51667 8.99167C6.71667 11.35 8.65 13.275 11.0083 14.4833L12.8417 12.65C13.0667 12.425 13.4 12.35 13.6917 12.45C14.625 12.7583 15.6333 12.925 16.6667 12.925C17.125 12.925 17.5 13.3 17.5 13.7583V16.6667C17.5 17.125 17.125 17.5 16.6667 17.5C8.84167 17.5 2.5 11.1583 2.5 3.33333C2.5 2.875 2.875 2.5 3.33333 2.5H6.25C6.70833 2.5 7.08333 2.875 7.08333 3.33333C7.08333 4.375 7.25 5.375 7.55833 6.30833C7.65 6.6 7.58333 6.925 7.35 7.15833L5.51667 8.99167Z" fill="currentColor" /></svg></div>
<div className="code-embed-4 w-embed"><svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.51667 8.99167C6.71667 11.35 8.65 13.275 11.0083 14.4833L12.8417 12.65C13.0667 12.425 13.4 12.35 13.6917 12.45C14.625 12.7583 15.6333 12.925 16.6667 12.925C17.125 12.925 17.5 13.3 17.5 13.7583V16.6667C17.5 17.125 17.125 17.5 16.6667 17.5C8.84167 17.5 2.5 11.1583 2.5 3.33333C2.5 2.875 2.875 2.5 3.33333 2.5H6.25C6.70833 2.5 7.08333 2.875 7.08333 3.33333C7.08333 4.375 7.25 5.375 7.55833 6.30833C7.65 6.6 7.58333 6.925 7.35 7.15833L5.51667 8.99167Z" fill="currentColor" /></svg></div>
<div className="phone-copy">+7 (495) 260-20-60</div>
</div>
</div>
@ -393,7 +391,7 @@ const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Au
onClick={() => setMenuOpen((open) => !open)}
style={{ cursor: "pointer" }}
>
<div className="code-embed-5 w-embed"><svg width="currentwidth" height="currenthight" viewBox="0 0 30 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<div className="code-embed-5 w-embed"><svg width="30" height="18" viewBox="0 0 30 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H30V3H0V0Z" fill="currentColor"></path>
<path d="M0 7.5H30V10.5H0V7.5Z" fill="currentColor"></path>
<path d="M0 15H30V18H0V15Z" fill="currentColor"></path>

View File

@ -59,19 +59,13 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
return match ? parseInt(match[0]) : 0;
};
const handleAddToCart = () => {
const handleAddToCart = async () => {
const availableStock = parseStock(stock);
// Проверяем наличие
if (count > availableStock) {
alert(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return;
}
const numericPrice = parsePrice(price);
const numericOldPrice = oldPrice ? parsePrice(oldPrice) : undefined;
addItem({
const result = await addItem({
productId: productId,
offerKey: offerKey,
name: title,
@ -81,6 +75,7 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
originalPrice: numericOldPrice,
currency: currency,
quantity: count,
stock: availableStock, // передаем информацию о наличии
deliveryTime: deliveryTime || delivery,
warehouse: warehouse || address,
supplier: supplier,
@ -88,8 +83,13 @@ const ProductListCard: React.FC<ProductListCardProps> = ({
image: image,
});
// Показываем уведомление о добавлении
alert(`Товар "${title}" добавлен в корзину (${count} шт.)`);
if (result.success) {
// Показываем уведомление о добавлении
alert(`Товар "${title}" добавлен в корзину (${count} шт.)`);
} else {
// Показываем ошибку
alert(result.error || 'Ошибка при добавлении товара в корзину');
}
};
return (

View File

@ -156,10 +156,15 @@ const QuickDetailSection: React.FC<QuickDetailSectionProps> = ({
};
const handleUnitClick = (unit: LaximoUnit) => {
setSelectedUnit({
...unit,
ssd: unit.ssd || ssd // Сохраняем правильный SSD в selectedUnit
// ИСПРАВЛЕНИЕ: Сохраняем SSD узла из API ответа
console.log('🔍 handleUnitClick - сохраняем узел с SSD:', {
unitId: unit.unitid,
unitName: unit.name,
unitSsd: unit.ssd ? `${unit.ssd.substring(0, 50)}...` : 'отсутствует',
unitSsdLength: unit.ssd?.length
});
setSelectedUnit(unit); // Сохраняем полный объект узла с его SSD
};
const handleBackFromUnit = () => {
@ -209,21 +214,23 @@ const QuickDetailSection: React.FC<QuickDetailSectionProps> = ({
// Если выбран узел для детального просмотра, показываем UnitDetailsSection
if (selectedUnit) {
const unitSsd = selectedUnit.ssd || ssd;
// ИСПРАВЛЕНИЕ: Используем SSD узла из API ответа, а не родительский SSD
// API Laximo возвращает для каждого узла свой собственный SSD
console.log('🔍 QuickDetailSection передает в UnitDetailsSection:', {
unitSsd: unitSsd ? `${unitSsd.substring(0, 50)}...` : 'отсутствует',
unitSsdLength: unitSsd?.length,
parentSsd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
parentSsdLength: ssd?.length,
selectedUnitSsd: selectedUnit.ssd ? `${selectedUnit.ssd.substring(0, 50)}...` : 'отсутствует',
fallbackSsd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
selectedUnitSsdLength: selectedUnit.ssd?.length,
unitId: selectedUnit.unitid,
unitName: selectedUnit.name
unitName: selectedUnit.name,
note: 'Используем SSD УЗЛА из API ответа'
});
return (
<UnitDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
ssd={unitSsd} // Используем SSD узла
ssd={selectedUnit.ssd || ssd} // Используем SSD узла, fallback на родительский SSD
unitId={selectedUnit.unitid}
unitName={selectedUnit.name}
onBack={handleBackFromUnit}

View File

@ -42,7 +42,8 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
ssdLength: ssd?.length,
unitId,
unitName
unitName,
note: 'Используем SSD узла для API запросов'
});
const { data: unitInfoData, loading: unitInfoLoading, error: unitInfoError } = useQuery<{ laximoUnitInfo: LaximoUnitInfo }>(
@ -52,11 +53,11 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
ssd
},
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
errorPolicy: 'all',
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуальных данных
notifyOnNetworkStatusChange: true
}
);
@ -76,9 +77,9 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
ssd
},
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
errorPolicy: 'all',
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
notifyOnNetworkStatusChange: true
@ -100,9 +101,9 @@ const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
ssd
},
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId,
skip: !catalogCode || vehicleId === undefined || vehicleId === null || !unitId || !ssd || ssd.trim() === '',
errorPolicy: 'all',
fetchPolicy: 'no-cache', // Отключаем кэширование для получения актуального SSD
notifyOnNetworkStatusChange: true

View File

@ -68,49 +68,49 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
Найдено автомобилей: {results.length}
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-wrap flex-1 gap-5 size-full max-md:max-w-full">
{results.map((vehicle, index) => (
<div
key={`${vehicle.vehicleid}-${index}`}
className="bg-white rounded-lg shadow-md border border-gray-200 p-4 hover:shadow-lg transition-shadow cursor-pointer"
className="flex flex-col flex-1 shrink p-8 bg-white rounded-lg border border-solid basis-0 border-stone-300 max-w-[504px] md:min-w-[370px] sm:min-w-[340px] min-w-[200px] max-md:px-5 cursor-pointer transition-shadow hover:shadow-lg"
onClick={() => handleSelectVehicle(vehicle)}
>
{/* Заголовок автомобиля */}
<div className="mb-3">
<h4 className="text-lg font-semibold text-blue-600 mb-1">
<div className="">
<h4 className="text-lg font-semibold text-red-600 mb-1 truncate">
{vehicle.name || `${vehicle.brand} ${vehicle.model}`}
</h4>
<p className="text-sm text-gray-500">
</h4>
{/* <p className="text-sm text-gray-500 truncate">
{vehicle.modification} ({vehicle.year})
</p>
</p> */}
</div>
{/* Основные характеристики */}
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Основные характеристики</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Основные характеристики</h5>
{renderAttribute('Марка', vehicle.brand)}
{renderAttribute('Модель', vehicle.model)}
{renderAttribute('Двигатель', vehicle.engine)}
</div>
</div>
{/* Все атрибуты из API */}
{vehicle.attributes && vehicle.attributes.length > 0 && (
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Дополнительные характеристики</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Дополнительные характеристики</h5>
{vehicle.attributes.map((attr, attrIndex) => (
<div key={attrIndex} className="flex justify-between py-1 border-b border-gray-100">
<span className="text-sm text-gray-600 font-medium">{attr.name || attr.key}:</span>
<span className="text-sm text-gray-900">{attr.value}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Технические характеристики (fallback для старых данных) */}
{(!vehicle.attributes || vehicle.attributes.length === 0) && (
<>
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Дополнительные характеристики</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Дополнительные характеристики</h5>
{renderAttribute('Год', vehicle.year)}
{renderAttribute('Кузов', vehicle.bodytype)}
{renderAttribute('Трансмиссия', vehicle.transmission)}
@ -123,7 +123,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
</div>
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Технические характеристики</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Технические характеристики</h5>
{renderAttribute('Информация о двигателе', vehicle.engine_info)}
{renderAttribute('Номер двигателя', vehicle.engineno)}
{renderAttribute('Дата производства', vehicle.date)}
@ -133,7 +133,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
</div>
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Даты и периоды</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Даты и периоды</h5>
{renderAttribute('Дата с', vehicle.datefrom)}
{renderAttribute('Дата по', vehicle.dateto)}
{renderAttribute('Модельный год с', vehicle.modelyearfrom)}
@ -143,7 +143,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
{/* Опции и описание */}
{(vehicle.options || vehicle.description || vehicle.notes) && (
<div className="space-y-1 mb-4">
<h5 className="text-sm font-semibold text-gray-700 mb-2">Опции и описание</h5>
<h5 className="text-base font-semibold text-gray-900 mb-2">Опции и описание</h5>
{renderAttribute('Опции', vehicle.options)}
{renderAttribute('Описание', vehicle.description)}
{renderAttribute('Примечания', vehicle.notes)}
@ -153,25 +153,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
)}
{/* Системная информация */}
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-400 space-y-1">
<div>ID: {vehicle.vehicleid}</div>
{vehicle.catalog && <div>Каталог: {vehicle.catalog}</div>}
{vehicle.ssd && (
<div>SSD: {vehicle.ssd.length > 50 ? `${vehicle.ssd.substring(0, 50)}...` : vehicle.ssd}</div>
)}
</div>
</div>
{/* Debug информация (только в development) */}
{process.env.NODE_ENV === 'development' && (
<div className="mt-4 p-2 bg-gray-100 rounded text-xs">
<div className="font-semibold text-gray-700 mb-1">Debug Info:</div>
<pre className="text-gray-600 whitespace-pre-wrap">
{JSON.stringify(vehicle, null, 2)}
</pre>
</div>
)}
</div>
))}
</div>

View File

@ -26,6 +26,11 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
const [getWizard2] = useLazyQuery(GET_LAXIMO_WIZARD2, {
onCompleted: (data) => {
if (data.laximoWizard2) {
console.log('🔄 Wizard обновлен:', {
steps: data.laximoWizard2.length,
selectedParams: Object.keys(selectedParams).length,
currentSsd
});
setWizardSteps(data.laximoWizard2);
setIsLoading(false);
}
@ -76,18 +81,28 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
// --- Автовыбор единственного варианта для всех шагов ---
React.useEffect(() => {
// Предотвращаем автовыбор во время загрузки
if (isLoading) return;
wizardSteps.forEach(step => {
const options = step.options || [];
const selectedKey = selectedParams[step.conditionid]?.key || (step.determined ? options.find(o => o.value === step.value)?.key : '');
if (options.length === 1 && selectedKey !== options[0].key) {
// Автовыбираем только если есть единственный вариант и он еще не выбран
if (options.length === 1 && selectedKey !== options[0].key && !selectedParams[step.conditionid]) {
handleParamSelect(step, options[0].key, options[0].value);
}
});
// eslint-disable-next-line
}, [wizardSteps, selectedParams]);
}, [wizardSteps, selectedParams, isLoading]);
// Обработка выбора параметра
const handleParamSelect = async (step: LaximoWizardStep, optionKey: string, optionValue: string) => {
// Проверяем, не выбран ли уже этот параметр
if (selectedParams[step.conditionid]?.key === optionKey) {
return;
}
setIsLoading(true);
setError('');
@ -118,6 +133,13 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
// Сброс параметра
const handleParamReset = async (step: LaximoWizardStep) => {
console.log('🔄 Сброс параметра:', {
stepName: step.name,
conditionId: step.conditionid,
currentSsd,
selectedParamsBefore: Object.keys(selectedParams)
});
setIsLoading(true);
setError('');
@ -126,8 +148,33 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
delete newSelectedParams[step.conditionid];
setSelectedParams(newSelectedParams);
// Используем SSD для сброса параметра, если он есть
const resetSsd = step.ssd || '';
// Находим правильный SSD для сброса этого параметра
// Нужно найти SSD, который соответствует состоянию до выбора этого параметра
let resetSsd = '';
// Ищем среди шагов wizard тот, который имеет правильный SSD для восстановления
const currentStepIndex = wizardSteps.findIndex(s => s.conditionid === step.conditionid);
// Если есть предыдущие шаги с выбранными параметрами, используем их SSD
for (let i = currentStepIndex - 1; i >= 0; i--) {
const prevStep = wizardSteps[i];
if (newSelectedParams[prevStep.conditionid]) {
resetSsd = newSelectedParams[prevStep.conditionid].key;
break;
}
}
// Если не нашли предыдущий SSD, используем step.ssd или пустую строку
if (!resetSsd) {
resetSsd = step.ssd || '';
}
console.log('🔄 Новый SSD для сброса:', {
resetSsd,
selectedParamsAfter: Object.keys(newSelectedParams),
stepSsd: step.ssd
});
setCurrentSsd(resetSsd);
try {
@ -325,36 +372,36 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
</div>
)}
{/* Кнопка поиска автомобилей */}
{!isLoading && canListVehicles && showSearchButton && (
<div className="pt-4 border-t">
{/* Информация о недостаточности параметров и кнопка поиска */}
{!isLoading && wizardSteps.length > 0 && (
<div className="flex flex-row gap-4 items-center w-full mx-auto max-sm:flex-col max-sm:items-stretch">
<button
onClick={() => {
handleFindVehicles();
setShowSearchButton(false);
}}
disabled={isLoading}
className="w-full sm:w-auto px-8 py-3 bg-red-600 !text-white font-medium rounded-lg shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
disabled={!canListVehicles || isLoading}
className="w-full sm:w-auto px-8 py-3 bg-red-600 !text-white font-medium rounded-lg shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center max-sm:w-full"
style={{ minWidth: 180 }}
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Найти автомобили
Найти
</button>
<div className="mt-3 text-sm text-gray-600">
Определено параметров: {wizardSteps.filter(s => s.determined).length} из {wizardSteps.length}
<div
layer-name="Выберите больше параметров для поиска автомобилей"
className="box-border inline-flex gap-5 items-center px-10 py-4 rounded-xl bg-slate-50 h-[52px] max-md:px-8 max-md:py-3.5 max-md:w-full max-md:h-auto max-md:max-w-[524px] max-md:min-h-[52px] max-sm:gap-3 max-sm:px-5 max-sm:py-3 max-sm:w-full max-sm:rounded-lg max-sm:justify-center"
>
<div>
<img src="/images/info.svg" alt="info" style={{ width: 18, height: 20, flexShrink: 0 }} />
</div>
<div
layer-name="Выберите больше параметров для поиска автомобилей"
className="relative text-base font-medium leading-5 text-center text-gray-950 max-md:text-sm max-sm:text-sm max-sm:leading-4 max-sm:text-center"
>
Выберите больше параметров для поиска автомобилей
</div>
</div>
</div>
)}
{/* Информация о недостаточности параметров */}
{!isLoading && !canListVehicles && wizardSteps.length > 0 && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-800 text-sm">
Выберите больше параметров для поиска автомобилей
</p>
</div>
)}
</div>
);
};

View File

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast";
import CartIcon from "../CartIcon";
interface ProductBuyBlockProps {
offer?: any;
@ -37,7 +38,7 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
}
// Добавляем товар в корзину
addItem({
const result = await addItem({
productId: offer.id ? String(offer.id) : undefined,
offerKey: offer.offerKey || undefined,
name: offer.name || `${offer.brand} ${offer.articleNumber}`,
@ -45,6 +46,7 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
price: offer.price,
currency: 'RUB',
quantity: quantity,
stock: offer.quantity, // передаем информацию о наличии
image: offer.image || undefined,
brand: offer.brand,
article: offer.articleNumber,
@ -53,17 +55,22 @@ const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
isExternal: offer.type === 'external'
});
// Показываем успешный тоастер
toast.success(
<div>
<div className="font-semibold">Товар добавлен в корзину!</div>
<div className="text-sm text-gray-600">{offer.name || `${offer.brand} ${offer.articleNumber}`}</div>
</div>,
{
duration: 3000,
icon: '🛒',
}
);
if (result.success) {
// Показываем успешный тоастер
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{offer.name || `${offer.brand} ${offer.articleNumber}`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
// Показываем ошибку
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка при добавлении товара в корзину');

View File

@ -15,8 +15,10 @@ const DEFAULT_MAX = 32000;
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));
const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max = DEFAULT_MAX, isMobile = false, value = null, onChange }) => {
const [from, setFrom] = useState(value ? value[0] : min);
const [to, setTo] = useState(value ? value[1] : max);
const [from, setFrom] = useState<string>(value ? String(value[0]) : String(min));
const [to, setTo] = useState<string>(value ? String(value[1]) : String(max));
const [confirmedFrom, setConfirmedFrom] = useState<number>(value ? value[0] : min);
const [confirmedTo, setConfirmedTo] = useState<number>(value ? value[1] : max);
const [dragging, setDragging] = useState<null | "from" | "to">(null);
const [trackWidth, setTrackWidth] = useState(0);
const [open, setOpen] = useState(true);
@ -25,11 +27,15 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
// Обновляем локальное состояние при изменении внешнего значения
useEffect(() => {
if (value) {
setFrom(value[0]);
setTo(value[1]);
setFrom(String(value[0]));
setTo(String(value[1]));
setConfirmedFrom(value[0]);
setConfirmedTo(value[1]);
} else {
setFrom(min);
setTo(max);
setFrom(String(min));
setTo(String(max));
setConfirmedFrom(min);
setConfirmedTo(max);
}
}, [value, min, max]);
@ -61,15 +67,15 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
x = clamp(x, 0, trackWidth);
const value = clamp(pxToValue(x), min, max);
if (dragging === "from") {
setFrom(v => clamp(Math.min(value, to), min, to));
setFrom(v => String(clamp(Math.min(value, Number(to)), min, Number(to))));
} else {
setTo(v => clamp(Math.max(value, from), from, max));
setTo(v => String(clamp(Math.max(value, Number(from)), Number(from), max)));
}
};
const onUp = () => {
setDragging(null);
if (onChange) {
onChange([from, to]);
onChange([Number(from), Number(to)]);
}
};
window.addEventListener("mousemove", onMove);
@ -82,25 +88,48 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
// Input handlers
const handleFromInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let v = Number(e.target.value.replace(/\D/g, ""));
if (isNaN(v)) v = min;
setFrom(clamp(Math.min(v, to), min, to));
let v = e.target.value.replace(/\D/g, "");
setFrom(v);
};
const handleToInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let v = Number(e.target.value.replace(/\D/g, ""));
if (isNaN(v)) v = max;
setTo(clamp(Math.max(v, from), from, max));
let v = e.target.value.replace(/\D/g, "");
setTo(v);
};
const handleInputBlur = () => {
if (onChange) {
onChange([from, to]);
const handleFromBlur = () => {
let v = Number(from);
if (isNaN(v) || v < min) v = min;
// если больше max — оставлять как есть
setFrom(String(v));
if (onChange) onChange([v, to === "" ? max : Number(to)]);
setConfirmedFrom(v);
};
const handleToBlur = () => {
let v = Number(to);
if (isNaN(v) || v < min) v = min;
if (v > max) v = max;
setTo(String(v));
if (onChange) onChange([from === "" ? min : Number(from), v]);
setConfirmedTo(v);
};
const handleFromKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleFromBlur();
(e.target as HTMLInputElement).blur();
}
};
const handleToKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleToBlur();
(e.target as HTMLInputElement).blur();
}
};
// px позиции для точек
const pxFrom = valueToPx(from);
const pxTo = valueToPx(to);
const pxFrom = valueToPx(dragging ? Number(from) : confirmedFrom);
const pxTo = valueToPx(dragging ? Number(to) : confirmedTo);
// Мобильная версия - без dropdown
if (isMobile) {
@ -124,7 +153,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
id="from"
value={from}
onChange={handleFromInput}
onBlur={handleInputBlur}
onBlur={handleFromBlur}
onKeyDown={handleFromKeyDown}
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
/>
</div>
@ -139,7 +169,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
id="to"
value={to}
onChange={handleToInput}
onBlur={handleInputBlur}
onBlur={handleToBlur}
onKeyDown={handleToKeyDown}
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
/>
</div>
@ -214,7 +245,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
id="from"
value={from}
onChange={handleFromInput}
onBlur={handleInputBlur}
onBlur={handleFromBlur}
onKeyDown={handleFromKeyDown}
/>
</div>
<div className="div-block-5">
@ -228,7 +260,8 @@ const FilterRange: React.FC<FilterRangeProps> = ({ title, min = DEFAULT_MIN, max
id="to"
value={to}
onChange={handleToInput}
onBlur={handleInputBlur}
onBlur={handleToBlur}
onKeyDown={handleToKeyDown}
/>
</div>
</form>

View File

@ -43,30 +43,72 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
const router = useRouter();
// Получаем инфо об узле (для картинки)
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_INFO запрос:', {
catalogCode,
vehicleId,
unitId,
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
ssdLength: ssd?.length,
skipCondition: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === ''
});
const { data: unitInfoData, loading: unitInfoLoading, error: unitInfoError } = useQuery(
GET_LAXIMO_UNIT_INFO,
{
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', unitId: unitId || '', ssd: ssd || '' },
skip: !catalogCode || !vehicleId || !unitId,
variables: {
catalogCode,
vehicleId,
unitId,
ssd
},
skip: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '',
errorPolicy: 'all',
}
);
// Получаем карту координат
console.log('🔍 KnotIn - GET_LAXIMO_UNIT_IMAGE_MAP запрос:', {
catalogCode,
vehicleId,
unitId,
ssd: ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует',
ssdLength: ssd?.length,
skipCondition: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === ''
});
const { data: imageMapData, loading: imageMapLoading, error: imageMapError } = useQuery(
GET_LAXIMO_UNIT_IMAGE_MAP,
{
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', unitId: unitId || '', ssd: ssd || '' },
skip: !catalogCode || !vehicleId || !unitId,
variables: {
catalogCode,
vehicleId,
unitId,
ssd
},
skip: !catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '',
errorPolicy: 'all',
}
);
// Если нет необходимых данных, показываем заглушку
if (!catalogCode || !vehicleId || !unitId) {
if (!catalogCode || !vehicleId || !unitId || !ssd || ssd.trim() === '') {
console.log('⚠️ KnotIn: отсутствуют необходимые данные:', {
catalogCode: !!catalogCode,
vehicleId: !!vehicleId,
unitId: !!unitId,
ssd: !!ssd,
ssdValid: ssd ? ssd.trim() !== '' : false
});
return (
<div className="text-center py-8 text-gray-500">
<div className="text-lg font-medium mb-2">Схема узла</div>
<div className="text-sm">Выберите узел для отображения схемы</div>
{process.env.NODE_ENV === 'development' && (
<div className="text-xs text-red-500 mt-2">
Debug: catalogCode={catalogCode}, vehicleId={vehicleId}, unitId={unitId}, ssd={ssd ? 'есть' : 'нет'}
</div>
)}
</div>
);
}
@ -75,6 +117,29 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
const coordinates = imageMapData?.laximoUnitImageMap?.coordinates || [];
const imageUrl = unitInfo?.imageurl ? getImageUrl(unitInfo.imageurl, selectedImageSize) : '';
// Логируем успешную загрузку данных
React.useEffect(() => {
if (unitInfo) {
console.log('✅ KnotIn: данные узла загружены:', {
unitName: unitInfo.name,
hasImage: !!unitInfo.imageurl,
imageUrl: unitInfo.imageurl,
processedImageUrl: imageUrl
});
}
}, [unitInfo, imageUrl]);
React.useEffect(() => {
if (coordinates.length > 0) {
console.log('✅ KnotIn: координаты карты загружены:', {
coordinatesCount: coordinates.length,
firstCoordinate: coordinates[0]
});
} else if (imageMapData) {
console.log('⚠️ KnotIn: карта изображений загружена, но координаты пустые:', imageMapData);
}
}, [coordinates, imageMapData]);
// Масштабируем точки после загрузки картинки
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
@ -110,13 +175,49 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
}, [parts, coordinates]);
if (unitInfoLoading || imageMapLoading) {
console.log('🔄 KnotIn: загрузка данных...', {
unitInfoLoading,
imageMapLoading,
unitInfoError: unitInfoError?.message,
imageMapError: imageMapError?.message
});
return <div className="text-center py-8 text-gray-500">Загружаем схему узла...</div>;
}
if (unitInfoError) {
return <div className="text-center py-8 text-red-600">Ошибка загрузки схемы: {unitInfoError.message}</div>;
console.error('❌ KnotIn: ошибка загрузки информации об узле:', unitInfoError);
return (
<div className="text-center py-8 text-red-600">
Ошибка загрузки схемы: {unitInfoError.message}
{process.env.NODE_ENV === 'development' && (
<div className="text-xs mt-2 text-gray-500">
GraphQL Error: {JSON.stringify(unitInfoError, null, 2)}
</div>
)}
</div>
);
}
if (imageMapError) {
console.error('❌ KnotIn: ошибка загрузки карты изображений:', imageMapError);
}
if (!imageUrl) {
return <div className="text-center py-8 text-gray-400">Нет изображения для этого узла</div>;
console.log('⚠️ KnotIn: нет URL изображения:', {
unitInfo: !!unitInfo,
imageurl: unitInfo?.imageurl,
unitInfoData: !!unitInfoData
});
return (
<div className="text-center py-8 text-gray-400">
Нет изображения для этого узла
{process.env.NODE_ENV === 'development' && unitInfo && (
<div className="text-xs mt-2 text-gray-500">
Debug: unitInfo.imageurl = {unitInfo.imageurl || 'отсутствует'}
</div>
)}
</div>
);
}
return (
@ -137,10 +238,10 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
/>
{/* Точки/области */}
{coordinates.map((coord: any, idx: number) => {
const scaledX = coord.x * imageScale.x;
const scaledY = coord.y * imageScale.y;
const scaledWidth = coord.width * imageScale.x;
const scaledHeight = coord.height * imageScale.y;
// Кружки всегда 32x32px, центрируем по координате
const size = 22;
const scaledX = coord.x * imageScale.x - size / 2;
const scaledY = coord.y * imageScale.y - size / 2;
return (
<div
key={`coord-${unitId}-${idx}-${coord.x}-${coord.y}`}
@ -149,19 +250,29 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') handlePointClick(coord.codeonimage);
}}
className="absolute flex items-center justify-center border-2 border-red-600 bg-white rounded-full cursor-pointer"
className="absolute flex items-center justify-center cursor-pointer transition-colors"
style={{
left: scaledX,
top: scaledY,
width: scaledWidth,
height: scaledHeight,
width: size,
height: size,
background: '#B7CAE2',
borderRadius: '50%',
pointerEvents: 'auto',
}}
title={coord.codeonimage}
onClick={() => handlePointClick(coord.codeonimage)}
onMouseEnter={e => {
(e.currentTarget as HTMLDivElement).style.background = '#EC1C24';
(e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#fff';
}}
onMouseLeave={e => {
(e.currentTarget as HTMLDivElement).style.background = '#B7CAE2';
(e.currentTarget.querySelector('span') as HTMLSpanElement).style.color = '#000';
}}
>
<span className="flex items-center justify-center w-full h-full text-black text-sm font-bold select-none pointer-events-none">
<span className="flex items-center justify-center w-full h-full text-black text-sm font-bold select-none pointer-events-none" style={{color: '#000'}}>
{coord.codeonimage}
</span>
</div>

View File

@ -10,18 +10,14 @@ interface VinCategoryProps {
activeTab?: 'uzly' | 'manufacturer';
onQuickGroupSelect?: (group: any) => void;
onCategoryClick?: (e?: React.MouseEvent) => void;
openedPath?: string[];
setOpenedPath?: (path: string[]) => void;
}
const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd, onNodeSelect, activeTab = 'uzly', onQuickGroupSelect, onCategoryClick }) => {
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd, onNodeSelect, activeTab = 'uzly', onQuickGroupSelect, onCategoryClick, openedPath = [], setOpenedPath = () => {} }) => {
const [unitsByCategory, setUnitsByCategory] = useState<{ [key: string]: any[] }>({});
const lastCategoryIdRef = useRef<string | null>(null);
// Сброс выбранной категории при смене вкладки
useEffect(() => {
setSelectedCategory(null);
}, [activeTab]);
// Запрос для "Общие" (QuickGroups)
const { data: quickGroupsData, loading: quickGroupsLoading, error: quickGroupsError } = useQuery(GET_LAXIMO_QUICK_GROUPS, {
variables: { catalogCode: catalogCode || '', vehicleId: vehicleId || '', ssd: ssd || '' },
@ -51,50 +47,41 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
}
});
const categories = activeTab === 'uzly' ? (quickGroupsData?.laximoQuickGroups || []) : (categoriesData?.laximoCategories || []);
// categories теперь зависят от activeTab
let categories = activeTab === 'uzly' ? (quickGroupsData?.laximoQuickGroups || []) : (categoriesData?.laximoCategories || []);
let selectedCategory: any = null;
let currentLevel = 0;
let currentList = categories;
while (openedPath[currentLevel]) {
const found = currentList.find((cat: any) => (cat.quickgroupid || cat.categoryid || cat.id) === openedPath[currentLevel]);
if (!found) break;
selectedCategory = found;
currentList = found.children || [];
currentLevel++;
}
const loading = activeTab === 'uzly' ? quickGroupsLoading : categoriesLoading;
const error = activeTab === 'uzly' ? quickGroupsError : categoriesError;
const handleBack = () => {
setSelectedCategory(null);
setOpenedPath(openedPath.slice(0, openedPath.length - 1));
};
const handleCategoryClick = (category: any) => {
// Если передан onCategoryClick, используем его
const handleCategoryClick = (category: any, level: number) => {
if (onCategoryClick) {
onCategoryClick();
return;
}
if (activeTab === 'uzly') {
// Логика для вкладки "Общие" (QuickGroups)
if (category.children && category.children.length > 0) {
setSelectedCategory(category);
} else if (category.link && onQuickGroupSelect) {
onQuickGroupSelect(category);
} else if (onNodeSelect) {
onNodeSelect(category);
}
} else {
// Логика для вкладки "От производителя" (Categories)
if (category.children && category.children.length > 0) {
setSelectedCategory(category);
if (category.children && category.children.length > 0) {
if (openedPath[level] === (category.quickgroupid || category.categoryid || category.id)) {
setOpenedPath(openedPath.slice(0, level));
} else {
// Если нет children, грузим units (подкатегории)
const categoryId = category.categoryid || category.quickgroupid || category.id;
if (!unitsByCategory[categoryId] && catalogCode && vehicleId) {
lastCategoryIdRef.current = categoryId;
getUnits({
variables: {
catalogCode,
vehicleId,
ssd: ssd || '',
categoryId
}
});
}
setSelectedCategory(category);
setOpenedPath([...openedPath.slice(0, level), (category.quickgroupid || category.categoryid || category.id)]);
}
} else if (category.link && onQuickGroupSelect) {
onQuickGroupSelect(category);
} else if (onNodeSelect) {
onNodeSelect(category);
}
};
@ -106,7 +93,7 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
unitid: subcat.unitid || subcat.categoryid || subcat.quickgroupid || subcat.id
});
} else {
handleCategoryClick(subcat);
handleCategoryClick(subcat, 0);
}
};
@ -150,7 +137,7 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
<div
className="div-block-131"
key={cat.quickgroupid || cat.categoryid || cat.id || idx}
onClick={() => handleCategoryClick(cat)}
onClick={() => handleCategoryClick(cat, 0)}
style={{ cursor: "pointer" }}
>
<div className="text-block-57">{cat.name}</div>
@ -165,32 +152,37 @@ const VinCategory: React.FC<VinCategoryProps> = ({ catalogCode, vehicleId, ssd,
) : (
// Список подкатегорий
<>
{/* <div className="div-block-131" onClick={handleBack} style={{ cursor: "pointer", fontWeight: 500 }}>
<div className="text-block-57">← Назад</div>
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
</svg>
</div>
</div> */}
{subcategories.length === 0 && <div style={{ color: "#888", padding: 8 }}>Нет подкатегорий</div>}
{subcategories.map((subcat: any, idx: number) => (
<div
className="div-block-131"
key={subcat.quickgroupid || subcat.categoryid || subcat.unitid || subcat.id || idx}
onClick={() => handleSubcategoryClick(subcat)}
style={{ cursor: "pointer" }}
>
<div className="text-block-57">{subcat.name}</div>
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
</svg>
{(() => {
// Найти текущий уровень вложенности для selectedCategory
let level = 0;
let list = categories;
while (openedPath[level] && list) {
const found = list.find((cat: any) => (cat.quickgroupid || cat.categoryid || cat.id) === openedPath[level]);
if (!found) break;
if (found === selectedCategory) break;
list = found.children || [];
level++;
}
// Теперь level - это уровень selectedCategory, подкатегории будут на level+1
const subcategories = selectedCategory.children || [];
if (subcategories.length === 0) return <div style={{ color: "#888", padding: 8 }}>Нет подкатегорий</div>;
return subcategories.map((subcat: any, idx: number) => (
<div
className="div-block-131"
key={subcat.quickgroupid || subcat.categoryid || subcat.unitid || subcat.id || idx}
onClick={() => handleCategoryClick(subcat, level + 1)}
style={{ cursor: "pointer" }}
>
<div className="text-block-57">{subcat.name}</div>
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="24" width="24" height="24" rx="12" transform="rotate(90 24 0)" fill="currentcolor"></rect>
<path fillRule="evenodd" clipRule="evenodd" d="M10.9303 17L10 16.0825L14.1395 12L10 7.91747L10.9303 7L16 12L10.9303 17Z" fill="white"></path>
</svg>
</div>
</div>
</div>
))}
));
})()}
</>
)}
</div>

View File

@ -19,6 +19,9 @@ interface VinLeftbarProps {
onNodeSelect?: (node: any) => void;
onActiveTabChange?: (tab: 'uzly' | 'manufacturer') => void;
onQuickGroupSelect?: (group: any) => void;
activeTab?: 'uzly' | 'manufacturer';
openedPath?: string[];
setOpenedPath?: (path: string[]) => void;
}
interface QuickGroup {
@ -28,13 +31,11 @@ interface QuickGroup {
children?: QuickGroup[];
}
const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, onNodeSelect, onActiveTabChange, onQuickGroupSelect }) => {
const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, onNodeSelect, onActiveTabChange, onQuickGroupSelect, activeTab: activeTabProp, openedPath = [], setOpenedPath = () => {} }) => {
const catalogCode = vehicleInfo?.catalog || '';
const vehicleId = vehicleInfo?.vehicleid || '';
const ssd = vehicleInfo?.ssd || '';
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly');
const [executeSearch, { data, loading, error }] = useLazyQuery(GET_LAXIMO_FULLTEXT_SEARCH, { errorPolicy: 'all' });
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(GET_LAXIMO_CATEGORIES, {
@ -58,11 +59,24 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
const lastCategoryIdRef = React.useRef<string | null>(null);
const handleToggle = (idx: number, categoryId: string) => {
setOpenIndex(openIndex === idx ? null : idx);
if (openIndex !== idx && !unitsByCategory[categoryId]) {
lastCategoryIdRef.current = categoryId;
getUnits({ variables: { catalogCode, vehicleId, ssd, categoryId } });
const handleToggle = (categoryId: string, level: number) => {
if (openedPath[level] === categoryId) {
setOpenedPath(openedPath.slice(0, level));
} else {
setOpenedPath([...openedPath.slice(0, level), categoryId]);
// Загружаем units для категории, если они еще не загружены
if (activeTabProp === 'manufacturer' && !unitsByCategory[categoryId]) {
lastCategoryIdRef.current = categoryId;
getUnits({
variables: {
catalogCode,
vehicleId,
ssd,
categoryId
}
});
}
}
};
@ -117,26 +131,11 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
});
const quickGroups = quickGroupsData?.laximoQuickGroups || [];
const [expandedQuickGroup, setExpandedQuickGroup] = useState<string | null>(null);
const [expandedSubQuickGroup, setExpandedSubQuickGroup] = useState<string | null>(null);
const handleQuickGroupToggle = (groupId: string) => {
setExpandedQuickGroup(prev => (prev === groupId ? null : groupId));
setExpandedSubQuickGroup(null);
};
const handleSubQuickGroupToggle = (groupId: string) => {
setExpandedSubQuickGroup(prev => (prev === groupId ? null : groupId));
};
const handleQuickGroupClick = (group: any) => {
if (group.link) {
// Передаем выбранную группу в родительский компонент для отображения справа
if (onQuickGroupSelect) {
onQuickGroupSelect(group);
}
const handleQuickGroupToggle = (groupId: string, level: number) => {
if (openedPath[level] === groupId) {
setOpenedPath(openedPath.slice(0, level));
} else {
handleQuickGroupToggle(group.quickgroupid);
setOpenedPath([...openedPath.slice(0, level), groupId]);
}
};
@ -207,12 +206,6 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
const fulltextResults = fulltextData?.laximoFulltextSearch?.details || [];
useEffect(() => {
if (onActiveTabChange) {
onActiveTabChange(activeTab);
}
}, [activeTab]);
// Если нет данных о транспортном средстве, показываем заглушку
if (!vehicleInfo) {
return (
@ -281,18 +274,15 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className={
searchQuery
? 'button-23 w-button'
: activeTab === 'uzly'
: activeTabProp === 'uzly'
? 'button-3 w-button'
: 'button-23 w-button'
}
onClick={e => {
e.preventDefault();
if (searchQuery) setSearchQuery('');
setActiveTab('uzly');
// Очищаем выбранную группу при смене таба
if (onQuickGroupSelect) {
onQuickGroupSelect(null);
}
if (onActiveTabChange) onActiveTabChange('uzly');
if (onQuickGroupSelect) onQuickGroupSelect(null);
}}
>
Узлы
@ -302,25 +292,22 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className={
searchQuery
? 'button-23 w-button'
: activeTab === 'manufacturer'
: activeTabProp === 'manufacturer'
? 'button-3 w-button'
: 'button-23 w-button'
}
onClick={e => {
e.preventDefault();
if (searchQuery) setSearchQuery('');
setActiveTab('manufacturer');
// Очищаем выбранную группу при смене таба
if (onQuickGroupSelect) {
onQuickGroupSelect(null);
}
if (onActiveTabChange) onActiveTabChange('manufacturer');
if (onQuickGroupSelect) onQuickGroupSelect(null);
}}
>
От производителя
</a>
</div>
{/* Tab content start */}
{activeTab === 'uzly' ? (
{activeTabProp === 'uzly' ? (
// Общие (QuickGroups - бывшие "От производителя")
quickGroupsLoading ? (
<div style={{ padding: 16, textAlign: 'center' }}>Загружаем группы быстрого поиска...</div>
@ -330,7 +317,7 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
<>
{(quickGroups as QuickGroup[]).map((group: QuickGroup) => {
const hasChildren = group.children && group.children.length > 0;
const isOpen = expandedQuickGroup === group.quickgroupid;
const isOpen = openedPath.includes(group.quickgroupid);
if (!hasChildren) {
return (
@ -340,7 +327,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className="dropdown-link-3 w-dropdown-link"
onClick={(e) => {
e.preventDefault();
handleQuickGroupClick(group);
// Если это конечная группа с link=true, открываем QuickGroup
if (group.link && onQuickGroupSelect) {
onQuickGroupSelect(group);
} else {
handleQuickGroupToggle(group.quickgroupid, 0);
}
}}
>
{group.name}
@ -357,7 +349,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
>
<div
className={`dropdown-toggle-3 w-dropdown-toggle${isOpen ? " w--open active" : ""}`}
onClick={() => handleQuickGroupToggle(group.quickgroupid)}
onClick={(e) => {
e.preventDefault();
handleQuickGroupToggle(group.quickgroupid, 0);
}}
style={{ cursor: "pointer" }}
>
<div className="w-icon-dropdown-toggle"></div>
@ -366,7 +361,7 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
<nav className={`dropdown-list-4 w-dropdown-list${isOpen ? " w--open" : ""}`}>
{group.children?.map((child: QuickGroup) => {
const hasSubChildren = child.children && child.children.length > 0;
const isChildOpen = expandedSubQuickGroup === child.quickgroupid;
const isChildOpen = openedPath.includes(child.quickgroupid);
if (!hasSubChildren) {
return (
@ -376,7 +371,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className="dropdown-link-3 w-dropdown-link"
onClick={(e) => {
e.preventDefault();
handleQuickGroupClick(child);
// Если это конечная группа с link=true, открываем QuickGroup
if (child.link && onQuickGroupSelect) {
onQuickGroupSelect(child);
} else {
handleQuickGroupToggle(child.quickgroupid, 1);
}
}}
>
{child.name}
@ -393,7 +393,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
>
<div
className={`dropdown-toggle-card w-dropdown-toggle pl-0${isChildOpen ? " w--open active" : ""}`}
onClick={() => handleSubQuickGroupToggle(child.quickgroupid)}
onClick={(e) => {
e.preventDefault();
handleQuickGroupToggle(child.quickgroupid, 2);
}}
style={{ cursor: "pointer" }}
>
<div className="w-icon-dropdown-toggle"></div>
@ -407,7 +410,12 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className="dropdown-link-3 w-dropdown-link"
onClick={(e) => {
e.preventDefault();
handleQuickGroupClick(subChild);
// Если это конечная группа с link=true, открываем QuickGroup
if (subChild.link && onQuickGroupSelect) {
onQuickGroupSelect(subChild);
} else {
handleQuickGroupToggle(subChild.quickgroupid, 3);
}
}}
>
{subChild.name}
@ -434,7 +442,7 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
) : (
<>
{categories.map((category: any, idx: number) => {
const isOpen = openIndex === idx;
const isOpen = openedPath.includes(category.quickgroupid);
const subcategories = category.children && category.children.length > 0
? category.children
: unitsByCategory[category.quickgroupid] || [];
@ -447,7 +455,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
>
<div
className={`dropdown-toggle-3 w-dropdown-toggle${isOpen ? " w--open" : ""}`}
onClick={() => handleToggle(idx, category.quickgroupid)}
onClick={(e) => {
e.preventDefault();
handleToggle(category.quickgroupid, 0);
}}
style={{ cursor: "pointer" }}
>
<div className="w-icon-dropdown-toggle"></div>
@ -462,7 +473,10 @@ const VinLeftbar: React.FC<VinLeftbarProps> = ({ vehicleInfo, onSearchResults, o
className="dropdown-link-3 w-dropdown-link pl-0"
onClick={e => {
e.preventDefault();
if (onNodeSelect) {
// Если это конечная категория с link=true, открываем QuickGroup
if (subcat.link && onQuickGroupSelect) {
onQuickGroupSelect(subcat);
} else if (onNodeSelect) {
onNodeSelect({
...subcat,
unitid: subcat.unitid || subcat.quickgroupid || subcat.id

View File

@ -72,8 +72,8 @@ const VinQuick: React.FC<VinQuickProps> = ({ quickGroup, catalogCode, vehicleId,
<div className="knot-img">
<h1 className="heading-19">{unit.name}</h1>
{unit.details && unit.details.length > 0 && unit.details.map((detail: any) => (
<div className="w-layout-hflex flex-block-115" key={detail.detailid}>
{unit.details && unit.details.length > 0 && unit.details.map((detail: any, index: number) => (
<div className="w-layout-hflex flex-block-115" key={`${unit.unitid}-${detail.detailid || index}`}>
<div className="oemnuber">{detail.oem}</div>
<div className="partsname">{detail.name}</div>
<a href="#" className="button-3 w-button" onClick={e => { e.preventDefault(); handleDetailClick(detail); }}>Показать цены</a>

View File

@ -25,6 +25,8 @@ const initialState: CartState = {
// Типы действий
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'id' | 'selected' | 'favorite'> }
| { 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 }
@ -44,6 +46,16 @@ type CartAction =
// Функция для генерации ID
const generateId = () => Math.random().toString(36).substr(2, 9)
// Утилитарная функция для парсинга количества в наличии
const parseStock = (stockStr: string | number | undefined): number => {
if (typeof stockStr === 'number') return stockStr;
if (typeof stockStr === 'string') {
const match = stockStr.match(/\d+/);
return match ? parseInt(match[0]) : 0;
}
return 0;
};
// Функция для расчета итогов
const calculateSummary = (items: CartItem[], deliveryPrice: number) => {
const selectedItems = items.filter(item => item.selected)
@ -78,9 +90,12 @@ const cartReducer = (state: CartState, action: CartAction): CartState => {
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: item.quantity + action.payload.quantity }
? { ...item, quantity: totalQuantity }
: item
)
} else {
@ -335,8 +350,31 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, [state.items, state.delivery, state.orderComment, isInitialized])
// Функции для работы с корзиной
const addItem = (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
const addItem = async (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => {
// Проверяем наличие товара на складе перед добавлением
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) => {
@ -344,6 +382,17 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
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;
}
}
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
}
@ -388,6 +437,10 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}
const clearError = () => {
dispatch({ type: 'SET_ERROR', payload: '' })
}
const contextValue: CartContextType = {
state,
addItem,
@ -401,7 +454,8 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
removeAll,
removeSelected,
updateDelivery,
clearCart
clearCart,
clearError
}
return (

View File

@ -4,6 +4,7 @@ import React, { createContext, useContext, useReducer, useEffect, ReactNode } fr
import { useMutation, useQuery } from '@apollo/client'
import toast from 'react-hot-toast'
import { GET_FAVORITES, ADD_TO_FAVORITES, REMOVE_FROM_FAVORITES, CLEAR_FAVORITES } from '@/lib/favorites-queries'
import DeleteCartIcon from '@/components/DeleteCartIcon'
// Типы
export interface FavoriteItem {
@ -133,7 +134,9 @@ const FavoritesProvider: React.FC<FavoritesProviderProps> = ({ children }) => {
const [removeFavoriteMutation] = useMutation(REMOVE_FROM_FAVORITES, {
onCompleted: () => {
toast.success('Товар удален из избранного')
toast.success('Товар удален из избранного', {
icon: <DeleteCartIcon size={20} color="#ec1c24" />,
})
},
onError: (error) => {
console.error('Ошибка удаления из избранного:', error)

View File

@ -231,6 +231,7 @@ export const useCatalogPrices = (): UseCatalogPricesReturn => {
price: cheapestOffer.price,
currency: cheapestOffer.currency || 'RUB',
quantity: 1,
stock: cheapestOffer.quantity, // передаем информацию о наличии
deliveryTime: cheapestOffer.deliveryDays?.toString() || '0',
warehouse: cheapestOffer.warehouse || 'Склад',
supplier: cheapestOffer.supplierName || 'Неизвестный поставщик',
@ -238,10 +239,14 @@ export const useCatalogPrices = (): UseCatalogPricesReturn => {
image: '', // Убираем мокап-фотку, изображения будут загружаться отдельно
};
addItem(itemToAdd);
const result = await addItem(itemToAdd);
// Показываем уведомление
toast.success(`Товар "${brand} ${articleNumber}" добавлен в корзину за ${cheapestOffer.price}`);
if (result.success) {
// Показываем уведомление
toast.success(`Товар "${brand} ${articleNumber}" добавлен в корзину за ${cheapestOffer.price}`);
} else {
toast.error(result.error || 'Ошибка добавления товара в корзину');
}
} catch (error) {
console.error('Ошибка добавления в корзину:', error);

View File

@ -60,8 +60,12 @@ export default function App({ Component, pageProps }: AppProps) {
},
success: {
duration: 3000,
style: {
background: '#22c55e', // Зеленый фон для успешных уведомлений
color: '#fff', // Белый текст
},
iconTheme: {
primary: '#4ade80',
primary: '#22c55e',
secondary: '#fff',
},
},

View File

@ -23,6 +23,7 @@ import { useProductPrices } from '@/hooks/useProductPrices';
import { PriceSkeleton } from '@/components/skeletons/ProductListSkeleton';
import { useCart } from '@/contexts/CartContext';
import toast from 'react-hot-toast';
import CartIcon from '@/components/CartIcon';
const mockData = Array(12).fill({
image: "",
@ -720,7 +721,7 @@ export default function Catalog() {
productId={entity.id}
artId={entity.id}
offerKey={priceData?.offerKey}
onAddToCart={() => {
onAddToCart={async () => {
// Если цена не загружена, загружаем её и добавляем в корзину
if (!priceData && !isLoadingPriceData) {
loadPriceOnDemand(productForPrice);
@ -740,6 +741,7 @@ export default function Catalog() {
price: priceData.price,
currency: priceData.currency || 'RUB',
quantity: 1,
stock: undefined, // информация о наличии не доступна для PartsIndex
deliveryTime: '1-3 дня',
warehouse: 'Parts Index',
supplier: 'Parts Index',
@ -747,10 +749,23 @@ export default function Catalog() {
image: entity.images?.[0] || '',
};
addItem(itemToAdd);
const result = await addItem(itemToAdd);
// Показываем уведомление
toast.success(`Товар "${entity.brand.name} ${entity.code}" добавлен в корзину за ${priceData.price.toLocaleString('ru-RU')}`);
if (result.success) {
// Показываем уведомление
toast.success(
<div>
<div className="font-semibold" style={{ color: '#fff' }}>Товар добавлен в корзину!</div>
<div className="text-sm" style={{ color: '#fff', opacity: 0.9 }}>{`${entity.brand.name} ${entity.code} за ${priceData.price.toLocaleString('ru-RU')}`}</div>
</div>,
{
duration: 3000,
icon: <CartIcon size={20} color="#fff" />,
}
);
} else {
toast.error(result.error || 'Ошибка при добавлении товара в корзину');
}
} else {
toast.error('Цена товара еще загружается. Попробуйте снова через несколько секунд.');
}

View File

@ -606,12 +606,11 @@ export default function SearchResult() {
return true; // Показываем загружающиеся аналоги
}
const analogOffers = transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
);
// Проверяем, есть ли предложения у аналога
const hasInternalOffers = loadedAnalogData.internalOffers && loadedAnalogData.internalOffers.length > 0;
const hasExternalOffers = loadedAnalogData.externalOffers && loadedAnalogData.externalOffers.length > 0;
// Показываем аналог только если у него есть предложения
return analogOffers.length > 0;
return hasInternalOffers || hasExternalOffers;
});
// Если нет аналогов с предложениями, не показываем секцию
@ -625,11 +624,79 @@ export default function SearchResult() {
const analogKey = `${analog.brand}-${analog.articleNumber}`;
const loadedAnalogData = loadedAnalogs[analogKey];
const analogOffers = loadedAnalogData
? transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
)
: [];
// Если данные аналога загружены, формируем предложения из всех его данных
const analogOffers = loadedAnalogData ? (() => {
const allAnalogOffers: any[] = [];
// Добавляем внутренние предложения
if (loadedAnalogData.internalOffers) {
loadedAnalogData.internalOffers.forEach((offer: any) => {
allAnalogOffers.push({
...offer,
type: 'internal',
brand: loadedAnalogData.brand,
articleNumber: loadedAnalogData.articleNumber,
name: loadedAnalogData.name,
isAnalog: true,
deliveryDuration: offer.deliveryDays
});
});
}
// Добавляем внешние предложения
if (loadedAnalogData.externalOffers) {
loadedAnalogData.externalOffers.forEach((offer: any) => {
allAnalogOffers.push({
...offer,
type: 'external',
brand: offer.brand || loadedAnalogData.brand,
articleNumber: offer.code || loadedAnalogData.articleNumber,
name: offer.name || loadedAnalogData.name,
isAnalog: true,
deliveryDuration: offer.deliveryTime
});
});
}
// Применяем фильтры только если они активны
const filteredAnalogOffers = allAnalogOffers.filter(offer => {
// Фильтр по бренду
if (selectedBrands.length > 0 && !selectedBrands.includes(offer.brand)) {
return false;
}
// Фильтр по цене
if (priceRange && (offer.price < priceRange[0] || offer.price > priceRange[1])) {
return false;
}
// Фильтр по сроку доставки
if (deliveryRange) {
const deliveryDays = offer.deliveryDuration;
if (deliveryDays < deliveryRange[0] || deliveryDays > deliveryRange[1]) {
return false;
}
}
// Фильтр по количеству наличия
if (quantityRange) {
const quantity = offer.quantity;
if (quantity < quantityRange[0] || quantity > quantityRange[1]) {
return false;
}
}
// Фильтр по поисковой строке
if (filterSearchTerm) {
const searchTerm = filterSearchTerm.toLowerCase();
const brandMatch = offer.brand.toLowerCase().includes(searchTerm);
const articleMatch = offer.articleNumber.toLowerCase().includes(searchTerm);
const nameMatch = offer.name.toLowerCase().includes(searchTerm);
if (!brandMatch && !articleMatch && !nameMatch) {
return false;
}
}
return true;
});
return transformOffersForCard(filteredAnalogOffers);
})() : [];
return (
<CoreProductCard
@ -647,20 +714,7 @@ export default function SearchResult() {
{(() => {
// Проверяем, есть ли еще аналоги с предложениями для загрузки
const remainingAnalogs = result.analogs.slice(visibleAnalogsCount);
const hasMoreAnalogsWithOffers = remainingAnalogs.some((analog: any) => {
const analogKey = `${analog.brand}-${analog.articleNumber}`;
const loadedAnalogData = loadedAnalogs[analogKey];
if (!loadedAnalogData) {
return true; // Могут быть предложения у незагруженных аналогов
}
const analogOffers = transformOffersForCard(
filteredOffers.filter(o => o.isAnalog && o.articleNumber === analog.articleNumber)
);
return analogOffers.length > 0;
});
const hasMoreAnalogsWithOffers = remainingAnalogs.length > 0;
return hasMoreAnalogsWithOffers && (
<div className="w-layout-hflex pagination">

View File

@ -58,6 +58,7 @@ const VehicleDetailsPage = () => {
const [showKnot, setShowKnot] = useState(false);
const [foundParts, setFoundParts] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly');
const [openedPath, setOpenedPath] = useState<string[]>([]);
const [searchState, setSearchState] = useState<{
loading: boolean;
error: any;
@ -329,6 +330,9 @@ const VehicleDetailsPage = () => {
onNodeSelect={setSelectedNode}
onActiveTabChange={(tab) => setActiveTab(tab)}
onQuickGroupSelect={setSelectedQuickGroup}
activeTab={activeTab}
openedPath={openedPath}
setOpenedPath={setOpenedPath}
/>
{searchState.isSearching ? (
<div className="knot-parts">
@ -399,6 +403,8 @@ const VehicleDetailsPage = () => {
onNodeSelect={setSelectedNode}
activeTab={activeTab}
onQuickGroupSelect={setSelectedQuickGroup}
openedPath={openedPath}
setOpenedPath={setOpenedPath}
/>
)}
</>
@ -408,10 +414,22 @@ const VehicleDetailsPage = () => {
<div className="w-layout-hflex flex-block-13">
<div className="w-layout-vflex flex-block-14-copy-copy">
{/* <button onClick={() => setSelectedNode(null)} style={{ marginBottom: 16 }}>Назад</button> */}
{/* ОТЛАДКА: Логируем передачу SSD в KnotIn */}
{(() => {
const knotSsd = selectedNode.ssd || vehicleInfo.ssd;
console.log('🔍 [vehicleId].tsx передает в KnotIn:', {
selectedNodeSsd: selectedNode.ssd ? `${selectedNode.ssd.substring(0, 50)}...` : 'отсутствует',
vehicleInfoSsd: vehicleInfo.ssd ? `${vehicleInfo.ssd.substring(0, 50)}...` : 'отсутствует',
finalSsd: knotSsd ? `${knotSsd.substring(0, 50)}...` : 'отсутствует',
unitId: selectedNode.unitid,
unitName: selectedNode.name
});
return null;
})()}
<KnotIn
catalogCode={vehicleInfo.catalog}
vehicleId={vehicleInfo.vehicleid}
ssd={vehicleInfo.ssd}
ssd={selectedNode.ssd || vehicleInfo.ssd} // ИСПРАВЛЕНИЕ: Используем SSD узла, fallback на родительский SSD
unitId={selectedNode.unitid}
unitName={selectedNode.name}
parts={unitDetails}

View File

@ -469,7 +469,13 @@ input#VinSearchInput {
.dropdown-toggle-card {
align-self: stretch;
margin-bottom: 5px;
margin-left: 0;
margin-right: 0;
padding: 6px 15px;
padding-left: 0 !important;
margin-left: 0 !important;
}
.dropdown-link-3 {
@ -480,11 +486,17 @@ input#VinSearchInput {
max-width: 230px;
}
.dropdown-toggle-3.active, .dropdown-toggle-card.active {
.dropdown-toggle-3.active{
background-color: var(--background);
font-weight: 700;
}
.dropdown-toggle-card.active {
background-color: var(--background);
}
.dropdown-toggle-3.active {
border-left: 2px solid var(--red);
@ -689,4 +701,43 @@ body {
}
.flex-block-108::-webkit-scrollbar, .flex-block-108-copy::-webkit-scrollbar, .w-layout-hflex.flex-block-121::-webkit-scrollbar, .core-product-search::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.flex-block-44 {
max-width: 33%;
}
.text-block-21 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flex-block-45 {
width: 80%;
}
.flex-block-39 {
max-width: 100%;
}
.heading-9-copy {
min-width: 100px;
}
@media screen and (max-width: 767px) {
.flex-block-15-copy {
grid-column-gap: 5px;
grid-row-gap: 5px;
width: 190px;
padding: 15px;
}
}
.flex-block-15-copy {
width: 230px;
}

View File

@ -10,6 +10,7 @@ export interface CartItem {
originalPrice?: number
currency: string
quantity: number
stock?: string | number // количество товара в наличии на складе
deliveryTime?: string
deliveryDate?: string
warehouse?: string
@ -52,7 +53,7 @@ export interface CartState {
export interface CartContextType {
state: CartState
addItem: (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => void
addItem: (item: Omit<CartItem, 'id' | 'selected' | 'favorite'>) => Promise<{ success: boolean; error?: string }>
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
toggleSelect: (id: string) => void
@ -64,4 +65,5 @@ export interface CartContextType {
removeSelected: () => void
updateDelivery: (delivery: Partial<DeliveryInfo>) => void
clearCart: () => void
clearError: () => void
}