переделаны счетчки фильтр рэндж, настроены выборы категорий и подкатегорий

This commit is contained in:
egortriston
2025-07-04 21:51:28 +03:00
parent d6d086299f
commit 9a604b39b3
11 changed files with 484 additions and 376 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useCart } from "@/contexts/CartContext"; import { useCart } from "@/contexts/CartContext";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@ -27,6 +27,11 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10); const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10);
const maxCount = isNaN(parsedStock) ? undefined : parsedStock; const maxCount = isNaN(parsedStock) ? undefined : parsedStock;
const [count, setCount] = useState(1); 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 handleMinus = () => setCount(prev => Math.max(1, prev - 1));
const handlePlus = () => { const handlePlus = () => {
@ -38,7 +43,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
}; };
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => { 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 (isNaN(value) || value < 1) value = 1;
if (maxCount !== undefined && value > maxCount) { if (maxCount !== undefined && value > maxCount) {
toast.error(`Максимум ${maxCount} шт.`); toast.error(`Максимум ${maxCount} шт.`);
@ -47,6 +58,13 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
setCount(value); setCount(value);
}; };
const handleInputBlur = () => {
if (inputValue === "") {
setInputValue("1");
setCount(1);
}
};
// Функция для парсинга цены из строки // Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => { const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.'); const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
@ -144,8 +162,9 @@ const BestPriceCard: React.FC<BestPriceCardProps> = ({
type="number" type="number"
min={1} min={1}
max={maxCount} max={maxCount}
value={count} value={inputValue}
onChange={handleInput} onChange={handleInput}
onBlur={handleInputBlur}
className="text-block-26 w-full text-center outline-none" className="text-block-26 w-full text-center outline-none"
aria-label="Количество" aria-label="Количество"
/> />

View File

@ -38,160 +38,180 @@ const CartItem: React.FC<CartItemProps> = ({
onRemove, onRemove,
isSummaryStep = false, isSummaryStep = false,
itemNumber, itemNumber,
}) => ( }) => {
<div className="w-layout-hflex cart-item"> // --- Фикс для input: можно стереть, при blur пустое = 1 ---
<div className="w-layout-hflex info-block-search-copy"> const [inputValue, setInputValue] = React.useState(count.toString());
{isSummaryStep ? ( React.useEffect(() => {
<div style={{ marginRight: 12, minWidth: 24, textAlign: 'center', fontWeight: 600, fontSize: 14 }}>{itemNumber}</div> setInputValue(count.toString());
) : ( }, [count]);
<div
className={"div-block-7" + (selected ? " active" : "")} return (
onClick={onSelect} <div className="w-layout-hflex cart-item">
style={{ marginRight: 12, cursor: 'pointer' }} <div className="w-layout-hflex info-block-search-copy">
>
{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">
{isSummaryStep ? ( {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
<div className={"div-block-7" + (selected ? " active" : "")}
className="minus-plus" onClick={onSelect}
onClick={() => onCountChange && onCountChange(count - 1)} style={{ marginRight: 12, cursor: 'pointer' }}
style={{ cursor: 'pointer' }} >
aria-label="Уменьшить количество" {selected && (
tabIndex={0} <svg width="14" height="10" viewBox="0 0 14 10" fill="none">
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onCountChange && onCountChange(count - 1)} <path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
role="button" </svg>
> )}
<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>
<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 block-name">
<div className="w-layout-hflex flex-block-39-copy-copy"> <h4 className="heading-9-copy">{name}</h4>
<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 <div
className="bdel" className={
role="button" "text-block-21-copy" +
tabIndex={0} (isSummaryStep && itemNumber === 1 ? " border-t-0" : "")
aria-label="Удалить из корзины" }
onClick={onRemove} style={
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onRemove && onRemove()} isSummaryStep && itemNumber === 1
style={{ display: 'inline-flex', cursor: 'pointer', transition: 'color 0.2s' }} ? { borderTop: 'none' }
onMouseEnter={e => { : undefined
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');
}}
> >
{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"> <svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <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"} />
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> </svg>
</div> </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> </div>
</div> );
); };
export default CartItem; export default CartItem;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useCart } from "@/contexts/CartContext"; import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext"; import { useFavorites } from "@/contexts/FavoritesContext";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@ -52,8 +52,16 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
const [quantities, setQuantities] = useState<{ [key: number]: number }>( const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {}) offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
); );
const [inputValues, setInputValues] = useState<{ [key: number]: string }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: "1" }), {})
);
const [quantityErrors, setQuantityErrors] = useState<{ [key: number]: string }>({}); 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 displayedOffers = offers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < offers.length; const hasMoreOffers = visibleOffersCount < offers.length;
@ -83,16 +91,35 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
return match ? parseInt(match[0]) : 0; return match ? parseInt(match[0]) : 0;
}; };
const handleQuantityInput = (index: number, value: string) => { const handleInputChange = (idx: number, val: string) => {
const offer = offers[index]; setInputValues(prev => ({ ...prev, [idx]: val }));
const availableStock = parseStock(offer.pcs); if (val === "") return;
let num = parseInt(value, 10); const valueNum = Math.max(1, parseInt(val, 10) || 1);
if (isNaN(num) || num < 1) num = 1; setQuantities(prev => ({ ...prev, [idx]: valueNum }));
if (num > availableStock) { };
toast.error(`Максимум ${availableStock} шт.`);
return; const handleInputBlur = (idx: number) => {
if (inputValues[idx] === "") {
setInputValues(prev => ({ ...prev, [idx]: "1" }));
setQuantities(prev => ({ ...prev, [idx]: 1 }));
} }
setQuantities(prev => ({ ...prev, [index]: num })); };
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 = (offer: CoreProductCardOffer, index: number) => { const handleAddToCart = (offer: CoreProductCardOffer, index: number) => {
@ -291,6 +318,7 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<div className="w-layout-vflex product-list-search-s1"> <div className="w-layout-vflex product-list-search-s1">
{displayedOffers.map((offer, idx) => { {displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1; const isLast = idx === displayedOffers.length - 1;
const maxCount = parseStock(offer.pcs);
return ( return (
<div <div
className="w-layout-hflex product-item-search-s1" className="w-layout-hflex product-item-search-s1"
@ -317,43 +345,48 @@ const CoreProductCard: React.FC<CoreProductCardProps> = ({
<div className="w-layout-hflex add-to-cart-block-s1"> <div className="w-layout-hflex add-to-cart-block-s1">
<div className="w-layout-hflex flex-block-82"> <div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-cart-s1"> <div className="w-layout-hflex pcs-cart-s1">
<button <div
type="button"
className="minus-plus" className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) - 1).toString())} onClick={() => handleMinus(idx)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество" aria-label="Уменьшить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handleMinus(idx)}
role="button"
> >
<div className="pluspcs w-embed"> <div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <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" /> <path d="M6 10.5V9.5H14V10.5H6Z" fill="currentColor" />
</svg> </svg>
</div> </div>
</button> </div>
<div className="input-pcs"> <div className="input-pcs">
<input <input
type="number" type="number"
min={1} min={1}
max={parseStock(offer.pcs)} max={maxCount}
value={quantities[idx] || 1} value={inputValues[idx]}
onChange={e => handleQuantityInput(idx, e.target.value)} onChange={e => handleInputChange(idx, e.target.value)}
onBlur={() => handleInputBlur(idx)}
className="text-block-26 w-full text-center outline-none" className="text-block-26 w-full text-center outline-none"
aria-label="Количество" aria-label="Количество"
/> />
</div> </div>
<button <div
type="button"
className="minus-plus" className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) + 1).toString())} onClick={() => handlePlus(idx, maxCount)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
aria-label="Увеличить количество" aria-label="Увеличить количество"
tabIndex={0}
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handlePlus(idx, maxCount)}
role="button"
> >
<div className="pluspcs w-embed"> <div className="pluspcs w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <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" /> <path d="M6 10.5V9.5H14V10.5H6ZM9.5 6H10.5V14H9.5V6Z" fill="currentColor" />
</svg> </svg>
</div> </div>
</button> </div>
</div> </div>
<button <button
type="button" type="button"

View File

@ -68,49 +68,49 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
Найдено автомобилей: {results.length} Найдено автомобилей: {results.length}
</h3> </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) => ( {results.map((vehicle, index) => (
<div <div
key={`${vehicle.vehicleid}-${index}`} 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)} onClick={() => handleSelectVehicle(vehicle)}
> >
{/* Заголовок автомобиля */} {/* Заголовок автомобиля */}
<div className="mb-3"> <div className="">
<h4 className="text-lg font-semibold text-blue-600 mb-1"> <h4 className="text-lg font-semibold text-red-600 mb-1 truncate">
{vehicle.name || `${vehicle.brand} ${vehicle.model}`} {vehicle.name || `${vehicle.brand} ${vehicle.model}`}
</h4> </h4>
<p className="text-sm text-gray-500"> {/* <p className="text-sm text-gray-500 truncate">
{vehicle.modification} ({vehicle.year}) {vehicle.modification} ({vehicle.year})
</p> </p> */}
</div> </div>
{/* Основные характеристики */} {/* Основные характеристики */}
<div className="space-y-1 mb-4"> <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.brand)}
{renderAttribute('Модель', vehicle.model)} {renderAttribute('Модель', vehicle.model)}
{renderAttribute('Двигатель', vehicle.engine)} {renderAttribute('Двигатель', vehicle.engine)}
</div> </div>
{/* Все атрибуты из API */} {/* Все атрибуты из API */}
{vehicle.attributes && vehicle.attributes.length > 0 && ( {vehicle.attributes && vehicle.attributes.length > 0 && (
<div className="space-y-1 mb-4"> <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) => ( {vehicle.attributes.map((attr, attrIndex) => (
<div key={attrIndex} className="flex justify-between py-1 border-b border-gray-100"> <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-600 font-medium">{attr.name || attr.key}:</span>
<span className="text-sm text-gray-900">{attr.value}</span> <span className="text-sm text-gray-900">{attr.value}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Технические характеристики (fallback для старых данных) */} {/* Технические характеристики (fallback для старых данных) */}
{(!vehicle.attributes || vehicle.attributes.length === 0) && ( {(!vehicle.attributes || vehicle.attributes.length === 0) && (
<> <>
<div className="space-y-1 mb-4"> <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.year)}
{renderAttribute('Кузов', vehicle.bodytype)} {renderAttribute('Кузов', vehicle.bodytype)}
{renderAttribute('Трансмиссия', vehicle.transmission)} {renderAttribute('Трансмиссия', vehicle.transmission)}
@ -123,7 +123,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
</div> </div>
<div className="space-y-1 mb-4"> <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.engine_info)}
{renderAttribute('Номер двигателя', vehicle.engineno)} {renderAttribute('Номер двигателя', vehicle.engineno)}
{renderAttribute('Дата производства', vehicle.date)} {renderAttribute('Дата производства', vehicle.date)}
@ -133,7 +133,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
</div> </div>
<div className="space-y-1 mb-4"> <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.datefrom)}
{renderAttribute('Дата по', vehicle.dateto)} {renderAttribute('Дата по', vehicle.dateto)}
{renderAttribute('Модельный год с', vehicle.modelyearfrom)} {renderAttribute('Модельный год с', vehicle.modelyearfrom)}
@ -143,7 +143,7 @@ const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
{/* Опции и описание */} {/* Опции и описание */}
{(vehicle.options || vehicle.description || vehicle.notes) && ( {(vehicle.options || vehicle.description || vehicle.notes) && (
<div className="space-y-1 mb-4"> <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.options)}
{renderAttribute('Описание', vehicle.description)} {renderAttribute('Описание', vehicle.description)}
{renderAttribute('Примечания', vehicle.notes)} {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>
))} ))}
</div> </div>

View File

@ -325,36 +325,36 @@ const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
</div> </div>
)} )}
{/* Кнопка поиска автомобилей */} {/* Информация о недостаточности параметров и кнопка поиска */}
{!isLoading && canListVehicles && showSearchButton && ( {!isLoading && wizardSteps.length > 0 && (
<div className="pt-4 border-t"> <div className="flex flex-row gap-4 items-center w-full mx-auto max-sm:flex-col max-sm:items-stretch">
<button <button
onClick={() => { onClick={() => {
handleFindVehicles(); handleFindVehicles();
setShowSearchButton(false); setShowSearchButton(false);
}} }}
disabled={isLoading} 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" 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> </button>
<div className="mt-3 text-sm text-gray-600"> <div
Определено параметров: {wizardSteps.filter(s => s.determined).length} из {wizardSteps.length} 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>
</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> </div>
); );
}; };

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

View File

@ -137,10 +137,10 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
/> />
{/* Точки/области */} {/* Точки/области */}
{coordinates.map((coord: any, idx: number) => { {coordinates.map((coord: any, idx: number) => {
const scaledX = coord.x * imageScale.x; // Кружки всегда 32x32px, центрируем по координате
const scaledY = coord.y * imageScale.y; const size = 22;
const scaledWidth = coord.width * imageScale.x; const scaledX = coord.x * imageScale.x - size / 2;
const scaledHeight = coord.height * imageScale.y; const scaledY = coord.y * imageScale.y - size / 2;
return ( return (
<div <div
key={`coord-${unitId}-${idx}-${coord.x}-${coord.y}`} key={`coord-${unitId}-${idx}-${coord.x}-${coord.y}`}
@ -149,19 +149,29 @@ const KnotIn: React.FC<KnotInProps> = ({ catalogCode, vehicleId, ssd, unitId, un
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') handlePointClick(coord.codeonimage); 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={{ style={{
left: scaledX, left: scaledX,
top: scaledY, top: scaledY,
width: scaledWidth, width: size,
height: scaledHeight, height: size,
background: '#B7CAE2',
borderRadius: '50%', borderRadius: '50%',
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
title={coord.codeonimage} title={coord.codeonimage}
onClick={() => handlePointClick(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} {coord.codeonimage}
</span> </span>
</div> </div>

View File

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

View File

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

View File

@ -58,6 +58,7 @@ const VehicleDetailsPage = () => {
const [showKnot, setShowKnot] = useState(false); const [showKnot, setShowKnot] = useState(false);
const [foundParts, setFoundParts] = useState<any[]>([]); const [foundParts, setFoundParts] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly'); const [activeTab, setActiveTab] = useState<'uzly' | 'manufacturer'>('uzly');
const [openedPath, setOpenedPath] = useState<string[]>([]);
const [searchState, setSearchState] = useState<{ const [searchState, setSearchState] = useState<{
loading: boolean; loading: boolean;
error: any; error: any;
@ -329,6 +330,9 @@ const VehicleDetailsPage = () => {
onNodeSelect={setSelectedNode} onNodeSelect={setSelectedNode}
onActiveTabChange={(tab) => setActiveTab(tab)} onActiveTabChange={(tab) => setActiveTab(tab)}
onQuickGroupSelect={setSelectedQuickGroup} onQuickGroupSelect={setSelectedQuickGroup}
activeTab={activeTab}
openedPath={openedPath}
setOpenedPath={setOpenedPath}
/> />
{searchState.isSearching ? ( {searchState.isSearching ? (
<div className="knot-parts"> <div className="knot-parts">
@ -399,6 +403,8 @@ const VehicleDetailsPage = () => {
onNodeSelect={setSelectedNode} onNodeSelect={setSelectedNode}
activeTab={activeTab} activeTab={activeTab}
onQuickGroupSelect={setSelectedQuickGroup} onQuickGroupSelect={setSelectedQuickGroup}
openedPath={openedPath}
setOpenedPath={setOpenedPath}
/> />
)} )}
</> </>

View File

@ -469,7 +469,13 @@ input#VinSearchInput {
.dropdown-toggle-card { .dropdown-toggle-card {
align-self: stretch;
margin-bottom: 5px;
margin-left: 0;
margin-right: 0;
padding: 6px 15px;
padding-left: 0 !important; padding-left: 0 !important;
margin-left: 0 !important;
} }
.dropdown-link-3 { .dropdown-link-3 {
@ -482,7 +488,7 @@ input#VinSearchInput {
.dropdown-toggle-3.active, .dropdown-toggle-card.active { .dropdown-toggle-3.active, .dropdown-toggle-card.active {
background-color: var(--background); background-color: var(--background);
font-weight: 700;
} }
.dropdown-toggle-3.active { .dropdown-toggle-3.active {
@ -689,4 +695,28 @@ 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 { .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 */ 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;
} }