first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View File

@ -0,0 +1,241 @@
import React from "react";
const AnalogueBlock: React.FC = () => (
<>
<h2 className="heading-11">Аналлоги от других производителей</h2>
{/* Аналог 1: AIX */}
<div className="w-layout-hflex core-product-search-s2">
<div className="w-layout-vflex core-product">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19"><img src="/images/info.svg" loading="lazy" alt="" className="image-9" /></div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex">
<h3 className="heading-10 name">AIX</h3>
<h3 className="heading-10">AIX10127</h3>
</div>
<div className="text-block-21">Кольцо уплотнительное клапанной крышки Chevrolet</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-hflex sort-list">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Рейтинг</div>
<div className="sort-item">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Цена</div>
</div>
<div className="w-layout-vflex product-list-search">
<div className="w-layout-hflex product-item-search">
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-copy">
<div className="w-layout-hflex raiting"><img src="/images/Star-1.svg" loading="lazy" alt="" className="image-8" />
<div className="text-block-22">4,8</div>
</div>
<div className="pcs-search">444 шт</div>
<div className="pcs-search">5 дней</div>
</div>
<div className="w-layout-hflex info-block-product-card-search">
<div className="w-layout-hflex item-recommend"><img src="/images/ri_refund-fill.svg" loading="lazy" alt="" /></div>
<div className="text-block-25">Рекомендуем</div>
</div>
<div className="price">от 17 323 </div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-re-s1">
<div className="minus-plus"><img src="/images/minus_icon.svg" loading="lazy" alt="" /></div>
<div className="input-pcs">
<div className="text-block-26">1</div>
</div>
<div className="minus-plus"><img src="/images/plus_icon.svg" loading="lazy" alt="" /></div>
</div>
<a href="#" className="button-icon w-inline-block"><img loading="lazy" src="/images/cart_icon.svg" alt="" className="image-11" /></a>
</div>
</div>
</div>
</div>
<div className="w-layout-hflex show-more-search">
<div className="text-block-27">Ещё предложения от 4726 руб и 5 дней</div><img src="/images/arrow_drop_down.svg" loading="lazy" alt="" />
</div>
</div>
</div>
{/* Аналог 2 */}
<div className="w-layout-hflex core-product-search-s2">
<div className="w-layout-vflex core-product">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19"><img src="/images/info.svg" loading="lazy" alt="" className="image-9" /></div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex">
<h3 className="heading-10 name">ABSEL</h3>
<h3 className="heading-10">WG052006K</h3>
</div>
<div className="text-block-21">Комплект ремня ГРМ</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-hflex sort-list">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Рейтинг</div>
<div className="sort-item">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Рейтинг</div>
</div>
<div className="w-layout-vflex product-list-search">
<div className="w-layout-hflex product-item-search">
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-copy">
<div className="w-layout-hflex raiting"><img src="/images/Star-1.svg" loading="lazy" alt="" className="image-8" />
<div className="text-block-22">4,8</div>
</div>
<div className="pcs-search">444 шт</div>
<div className="pcs-search">5 дней</div>
</div>
<div className="w-layout-hflex info-block-product-card-search">
<div className="w-layout-hflex item-recommend"><img src="/images/ri_refund-fill.svg" loading="lazy" alt="" /></div>
<div className="text-block-25">Рекомендуем</div>
</div>
<div className="price">от 17 323 </div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-re-s1">
<div className="minus-plus"><img src="/images/minus_icon.svg" loading="lazy" alt="" /></div>
<div className="input-pcs">
<div className="text-block-26">1</div>
</div>
<div className="minus-plus"><img src="/images/plus_icon.svg" loading="lazy" alt="" /></div>
</div>
<a href="#" className="button-icon w-inline-block"><img loading="lazy" src="/images/cart_icon.svg" alt="" className="image-11" /></a>
</div>
</div>
</div>
</div>
<div className="w-layout-hflex show-more-search">
<div className="text-block-27">Ещё предложения от 4726 руб и 5 дней</div><img src="/images/arrow_drop_down.svg" loading="lazy" alt="" />
</div>
</div>
</div>
{/* Аналог 3 */}
<div className="w-layout-hflex core-product-search-s2">
<div className="w-layout-vflex core-product">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19"><img src="/images/info.svg" loading="lazy" alt="" className="image-9" /></div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex">
<h3 className="heading-10 name">Ganz</h3>
<h3 className="heading-10">GIE34006</h3>
</div>
<div className="text-block-21">РЕМКОМПЛЕКТ ГРМ VAG+SKODA 2012- MOT.1,2TSI/1,4TSI</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-hflex sort-list">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Рейтинг</div>
<div className="sort-item">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Рейтинг</div>
</div>
<div className="w-layout-vflex product-list-search">
<div className="w-layout-hflex product-item-search">
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-copy">
<div className="w-layout-hflex raiting"><img src="/images/Star-1.svg" loading="lazy" alt="" className="image-8" />
<div className="text-block-22">4,8</div>
</div>
<div className="pcs-search">444 шт</div>
<div className="pcs-search">5 дней</div>
</div>
<div className="w-layout-hflex info-block-product-card-search">
<div className="w-layout-hflex item-recommend"><img src="/images/ri_refund-fill.svg" loading="lazy" alt="" /></div>
<div className="text-block-25">Рекомендуем</div>
</div>
<div className="price">от 17 323 </div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-re-s1">
<div className="minus-plus"><img src="/images/minus_icon.svg" loading="lazy" alt="" /></div>
<div className="input-pcs">
<div className="text-block-26">1</div>
</div>
<div className="minus-plus"><img src="/images/plus_icon.svg" loading="lazy" alt="" /></div>
</div>
<a href="#" className="button-icon w-inline-block"><img loading="lazy" src="/images/cart_icon.svg" alt="" className="image-11" /></a>
</div>
</div>
</div>
</div>
<div className="w-layout-hflex show-more-search">
<div className="text-block-27">Ещё предложения от 4726 руб и 5 дней</div><img src="/images/arrow_drop_down.svg" loading="lazy" alt="" />
</div>
</div>
</div>
{/* Аналог 4 */}
<div className="w-layout-hflex core-product-search-s2">
<div className="w-layout-vflex core-product">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19"><img src="/images/info.svg" loading="lazy" alt="" className="image-9" /></div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex">
<h3 className="heading-10 name">Gates</h3>
<h3 className="heading-10">K015680XS</h3>
</div>
<div className="text-block-21">Ремень ГРМ [163 зуб.,20mm] + 2 ролика + крепеж 788</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-hflex sort-list">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Рейтинг</div>
<div className="sort-item">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Рейтинг</div>
</div>
<div className="w-layout-vflex product-list-search">
<div className="w-layout-hflex product-item-search">
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-copy">
<div className="w-layout-hflex raiting"><img src="/images/Star-1.svg" loading="lazy" alt="" className="image-8" />
<div className="text-block-22">4,8</div>
</div>
<div className="pcs-search">444 шт</div>
<div className="pcs-search">5 дней</div>
</div>
<div className="w-layout-hflex info-block-product-card-search">
<div className="w-layout-hflex item-recommend"><img src="/images/ri_refund-fill.svg" loading="lazy" alt="" /></div>
<div className="text-block-25">Рекомендуем</div>
</div>
<div className="price">от 17 323 </div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs-re-s1">
<div className="minus-plus"><img src="/images/minus_icon.svg" loading="lazy" alt="" /></div>
<div className="input-pcs">
<div className="text-block-26">1</div>
</div>
<div className="minus-plus"><img src="/images/plus_icon.svg" loading="lazy" alt="" /></div>
</div>
<a href="#" className="button-icon w-inline-block"><img loading="lazy" src="/images/cart_icon.svg" alt="" className="image-11" /></a>
</div>
</div>
</div>
</div>
<div className="w-layout-hflex show-more-search">
<div className="text-block-27">Ещё предложения от 4726 руб и 5 дней</div><img src="/images/arrow_drop_down.svg" loading="lazy" alt="" />
</div>
</div>
</div>
</>
);
export default AnalogueBlock;

View File

@ -0,0 +1,123 @@
import React, { memo, useState, useEffect } from 'react';
import CatalogProductCard from './CatalogProductCard';
import CatalogProductCardSkeleton from './CatalogProductCardSkeleton';
import { useArticleImage } from '@/hooks/useArticleImage';
import { useCatalogPrices } from '@/hooks/useCatalogPrices';
import { PartsAPIArticle } from '@/types/partsapi';
import toast from 'react-hot-toast';
interface ArticleCardProps {
article: PartsAPIArticle;
index: number;
onVisibilityChange?: (index: number, isVisible: boolean) => void;
}
const ArticleCard: React.FC<ArticleCardProps> = memo(({ article, index, onVisibilityChange }) => {
const [shouldShow, setShouldShow] = useState(false);
const [isChecking, setIsChecking] = useState(true);
// Используем хук для получения изображения
const { imageUrl, isLoading: imageLoading, error } = useArticleImage(article.artId, {
enabled: !!article.artId
});
// Проверяем и очищаем данные артикула и бренда
const articleNumber = article.artArticleNr?.trim();
const brandName = article.artSupBrand?.trim();
// Используем хук для получения цен только если есть и артикул, и бренд
const { getPriceData, addToCart } = useCatalogPrices();
const shouldFetchPrices = articleNumber && brandName && articleNumber !== '' && brandName !== '';
const priceData = shouldFetchPrices ? getPriceData(articleNumber, brandName) : { minPrice: null, cheapestOffer: null, isLoading: false, hasOffers: false };
// Определяем, должен ли отображаться товар
useEffect(() => {
if (!shouldFetchPrices) {
// Если нет данных для поиска, не показываем товар
setShouldShow(false);
setIsChecking(false);
onVisibilityChange?.(index, false);
console.log('❌ ArticleCard: скрываем товар без данных:', { articleNumber, brandName });
return;
}
if (priceData.isLoading) {
// Если данные загружаются, ждем
setIsChecking(true);
return;
}
// Данные загружены - проверяем результат
if (priceData.hasOffers) {
setShouldShow(true);
setIsChecking(false);
onVisibilityChange?.(index, true);
console.log('✅ ArticleCard: показываем товар с предложениями:', { articleNumber, brandName, hasPrice: !!priceData.minPrice });
} else {
setShouldShow(false);
setIsChecking(false);
onVisibilityChange?.(index, false);
console.log('❌ ArticleCard: скрываем товар без предложений:', { articleNumber, brandName });
}
}, [shouldFetchPrices, priceData.isLoading, priceData.hasOffers, articleNumber, brandName, priceData.minPrice, index, onVisibilityChange]);
// Показываем скелетон если данные загружаются или проверяются
if (isChecking || (shouldShow && (priceData.isLoading || imageLoading))) {
return <CatalogProductCardSkeleton />;
}
// Не отображаем ничего если товар не должен показываться
if (!shouldShow) {
return null;
}
// Формируем название товара
const title = [
brandName || 'N/A',
articleNumber || 'N/A',
].filter(part => part !== 'N/A').join(', ');
const brand = brandName || 'Unknown';
// Формируем цену для отображения
let priceText = '';
if (priceData.isLoading) {
priceText = 'Загрузка...';
} else if (priceData.minPrice && priceData.minPrice > 0) {
priceText = `от ${priceData.minPrice.toLocaleString('ru-RU')}`;
} else {
priceText = 'По запросу';
}
// Обработчик добавления в корзину
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!shouldFetchPrices) {
toast.error('Недостаточно данных для добавления товара в корзину');
return;
}
await addToCart(articleNumber!, brandName!);
};
return (
<CatalogProductCard
image={imageUrl}
discount="Новинка"
price={priceText}
oldPrice=""
title={title}
brand={brand}
articleNumber={articleNumber}
brandName={brandName}
artId={article.artId}
onAddToCart={handleAddToCart}
/>
);
});
ArticleCard.displayName = 'ArticleCard';
export default ArticleCard;

View File

@ -0,0 +1,95 @@
import React, { useState } from "react";
interface BestPriceCardProps {
bestOfferType: string;
title: string;
description: string;
price: string;
delivery: string;
stock: string;
}
const BestPriceCard: React.FC<BestPriceCardProps> = ({ bestOfferType, title, description, price, delivery, stock }) => {
// Парсим stock в число, если возможно
const parsedStock = parseInt(stock.replace(/[^\d]/g, ""), 10);
const maxCount = isNaN(parsedStock) ? undefined : parsedStock;
const [count, setCount] = useState(1);
const handleMinus = () => setCount(prev => Math.max(1, prev - 1));
const handlePlus = () => {
if (maxCount !== undefined) {
setCount(prev => (prev < maxCount ? prev + 1 : prev));
} else {
setCount(prev => prev + 1);
}
};
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) value = 1;
if (maxCount !== undefined && value > maxCount) {
window.alert(`Максимум ${maxCount} шт.`);
return;
}
setCount(value);
};
return (
<div className="w-layout-vflex flex-block-44">
<h3 className="heading-8-copy line-clamp-2 md:line-clamp-1 min-h-[2.5em] md:min-h-0">{bestOfferType}</h3>
<div className="w-layout-vflex flex-block-40">
<div className="w-layout-hflex flex-block-45">
<div className="w-layout-hflex flex-block-39 flex flex-col">
<h4 className="heading-9 truncate overflow-hidden whitespace-nowrap max-w-[140px] md:max-w-full w-full">{title}</h4>
<div className="text-block-21 truncate overflow-hidden whitespace-nowrap max-w-[140px] md:max-w-full w-full">{description}</div>
</div>
</div>
<div className="heading-9-copy">{price}</div>
</div>
<div className="w-layout-vflex flex-block-37">
<div className="w-layout-hflex flex-block-43">
<div className="w-layout-hflex flex-block-78">
<div className="w-layout-hflex flex-block-80">
<div className="w-layout-vflex flex-block-106">
<div className="text-block-23">Срок</div>
<div className="text-block-24">{delivery}</div>
</div>
<div className="w-layout-vflex flex-block-105">
<div className="text-block-23">Наличие</div>
<div className="text-block-24">{stock}</div>
</div>
</div>
<div className="w-layout-hflex pcs-cart-s1">
<button type="button" className="minus-plus" aria-label="Уменьшить количество" onClick={handleMinus}>
<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 className="input-pcs">
<input
type="number"
min={1}
max={maxCount}
value={count}
onChange={handleInput}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
/>
</div>
<button type="button" className="minus-plus" aria-label="Увеличить количество" onClick={handlePlus}>
<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>
<div className="w-layout-hflex flex-block-42">
<a href="#" className="button-icon w-inline-block">
<div className="div-block-26">
<div className="icon-setting w-embed"><svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"/></svg></div>
</div>
</a>
</div>
</div>
</div>
</div>
);
};
export default BestPriceCard;

View File

@ -0,0 +1,501 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useQuery } from '@apollo/client';
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql';
import { PartsIndexCatalogsData, PartsIndexCatalogsVariables, PartsIndexCatalog } from '@/types/partsindex';
function useIsMobile(breakpoint = 767) {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= breakpoint);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, [breakpoint]);
return isMobile;
}
// Fallback статичные данные
const fallbackTabData = [
{
label: "Оригинальные каталоги",
heading: "Оригинальные каталоги",
links: [
"Моторные масла",
"Трансмиссионные масла",
"Тормозные жидкости",
"Смазки",
"Дистиллированная вода",
"Жидкости для стеклоомывателей",
"Индустриальные жидкости",
"Антифриз и охлаждающие жидкости",
"Промывочные жидкости",
],
},
{
label: "Масла и технические жидкости",
heading: "Масла и технические жидкости",
links: [
"Моторные масла",
"Трансмиссионные масла",
"Тормозные жидкости",
"Смазки",
"Дистиллированная вода",
"Жидкости для стеклоомывателей",
"Индустриальные жидкости",
"Антифриз и охлаждающие жидкости",
"Промывочные жидкости",
],
},
{
label: "Оборудование",
heading: "Оборудование",
links: [
"Моторные масла",
"Трансмиссионные масла",
"Тормозные жидкости",
"Смазки",
"Дистиллированная вода",
"Жидкости для стеклоомывателей",
"Индустриальные жидкости",
"Антифриз и охлаждающие жидкости",
"Промывочные жидкости",
],
},
];
// Преобразуем данные PartsIndex в формат нашего меню
const transformPartsIndexToTabData = (catalogs: PartsIndexCatalog[]) => {
console.log('🔄 Преобразуем каталоги PartsIndex:', catalogs.length, 'элементов');
const transformed = catalogs.map(catalog => {
const groupsCount = catalog.groups?.length || 0;
console.log(`📝 Каталог: "${catalog.name}" (${groupsCount} групп)`);
let links: string[] = [];
if (catalog.groups && catalog.groups.length > 0) {
// Для каждой группы проверяем есть ли подгруппы
catalog.groups.forEach(group => {
if (group.subgroups && group.subgroups.length > 0) {
// Если есть подгруппы, добавляем их названия
links.push(...group.subgroups.slice(0, 9 - links.length).map(subgroup => subgroup.name));
} else {
// Если подгрупп нет, добавляем название самой группы
if (links.length < 9) {
links.push(group.name);
}
}
});
}
// Если подкатегорий нет, показываем название категории как указано в требованиях
if (links.length === 0) {
links = [catalog.name];
}
console.log(`🔗 Подкатегории для "${catalog.name}":`, links);
return {
label: catalog.name,
heading: catalog.name,
links: links.slice(0, 9), // Ограничиваем максимум 9 элементов
catalogId: catalog.id // Сохраняем ID каталога для навигации
};
});
console.log('✅ Преобразование завершено:', transformed.length, 'табов');
return transformed;
};
const BottomHead = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => {
const isMobile = useIsMobile();
const router = useRouter();
const [mobileCategory, setMobileCategory] = useState<null | any>(null);
const [tabData, setTabData] = useState(fallbackTabData);
const [activeTabIndex, setActiveTabIndex] = useState(0);
console.log('🔄 BottomHead render:', {
menuOpen,
tabDataLength: tabData.length,
activeTabIndex,
isMobile
});
// --- Overlay animation state ---
const [showOverlay, setShowOverlay] = useState(false);
useEffect(() => {
if (menuOpen) {
setShowOverlay(true);
} else {
// Ждём окончания transition перед удалением из DOM
const timeout = setTimeout(() => setShowOverlay(false), 300);
return () => clearTimeout(timeout);
}
}, [menuOpen]);
// --- End overlay animation state ---
// Получаем каталоги PartsIndex
const { data: catalogsData, loading, error } = useQuery<PartsIndexCatalogsData, PartsIndexCatalogsVariables>(
GET_PARTSINDEX_CATEGORIES,
{
variables: {
lang: 'ru'
},
errorPolicy: 'all',
onCompleted: (data) => {
console.log('🎉 Apollo Query onCompleted - данные получены:', data);
},
onError: (error) => {
console.error('❌ Apollo Query onError:', error);
}
}
);
// Обновляем данные табов когда получаем данные от API
useEffect(() => {
if (catalogsData?.partsIndexCategoriesWithGroups && catalogsData.partsIndexCategoriesWithGroups.length > 0) {
console.log('✅ Обновляем меню с данными PartsIndex:', catalogsData.partsIndexCategoriesWithGroups.length, 'каталогов');
console.log('🔍 Первые 3 каталога:', catalogsData.partsIndexCategoriesWithGroups.slice(0, 3).map(catalog => ({
name: catalog.name,
id: catalog.id,
groupsCount: catalog.groups?.length || 0,
groups: catalog.groups?.slice(0, 3).map(group => group.name)
})));
const apiTabData = transformPartsIndexToTabData(catalogsData.partsIndexCategoriesWithGroups);
setTabData(apiTabData);
// Сбрасываем активный таб на первый при обновлении данных
setActiveTabIndex(0);
} else if (error) {
console.warn('⚠️ Используем fallback данные из-за ошибки PartsIndex:', error);
setTabData(fallbackTabData);
setActiveTabIndex(0);
}
}, [catalogsData, error]);
// Логирование для отладки
useEffect(() => {
if (loading) {
console.log('🔄 Загружаем каталоги PartsIndex...');
}
if (error) {
console.error('❌ Ошибка загрузки каталогов PartsIndex:', error);
}
}, [loading, error]);
// Обработка клика по категории для перехода в каталог с товарами
const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => {
console.log('🔍 Клик по категории:', { catalogId, categoryName, entityId });
// Закрываем меню
onClose();
// Переходим на страницу каталога с параметрами PartsIndex
router.push({
pathname: '/catalog',
query: {
partsIndexCatalog: catalogId,
categoryName: encodeURIComponent(categoryName),
...(entityId && { partsIndexCategory: entityId })
}
});
};
// Только мобильный UX
if (isMobile && menuOpen) {
// Оверлей для мобильного меню
return (
<>
{showOverlay && (
<div
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-label="Закрыть меню"
/>
)}
{/* Экран подкатегорий */}
{mobileCategory ? (
<div className="mobile-category-overlay z-50">
<div className="mobile-header">
<button className="mobile-back-btn" onClick={() => setMobileCategory(null)}>
</button>
<span>{mobileCategory.label}</span>
</div>
<div className="mobile-subcategories">
{mobileCategory.links.map((link: string, linkIndex: number) => (
<div
className="mobile-subcategory"
key={link}
onClick={() => {
// Ищем соответствующую подгруппу по названию
let subcategoryId = `${mobileCategory.catalogId}_${linkIndex}`;
if (mobileCategory.groups) {
for (const group of mobileCategory.groups) {
// Проверяем в подгруппах
if (group.subgroups && group.subgroups.length > 0) {
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
if (foundSubgroup) {
subcategoryId = foundSubgroup.id;
break;
}
}
// Если нет подгрупп, проверяем саму группу
else if (group.name === link) {
subcategoryId = group.id;
break;
}
}
}
// Получаем catalogId из данных
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[tabData.findIndex(tab => tab === mobileCategory)];
const catalogId = activeCatalog?.id || 'fallback';
handleCategoryClick(catalogId, link, subcategoryId);
}}
>
{link}
</div>
))}
</div>
</div>
) : (
// Экран выбора категории
<div className="mobile-category-overlay z-50">
<div className="mobile-header">
<button className="mobile-back-btn" onClick={onClose} aria-label="Закрыть меню">
<svg width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M4.11 2.697L2.698 4.11 6.586 8l-3.89 3.89 1.415 1.413L8 9.414l3.89 3.89 1.413-1.415L9.414 8l3.89-3.89-1.415-1.413L8 6.586l-3.89-3.89z" fill="currentColor"></path>
</svg>
</button>
<span>Категории</span>
{loading && <span className="text-sm text-gray-500 ml-2">(загрузка...)</span>}
</div>
<div className="mobile-subcategories" style={{ maxHeight: "70vh", overflowY: "auto" }}>
{tabData.map((cat, index) => {
// Получаем ID каталога из данных PartsIndex или создаем fallback ID
const catalogId = catalogsData?.partsIndexCategoriesWithGroups?.[index]?.id || `fallback_${index}`;
return (
<div
className="mobile-subcategory"
key={cat.label}
onClick={() => {
// Добавляем catalogId и groups для правильной обработки
const categoryWithData = {
...cat,
catalogId,
groups: catalogsData?.partsIndexCategoriesWithGroups?.[index]?.groups
};
setMobileCategory(categoryWithData);
}}
style={{ cursor: "pointer" }}
>
{cat.label}
</div>
);
})}
</div>
</div>
)}
</>
);
}
// Десктоп: оставить всё как есть, но добавить оверлей
return (
<>
{showOverlay && (
<div
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-label="Закрыть меню"
/>
)}
{showOverlay && (
<div
className={`fixed inset-0 bg-black/7 z-1900 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-label="Закрыть меню"
/>
)}
<nav
role="navigation"
className="nav-menu-3 w-nav-menu z-2000"
style={{ display: menuOpen ? "block" : "none" }}
onClick={e => e.stopPropagation()} // чтобы клик внутри меню не закрывал его
>
<div className="div-block-28">
<div className="w-layout-hflex flex-block-90">
<div className="w-layout-vflex flex-block-88" style={{ maxHeight: "60vh", overflowY: "auto" }}>
{/* Меню с иконками - показываем все категории из API */}
{tabData.map((tab, idx) => (
<a
href="#"
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
key={tab.label}
onClick={() => {
setActiveTabIndex(idx);
}}
style={{ cursor: "pointer" }}
>
<div className="div-block-29">
<div className="code-embed-12 w-embed">
{/* SVG-звезда */}
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3158 0.643914C10.4674 0.365938 10.8666 0.365938 11.0182 0.643914L14.0029 6.11673C14.0604 6.22222 14.1623 6.29626 14.2804 6.31838L20.4077 7.46581C20.7189 7.52409 20.8423 7.9037 20.6247 8.13378L16.3421 12.6636C16.2595 12.7509 16.2206 12.8707 16.2361 12.9899L17.0382 19.1718C17.079 19.4858 16.7561 19.7204 16.47 19.5847L10.8385 16.9114C10.73 16.8599 10.604 16.8599 10.4955 16.9114L4.86394 19.5847C4.5779 19.7204 4.25499 19.4858 4.29573 19.1718L5.0979 12.9899C5.11336 12.8707 5.07444 12.7509 4.99189 12.6636L0.709252 8.13378C0.491728 7.9037 0.615069 7.52409 0.926288 7.46581L7.05357 6.31838C7.17168 6.29626 7.27358 6.22222 7.33112 6.11673L10.3158 0.643914Z" fill="CurrentColor"></path>
</svg>
</div>
</div>
<div className="text-block-47">{tab.label}</div>
</a>
))}
</div>
{/* Правая часть меню с подкатегориями и картинками */}
<div className="w-layout-vflex flex-block-89">
<h3 className="heading-16">{tabData[activeTabIndex]?.heading || tabData[0].heading}{loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>}</h3>
<div className="w-layout-hflex flex-block-92">
<div className="w-layout-vflex flex-block-91">
{(tabData[activeTabIndex]?.links || tabData[0].links).map((link, linkIndex) => {
const activeCatalog = catalogsData?.partsIndexCategoriesWithGroups?.[activeTabIndex];
// Ищем соответствующую подгруппу по названию
let subcategoryId = `fallback_${activeTabIndex}_${linkIndex}`;
if (activeCatalog?.groups) {
for (const group of activeCatalog.groups) {
// Проверяем в подгруппах
if (group.subgroups && group.subgroups.length > 0) {
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
if (foundSubgroup) {
subcategoryId = foundSubgroup.id;
break;
}
}
// Если нет подгрупп, проверяем саму группу
else if (group.name === link) {
subcategoryId = group.id;
break;
}
}
}
return (
<div
className="link-2"
key={link}
onClick={() => {
const catalogId = activeCatalog?.id || 'fallback';
handleCategoryClick(catalogId, link, subcategoryId);
}}
style={{ cursor: "pointer" }}
>
{link}
</div>
);
})}
</div>
<div className="w-layout-vflex flex-block-91-copy">
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
</div>
</div>
</div>
</div>
{/* Табы */}
<div data-current="Tab 1" data-easing="ease" data-duration-in="300" data-duration-out="100" className="tabs w-tabs">
<div className="tabs-menu w-tab-menu" style={{ maxHeight: "50vh", overflowY: "auto" }}>
{tabData.map((tab, idx) => (
<a
key={tab.label}
data-w-tab={`Tab ${idx + 1}`}
className={`tab-link w-inline-block w-tab-link${activeTabIndex === idx ? " w--current" : ""}`}
onClick={() => {
setActiveTabIndex(idx);
}}
style={{ cursor: "pointer" }}
>
<div className="div-block-29">
<div className="code-embed-12 w-embed">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3158 0.643914C10.4674 0.365938 10.8666 0.365938 11.0182 0.643914L14.0029 6.11673C14.0604 6.22222 14.1623 6.29626 14.2804 6.31838L20.4077 7.46581C20.7189 7.52409 20.8423 7.9037 20.6247 8.13378L16.3421 12.6636C16.2595 12.7509 16.2206 12.8707 16.2361 12.9899L17.0382 19.1718C17.079 19.4858 16.7561 19.7204 16.47 19.5847L10.8385 16.9114C10.73 16.8599 10.604 16.8599 10.4955 16.9114L4.86394 19.5847C4.5779 19.7204 4.25499 19.4858 4.29573 19.1718L5.0979 12.9899C5.11336 12.8707 5.07444 12.7509 4.99189 12.6636L0.709252 8.13378C0.491728 7.9037 0.615069 7.52409 0.926288 7.46581L7.05357 6.31838C7.17168 6.29626 7.27358 6.22222 7.33112 6.11673L10.3158 0.643914Z" fill="CurrentColor"></path>
</svg>
</div>
</div>
<div className="text-block-49">{tab.label}</div>
</a>
))}
</div>
<div className="tabs-content w-tab-content">
{tabData.map((tab, idx) => (
<div
key={tab.label}
data-w-tab={`Tab ${idx + 1}`}
className={`tab-pane w-tab-pane${activeTabIndex === idx ? " w--tab-active" : ""}`}
style={{ display: activeTabIndex === idx ? "block" : "none" }}
>
<div className="w-layout-vflex flex-block-89">
<h3 className="heading-16">{tab.heading}</h3>
<div className="w-layout-hflex flex-block-92">
<div className="w-layout-vflex flex-block-91">
{tab.links.map((link, linkIndex) => {
const catalog = catalogsData?.partsIndexCategoriesWithGroups?.[idx];
// Ищем соответствующую подгруппу по названию
let subcategoryId = `fallback_${idx}_${linkIndex}`;
if (catalog?.groups) {
for (const group of catalog.groups) {
// Проверяем в подгруппах
if (group.subgroups && group.subgroups.length > 0) {
const foundSubgroup = group.subgroups.find((subgroup: any) => subgroup.name === link);
if (foundSubgroup) {
subcategoryId = foundSubgroup.id;
break;
}
}
// Если нет подгрупп, проверяем саму группу
else if (group.name === link) {
subcategoryId = group.id;
break;
}
}
}
return (
<div
className="link-2"
key={link}
onClick={() => {
const catalogId = catalog?.id || 'fallback';
handleCategoryClick(catalogId, link, subcategoryId);
}}
style={{ cursor: "pointer" }}
>
{link}
</div>
);
})}
</div>
<div className="w-layout-vflex flex-block-91-copy">
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</nav>
</>
);
};
export default BottomHead;

View File

@ -0,0 +1,439 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
function useIsMobile(breakpoint = 767) {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= breakpoint);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, [breakpoint]);
return isMobile;
}
// Типы для Parts Index API
interface PartsIndexCatalog {
id: string;
name: string;
image: string;
}
interface PartsIndexEntityName {
id: string;
name: string;
}
interface PartsIndexGroup {
id: string;
name: string;
lang: string;
image: string;
lft: number;
rgt: number;
entityNames: PartsIndexEntityName[];
subgroups: PartsIndexGroup[];
}
interface PartsIndexTabData {
label: string;
heading: string;
links: string[];
catalogId: string;
group?: PartsIndexGroup;
}
// Fallback статичные данные
const fallbackTabData: PartsIndexTabData[] = [
{
label: "Детали ТО",
heading: "Детали ТО",
catalogId: "parts_to",
links: ["Детали ТО"],
},
{
label: "Масла",
heading: "Масла",
catalogId: "oils",
links: ["Масла"],
},
{
label: "Шины",
heading: "Шины",
catalogId: "tyres",
links: ["Шины"],
},
];
// Сервис для работы с Parts Index API
const PARTS_INDEX_API_BASE = 'https://api.parts-index.com';
const API_KEY = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
async function fetchCatalogs(): Promise<PartsIndexCatalog[]> {
try {
const response = await fetch(`${PARTS_INDEX_API_BASE}/v1/catalogs?lang=ru`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.list;
} catch (error) {
console.error('Ошибка получения каталогов Parts Index:', error);
return [];
}
}
async function fetchCatalogGroup(catalogId: string): Promise<PartsIndexGroup | null> {
try {
const response = await fetch(
`${PARTS_INDEX_API_BASE}/v1/catalogs/${catalogId}/groups?lang=ru`,
{
headers: {
'Accept': 'application/json',
'Authorization': API_KEY,
},
}
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (error) {
console.error(`Ошибка получения группы каталога ${catalogId}:`, error);
return null;
}
}
const BottomHeadPartsIndex = ({ menuOpen, onClose }: { menuOpen: boolean; onClose: () => void }) => {
const isMobile = useIsMobile();
const router = useRouter();
const [mobileCategory, setMobileCategory] = useState<null | any>(null);
const [tabData, setTabData] = useState<PartsIndexTabData[]>(fallbackTabData);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [loading, setLoading] = useState(false);
// Пагинация категорий
const [currentPage, setCurrentPage] = useState(0);
const categoriesPerPage = 6; // Количество категорий на странице
// --- Overlay animation state ---
const [showOverlay, setShowOverlay] = useState(false);
useEffect(() => {
if (menuOpen) {
setShowOverlay(true);
} else {
const timeout = setTimeout(() => setShowOverlay(false), 300);
return () => clearTimeout(timeout);
}
}, [menuOpen]);
// Загрузка каталогов и их групп
useEffect(() => {
const loadData = async () => {
if (tabData === fallbackTabData) { // Загружаем только если еще не загружали
setLoading(true);
try {
console.log('🔄 Загружаем каталоги Parts Index...');
const catalogs = await fetchCatalogs();
if (catalogs.length > 0) {
console.log(`✅ Получено ${catalogs.length} каталогов`);
// Загружаем группы для первых нескольких каталогов
const catalogsToLoad = catalogs.slice(0, 10);
const tabDataPromises = catalogsToLoad.map(async (catalog) => {
const group = await fetchCatalogGroup(catalog.id);
// Получаем подкатегории из entityNames или повторяем название категории
const links = group?.entityNames && group.entityNames.length > 0
? group.entityNames.slice(0, 9).map(entity => entity.name)
: [catalog.name]; // Если нет подкатегорий, повторяем название категории
return {
label: catalog.name,
heading: catalog.name,
links,
catalogId: catalog.id,
group
};
});
const apiTabData = await Promise.all(tabDataPromises);
console.log('✅ Данные обновлены:', apiTabData.length, 'категорий');
setTabData(apiTabData as PartsIndexTabData[]);
setActiveTabIndex(0);
}
} catch (error) {
console.error('Ошибка загрузки данных Parts Index:', error);
} finally {
setLoading(false);
}
}
};
loadData();
}, []);
// Обработка клика по категории для перехода в каталог
const handleCategoryClick = (catalogId: string, categoryName: string, entityId?: string) => {
console.log('🔍 Клик по категории Parts Index:', { catalogId, categoryName, entityId });
onClose();
router.push({
pathname: '/catalog',
query: {
partsIndexCatalog: catalogId,
categoryName: encodeURIComponent(categoryName),
...(entityId && { entityId })
}
});
};
// Получаем текущие категории для отображения с пагинацией
const getCurrentPageCategories = () => {
const startIndex = currentPage * categoriesPerPage;
const endIndex = startIndex + categoriesPerPage;
return tabData.slice(startIndex, endIndex);
};
// Проверяем, есть ли следующая/предыдущая страница
const hasNextPage = (currentPage + 1) * categoriesPerPage < tabData.length;
const hasPrevPage = currentPage > 0;
// Обработчики пагинации
const handleNextPage = () => {
if (hasNextPage) {
setCurrentPage(prev => prev + 1);
setActiveTabIndex(0);
}
};
const handlePrevPage = () => {
if (hasPrevPage) {
setCurrentPage(prev => prev - 1);
setActiveTabIndex(0);
}
};
const currentPageCategories = getCurrentPageCategories();
// Только мобильный UX
if (isMobile && menuOpen) {
return (
<>
{showOverlay && (
<div
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-label="Закрыть меню"
/>
)}
{/* Экран подкатегорий */}
{mobileCategory ? (
<div className="mobile-category-overlay z-50">
<div className="mobile-header">
<button className="mobile-back-btn" onClick={() => setMobileCategory(null)}>
</button>
<span>{mobileCategory.label}</span>
</div>
<div className="mobile-subcategories">
{mobileCategory.links.map((link: string, index: number) => (
<div
className="mobile-subcategory"
key={link}
onClick={() => {
const entityId = mobileCategory.group?.entityNames?.[index]?.id;
handleCategoryClick(mobileCategory.catalogId, link, entityId);
}}
>
{link}
</div>
))}
</div>
</div>
) : (
// Экран выбора категории
<div className="mobile-category-overlay z-50">
<div className="mobile-header">
<button className="mobile-back-btn" onClick={onClose} aria-label="Закрыть меню">
<svg width="24" height="24" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M4.11 2.697L2.698 4.11 6.586 8l-3.89 3.89 1.415 1.413L8 9.414l3.89 3.89 1.413-1.415L9.414 8l3.89-3.89-1.415-1.413L8 6.586l-3.89-3.89z" fill="currentColor"></path>
</svg>
</button>
<span>Категории Parts Index</span>
{loading && <span className="text-sm text-gray-500 ml-2">(загрузка...)</span>}
</div>
{/* Пагинация для мобильной версии */}
{tabData.length > categoriesPerPage && (
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-b">
<button
onClick={handlePrevPage}
disabled={!hasPrevPage}
className="text-sm text-blue-600 disabled:text-gray-400"
>
Предыдущие
</button>
<span className="text-sm text-gray-600">
{currentPage + 1} из {Math.ceil(tabData.length / categoriesPerPage)}
</span>
<button
onClick={handleNextPage}
disabled={!hasNextPage}
className="text-sm text-blue-600 disabled:text-gray-400"
>
Следующие
</button>
</div>
)}
<div className="mobile-subcategories">
{currentPageCategories.map((cat) => (
<div
className="mobile-subcategory"
key={cat.catalogId}
onClick={() => {
const categoryWithData = {
...cat,
catalogId: cat.catalogId,
group: cat.group
};
setMobileCategory(categoryWithData);
}}
style={{ cursor: "pointer" }}
>
{cat.label}
</div>
))}
</div>
</div>
)}
</>
);
}
// Если не мобильный или меню закрыто, возвращаем пустой элемент
if (!menuOpen) {
return null;
}
// Desktop версия
return (
<>
{showOverlay && (
<div
className={`fixed inset-0 bg-black/7 z-40 transition-opacity duration-300 ${menuOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-label="Закрыть меню"
/>
)}
<div className="menu-all">
<div className="div-block-28">
<div className="w-layout-hflex flex-block-90">
<div className="w-layout-vflex flex-block-88">
{/* Кнопки пагинации */}
{tabData.length > categoriesPerPage && (
<div className="flex justify-between items-center mb-4 px-3">
<button
onClick={handlePrevPage}
disabled={!hasPrevPage}
className="flex items-center space-x-1 text-sm text-blue-600 disabled:text-gray-400 hover:underline"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Назад</span>
</button>
<span className="text-sm text-gray-600">
{currentPage + 1} / {Math.ceil(tabData.length / categoriesPerPage)}
</span>
<button
onClick={handleNextPage}
disabled={!hasNextPage}
className="flex items-center space-x-1 text-sm text-blue-600 disabled:text-gray-400 hover:underline"
>
<span>Далее</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
{/* Меню с иконками - показываем текущую страницу категорий */}
{currentPageCategories.map((tab, idx) => (
<a
href="#"
className={`link-block-7 w-inline-block${activeTabIndex === idx ? " w--current" : ""}`}
key={tab.catalogId}
onClick={() => {
setActiveTabIndex(idx);
}}
style={{ cursor: "pointer" }}
>
<div className="div-block-29">
<div className="code-embed-12 w-embed">
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3158 0.643914C10.4674 0.365938 10.8666 0.365938 11.0182 0.643914L14.0029 6.11673C14.0604 6.22222 14.1623 6.29626 14.2804 6.31838L20.4077 7.46581C20.7189 7.52409 20.8423 7.9037 20.6247 8.13378L16.3421 12.6636C16.2595 12.7509 16.2206 12.8707 16.2361 12.9899L17.0382 19.1718C17.079 19.4858 16.7561 19.7204 16.47 19.5847L10.8385 16.9114C10.73 16.8599 10.604 16.8599 10.4955 16.9114L4.86394 19.5847C4.5779 19.7204 4.25499 19.4858 4.29573 19.1718L5.0979 12.9899C5.11336 12.8707 5.07444 12.7509 4.99189 12.6636L0.709252 8.13378C0.491728 7.9037 0.615069 7.52409 0.926288 7.46581L7.05357 6.31838C7.17168 6.29626 7.27358 6.22222 7.33112 6.11673L10.3158 0.643914Z" fill="CurrentColor"></path>
</svg>
</div>
</div>
<div className="text-block-47">{tab.label}</div>
</a>
))}
</div>
{/* Правая часть меню с подкатегориями и картинками */}
<div className="w-layout-vflex flex-block-89">
<h3 className="heading-16">
{currentPageCategories[activeTabIndex]?.heading || currentPageCategories[0]?.heading}
{loading && <span className="text-sm text-gray-500 ml-2">(обновление...)</span>}
</h3>
<div className="w-layout-hflex flex-block-92">
<div className="w-layout-vflex flex-block-91">
{(currentPageCategories[activeTabIndex]?.links || currentPageCategories[0]?.links || []).map((link, index) => {
const activeCategory = currentPageCategories[activeTabIndex] || currentPageCategories[0];
const entityId = activeCategory?.group?.entityNames?.[index]?.id;
return (
<div
className="link-2"
key={link}
onClick={() => handleCategoryClick(activeCategory.catalogId, link, entityId)}
style={{ cursor: "pointer" }}
>
{link}
</div>
);
})}
</div>
<div className="w-layout-vflex flex-block-91-copy">
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
<img src="https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg" loading="lazy" alt="" className="image-17" />
</div>
</div>
</div>
</div>
{/* Табы */}
<div className="w-layout-hflex flex-block-93">
<div className="w-layout-vflex flex-block-95">
<div className="w-layout-hflex flex-block-94">
<div className="text-block-48">Parts Index API</div>
<div className="text-block-48">Каталоги ТО</div>
<div className="text-block-48">Каталоги запчастей</div>
</div>
<div className="w-layout-hflex flex-block-96">
<div className="text-block-49">Все каталоги</div>
<img src="/images/Arrow_right.svg" loading="lazy" alt="" className="image-19" />
</div>
</div>
<div className="w-layout-vflex flex-block-97">
<img src="/images/img3.png" loading="lazy" alt="" className="image-18" />
</div>
</div>
</div>
</div>
</>
);
};
export default BottomHeadPartsIndex;

View File

@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import { useRouter } from 'next/router';
import { GET_BRANDS_BY_CODE } from '@/lib/graphql';
interface BrandSelectionModalProps {
isOpen: boolean;
onClose: () => void;
articleNumber: string;
detailName: string;
}
const BrandSelectionModal: React.FC<BrandSelectionModalProps> = ({
isOpen,
onClose,
articleNumber,
detailName
}) => {
const router = useRouter();
const [selectedBrand, setSelectedBrand] = useState<string>('');
const { data, loading, error } = useQuery(GET_BRANDS_BY_CODE, {
variables: { code: articleNumber },
skip: !isOpen || !articleNumber,
errorPolicy: 'all'
});
useEffect(() => {
if (!isOpen) {
setSelectedBrand('');
}
}, [isOpen]);
const handleBrandSelect = (brand: string) => {
console.log('🎯 Выбран бренд:', { articleNumber, brand });
router.push(`/search-result?article=${encodeURIComponent(articleNumber)}&brand=${encodeURIComponent(brand)}`);
onClose();
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const brandsData = data?.getBrandsByCode;
const brands = brandsData?.brands || [];
const hasError = brandsData?.error || error;
const hasNoBrands = brandsData?.success && brands.length === 0;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden">
{/* Заголовок */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
Выберите производителя
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" 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>
<p className="text-sm text-gray-600 mt-1">
Артикул: <span className="font-medium">{articleNumber}</span>
</p>
<p className="text-sm text-gray-600">
Деталь: <span className="font-medium">{detailName}</span>
</p>
</div>
{/* Содержимое */}
<div className="px-6 py-4 max-h-96 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<span className="ml-3 text-gray-600">Загружаем производителей...</span>
</div>
)}
{hasError && (
<div className="text-center py-8">
<div className="text-red-500 mb-3">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">Ошибка загрузки</h4>
<p className="text-gray-600 mb-4">
{brandsData?.error || error?.message || 'Не удалось загрузить список производителей'}
</p>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
Закрыть
</button>
</div>
)}
{hasNoBrands && (
<div className="text-center py-8">
<div className="text-yellow-500 mb-3">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 15.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">Производители не найдены</h4>
<p className="text-gray-600 mb-4">
К сожалению, по данному артикулу производители не найдены
</p>
<p className="text-sm text-gray-500 mb-4">
Попробуйте изменить параметры поиска или обратитесь к нашим менеджерам
</p>
<div className="space-y-2 text-sm text-gray-600">
<p>Телефон: <span className="font-medium">+7 (495) 123-45-67</span></p>
<p>Email: <span className="font-medium">info@protek.ru</span></p>
</div>
</div>
)}
{!loading && !hasError && brands.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-gray-600 mb-4">
Найдено производителей: <span className="font-medium">{brands.length}</span>
</p>
{brands.map((brand: any, index: number) => (
<button
key={index}
onClick={() => handleBrandSelect(brand.brand)}
className="w-full text-left px-4 py-3 rounded-lg border border-gray-200 hover:border-red-500 hover:bg-red-50 transition-all duration-200 group"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 group-hover:text-red-700">
{brand.brand}
</div>
{brand.name && brand.name !== brand.brand && (
<div className="text-sm text-gray-600 group-hover:text-red-600">
{brand.name}
</div>
)}
<div className="text-xs text-gray-500 group-hover:text-red-500">
Код: {brand.code}
</div>
</div>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-red-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
))}
</div>
)}
</div>
{/* Футер */}
{!loading && !hasError && brands.length > 0 && (
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
<button
onClick={onClose}
className="w-full px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Отмена
</button>
</div>
)}
</div>
</div>
);
};
export default BrandSelectionModal;

View File

@ -0,0 +1,163 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import { Combobox } from '@headlessui/react';
import { GET_LAXIMO_BRANDS, GET_LAXIMO_CATALOG_INFO } from '@/lib/graphql';
import { LaximoBrand, LaximoVehicleSearchResult } from '@/types/laximo';
import WizardSearchForm from './WizardSearchForm';
import VehicleSearchResults from './VehicleSearchResults';
import { useRouter } from 'next/router';
const BrandWizardSearchSection: React.FC = () => {
const router = useRouter();
const { data: brandsData, loading: brandsLoading, error: brandsError } = useQuery<{ laximoBrands: LaximoBrand[] }>(GET_LAXIMO_BRANDS, { errorPolicy: 'all' });
const [selectedBrand, setSelectedBrand] = useState<LaximoBrand | null>(null);
const [vehicles, setVehicles] = useState<LaximoVehicleSearchResult[] | null>(null);
const [brandQuery, setBrandQuery] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
// Получение информации о каталоге через useQuery
const {
data: catalogData,
loading: catalogLoading,
error: catalogError
} = useQuery(
GET_LAXIMO_CATALOG_INFO,
{
variables: { catalogCode: selectedBrand?.code },
skip: !selectedBrand,
errorPolicy: 'all',
}
);
// Мемоизация брендов для селекта
const brands = useMemo(() => {
if (brandsData?.laximoBrands?.length) {
return [...brandsData.laximoBrands].sort((a, b) => a.name.localeCompare(b.name));
}
return [];
}, [brandsData]);
// Фильтрация брендов по поисковому запросу
const filteredBrands = useMemo(() => {
if (!brandQuery) return brands;
return brands.filter(b => b.name.toLowerCase().includes(brandQuery.toLowerCase()));
}, [brands, brandQuery]);
// Автоматически выбираем бренд из query, если есть selected
useEffect(() => {
if (!brandsData?.laximoBrands) return;
const selected = router.query.selected;
if (selected && !selectedBrand) {
const found = brandsData.laximoBrands.find(b => b.name.toLowerCase() === String(selected).toLowerCase() || b.code.toLowerCase() === String(selected).toLowerCase());
if (found) setSelectedBrand(found);
}
}, [brandsData, router.query.selected, selectedBrand]);
// Обработчик выбора бренда
const handleBrandChange = (brand: LaximoBrand | null) => {
setSelectedBrand(brand);
setVehicles(null);
};
// Обработчик найденных авто
const handleVehicleFound = (vehicles: LaximoVehicleSearchResult[]) => {
setVehicles(vehicles);
};
// Каталожная информация
const catalogInfo = catalogData?.laximoCatalogInfo;
return (
<section className="max-w-[1100px] min-h-[450px] mx-auto bg-white rounded-2xl shadow p-6 md:p-10 my-8">
{/* <div className="text-2xl font-bold text-gray-900 mb-6 mt-6 text-center" style={{ fontSize: '28px' }}>Подбор автомобиля по параметрам</div> */}
{/* Combobox бренда */}
<div className="mb-8 w-full">
<div className="w-full max-w-[320px] min-w-[320px]">
<div className="flex items-center justify-between mb-[12px]" >
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-gray-900">
Марка автомобиля
</h4>
</div>
</div>
<Combobox value={selectedBrand} onChange={handleBrandChange} nullable>
<div className="relative">
<Combobox.Input
id="brand-combobox"
className="w-full px-6 py-4 bg-white rounded border border-stone-300 text-sm text-gray-950 placeholder:text-neutral-500 outline-none focus:shadow-none focus:border-stone-300 transition-colors"
displayValue={(brand: LaximoBrand | null) => brand?.name || ''}
onChange={e => setBrandQuery(e.target.value)}
placeholder="Начните вводить бренд..."
autoComplete="off"
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center px-3 focus:outline-none w-12">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9l6 6 6-6" />
</svg>
</Combobox.Button>
<Combobox.Options
className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full max-h-60 overflow-auto scrollbar-none"
style={{ scrollbarWidth: 'none' }}
data-hide-scrollbar
>
{brandsLoading && (
<div className="px-6 py-4 text-gray-500">Загрузка брендов...</div>
)}
{brandsError && (
<div className="px-6 py-4 text-red-500">Ошибка загрузки брендов</div>
)}
{filteredBrands.length === 0 && !brandsLoading && !brandsError && (
<div className="px-6 py-4 text-gray-500">Бренды не найдены</div>
)}
{filteredBrands.map(brand => (
<Combobox.Option
key={brand.code}
value={brand}
className={({ active, selected }) =>
`px-6 py-4 cursor-pointer hover:!bg-[rgb(236,28,36)] hover:!text-white text-sm transition-colors ${selected ? 'bg-red-50 font-semibold text-gray-950' : 'text-neutral-500'}`
}
>
{brand.name}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
</div>
</div>
{/* Каталог и wizard */}
{catalogLoading && selectedBrand && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<span className="ml-3 text-gray-600">Загружаем каталог...</span>
</div>
)}
{catalogError && selectedBrand && (
<div className="text-red-600 text-center py-4">Ошибка загрузки каталога</div>
)}
{catalogInfo && catalogInfo.supportparameteridentification2 && (
<>
<div className="mt-6">
<WizardSearchForm
catalogCode={catalogInfo.code}
onVehicleFound={handleVehicleFound}
/>
</div>
{vehicles && (
<div className="mt-8">
<VehicleSearchResults results={vehicles} catalogInfo={catalogInfo} />
</div>
)}
</>
)}
{catalogInfo && !catalogInfo.supportparameteridentification2 && (
<div className="text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-6 text-center">
Для выбранного бренда подбор по параметрам недоступен.
</div>
)}
</section>
);
};
export default BrandWizardSearchSection;

View File

@ -0,0 +1,26 @@
import React from "react";
import Link from "next/link";
import { useCart } from "@/contexts/CartContext";
const CartButton: React.FC = () => {
const { state } = useCart();
const { summary } = state;
return (
<Link href="/cart" className="button_h w-inline-block">
<div className="code-embed-7 w-embed">
<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>
</div>
<div className="text-block-2">Корзина</div>
{summary.totalItems > 0 && (
<div className="pcs-info">
<div className="text-block-39">{summary.totalItems}</div>
</div>
)}
</Link>
);
};
export default CartButton;

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { useCart } from '@/contexts/CartContext';
const CartDebug: React.FC = () => {
const { state, addItem, clearCart } = useCart();
const [debugInfo, setDebugInfo] = useState<any>({});
useEffect(() => {
if (typeof window !== 'undefined') {
const cartState = localStorage.getItem('cartState');
const cartSummaryState = localStorage.getItem('cartSummaryState');
const oldCart = localStorage.getItem('cart');
setDebugInfo({
cartState: cartState ? JSON.parse(cartState) : null,
cartSummaryState: cartSummaryState ? JSON.parse(cartSummaryState) : null,
oldCart: oldCart ? JSON.parse(oldCart) : null,
currentItems: state.items.length
});
}
}, [state.items]);
const addTestItem = () => {
addItem({
name: 'Тестовый товар',
description: 'Описание тестового товара',
article: 'TEST123',
brand: 'TestBrand',
price: 1000,
currency: 'RUB',
quantity: 1,
image: '',
productId: 'test-product',
offerKey: 'test-offer',
isExternal: false
});
};
const clearStorage = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('cartState');
localStorage.removeItem('cartSummaryState');
localStorage.removeItem('cart');
window.location.reload();
}
};
return (
<div style={{
position: 'fixed',
top: '10px',
right: '10px',
background: 'white',
border: '1px solid #ccc',
padding: '10px',
borderRadius: '5px',
maxWidth: '300px',
fontSize: '12px',
zIndex: 9999
}}>
<h4>Cart Debug</h4>
<button onClick={addTestItem} style={{ marginBottom: '5px', marginRight: '5px' }}>
Добавить товар
</button>
<button onClick={clearCart} style={{ marginBottom: '5px', marginRight: '5px' }}>
Очистить корзину
</button>
<button onClick={clearStorage} style={{ marginBottom: '10px' }}>
Очистить localStorage
</button>
<div>
<strong>Товаров в корзине:</strong> {state.items.length}
</div>
<pre style={{ fontSize: '10px', maxHeight: '200px', overflow: 'auto' }}>
{JSON.stringify(debugInfo, null, 2)}
</pre>
</div>
);
};
export default CartDebug;

View File

@ -0,0 +1,50 @@
import React from "react";
import { useCart } from "@/contexts/CartContext";
const CartInfo: React.FC = () => {
const { state } = useCart();
const { summary } = state;
// Функция для форматирования цены
const formatPrice = (price: number) => {
return `${price.toLocaleString('ru-RU')}`;
};
return (
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="/catalog" className="link-block w-inline-block">
<div>Каталог</div>
</a>
<div className="text-block-3"></div>
<a href="/cart" className="link-block-2 w-inline-block">
<div>Корзина</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Корзина</h1>
<div className="text-block-4">
{summary.totalItems > 0 ? (
<>В вашей корзине {summary.totalItems} товара на <strong>{formatPrice(summary.finalPrice)}</strong></>
) : (
'Ваша корзина пуста'
)}
</div>
</div>
<div className="w-layout-hflex flex-block-11">
<img src="/images/qwestions.svg" loading="lazy" alt="" className="image-4" />
<div className="text-block-5">Как оформить заказ?</div>
</div>
</div>
</div>
</div>
);
};
export default CartInfo;

109
src/components/CartItem.tsx Normal file
View File

@ -0,0 +1,109 @@
import React from "react";
interface CartItemProps {
name: string;
description: string;
delivery: string;
deliveryDate: string;
price: string;
pricePerItem: string;
count: number;
comment: string;
selected: boolean;
favorite: boolean;
onSelect: () => void;
onFavorite: () => void;
onComment: (comment: string) => void;
onCountChange?: (count: number) => void;
onRemove?: () => void;
}
const CartItem: React.FC<CartItemProps> = ({
name,
description,
delivery,
deliveryDate,
price,
pricePerItem,
count,
comment,
selected,
favorite,
onSelect,
onFavorite,
onComment,
onCountChange,
onRemove,
}) => (
<div className="w-layout-hflex cart-item">
<div className="w-layout-hflex info-block-search-copy">
<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">{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)}
/>
</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">
<div className="minus-plus" onClick={() => onCountChange && onCountChange(count - 1)} style={{ cursor: 'pointer' }}>
<img loading="lazy" src="/images/minus_icon.svg" alt="-" />
</div>
<div className="input-pcs">
<div className="text-block-26">{count}</div>
</div>
<div className="minus-plus" onClick={() => onCountChange && onCountChange(count + 1)} style={{ cursor: 'pointer' }}>
<img loading="lazy" src="/images/plus_icon.svg" alt="+" />
</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>
<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>
<img src="/images/delete.svg" loading="lazy" alt="" className="image-13" style={{ cursor: 'pointer' }} onClick={onRemove} />
</div>
</div>
</div>
);
export default CartItem;

126
src/components/CartList.tsx Normal file
View File

@ -0,0 +1,126 @@
import React from "react";
import CartItem from "./CartItem";
import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
const CartList: React.FC = () => {
const { state, toggleSelect, updateComment, removeItem, selectAll, removeSelected, updateQuantity } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites();
const { items } = state;
const allSelected = items.length > 0 && items.every((item) => item.selected);
const handleSelectAll = () => {
selectAll();
};
const handleRemoveSelected = () => {
removeSelected();
};
const handleSelect = (id: string) => {
toggleSelect(id);
};
const handleFavorite = (id: string) => {
const item = items.find(item => item.id === id);
if (!item) return;
const isInFavorites = isFavorite(item.productId, item.offerKey, item.article, item.brand);
if (isInFavorites) {
// Удаляем из избранного
const favoriteId = `${item.productId || item.offerKey || ''}:${item.article}:${item.brand}`;
removeFromFavorites(favoriteId);
} else {
// Добавляем в избранное
addToFavorites({
productId: item.productId,
offerKey: item.offerKey,
name: item.name,
brand: item.brand || '',
article: item.article || '',
price: item.price,
currency: item.currency,
image: item.image
});
}
};
const handleComment = (id: string, comment: string) => {
updateComment(id, comment);
};
const handleRemove = (id: string) => {
removeItem(id);
};
const handleCountChange = (id: string, count: number) => {
updateQuantity(id, count);
};
// Функция для форматирования цены
const formatPrice = (price: number, currency: string = 'RUB') => {
return `${price.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
return (
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-vflex product-list-cart">
<div className="w-layout-hflex multi-control">
<div className="w-layout-hflex select-all-block" onClick={handleSelectAll} style={{ cursor: 'pointer' }}>
<div
className={"div-block-7" + (allSelected ? " active" : "")}
style={{ marginRight: 8, cursor: 'pointer' }}
>
{allSelected && (
<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="text-block-30">Выделить всё</div>
</div>
<div className="w-layout-hflex select-all-block" onClick={handleRemoveSelected} style={{ cursor: 'pointer' }}>
<div className="text-block-30">Удалить выбранные</div>
<img src="/images/delete.svg" loading="lazy" alt="" className="image-13" />
</div>
</div>
{items.length === 0 ? (
<div className="empty-cart-message" style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
<p>Ваша корзина пуста</p>
<p>Добавьте товары из каталога</p>
</div>
) : (
items.map((item) => {
const isInFavorites = isFavorite(item.productId, item.offerKey, item.article, item.brand);
return (
<div className="div-block-21" key={item.id}>
<CartItem
name={item.name}
description={item.description}
delivery={item.deliveryTime || 'Уточняется'}
deliveryDate={item.deliveryDate || ''}
price={formatPrice(item.price, item.currency)}
pricePerItem={`${formatPrice(item.price, item.currency)}/шт`}
count={item.quantity}
comment={item.comment || ''}
selected={item.selected}
favorite={isInFavorites}
onSelect={() => handleSelect(item.id)}
onFavorite={() => handleFavorite(item.id)}
onComment={(comment) => handleComment(item.id, comment)}
onCountChange={(count) => handleCountChange(item.id, count)}
onRemove={() => handleRemove(item.id)}
/>
</div>
);
})
)}
</div>
</div>
);
};
export default CartList;

View File

@ -0,0 +1,91 @@
import React, { useState } from "react";
const initialItems = [
{
id: 1,
name: "Ganz GIE37312",
description: "Ролик ремня ГРМ VW AD GANZ GIE37312",
delivery: "Послезавтра, курьером",
deliveryDate: "пт, 7 февраля",
price: "18 763 ₽",
pricePerItem: "18 763 ₽/шт",
count: 1,
comment: "",
},
{
id: 2,
name: "Ganz GIE37312",
description: "Ролик ремня ГРМ VW AD GANZ GIE37312",
delivery: "Послезавтра, курьером",
deliveryDate: "пт, 7 февраля",
price: "18 763 ₽",
pricePerItem: "18 763 ₽/шт",
count: 1,
comment: "",
},
// ...ещё товары
];
const CartList2: React.FC = () => {
const [items, setItems] = useState(initialItems);
const handleComment = (id: number, comment: string) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, comment } : item));
};
return (
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-vflex product-list-cart-check">
{items.map((item) => (
<div className="div-block-21-copy" key={item.id}>
<div className="w-layout-hflex cart-item-check">
<div className="w-layout-hflex info-block-search">
<div className="text-block-35">{item.count}</div>
<div className="w-layout-hflex block-name">
<h4 className="heading-9-copy">{item.name}</h4>
<div className="text-block-21-copy">{item.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={item.comment}
onChange={e => handleComment(item.id, e.target.value)}
/>
</form>
<div className="success-message w-form-done">
<div>Thank you! Your submission has been received!</div>
</div>
<div className="error-message w-form-fail">
<div>Oops! Something went wrong while submitting the form.</div>
</div>
</div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-39-copy">
<h4 className="heading-9-copy">{item.delivery}</h4>
<div className="text-block-21-copy">{item.deliveryDate}</div>
</div>
<div className="w-layout-hflex pcs">
<div className="pcs-text">{item.count} шт.</div>
</div>
<div className="w-layout-hflex flex-block-39-copy-copy">
<h4 className="heading-9-copy-copy">{item.price}</h4>
<div className="text-block-21-copy-copy">{item.pricePerItem}</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default CartList2;

View File

@ -0,0 +1,126 @@
import React from "react";
import CatalogProductCard from "./CatalogProductCard";
import { useArticleImage } from "@/hooks/useArticleImage";
import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast";
interface CartRecommendedProps {
recommendedProducts?: any[];
isLoadingPrices?: boolean;
}
// Компонент для отдельной карточки рекомендуемого товара с реальным изображением
const RecommendedProductCard: React.FC<{
item: any;
isLoadingPrice: boolean;
formatPrice: (price: number | null, isLoading: boolean) => string;
}> = ({ item, isLoadingPrice, formatPrice }) => {
const { imageUrl } = useArticleImage(item.artId, { enabled: !!item.artId });
const { addItem } = useCart();
// Если нет изображения, используем заглушку с иконкой (но не мокап-фотку)
const displayImage = imageUrl || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjE5MCIgdmlld0JveD0iMCAwIDIxMCAxOTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMTAiIGhlaWdodD0iMTkwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04NSA5NUw5NSA4NUwxMjUgMTE1TDE0MCA5NUwxNjUgMTIwSDE2NVY5MEg0NVY5MEw4NSA5NVoiIGZpbGw9IiNEMUQ1REIiLz4KPGNpcmNsZSBjeD0iNzUiIGN5PSI3NSIgcj0iMTAiIGZpbGw9IiNEMUQ1REIiLz4KPHRleHQgeD0iMTA1IiB5PSIxNTAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMiIgZmlsbD0iIzlDQTNBRiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+Tm8gaW1hZ2U8L3RleHQ+Cjwvc3ZnPgo=';
// Обработчик добавления в корзину с тоастером
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
// Извлекаем цену как число
const numericPrice = item.minPrice || 0;
if (numericPrice <= 0) {
toast.error('Цена товара не найдена');
return;
}
// Добавляем товар в корзину
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,
image: displayImage,
brand: item.brand,
article: item.articleNumber,
supplier: 'AutoEuro',
deliveryTime: '1 день',
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: '🛒',
}
);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка при добавлении товара в корзину');
}
};
return (
<CatalogProductCard
image={displayImage}
discount=""
price={formatPrice(item.minPrice, isLoadingPrice && item.minPrice === undefined)}
oldPrice=""
title={item.name || `${item.brand} ${item.articleNumber}`}
brand={item.brand}
articleNumber={item.articleNumber}
brandName={item.brand}
artId={item.artId}
productId={item.artId ? String(item.artId) : undefined} // Добавляем productId для работы избранного
currency="RUB"
onAddToCart={handleAddToCart} // Передаем обработчик добавления в корзину
/>
);
};
const CartRecommended: React.FC<CartRecommendedProps> = ({
recommendedProducts = [],
isLoadingPrices = false
}) => {
// Фильтруем и ограничиваем количество рекомендаций
const validRecommendations = recommendedProducts
.filter(item => item && item.brand && item.articleNumber) // Фильтруем только валидные товары
.slice(0, 5); // Ограничиваем до 5 товаров
// Если нет валидных рекомендаций, не показываем блок
if (validRecommendations.length === 0) {
return null;
}
const formatPrice = (price: number | null, isLoading: boolean = false) => {
if (isLoading) return "Загрузка...";
if (!price) return "По запросу";
return `от ${price.toLocaleString('ru-RU')}`;
};
return (
<>
<h2 className="heading-11">Рекомендуемые</h2>
<div className="w-layout-hflex core-product-search">
{validRecommendations.map((item, idx) => (
<RecommendedProductCard
key={`${item.brand}-${item.articleNumber}-${idx}`}
item={item}
isLoadingPrice={isLoadingPrices}
formatPrice={formatPrice}
/>
))}
</div>
</>
);
};
export default CartRecommended;

View File

@ -0,0 +1,55 @@
import React from "react";
import Link from "next/link";
interface CartRecommendedProductCardProps {
image: string;
discount: string;
price: string;
oldPrice: string;
title: string;
brand: string;
articleNumber?: string;
brandName?: string;
artId?: string;
}
const CartRecommendedProductCard: React.FC<CartRecommendedProductCardProps> = ({
image,
discount,
price,
oldPrice,
title,
brand,
articleNumber,
brandName,
artId,
}) => (
<div className="w-layout-vflex flex-block-15">
<Link href="/card" className="card-link" style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}>
<div className="div-block-4">
<img src={image} loading="lazy" width={210} height={190} alt="" className="image-5" />
<div className="text-block-7">{discount}</div>
</div>
<div className="div-block-3">
<div className="w-layout-hflex flex-block-16">
<div className="text-block-8">{price}</div>
<div className="text-block-9">{oldPrice}</div>
</div>
<div className="text-block-10">{title}</div>
<div className="text-block-11">{brand}</div>
</div>
</Link>
<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">
<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>
</div>
<div className="text-block-6">Купить</div>
</Link>
</div>
);
export default CartRecommendedProductCard;

View File

@ -0,0 +1,1240 @@
import React, { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useCart } from "@/contexts/CartContext";
import { useMutation, useQuery } from "@apollo/client";
import { CREATE_ORDER, CREATE_PAYMENT, GET_CLIENT_ME, GET_CLIENT_DELIVERY_ADDRESSES, GET_DELIVERY_OFFERS } from "@/lib/graphql";
const CartSummary: React.FC = () => {
const { state, updateDelivery, updateOrderComment, clearCart } = useCart();
const { summary, delivery, items, orderComment } = state;
const legalEntityDropdownRef = useRef<HTMLDivElement>(null);
const addressDropdownRef = useRef<HTMLDivElement>(null);
const paymentDropdownRef = useRef<HTMLDivElement>(null);
const [consent, setConsent] = useState(false);
const [error, setError] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [showAuthWarning, setShowAuthWarning] = useState(false);
const [currentStep, setCurrentStep] = useState(1); // 1 - первый шаг, 2 - второй шаг
// Новые состояния для первого шага
const [selectedLegalEntity, setSelectedLegalEntity] = useState<string>("");
const [selectedLegalEntityId, setSelectedLegalEntityId] = useState<string>("");
const [isIndividual, setIsIndividual] = useState(true); // true = физ лицо, false = юр лицо
const [showLegalEntityDropdown, setShowLegalEntityDropdown] = useState(false);
const [selectedDeliveryAddress, setSelectedDeliveryAddress] = useState<string>("");
const [showAddressDropdown, setShowAddressDropdown] = useState(false);
const [recipientName, setRecipientName] = useState("");
const [recipientPhone, setRecipientPhone] = useState("");
// Новые состояния для способа оплаты
const [paymentMethod, setPaymentMethod] = useState<string>("yookassa");
const [showPaymentDropdown, setShowPaymentDropdown] = useState(false);
// Состояния для офферов доставки
const [deliveryOffers, setDeliveryOffers] = useState<any[]>([]);
const [selectedDeliveryOffer, setSelectedDeliveryOffer] = useState<any>(null);
const [loadingOffers, setLoadingOffers] = useState(false);
const [offersError, setOffersError] = useState<string>("");
const [createOrder] = useMutation(CREATE_ORDER);
const [createPayment] = useMutation(CREATE_PAYMENT);
const [getDeliveryOffers] = useMutation(GET_DELIVERY_OFFERS);
// Получаем данные клиента
const { data: clientData, loading: clientLoading } = useQuery(GET_CLIENT_ME);
const { data: addressesData, loading: addressesLoading } = useQuery(GET_CLIENT_DELIVERY_ADDRESSES);
// Получаем пользователя из localStorage для проверки авторизации
const [userData, setUserData] = useState<any>(null);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedUserData = localStorage.getItem('userData');
if (storedUserData) {
setUserData(JSON.parse(storedUserData));
}
}
}, []);
// Загрузка состояния компонента из localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
const savedCartSummaryState = localStorage.getItem('cartSummaryState');
if (savedCartSummaryState) {
try {
const state = JSON.parse(savedCartSummaryState);
setCurrentStep(state.currentStep || 1);
setSelectedLegalEntity(state.selectedLegalEntity || '');
setSelectedLegalEntityId(state.selectedLegalEntityId || '');
setIsIndividual(state.isIndividual ?? true);
setSelectedDeliveryAddress(state.selectedDeliveryAddress || '');
setRecipientName(state.recipientName || '');
setRecipientPhone(state.recipientPhone || '');
setPaymentMethod(state.paymentMethod || 'yookassa');
setConsent(state.consent || false);
} catch (error) {
console.error('Ошибка загрузки состояния CartSummary:', error);
}
}
}
}, []);
// Сохранение состояния компонента в localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
const stateToSave = {
currentStep,
selectedLegalEntity,
selectedLegalEntityId,
isIndividual,
selectedDeliveryAddress,
recipientName,
recipientPhone,
paymentMethod,
consent
};
localStorage.setItem('cartSummaryState', JSON.stringify(stateToSave));
}
}, [currentStep, selectedLegalEntity, selectedLegalEntityId, isIndividual, selectedDeliveryAddress, recipientName, recipientPhone, paymentMethod, consent]);
// Инициализация данных получателя
useEffect(() => {
if (clientData?.clientMe && !recipientName && !recipientPhone) {
setRecipientName(clientData.clientMe.name || '');
setRecipientPhone(clientData.clientMe.phone || '');
}
}, [clientData, recipientName, recipientPhone]);
// Закрытие dropdown при клике вне их
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Проверяем клик вне дропдауна типа лица
if (legalEntityDropdownRef.current && !legalEntityDropdownRef.current.contains(event.target as Node)) {
setShowLegalEntityDropdown(false);
}
// Проверяем клик вне дропдауна адресов
if (addressDropdownRef.current && !addressDropdownRef.current.contains(event.target as Node)) {
setShowAddressDropdown(false);
}
// Проверяем клик вне дропдауна способов оплаты
if (paymentDropdownRef.current && !paymentDropdownRef.current.contains(event.target as Node)) {
setShowPaymentDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Функция для загрузки офферов доставки
const loadDeliveryOffers = async () => {
if (!selectedDeliveryAddress || !recipientName || !recipientPhone || items.length === 0) {
return;
}
setLoadingOffers(true);
setOffersError("");
try {
// Подготавливаем данные для API
const deliveryOffersInput = {
items: items.map(item => {
// Извлекаем срок поставки из deliveryTime товара
let deliveryDays = 0;
if (item.deliveryTime) {
const match = item.deliveryTime.match(/(\d+)/);
if (match) {
deliveryDays = parseInt(match[0]);
}
}
return {
name: item.name,
article: item.article || '',
brand: item.brand || '',
price: item.price,
quantity: item.quantity,
weight: item.weight || 500, // Примерный вес в граммах
dimensions: "10x10x5", // Примерные размеры
deliveryTime: deliveryDays, // Срок поставки товара в днях
offerKey: item.offerKey,
isExternal: item.isExternal
};
}),
deliveryAddress: selectedDeliveryAddress,
recipientName,
recipientPhone
};
const { data } = await getDeliveryOffers({
variables: { input: deliveryOffersInput }
});
if (data?.getDeliveryOffers?.success && data.getDeliveryOffers.offers && Array.isArray(data.getDeliveryOffers.offers) && data.getDeliveryOffers.offers.length > 0) {
setDeliveryOffers(data.getDeliveryOffers.offers);
setOffersError('');
// Автоматически выбираем первый оффер
const firstOffer = data.getDeliveryOffers.offers[0];
setSelectedDeliveryOffer(firstOffer);
// Обновляем стоимость доставки в корзине
updateDelivery({
address: selectedDeliveryAddress,
cost: firstOffer.cost,
date: firstOffer.deliveryDate,
time: firstOffer.deliveryTime
});
} else {
const errorMessage = data?.getDeliveryOffers?.error || 'Не удалось получить варианты доставки';
setOffersError(errorMessage);
// Добавляем стандартные варианты доставки как fallback
const standardOffers = data?.getDeliveryOffers?.offers || [
{
id: 'standard',
name: 'Стандартная доставка',
description: 'Доставка в течение 3-5 рабочих дней',
deliveryDate: 'в течение 3-5 рабочих дней',
deliveryTime: '',
cost: 500
},
{
id: 'express',
name: 'Экспресс доставка',
description: 'Доставка на следующий день',
deliveryDate: 'завтра',
deliveryTime: '10:00-18:00',
cost: 1000
}
];
setDeliveryOffers(standardOffers);
setSelectedDeliveryOffer(standardOffers[0]);
updateDelivery({
address: selectedDeliveryAddress,
cost: standardOffers[0].cost,
date: standardOffers[0].deliveryDate,
time: standardOffers[0].deliveryTime
});
}
} catch (error) {
setOffersError('Ошибка загрузки вариантов доставки');
// Добавляем стандартные варианты доставки как fallback при ошибке
const standardOffers = [
{
id: 'standard',
name: 'Стандартная доставка',
description: 'Доставка в течение 3-5 рабочих дней',
deliveryDate: 'в течение 3-5 рабочих дней',
deliveryTime: '',
cost: 500
}
];
setDeliveryOffers(standardOffers);
setSelectedDeliveryOffer(standardOffers[0]);
updateDelivery({
address: selectedDeliveryAddress,
cost: standardOffers[0].cost,
date: standardOffers[0].deliveryDate,
time: standardOffers[0].deliveryTime
});
} finally {
setLoadingOffers(false);
}
};
// Автоматическая загрузка офферов при изменении ключевых данных
useEffect(() => {
if (selectedDeliveryAddress && recipientName && recipientPhone && items.length > 0) {
// Загружаем офферы с небольшой задержкой для избежания множественных запросов
const timeoutId = setTimeout(() => {
loadDeliveryOffers();
}, 500);
return () => clearTimeout(timeoutId);
}
}, [selectedDeliveryAddress, recipientName, recipientPhone, items.length]);
const handleProceedToStep2 = () => {
if (!selectedDeliveryAddress) {
setError("Пожалуйста, выберите адрес доставки.");
return;
}
if (summary.totalItems === 0) {
setError("Корзина пуста. Добавьте товары для оформления заказа.");
return;
}
if (!selectedDeliveryOffer) {
setError("Пожалуйста, выберите способ доставки.");
return;
}
// Проверяем достаточность средств для оплаты с баланса
if (paymentMethod === 'balance' && !isIndividual) {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive);
const finalAmount = summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice);
const availableBalance = (defaultContract?.balance || 0) + (defaultContract?.creditLimit || 0);
if (availableBalance < finalAmount) {
setError("Недостаточно средств на балансе для оплаты заказа. Выберите другой способ оплаты.");
return;
}
}
setError("");
setCurrentStep(2);
};
const handleBackToStep1 = () => {
setCurrentStep(1);
};
const handleSubmit = async () => {
if (!recipientName.trim() || !recipientPhone.trim() || !consent) {
setError("Пожалуйста, заполните данные получателя и согласитесь с правилами.");
return;
}
// Проверяем авторизацию
const userData = typeof window !== 'undefined' ? localStorage.getItem('userData') : null;
if (!userData) {
setError("Для оформления заказа необходимо войти в систему.");
setShowAuthWarning(true);
return;
}
setIsProcessing(true);
setError("");
setShowAuthWarning(false);
try {
const user = JSON.parse(userData);
const selectedItems = items.filter(item => item.selected);
// Создаем заказ с clientId для авторизованных пользователей
const orderResult = await createOrder({
variables: {
input: {
clientId: user.id,
clientEmail: user.email || '',
clientPhone: recipientPhone,
clientName: recipientName,
deliveryAddress: selectedDeliveryAddress || delivery.address,
legalEntityId: !isIndividual ? selectedLegalEntityId : null,
paymentMethod: paymentMethod,
comment: orderComment || `Адрес доставки: ${selectedDeliveryAddress}. ${!isIndividual && selectedLegalEntity ? `Юридическое лицо: ${selectedLegalEntity}.` : 'Физическое лицо.'} Способ оплаты: ${getPaymentMethodName(paymentMethod)}. Доставка: ${selectedDeliveryOffer?.name || 'Стандартная доставка'} (${selectedDeliveryOffer?.deliveryDate || ''} ${selectedDeliveryOffer?.deliveryTime || ''}).`,
items: selectedItems.map(item => ({
productId: item.productId,
externalId: item.offerKey,
name: item.name,
article: item.article || '',
brand: item.brand || '',
price: item.price,
quantity: item.quantity
}))
}
}
});
const order = orderResult.data?.createOrder;
if (!order) {
throw new Error('Не удалось создать заказ');
}
// Обрабатываем разные способы оплаты
if (paymentMethod === 'balance') {
// Для оплаты с баланса - заказ уже оплачен, переходим на страницу успеха
clearCart();
// Очищаем сохраненное состояние оформления заказа
if (typeof window !== 'undefined') {
localStorage.removeItem('cartSummaryState');
}
window.location.href = `/payment/success?orderId=${order.id}&orderNumber=${order.orderNumber}&paymentMethod=balance`;
} else if (paymentMethod === 'invoice') {
// Для оплаты по реквизитам - переходим на страницу с реквизитами
clearCart();
// Очищаем сохраненное состояние оформления заказа
if (typeof window !== 'undefined') {
localStorage.removeItem('cartSummaryState');
}
window.location.href = `/payment/invoice?orderId=${order.id}&orderNumber=${order.orderNumber}`;
} else {
// Для ЮКассы - создаем платеж и переходим на оплату
const paymentResult = await createPayment({
variables: {
input: {
orderId: order.id,
returnUrl: `${window.location.origin}/payment/success?orderId=${order.id}&orderNumber=${order.orderNumber}`,
description: `Оплата заказа №${order.orderNumber}`
}
}
});
const payment = paymentResult.data?.createPayment;
if (!payment?.confirmationUrl) {
throw new Error('Не удалось создать платеж');
}
// Очищаем корзину и переходим на оплату
clearCart();
// Очищаем сохраненное состояние оформления заказа
if (typeof window !== 'undefined') {
localStorage.removeItem('cartSummaryState');
}
window.location.href = payment.confirmationUrl;
}
} catch (error) {
console.error('Ошибка при создании заказа:', error);
setError(error instanceof Error ? error.message : 'Произошла ошибка при оформлении заказа');
} finally {
setIsProcessing(false);
}
};
// Функция для форматирования цены
const formatPrice = (price: number) => {
return `${price.toLocaleString('ru-RU')}`;
};
// Функция для получения названия способа оплаты
const getPaymentMethodName = (method: string) => {
switch (method) {
case 'yookassa':
return 'ЮКасса (банковские карты)';
case 'balance':
return 'Оплата с баланса';
case 'invoice':
return 'Оплата по реквизитам';
default:
return 'Выберите способ оплаты';
}
};
if (currentStep === 1) {
// Первый шаг - настройка доставки
return (
<div className="w-layout-vflex cart-ditail">
<div className="cart-detail-info">
{/* Тип клиента - показываем всегда */}
<div className="w-layout-vflex flex-block-58" style={{ position: 'relative' }} ref={legalEntityDropdownRef}>
<div className="text-block-31">Тип клиента</div>
<div
className="w-layout-hflex flex-block-62"
onClick={() => setShowLegalEntityDropdown(!showLegalEntityDropdown)}
style={{ cursor: 'pointer', justifyContent: 'space-between', alignItems: 'center' }}
>
<div className="text-block-31">
{isIndividual ? 'Физическое лицо' : selectedLegalEntity || 'Выберите юридическое лицо'}
</div>
<div className="code-embed w-embed" style={{ transform: showLegalEntityDropdown ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" strokeWidth="2"></path>
</svg>
</div>
</div>
{/* Dropdown список типов клиента */}
{showLegalEntityDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 1000,
maxHeight: '200px',
overflowY: 'auto'
}}>
{/* Опция физического лица */}
<div
onClick={() => {
setIsIndividual(true);
setSelectedLegalEntity('');
setSelectedLegalEntityId('');
setPaymentMethod('yookassa'); // Для физ лица только ЮКасса
setShowLegalEntityDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
backgroundColor: isIndividual ? '#f8f9fa' : 'white',
fontSize: '14px',
fontWeight: isIndividual ? 500 : 400
}}
onMouseEnter={(e) => {
if (!isIndividual) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (!isIndividual) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
Физическое лицо
</div>
{/* Юридические лица (если есть) */}
{clientData?.clientMe?.legalEntities && clientData.clientMe.legalEntities.length > 0 &&
clientData.clientMe.legalEntities.map((entity: any, index: number) => (
<div
key={entity.id}
onClick={() => {
setIsIndividual(false);
setSelectedLegalEntity(entity.shortName || entity.fullName);
setSelectedLegalEntityId(entity.id);
setPaymentMethod('yookassa'); // По умолчанию ЮКасса для юр лица
setShowLegalEntityDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
borderBottom: index < clientData.clientMe.legalEntities.length - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: !isIndividual && (entity.shortName || entity.fullName) === selectedLegalEntity ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (isIndividual || (entity.shortName || entity.fullName) !== selectedLegalEntity) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (isIndividual || (entity.shortName || entity.fullName) !== selectedLegalEntity) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
{entity.shortName || entity.fullName}
</div>
))
}
</div>
)}
</div>
{/* Адрес доставки */}
<div className="w-layout-vflex flex-block-58" style={{ position: 'relative' }} ref={addressDropdownRef}>
<div className="text-block-31">Адрес доставки</div>
<div
className="w-layout-hflex flex-block-62"
onClick={() => setShowAddressDropdown(!showAddressDropdown)}
style={{ cursor: 'pointer', justifyContent: 'space-between', alignItems: 'center' }}
>
<div className="text-block-31" style={{ fontSize: '14px', color: selectedDeliveryAddress ? '#333' : '#999' }}>
{selectedDeliveryAddress || 'Выберите адрес доставки'}
</div>
<div className="code-embed w-embed" style={{ transform: showAddressDropdown ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" strokeWidth="2"></path>
</svg>
</div>
</div>
{/* Dropdown список адресов */}
{showAddressDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 1000,
maxHeight: '200px',
overflowY: 'auto'
}}>
{/* Кнопка добавления нового адреса */}
<div
onClick={() => {
// Переход в личный кабинет на страницу адресов
window.location.href = '/profile-addresses';
setShowAddressDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: '#f8f9fa',
fontSize: '14px',
fontWeight: 500,
color: '#007bff',
borderBottom: '1px solid #dee2e6'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}}
>
+ Добавить новый адрес
</div>
{/* Существующие адреса */}
{addressesData?.clientMe?.deliveryAddresses?.map((address: any, index: number) => (
<div
key={address.id}
onClick={() => {
setSelectedDeliveryAddress(address.address);
setShowAddressDropdown(false);
// Обновляем адрес в контексте корзины
updateDelivery({ address: address.address });
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
borderBottom: index < (addressesData?.clientMe?.deliveryAddresses?.length || 0) - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: address.address === selectedDeliveryAddress ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (address.address !== selectedDeliveryAddress) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (address.address !== selectedDeliveryAddress) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div style={{ fontWeight: 500, marginBottom: '4px' }}>
{address.name || address.deliveryType}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{address.address}
</div>
</div>
)) || (
<div style={{
padding: '12px 16px',
fontSize: '14px',
color: '#666',
textAlign: 'center'
}}>
Нет сохранённых адресов
</div>
)}
</div>
)}
{/* Показываем выбранный адрес */}
{selectedDeliveryAddress && (
<div className="text-block-32" style={{ marginTop: '8px', fontSize: '14px', color: '#666' }}>
{selectedDeliveryAddress}
</div>
)}
</div>
{/* Варианты доставки */}
<div className="w-layout-vflex flex-block-66">
<div className="text-block-31" style={{ marginBottom: '12px' }}>Варианты доставки</div>
{loadingOffers && (
<div style={{
padding: '16px',
textAlign: 'center',
fontSize: '14px',
color: '#666'
}}>
Загружаем варианты доставки...
</div>
)}
{offersError && (
<div style={{
padding: '12px',
backgroundColor: '#FEF3C7',
border: '1px solid #F59E0B',
borderRadius: '4px',
fontSize: '12px',
color: '#92400E',
marginBottom: '12px'
}}>
{offersError}
</div>
)}
{deliveryOffers.length > 0 && !loadingOffers && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{deliveryOffers.map((offer, index) => (
<div
key={offer.id}
onClick={() => {
setSelectedDeliveryOffer(offer);
updateDelivery({
address: selectedDeliveryAddress,
cost: offer.cost,
date: offer.deliveryDate,
time: offer.deliveryTime
});
}}
style={{
padding: '12px',
border: selectedDeliveryOffer?.id === offer.id ? '2px solid #007bff' : '1px solid #dee2e6',
borderRadius: '8px',
cursor: 'pointer',
backgroundColor: selectedDeliveryOffer?.id === offer.id ? '#f8f9fa' : 'white',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (selectedDeliveryOffer?.id !== offer.id) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (selectedDeliveryOffer?.id !== offer.id) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: '14px', marginBottom: '4px' }}>
{offer.name}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
{offer.description}
</div>
<div style={{ fontSize: '12px', color: '#007bff' }}>
{offer.deliveryDate} {offer.deliveryTime}
</div>
</div>
<div style={{
fontWeight: 500,
fontSize: '14px',
color: offer.cost === 0 ? '#28a745' : '#333'
}}>
{offer.cost === 0 ? 'Бесплатно' : `${offer.cost}`}
</div>
</div>
</div>
))}
</div>
)}
{deliveryOffers.length === 0 && !loadingOffers && selectedDeliveryAddress && (
<div style={{
padding: '16px',
textAlign: 'center',
fontSize: '14px',
color: '#666',
border: '1px dashed #dee2e6',
borderRadius: '8px'
}}>
Выберите адрес доставки для просмотра вариантов
</div>
)}
</div>
{/* Способ оплаты */}
<div className="w-layout-vflex flex-block-58" style={{ position: 'relative' }} ref={paymentDropdownRef}>
<div className="text-block-31">Способ оплаты</div>
<div
className="w-layout-hflex flex-block-62"
onClick={() => setShowPaymentDropdown(!showPaymentDropdown)}
style={{ cursor: 'pointer', justifyContent: 'space-between', alignItems: 'center' }}
>
<div className="text-block-31" style={{ fontSize: '14px', color: '#333' }}>
{getPaymentMethodName(paymentMethod)}
</div>
<div className="code-embed w-embed" style={{ transform: showPaymentDropdown ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" strokeWidth="2"></path>
</svg>
</div>
</div>
{/* Dropdown список способов оплаты */}
{showPaymentDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 1000,
maxHeight: '200px',
overflowY: 'auto'
}}>
{/* ЮКасса - доступна всегда */}
<div
onClick={() => {
setPaymentMethod('yookassa');
setShowPaymentDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
backgroundColor: paymentMethod === 'yookassa' ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (paymentMethod !== 'yookassa') {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (paymentMethod !== 'yookassa') {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
ЮКасса (банковские карты)
</div>
{/* Дополнительные способы оплаты для юридических лиц */}
{!isIndividual && (
<>
<div
onClick={() => {
setPaymentMethod('balance');
setShowPaymentDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
backgroundColor: paymentMethod === 'balance' ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (paymentMethod !== 'balance') {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (paymentMethod !== 'balance') {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div>Оплата с баланса</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
{(() => {
if (clientLoading) {
return (
<span style={{ fontWeight: 500, color: '#666' }}>
Загрузка...
</span>
);
}
if (!clientData?.clientMe) {
return (
<span style={{ fontWeight: 500, color: '#e74c3c' }}>
Ошибка загрузки данных
</span>
);
}
const contracts = clientData?.clientMe?.contracts || [];
const defaultContract = contracts.find((contract: any) => contract.isDefault && contract.isActive);
if (!defaultContract) {
const anyActiveContract = contracts.find((contract: any) => contract.isActive);
if (!anyActiveContract) {
return (
<span style={{ fontWeight: 500, color: '#e74c3c' }}>
Нет активных контрактов
</span>
);
}
}
const contract = defaultContract || contracts.find((contract: any) => contract.isActive);
const balance = contract?.balance || 0;
const creditLimit = contract?.creditLimit || 0;
const totalAvailable = balance + creditLimit;
return (
<span style={{ fontWeight: 500 }}>
Доступно: {formatPrice(totalAvailable)}
</span>
);
})()}
</div>
</div>
<div
onClick={() => {
setPaymentMethod('invoice');
setShowPaymentDropdown(false);
}}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: paymentMethod === 'invoice' ? '#f8f9fa' : 'white',
fontSize: '14px'
}}
onMouseEnter={(e) => {
if (paymentMethod !== 'invoice') {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (paymentMethod !== 'invoice') {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
Оплата по реквизитам
</div>
</>
)}
</div>
)}
{/* Показываем предупреждение для оплаты с баланса если недостаточно средств */}
{paymentMethod === 'balance' && !isIndividual && (
(() => {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive);
const availableBalance = (defaultContract?.balance || 0) + (defaultContract?.creditLimit || 0);
const finalAmount = summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice);
const isInsufficientFunds = availableBalance < finalAmount;
return isInsufficientFunds ? (
<div style={{
marginTop: '8px',
padding: '8px 12px',
backgroundColor: '#FEF3C7',
border: '1px solid #F59E0B',
borderRadius: '4px',
fontSize: '12px',
color: '#92400E'
}}>
Недостаточно средств на балансе для оплаты заказа
</div>
) : null;
})()
)}
</div>
<div className="px-line"></div>
{/* Сводка заказа */}
<div className="w-layout-vflex flex-block-60">
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">
Товары, {summary.totalItems} шт.
</div>
<div className="text-block-33">{formatPrice(summary.totalPrice)}</div>
</div>
{summary.totalDiscount > 0 && (
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Моя скидка</div>
<div className="text-block-33">-{formatPrice(summary.totalDiscount)}</div>
</div>
)}
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Доставка</div>
<div className="text-block-33">
{selectedDeliveryOffer?.cost === 0
? 'Бесплатно'
: formatPrice(selectedDeliveryOffer?.cost || summary.deliveryPrice)
}
</div>
</div>
</div>
<div className="px-line"></div>
<div className="w-layout-hflex flex-block-59">
<div className="text-block-32">Итого</div>
<h4 className="heading-9-copy-copy">
{formatPrice(
summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice)
)}
</h4>
</div>
<button
className="submit-button fill w-button"
onClick={handleProceedToStep2}
disabled={summary.totalItems === 0}
style={{
opacity: summary.totalItems === 0 ? 0.5 : 1,
cursor: summary.totalItems === 0 ? 'not-allowed' : 'pointer'
}}
>
Оформить заказ
</button>
{error && <div style={{ color: 'red', marginTop: 10 }}>{error}</div>}
<div className="w-layout-hflex privacy-consent" style={{ cursor: 'pointer' }} onClick={() => setConsent((v) => !v)}>
<div
className={"div-block-7" + (consent ? " active" : "")}
style={{ marginRight: 8, cursor: 'pointer' }}
>
{consent && (
<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="consent-text">Соглашаюсь с правилами пользования торговой площадкой и возврата</div>
</div>
</div>
</div>
);
}
// Второй шаг - подтверждение и оплата
return (
<div className="w-layout-vflex cart-ditail">
<div className="cart-detail-info">
{/* Адрес доставки */}
<div className="w-layout-vflex flex-block-58">
<div className="text-block-31">Адрес доставки</div>
<div className="w-layout-hflex flex-block-57">
<h4 className="heading-12">Доставка</h4>
<div className="link-r" onClick={handleBackToStep1} style={{ cursor: 'pointer' }}>Изменить</div>
</div>
<div className="text-block-32">{selectedDeliveryAddress || delivery.address}</div>
</div>
{/* Получатель */}
<div className="w-layout-vflex flex-block-63">
<h4 className="heading-12">Получатель</h4>
<div className="w-layout-hflex flex-block-62" style={{ marginBottom: '8px' }}>
<input
type="text"
placeholder="Имя и фамилия"
value={recipientName}
onChange={(e) => setRecipientName(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #D0D0D0',
borderRadius: '4px',
fontSize: '14px',
fontFamily: 'inherit'
}}
/>
</div>
<div className="w-layout-hflex flex-block-62">
<input
type="tel"
placeholder="Номер телефона"
value={recipientPhone}
onChange={(e) => setRecipientPhone(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #D0D0D0',
borderRadius: '4px',
fontSize: '14px',
fontFamily: 'inherit'
}}
/>
</div>
</div>
{/* Тип клиента и способ оплаты */}
<div className="w-layout-vflex flex-block-58">
<div className="text-block-31">Тип клиента и оплата</div>
<div className="w-layout-hflex flex-block-57">
<h4 className="heading-12">
{isIndividual ? 'Физическое лицо' : selectedLegalEntity}
</h4>
<div className="link-r" onClick={handleBackToStep1} style={{ cursor: 'pointer' }}>Изменить</div>
</div>
<div className="text-block-32" style={{ fontSize: '14px', color: '#666' }}>
Способ оплаты: {getPaymentMethodName(paymentMethod)}
</div>
{paymentMethod === 'balance' && !isIndividual && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
{(() => {
const defaultContract = clientData?.clientMe?.contracts?.find((contract: any) => contract.isDefault && contract.isActive);
const balance = defaultContract?.balance || 0;
const creditLimit = defaultContract?.creditLimit || 0;
const totalAvailable = balance + creditLimit;
return (
<span style={{ fontWeight: 500 }}>
Доступно: {formatPrice(totalAvailable)}
</span>
);
})()}
</div>
)}
</div>
{/* Комментарий к заказу */}
<div className="w-layout-vflex flex-block-58">
<div className="text-block-31">Комментарий к заказу</div>
<textarea
value={orderComment}
onChange={(e) => updateOrderComment(e.target.value)}
placeholder="Добавьте комментарий к заказу (необязательно)"
className="text-block-32"
style={{
width: '100%',
minHeight: '60px',
padding: '8px 12px',
border: '1px solid #D0D0D0',
borderRadius: '4px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical',
outline: 'none'
}}
/>
</div>
<div className="px-line"></div>
{/* Сводка заказа */}
<div className="w-layout-vflex flex-block-60">
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">
Товары, {summary.totalItems} шт.
</div>
<div className="text-block-33">{formatPrice(summary.totalPrice)}</div>
</div>
{summary.totalDiscount > 0 && (
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Моя скидка</div>
<div className="text-block-33">-{formatPrice(summary.totalDiscount)}</div>
</div>
)}
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy-copy">Доставка</div>
<div className="text-block-33">
{selectedDeliveryOffer?.cost === 0
? 'Бесплатно'
: formatPrice(selectedDeliveryOffer?.cost || summary.deliveryPrice)
}
</div>
</div>
</div>
<div className="px-line"></div>
<div className="w-layout-hflex flex-block-59">
<div className="text-block-32">Итого</div>
<h4 className="heading-9-copy-copy">
{formatPrice(
summary.totalPrice - summary.totalDiscount + (selectedDeliveryOffer?.cost || summary.deliveryPrice)
)}
</h4>
</div>
{showAuthWarning && (
<div style={{
backgroundColor: '#FEF3C7',
border: '1px solid #F59E0B',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
color: '#92400E'
}}>
<div style={{ fontWeight: 600, marginBottom: '8px' }}>
Требуется авторизация
</div>
<div style={{ fontSize: '14px', marginBottom: '12px' }}>
Для оформления заказа необходимо войти в систему или зарегистрироваться
</div>
<button
onClick={() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
style={{
backgroundColor: '#F59E0B',
color: 'white',
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
fontSize: '14px',
cursor: 'pointer',
fontWeight: 500
}}
>
Войти в систему
</button>
</div>
)}
<button
className="submit-button fill w-button"
onClick={handleSubmit}
disabled={summary.totalItems === 0 || isProcessing || !recipientName.trim() || !recipientPhone.trim()}
style={{
opacity: (summary.totalItems === 0 || isProcessing || !recipientName.trim() || !recipientPhone.trim()) ? 0.5 : 1,
cursor: (summary.totalItems === 0 || isProcessing || !recipientName.trim() || !recipientPhone.trim()) ? 'not-allowed' : 'pointer'
}}
>
{isProcessing ? 'Оформляем заказ...' :
paymentMethod === 'balance' ? 'Оплатить с баланса' :
paymentMethod === 'invoice' ? 'Выставить счёт' :
'Оплатить'}
</button>
{error && <div style={{ color: 'red', marginTop: 10 }}>{error}</div>}
{/* Кнопка "Назад" */}
<button
onClick={handleBackToStep1}
style={{
background: 'none',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '12px 24px',
marginTop: '12px',
cursor: 'pointer',
fontSize: '14px',
color: '#666'
}}
>
Назад к настройкам доставки
</button>
<div className="w-layout-hflex privacy-consent" style={{ cursor: 'pointer' }} onClick={() => setConsent((v) => !v)}>
<div
className={"div-block-7" + (consent ? " active" : "")}
style={{ marginRight: 8, cursor: 'pointer' }}
>
{consent && (
<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="consent-text">Соглашаюсь с правилами пользования торговой площадкой и возврата</div>
</div>
</div>
</div>
);
};
export default CartSummary;

View File

@ -0,0 +1,212 @@
import React, { useState } from "react";
import InfoOrder1 from "./InfoOrder1";
const subdivisions = [
'ООО "Рога и копыта"',
'ООО "Рога и копыта 2"',
'ООО "Рога и копыта 3"',
];
const tags = [
'Чт, 17 апреля',
'Пт, 18 апреля',
'Сб, 19 апреля',
'Вс, 20 апреля',
];
const ACTIVE_COLOR = 'var(--_button---primary)';
const INACTIVE_COLOR = '#F6F8FA';
const ACTIVE_TEXT = '#fff';
const INACTIVE_TEXT = '#222';
const CartSummary2: React.FC = () => {
const [consent, setConsent] = useState(false);
const [selectedTag, setSelectedTag] = useState<number | null>(null);
const [selectedSubdivision, setSelectedSubdivision] = useState(subdivisions[0]);
const [subdivDropdown, setSubdivDropdown] = useState(false);
const [groupChecked, setGroupChecked] = useState(false);
const [separateChecked, setSeparateChecked] = useState(false);
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [showInfo, setShowInfo] = useState(false);
const [showInfo2, setShowInfo2] = useState(false);
// Логика блокировки выбора даты и радиокнопок
const canSelectTag = !separateChecked;
return (
<div className="w-layout-vflex cart-ditail">
<div className="cart-detail-info">
<div className="w-layout-vflex flex-block-58">
<div className="text-block-31">Подразделение</div>
<div className="w-layout-hflex flex-block-62" style={{ position: 'relative', cursor: 'pointer', minWidth: 220 }} onClick={() => setSubdivDropdown(v => !v)}>
<div className="text-block-31" style={{ width: '100%' }}>{selectedSubdivision}</div>
<div className="code-embed w-embed" style={{ transform: subdivDropdown ? 'rotate(180deg)' : undefined, transition: '0.2s' }}>
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" strokeWidth="2" />
</svg>
</div>
{subdivDropdown && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
background: '#fff',
border: '1px solid #eee',
borderTop: 'none',
zIndex: 10,
borderRadius: '0 0 10px 10px',
boxShadow: '0 4px 16px #0001',
marginTop: 0,
overflow: 'hidden',
minWidth: 220
}}>
{subdivisions.map(sub => (
<div key={sub} style={{ padding: '10px 16px', cursor: 'pointer', background: sub === selectedSubdivision ? 'var(--_button---primary)' : 'transparent', color: sub === selectedSubdivision ? '#fff' : '#222', transition: 'background 0.2s, color 0.2s' }}
onMouseDown={e => { e.preventDefault(); setSelectedSubdivision(sub); setSubdivDropdown(false); }}
onMouseOver={e => { e.currentTarget.style.background = sub === selectedSubdivision ? 'var(--_button---primary)' : '#f6f8fa'; e.currentTarget.style.color = sub === selectedSubdivision ? '#fff' : '#222'; }}
onMouseOut={e => { e.currentTarget.style.background = sub === selectedSubdivision ? 'var(--_button---primary)' : 'transparent'; e.currentTarget.style.color = sub === selectedSubdivision ? '#fff' : '#222'; }}
>
{sub}
</div>
))}
</div>
)}
</div>
</div>
<div className="w-layout-vflex flex-block-66">
<div className="w-layout-hflex flex-block-64" style={{ cursor: 'pointer' }} onClick={() => setGroupChecked(v => !v)}>
<div className="div-block-22" style={{ border: `1.5px solid ${ACTIVE_COLOR}`, borderRadius: '50%', width: 18, height: 18, marginRight: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', background: groupChecked ? ACTIVE_COLOR : '#fff', transition: 'background 0.2s' }}>
{groupChecked && <div style={{ width: 10, height: 10, borderRadius: '50%', background: '#fff' }} />}
</div>
<div className="radio-text">Объединить получения</div>
<div
className="code-embed-2 w-embed"
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(false)}
style={{ position: 'relative' }}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.00033 1.16663C3.78033 1.16663 1.16699 3.77996 1.16699 6.99996C1.16699 10.22 3.78033 12.8333 7.00033 12.8333C10.2203 12.8333 12.8337 10.22 12.8337 6.99996C12.8337 3.77996 10.2203 1.16663 7.00033 1.16663ZM7.58366 9.91663H6.41699V8.74996H7.58366V9.91663ZM7.58366 7.58329H6.41699V4.08329H7.58366V7.58329Z" fill="currentColor" />
</svg>
{showInfo && (
<div style={{ position: 'absolute', left: '50%', top: '120%', transform: 'translateX(-50%)', zIndex: 100 }}>
<InfoOrder1>
Заказанный товар будет <br />доставлен, как только весь<br />товар поступит на склад
</InfoOrder1>
</div>
)}
</div>
</div>
<div className="w-layout-hflex flex-block-65">
{tags.map((tag, i) => (
<div
className="w-layout-hflex tag-button"
key={i}
onClick={() => canSelectTag && setSelectedTag(selectedTag === i ? null : i)}
style={{
background: selectedTag === i ? ACTIVE_COLOR : INACTIVE_COLOR,
color: selectedTag === i ? ACTIVE_TEXT : INACTIVE_TEXT,
cursor: canSelectTag ? 'pointer' : 'not-allowed',
borderRadius: 8,
padding: '4px 12px',
marginRight: 8,
opacity: canSelectTag ? 1 : 0.5,
transition: 'background 0.2s, color 0.2s, opacity 0.2s',
}}
>
<div className="tag-text">{tag}</div>
</div>
))}
</div>
<div className="w-layout-hflex flex-block-64" style={{ cursor: 'pointer' }} onClick={() => setSeparateChecked(v => !v)}>
<div className="div-block-22" style={{ border: `1.5px solid ${ACTIVE_COLOR}`, borderRadius: '50%', width: 18, height: 18, marginRight: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', background: separateChecked ? ACTIVE_COLOR : '#fff', transition: 'background 0.2s' }}>
{separateChecked && <div style={{ width: 10, height: 10, borderRadius: '50%', background: '#fff' }} />}
</div>
<div className="radio-text">Получать по мере поступления</div>
<div
className="code-embed-2 w-embed"
onMouseEnter={() => setShowInfo2(true)}
onMouseLeave={() => setShowInfo2(false)}
style={{ position: 'relative' }}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.00033 1.16663C3.78033 1.16663 1.16699 3.77996 1.16699 6.99996C1.16699 10.22 3.78033 12.8333 7.00033 12.8333C10.2203 12.8333 12.8337 10.22 12.8337 6.99996C12.8337 3.77996 10.2203 1.16663 7.00033 1.16663ZM7.58366 9.91663H6.41699V8.74996H7.58366V9.91663ZM7.58366 7.58329H6.41699V4.08329H7.58366V7.58329Z" fill="currentColor" />
</svg>
{showInfo2 && (
<div style={{ position: 'absolute', left: '50%', top: '120%', transform: 'translateX(-50%)', zIndex: 100 }}>
<InfoOrder1>
Заказанный товар будет <br />доставлен раздельно, по мере поступления на склад
</InfoOrder1>
</div>
)}
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-58">
<div className="text-block-31">Способ получения</div>
<h4 className="heading-12">Доставка курьером</h4>
<div className="text-block-32">Калининградская область, Калиниград, улица Понартская, 5, кв./офис 1, Подъезд 1, этаж 1</div>
</div>
<div className="px-line"></div>
<div className="w-layout-vflex flex-block-63">
<h4 className="heading-12">Получатель</h4>
<div className="w-layout-hflex flex-block-62">
<input
className="text-block-31"
style={{ border: 'none', outline: 'none', borderRadius: 6, padding: '4px 8px', width: '100%', background: '#f6f8fa' }}
placeholder="Имя и фамилия"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="w-layout-hflex flex-block-62">
<input
className="text-block-31"
style={{ border: 'none', outline: 'none', borderRadius: 6, padding: '4px 8px', width: '100%', background: '#f6f8fa' }}
placeholder="Номер телефона"
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</div>
</div>
<div className="px-line"></div>
<div className="w-layout-vflex flex-block-60">
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy">Товары, 3 шт.</div>
<div className="text-block-33">2 538 </div>
</div>
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy">Моя скидка</div>
<div className="text-block-33">-570 </div>
</div>
<div className="w-layout-hflex flex-block-59">
<div className="text-block-21-copy">Доставка</div>
<div className="text-block-33">39 </div>
</div>
</div>
<div className="px-line"></div>
<div className="w-layout-hflex flex-block-59">
<div className="text-block-32">Итого</div>
<h4 className="heading-9-copy-copy">39 389 </h4>
</div>
<a href="/payments-method" className="submit-button fill w-button">Оформить заказ</a>
<div className="w-layout-hflex privacy-consent" style={{ cursor: 'pointer' }} onClick={() => setConsent((v) => !v)}>
<div
className={"div-block-7" + (consent ? " active" : "")}
style={{ marginRight: 8, cursor: 'pointer' }}
>
{consent && (
<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="consent-text">Соглашаюсь с правилами пользования торговой площадкой и возврата</div>
</div>
</div>
</div>
);
};
export default CartSummary2;

View File

@ -0,0 +1,115 @@
import React from 'react';
interface CatalogEmptyStateProps {
categoryName?: string;
hasFilters?: boolean;
onResetFilters?: () => void;
}
const CatalogEmptyState: React.FC<CatalogEmptyStateProps> = ({
categoryName = "товаров",
hasFilters = false,
onResetFilters
}) => {
return (
<div className="flex flex-col items-center justify-center px-4 mx-auto">
{/* Иконка */}
<div className="mb-8 relative">
<div className="w-32 h-32 bg-gradient-to-br from-blue-100 to-red-100 rounded-full flex items-center justify-center">
<svg
className="w-16 h-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</div>
{/* Анимированные точки */}
<div className="absolute -top-2 -right-2 w-4 h-4 bg-red-500 rounded-full animate-pulse"></div>
<div className="absolute -bottom-2 -left-2 w-3 h-3 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0.5s' }}></div>
<div className="absolute top-1/2 -right-4 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" style={{ animationDelay: '1s' }}></div>
</div>
{/* Заголовок */}
<h3 className="text-2xl font-bold text-gray-800 mb-4 text-center">
{hasFilters ? "По вашему запросу ничего не найдено" : `Пока нет ${categoryName} в наличии`}
</h3>
{/* Описание */}
<div className="text-center max-w-md mb-8">
{hasFilters ? (
<p className="text-gray-600 leading-relaxed">
Попробуйте изменить параметры поиска или фильтры.
Возможно, нужные товары появятся в ближайшее время!
</p>
) : (
<p className="text-gray-600 leading-relaxed">
Мы активно работаем над расширением ассортимента.
Скоро здесь появятся новые товары с лучшими ценами!
</p>
)}
</div>
{/* Действия */}
<div className="flex flex-col sm:flex-row gap-4 items-center">
{hasFilters && (
<button
onClick={onResetFilters}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Сбросить фильтры
</button>
)}
<a
href="/"
className="px-6 py-3 border-2 border-gray-300 text-gray-700 rounded-lg hover:border-gray-400 hover:bg-gray-50 transition-colors font-medium"
>
На главную
</a>
</div>
{/* Дополнительная информация */}
<div className="mt-12 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-gray-500 mb-4">
<svg className="w-4 h-4" 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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span>Хотите узнать о поступлениях первыми?</span>
</div>
<div className="flex flex-col sm:flex-row gap-2 items-center justify-center">
<span className="text-sm text-gray-600">Подпишитесь на уведомления:</span>
<div className="flex gap-3">
<a
href="https://t.me/protekauto"
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm font-medium"
target="_blank"
rel="noopener noreferrer"
>
<img src="/images/tg_icon.svg" alt="Telegram" className="w-4 h-4" />
Telegram
</a>
<a
href="https://wa.me/79991234567"
className="flex items-center gap-1 text-green-600 hover:text-green-700 text-sm font-medium"
target="_blank"
rel="noopener noreferrer"
>
<img src="/images/wa_icon.svg" alt="WhatsApp" className="w-4 h-4" />
WhatsApp
</a>
</div>
</div>
</div>
</div>
);
};
export default CatalogEmptyState;

View File

@ -0,0 +1,26 @@
import React from "react";
interface CatalogFiltersButtonProps {
onClick: () => void;
}
const CatalogFiltersButton: React.FC<CatalogFiltersButtonProps> = ({ onClick }) => (
<button className="w-layout-hflex flex-block-85 filters-btn-mobile" onClick={onClick} type="button">
<span className="code-embed-9 w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 4H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 4H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 12H12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 20H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 20H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 10V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 18V22" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div>Фильтры</div>
</button>
);
export default CatalogFiltersButton;

View File

@ -0,0 +1,508 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import { GET_LAXIMO_QUICK_GROUPS, GET_LAXIMO_CATEGORIES, GET_LAXIMO_UNITS, GET_LAXIMO_QUICK_DETAIL } from '@/lib/graphql';
import { LaximoQuickGroup, LaximoQuickDetail } from '@/types/laximo';
import GroupDetailsSection from './GroupDetailsSection';
import { mapToStandardCategories, getStaticCategories, CATEGORY_MAPPING } from '@/lib/laximo-categories';
interface CatalogGroupsSectionProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
}
interface GroupItemProps {
group: LaximoQuickGroup;
level: number;
onGroupClick: (group: LaximoQuickGroup) => void;
}
interface PredefinedCategoryDetailsSectionProps {
catalogCode: string;
vehicleId: string;
predefinedCategory: LaximoQuickGroup;
ssd: string;
onBack: () => void;
}
const PredefinedCategoryDetailsSection: React.FC<PredefinedCategoryDetailsSectionProps> = ({
catalogCode,
vehicleId,
predefinedCategory,
ssd,
onBack
}) => {
const [selectedChildGroup, setSelectedChildGroup] = useState<LaximoQuickGroup | null>(null);
const handleChildGroupClick = (child: LaximoQuickGroup) => {
// Проверяем, что ID дочерней категории является валидным числовым ID Laximo
if (!/^\d+$/.test(child.quickgroupid)) {
alert(`Ошибка: ID категории "${child.quickgroupid}" не является валидным ID Laximo.\n\nЭта категория пока не поддерживается для просмотра деталей.\опробуйте использовать другие разделы каталога.`);
return;
}
setSelectedChildGroup(child);
};
if (selectedChildGroup) {
return (
<GroupDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
quickGroupId={selectedChildGroup.quickgroupid}
groupName={selectedChildGroup.name}
ssd={ssd}
onBack={() => setSelectedChildGroup(null)}
/>
);
}
return (
<div className="w-full">
<div className="mb-4 flex items-center">
<button
onClick={onBack}
className="flex items-center text-blue-600 hover:text-blue-800 mr-4"
>
Назад к группам
</button>
<h2 className="text-xl font-semibold">{predefinedCategory.name}</h2>
</div>
<div className="border rounded p-4">
<h3 className="text-lg font-semibold mb-4">Подкатегории</h3>
{predefinedCategory.children && predefinedCategory.children.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{predefinedCategory.children.map((child) => {
const isValidId = /^\d+$/.test(child.quickgroupid);
return (
<div
key={child.quickgroupid}
className={`border border-gray-200 rounded-lg p-4 transition-shadow cursor-pointer ${
isValidId
? 'hover:shadow-md hover:border-blue-300'
: 'opacity-60 cursor-not-allowed bg-gray-50'
}`}
onClick={() => handleChildGroupClick(child)}
>
<h4 className="font-medium text-gray-900 mb-2">{child.name}</h4>
<p className="text-sm text-gray-600">ID: {child.quickgroupid}</p>
{isValidId ? (
<div className="mt-3 text-blue-600 text-sm font-medium">
Посмотреть детали
</div>
) : (
<div className="mt-3 text-gray-500 text-sm">
Пока недоступно
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p>В этой категории пока нет доступных подкатегорий</p>
</div>
)}
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">💡 Совет</h4>
<p className="text-sm text-blue-800">
Для поиска деталей в категории "{predefinedCategory.name}" рекомендуем использовать:
</p>
<ul className="text-sm text-blue-700 mt-2 ml-4 list-disc">
<li>Поиск по артикулу (OEM номеру)</li>
<li>Поиск по названию детали</li>
<li>Категории оригинального каталога</li>
<li>Группы быстрого поиска</li>
</ul>
</div>
</div>
</div>
);
};
const GroupItem: React.FC<GroupItemProps> = ({ group, level, onGroupClick }) => {
const [isExpanded, setIsExpanded] = useState(false);
const handleClick = () => {
console.log('🖱️ Клик по элементу группы:', {
name: group.name,
quickgroupid: group.quickgroupid,
link: group.link,
hasChildren: group.children && group.children.length > 0
});
// Если у группы есть дети - разворачиваем/сворачиваем
if (group.children && group.children.length > 0) {
console.log('📂 Разворачиваем группу с детьми');
setIsExpanded(!isExpanded);
}
// Если у группы есть ссылка - передаем клик наверх для загрузки деталей
else if (group.link && group.quickgroupid) {
console.log('🔗 Переходим к группе с ссылкой');
onGroupClick(group);
}
// Иначе - это группа без ссылки и без детей
else {
console.log('⚠️ Группа без ссылки и без детей:', group.name);
}
};
return (
<div style={{ marginLeft: `${level * 20}px` }}>
<div
className={`flex items-center p-2 hover:bg-gray-100 cursor-pointer ${group.link ? 'text-blue-600' : ''}`}
onClick={handleClick}
>
{group.children && group.children.length > 0 && (
<span className="mr-2">{isExpanded ? '▼' : '▶'}</span>
)}
<span>{group.name}</span>
{/* Показываем количество дочерних элементов для предопределенных категорий */}
{group.children && group.children.length > 0 && (
<span className="ml-auto text-xs text-gray-500">
({group.children.length})
</span>
)}
</div>
{isExpanded && group.children && (
<div>
{group.children.map((child, index) => (
<GroupItem
key={child.quickgroupid || index}
group={child}
level={level + 1}
onGroupClick={onGroupClick}
/>
))}
</div>
)}
</div>
);
};
type CatalogType = 'quickGroups' | 'categories' | 'units' | 'standardCategories';
const CatalogGroupsSection: React.FC<CatalogGroupsSectionProps> = ({
catalogCode,
vehicleId,
ssd
}) => {
// По умолчанию используем стандартные категории
const [catalogType, setCatalogType] = useState<CatalogType>('standardCategories');
const [selectedGroup, setSelectedGroup] = useState<{ group: LaximoQuickGroup; type: CatalogType } | null>(null);
const [selectedPredefinedCategory, setSelectedPredefinedCategory] = useState<LaximoQuickGroup | null>(null);
const [standardCategories, setStandardCategories] = useState<LaximoQuickGroup[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
// Запросы для разных типов каталогов
const { data: quickGroupsData, loading: quickGroupsLoading, error: quickGroupsError } = useQuery<{ laximoQuickGroups: LaximoQuickGroup[] }>(
GET_LAXIMO_QUICK_GROUPS,
{
variables: {
catalogCode,
vehicleId,
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || !vehicleId || catalogType !== 'quickGroups',
errorPolicy: 'all'
}
);
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery<{ laximoCategories: LaximoQuickGroup[] }>(
GET_LAXIMO_CATEGORIES,
{
variables: {
catalogCode,
...(vehicleId && { vehicleId }),
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || catalogType !== 'categories',
errorPolicy: 'all'
}
);
// Запрос для стандартных категорий (используем тот же запрос, что и для обычных категорий)
const { data: standardCategoriesData, loading: standardCategoriesLoading, error: standardCategoriesError } = useQuery<{ laximoCategories: LaximoQuickGroup[] }>(
GET_LAXIMO_CATEGORIES,
{
variables: {
catalogCode,
...(vehicleId && { vehicleId }),
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || catalogType !== 'standardCategories',
errorPolicy: 'all'
}
);
const { data: unitsData, loading: unitsLoading, error: unitsError } = useQuery<{ laximoUnits: LaximoQuickGroup[] }>(
GET_LAXIMO_UNITS,
{
variables: {
catalogCode,
...(vehicleId && { vehicleId }),
...(ssd && ssd.trim() !== '' && { ssd }),
...(selectedCategoryId && { categoryId: selectedCategoryId })
},
skip: !catalogCode || catalogType !== 'units',
errorPolicy: 'all'
}
);
// Обработка данных для стандартных категорий
useEffect(() => {
console.log('🔄 Обработка данных стандартных категорий...');
console.log('📊 standardCategoriesData:', standardCategoriesData);
console.log('❌ standardCategoriesError:', standardCategoriesError);
if (standardCategoriesData?.laximoCategories && standardCategoriesData.laximoCategories.length > 0) {
console.log('📋 Получены категории от API:', standardCategoriesData.laximoCategories.length, 'категорий');
console.log('📋 Категории:', standardCategoriesData.laximoCategories.map(c => `${c.name} (ID: ${c.quickgroupid})`));
// Используем ТОЛЬКО реальные категории от API Laximo
console.log('✅ Используем реальные категории от API Laximo');
setStandardCategories(standardCategoriesData.laximoCategories);
} else if (standardCategoriesError) {
console.warn('❌ Ошибка загрузки категорий:', standardCategoriesError.message);
console.log('📋 Используем статические категории как fallback');
setStandardCategories(getStaticCategories());
} else if (!standardCategoriesLoading) {
// Только если загрузка завершена и нет данных
console.log('📋 Нет данных от API, используем статические категории');
setStandardCategories(getStaticCategories());
}
// Если идет загрузка, не устанавливаем никаких данных
}, [standardCategoriesData, standardCategoriesError, standardCategoriesLoading]);
const handleGroupClick = (group: LaximoQuickGroup) => {
console.log('🔍 Клик по группе:', group);
console.log('📂 Тип каталога:', catalogType);
// Проверяем что группа имеет ссылку и валидный ID
if (!group.link || !group.quickgroupid) {
console.warn('⚠️ Группа не имеет ссылки или ID:', group);
return;
}
// Проверяем что ID группы не пустой
if (!group.quickgroupid.trim()) {
console.error('❌ Пустой ID группы:', group.quickgroupid);
alert(`Ошибка: Пустой ID группы для группы "${group.name}"`);
return;
}
// Для стандартных категорий (реальные категории от API Laximo)
if (catalogType === 'standardCategories') {
console.log('🔍 Клик по категории API Laximo:', group.name, 'ID:', group.quickgroupid);
// Для категорий от API нужно получить узлы через ListUnits
// Переключаемся на вкладку "Узлы" и передаем categoryId
if (ssd && ssd.trim() !== '') {
console.log('✅ Переключаемся на узлы категории:', group.quickgroupid);
// Переключаемся на тип 'units' и сохраняем ID категории
setCatalogType('units');
setSelectedCategoryId(group.quickgroupid);
return;
} else {
alert('Ошибка: Для поиска узлов необходимы данные автомобиля (SSD). Пожалуйста, выберите автомобиль заново.');
return;
}
}
// Для групп быстрого поиска, реальных категорий и валидных дочерних категорий переходим к деталям
if ((catalogType === 'quickGroups' || catalogType === 'categories' || catalogType === 'units' ||
(catalogType === 'standardCategories' && (!group.children || group.children.length === 0))) &&
ssd && ssd.trim() !== '') {
console.log('✅ Переходим к деталям группы:', group.quickgroupid);
setSelectedGroup({ group, type: catalogType });
} else if (!ssd || ssd.trim() === '') {
alert('Ошибка: Для поиска деталей необходимы данные автомобиля (SSD). Пожалуйста, выберите автомобиль заново.');
} else {
// Для других типов каталогов пока показываем alert
alert(`Поиск запчастей в группе: ${group.name}\nID группы: ${group.quickgroupid}\nТип каталога: ${catalogType}`);
}
};
const handleBackToGroups = () => {
setSelectedGroup(null);
setSelectedPredefinedCategory(null);
};
// Сбрасываем selectedCategoryId при переключении типа каталога
useEffect(() => {
if (catalogType !== 'units') {
setSelectedCategoryId(null);
}
}, [catalogType]);
// Определяем текущие данные на основе выбранного типа
let currentData: LaximoQuickGroup[] = [];
let loading = false;
let error = null;
switch (catalogType) {
case 'quickGroups':
currentData = quickGroupsData?.laximoQuickGroups || [];
loading = quickGroupsLoading;
error = quickGroupsError;
break;
case 'categories':
currentData = categoriesData?.laximoCategories || [];
loading = categoriesLoading;
error = categoriesError;
break;
case 'units':
currentData = unitsData?.laximoUnits || [];
loading = unitsLoading;
error = unitsError;
break;
case 'standardCategories':
currentData = standardCategories;
loading = standardCategoriesLoading && standardCategories.length === 0;
error = standardCategoriesError;
break;
}
// Получаем заголовок для выбранного типа каталога
let catalogTypeTitle = '';
if (catalogType === 'quickGroups') {
catalogTypeTitle = 'Группы быстрого поиска';
} else if (catalogType === 'categories') {
catalogTypeTitle = 'Категории оригинального каталога';
} else if (catalogType === 'units') {
if (selectedCategoryId) {
// Находим название категории
const selectedCategory = standardCategories.find(cat => cat.quickgroupid === selectedCategoryId);
catalogTypeTitle = selectedCategory ? `Узлы категории: ${selectedCategory.name}` : 'Узлы категории';
} else {
catalogTypeTitle = 'Узлы каталога';
}
} else if (catalogType === 'standardCategories') {
catalogTypeTitle = 'Группы запчастей';
}
// Если выбрана предопределенная категория, показываем её подкатегории
if (selectedPredefinedCategory && ssd) {
return (
<PredefinedCategoryDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
predefinedCategory={selectedPredefinedCategory}
ssd={ssd}
onBack={handleBackToGroups}
/>
);
}
// Если выбрана группа для просмотра деталей
if (selectedGroup) {
return (
<GroupDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
quickGroupId={selectedGroup.group.quickgroupid}
groupName={selectedGroup.group.name}
ssd={ssd || ''}
onBack={handleBackToGroups}
/>
);
}
return (
<div className="w-full">
<div className="mb-4 flex space-x-4">
<button
onClick={() => setCatalogType('standardCategories')}
className={`px-4 py-2 rounded ${
catalogType === 'standardCategories' ? 'bg-red-600 text-white' : 'bg-gray-200'
}`}
>
Группы запчастей
</button>
<button
onClick={() => setCatalogType('categories')}
className={`px-4 py-2 rounded ${
catalogType === 'categories' ? 'bg-red-600 text-white' : 'bg-gray-200'
}`}
>
Категории каталога
</button>
<button
onClick={() => setCatalogType('quickGroups')}
className={`px-4 py-2 rounded ${
catalogType === 'quickGroups' ? 'bg-red-600 text-white' : 'bg-gray-200'
}`}
>
Быстрый поиск
</button>
<button
onClick={() => setCatalogType('units')}
className={`px-4 py-2 rounded ${
catalogType === 'units' ? 'bg-red-600 text-white' : 'bg-gray-200'
}`}
>
Узлы
</button>
</div>
<div className="border rounded p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">{catalogTypeTitle}</h3>
{catalogType === 'units' && selectedCategoryId && (
<button
onClick={() => {
setCatalogType('standardCategories');
setSelectedCategoryId(null);
}}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded"
>
Назад к категориям
</button>
)}
</div>
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<span className="ml-2">Загрузка...</span>
</div>
)}
{error && (
<div className="text-red-600 py-4">
Ошибка загрузки данных. Попробуйте обновить страницу.
</div>
)}
{!loading && !error && currentData.length === 0 && (
<div className="text-gray-500 py-4">
Нет доступных групп для отображения
</div>
)}
{!loading && !error && currentData.length > 0 && (
<div className="space-y-2">
<div className="mb-4 text-sm text-gray-600">
Найдено категорий: {currentData.length}
</div>
{currentData.map((group) => (
<GroupItem
key={group.quickgroupid}
group={group}
level={0}
onGroupClick={handleGroupClick}
/>
))}
</div>
)}
</div>
</div>
);
};
export default CatalogGroupsSection;

View File

@ -0,0 +1,77 @@
import React from "react";
interface Breadcrumb {
label: string;
href?: string;
}
interface CatalogInfoHeaderProps {
title: string;
count?: number;
productName?: string;
breadcrumbs?: Breadcrumb[];
showCount?: boolean;
showProductHelp?: boolean;
}
const CatalogInfoHeader: React.FC<CatalogInfoHeaderProps> = ({
title,
count,
productName,
breadcrumbs,
showCount = false,
showProductHelp = false,
}) => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
{breadcrumbs && breadcrumbs.length > 0 && (
<div className="w-layout-hflex flex-block-7">
{breadcrumbs.map((bc, idx) => (
<React.Fragment key={idx}>
{idx > 0 && <div className="text-block-3"></div>}
{bc.href ? (
<a href={bc.href} className="link-block w-inline-block">
<div>{bc.label}</div>
</a>
) : (
<span className="link-block-2 w-inline-block">
<div>{bc.label}</div>
</span>
)}
</React.Fragment>
))}
</div>
)}
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">{title}</h1>
{showCount && (
<div className="text-block-4">
{typeof count === 'number' ? (
`Найдено ${count} товаров`
) : (
<span className="flex items-center gap-2">
Подсчитываем товары...
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
)}
</div>
)}
</div>
{showProductHelp && productName && (
<div className="w-layout-hflex flex-block-11">
<img src="/images/qwestions.svg" loading="lazy" alt="" className="image-4" />
<div className="text-block-5">Как правильно выбрать {productName}?</div>
</div>
)}
</div>
</div>
</div>
</section>
);
export default CatalogInfoHeader;

View File

@ -0,0 +1,9 @@
import React from "react";
const CatalogPagination: React.FC = () => (
<div className="w-layout-hflex pagination">
<a href="#" className="button_strock w-button">Показать ещё</a>
</div>
);
export default CatalogPagination;

View File

@ -0,0 +1,139 @@
import Link from "next/link";
import React from "react";
import { useFavorites } from "@/contexts/FavoritesContext";
interface CatalogProductCardProps {
image: string;
discount: string;
price: string;
oldPrice: string;
title: string;
brand: string;
articleNumber?: string;
brandName?: string;
artId?: string;
productId?: string;
offerKey?: string;
currency?: string;
priceElement?: React.ReactNode; // Элемент для отображения цены (например, скелетон)
onAddToCart?: (e: React.MouseEvent) => void | Promise<void>;
}
const CatalogProductCard: React.FC<CatalogProductCardProps> = ({
image,
discount,
price,
oldPrice,
title,
brand,
articleNumber,
brandName,
artId,
productId,
offerKey,
currency = 'RUB',
priceElement,
onAddToCart,
}) => {
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites();
// Обрабатываем пустое изображение - используем SVG-заглушку вместо мокап-фотки
const displayImage = image || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjE5MCIgdmlld0JveD0iMCAwIDIxMCAxOTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMTAiIGhlaWdodD0iMTkwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik04NSA5NUw5NSA4NUwxMjUgMTE1TDE0MCA5NUwxNjUgMTIwSDE2NVY5MEg0NVY5MEw4NSA5NVoiIGZpbGw9IiNEMUQ1REIiLz4KPGNpcmNsZSBjeD0iNzUiIGN5PSI3NSIgcj0iMTAiIGZpbGw9IiNEMUQ1REIiLz4KPHRleHQgeD0iMTA1IiB5PSIxNTAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMiIgZmlsbD0iIzlDQTNBRiIgdGV4dC1hbmNob3I9Im1pZGRsZSI+Tm8gaW1hZ2U8L3RleHQ+Cjwvc3ZnPgo=';
// Создаем ссылку на card с параметрами товара
const cardUrl = articleNumber && brandName
? `/card?article=${encodeURIComponent(articleNumber)}&brand=${encodeURIComponent(brandName)}${artId ? `&artId=${artId}` : ''}`
: '/card'; // Fallback на card если нет данных
// Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(productId, offerKey, articleNumber, brandName || brand);
// Обработчик клика по сердечку
const handleFavoriteClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Извлекаем цену как число
const numericPrice = parseFloat(price.replace(/[^\d.,]/g, '').replace(',', '.')) || 0;
if (isItemFavorite) {
// Создаем ID для удаления
const id = `${productId || offerKey || ''}:${articleNumber}:${brandName || brand}`;
removeFromFavorites(id);
} else {
// Добавляем в избранное
addToFavorites({
productId,
offerKey,
name: title,
brand: brandName || brand,
article: articleNumber || '',
price: numericPrice,
currency,
image
});
}
};
// Обработчик клика по кнопке "Купить"
const handleBuyClick = (e: React.MouseEvent) => {
if (onAddToCart) {
onAddToCart(e);
} else {
// Fallback - переходим на страницу товара
window.location.href = cardUrl;
}
};
return (
<div className="w-layout-vflex flex-block-15-copy" data-article-card="visible">
<div
className={`favcardcat ${isItemFavorite ? 'favorite-active' : ''}`}
onClick={handleFavoriteClick}
style={{
cursor: 'pointer',
color: isItemFavorite ? '#ff4444' : '#ccc'
}}
>
<div className="icon-setting w-embed">
<svg width="currentwidth" height="currentheight" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5996 3.5C15.8107 3.5 17.5 5.1376 17.5 7.19629C17.5 8.46211 16.9057 9.65758 15.7451 11.0117C14.8712 12.0314 13.7092 13.1034 12.3096 14.3311L10.833 15.6143L10.832 15.6152L10 16.3369L9.16797 15.6152L9.16699 15.6143L7.69043 14.3311C6.29084 13.1034 5.12883 12.0314 4.25488 11.0117C3.09428 9.65758 2.50003 8.46211 2.5 7.19629C2.5 5.1376 4.18931 3.5 6.40039 3.5C7.6497 3.50012 8.85029 4.05779 9.62793 4.92188L10 5.33398L10.3721 4.92188C11.1497 4.05779 12.3503 3.50012 13.5996 3.5Z" fill="currentColor" ></path>
</svg>
</div>
</div>
{/* Делаем картинку и контент кликабельными для перехода на card */}
<Link href={cardUrl} className="div-block-4" style={{ textDecoration: 'none', color: 'inherit' }}>
<img src={displayImage} loading="lazy" width="Auto" height="Auto" alt="" className="image-5" />
<div className="text-block-7">{discount}</div>
</Link>
<Link href={cardUrl} className="div-block-3" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="w-layout-hflex flex-block-16">
{priceElement ? (
<div className="text-block-8">{priceElement}</div>
) : (
<div className="text-block-8">{price}</div>
)}
<div className="text-block-9">{oldPrice}</div>
</div>
<div className="text-block-10">{title}</div>
<div className="text-block-11">{brand}</div>
</Link>
{/* Обновляем кнопку купить */}
<div className="catc w-inline-block" onClick={handleBuyClick} style={{ cursor: 'pointer' }}>
<div className="div-block-25">
<div className="icon-setting w-embed">
<svg width="currentWidht" height="currentHeight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1998 22.2C8.8798 22.2 7.81184 23.28 7.81184 24.6C7.81184 25.92 8.8798 27 10.1998 27C11.5197 27 12.5997 25.92 12.5997 24.6C12.5997 23.28 11.5197 22.2 10.1998 22.2ZM3 3V5.4H5.39992L9.71977 14.508L8.09982 17.448C7.90783 17.784 7.79984 18.18 7.79984 18.6C7.79984 19.92 8.8798 21 10.1998 21H24.5993V18.6H10.7037C10.5357 18.6 10.4037 18.468 10.4037 18.3L10.4397 18.156L11.5197 16.2H20.4594C21.3594 16.2 22.1513 15.708 22.5593 14.964L26.8552 7.176C26.9542 6.99286 27.004 6.78718 26.9997 6.57904C26.9955 6.37089 26.9373 6.16741 26.8309 5.98847C26.7245 5.80952 26.5736 5.66124 26.3927 5.55809C26.2119 5.45495 26.0074 5.40048 25.7992 5.4H8.05183L6.92387 3H3ZM22.1993 22.2C20.8794 22.2 19.8114 23.28 19.8114 24.6C19.8114 25.92 20.8794 27 22.1993 27C23.5193 27 24.5993 25.92 24.5993 24.6C24.5993 23.28 23.5193 22.2 22.1993 22.2Z" fill="currentColor"></path>
</svg>
</div>
</div>
<div className="text-block-6">Купить</div>
</div>
</div>
);
};
export default CatalogProductCard;

View File

@ -0,0 +1,56 @@
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
const CatalogProductCardSkeleton: React.FC = () => {
return (
<div className="w-layout-vflex flex-block-15-copy" data-article-card="skeleton">
{/* Иконка избранного */}
<div className="favcardcat">
<div className="icon-setting w-embed">
<Skeleton width={24} height={24} />
</div>
</div>
{/* Изображение товара */}
<div className="div-block-4">
<Skeleton height={200} className="image-5" />
<div className="text-block-7">
<Skeleton width={60} height={20} />
</div>
</div>
{/* Информация о товаре */}
<div className="div-block-3">
<div className="w-layout-hflex flex-block-16">
<div className="text-block-8">
<Skeleton width={80} height={24} />
</div>
<div className="text-block-9">
<Skeleton width={60} height={20} />
</div>
</div>
<div className="text-block-10" style={{ marginTop: '8px' }}>
<Skeleton height={20} />
</div>
<div className="text-block-11" style={{ marginTop: '4px' }}>
<Skeleton width="70%" height={16} />
</div>
</div>
{/* Кнопка купить */}
<div className="catc w-inline-block">
<div className="div-block-25">
<div className="icon-setting w-embed">
<Skeleton width={24} height={24} />
</div>
</div>
<div className="text-block-6">
<Skeleton width={50} height={16} />
</div>
</div>
</div>
);
};
export default CatalogProductCardSkeleton;

View File

@ -0,0 +1,34 @@
import React from "react";
const sortOptions = [
"По популярности",
"Сначала дешевле",
"Сначала дороже",
"Высокий рейтинг",
];
interface CatalogSortProps {
active: number;
onChange: (idx: number) => void;
}
const CatalogSort: React.FC<CatalogSortProps> = ({ active, onChange }) => {
return (
<div className="w-layout-hflex sort_block">
{sortOptions.map((option, idx) => (
<button
key={option}
className={
"sort_btn" + (active === idx ? " sort_btn-active" : "")
}
onClick={() => onChange(idx)}
type="button"
>
{option}
</button>
))}
</div>
);
};
export default CatalogSort;

View File

@ -0,0 +1,21 @@
import React from "react";
interface CatalogSortButtonProps {
onClick: () => void;
}
const CatalogSortButton: React.FC<CatalogSortButtonProps> = ({ onClick }) => (
<button className="w-layout-hflex flex-block-85 sort-btn-mobile" onClick={onClick} type="button">
<span className="code-embed-9 w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 16L7 20L11 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 20V4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 8L17 4L13 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17 4V20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<div>Сортировка</div>
</button>
);
export default CatalogSortButton;

View File

@ -0,0 +1,75 @@
import React, { useState, useRef, useEffect } from 'react';
interface CatalogSortDropdownProps {
active: number;
onChange: (index: number) => void;
}
const sortOptions = [
'По популярности',
'Сначала дешевле',
'Сначала дороже',
'Высокий рейтинг',
];
const CatalogSortDropdown: React.FC<CatalogSortDropdownProps> = ({ active, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]);
return (
<div
data-hover="false"
data-delay="0"
className="dropdown-2 w-dropdown desktop-only"
ref={dropdownRef}
>
<div
className="flex-block-85 w-dropdown-toggle"
onClick={() => setIsOpen((v) => !v)}
tabIndex={0}
role="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<div className="code-embed-9 w-embed">
<svg width="currentwidth" height="currentheight" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 16L7 20L11 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 20V4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 8L17 4L13 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17 4V20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>Сортировка</div>
</div>
<nav className={`dropdown-list-2 w-dropdown-list${isOpen ? ' w--open' : ''}`} style={{ minWidth: 180, whiteSpace: 'normal' }}>
{sortOptions.map((option, index) => (
<a
key={index}
href="#"
className={`w-dropdown-link${active === index ? ' w--current' : ''}`}
tabIndex={0}
onClick={e => {
e.preventDefault();
onChange(index);
setIsOpen(false);
}}
>
{option}
</a>
))}
</nav>
</div>
);
};
export default CatalogSortDropdown;

View File

@ -0,0 +1,20 @@
import React from "react";
const CatalogSubscribe: React.FC = () => (
<div className="w-layout-blockcontainer container subscribe w-container">
<div className="w-layout-hflex flex-block-18">
<div className="div-block-9">
<h3 className="heading-3 sub">Подпишитесь на новостную рассылку</h3>
<div className="text-block-14">Оставайтесь в курсе акций, <br />новинок и специальных предложений</div>
</div>
<div className="form-block-3 w-form">
<form className="form-3" onSubmit={e => e.preventDefault()}>
<input className="text-field-3 w-input" maxLength={256} name="name-6" placeholder="Введите E-mail" type="text" id="name-6" />
<input type="submit" className="submit-button w-button" value="Подписаться" />
</form>
</div>
</div>
</div>
);
export default CatalogSubscribe;

View File

@ -0,0 +1,30 @@
import React, { useState } from "react";
const tabs = [
"Популярные",
"Сначала дешевле",
"Сначала дороже",
"Высокий рейтинг",
];
const CatalogTabs: React.FC = () => {
const [active, setActive] = useState(0);
return (
<div className="w-layout-hflex tabs_block">
{tabs.map((tab, idx) => (
<div
key={tab}
className={
"tab_c" + (active === idx ? " tab_card-activ" : "")
}
style={active === idx ? { borderBottomColor: "var(--_button---primary)" } : {}}
onClick={() => setActive(idx)}
>
{tab}
</div>
))}
</div>
);
};
export default CatalogTabs;

View File

@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { GET_LAXIMO_CATEGORIES, GET_LAXIMO_UNITS } from '@/lib/graphql';
import { LaximoQuickGroup } from '@/types/laximo';
import UnitsSection from './UnitsSection';
interface CategoriesSectionProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
}
interface LaximoCategory {
quickgroupid: string; // categoryid в API
name: string;
link: boolean;
}
const CategoriesSection: React.FC<CategoriesSectionProps> = ({
catalogCode,
vehicleId,
ssd
}) => {
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedCategoryName, setSelectedCategoryName] = useState<string>('');
// Получаем список категорий каталога
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery<{ laximoCategories: LaximoCategory[] }>(
GET_LAXIMO_CATEGORIES,
{
variables: {
catalogCode,
vehicleId,
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || !vehicleId,
errorPolicy: 'all'
}
);
const handleCategorySelect = (categoryId: string, categoryName: string) => {
console.log('🔍 CategoriesSection: выбрана категория', {
categoryId,
categoryName,
catalogCode,
vehicleId,
hasSSD: !!ssd,
ssdLength: ssd?.length,
ssdPreview: ssd ? ssd.substring(0, 50) + '...' : 'отсутствует'
});
setSelectedCategoryId(categoryId);
setSelectedCategoryName(categoryName);
};
const handleBackToCategories = () => {
setSelectedCategoryId(null);
setSelectedCategoryName('');
};
// Если выбрана категория, показываем узлы этой категории
if (selectedCategoryId) {
return (
<UnitsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
ssd={ssd}
categoryId={selectedCategoryId}
categoryName={selectedCategoryName}
onBack={handleBackToCategories}
/>
);
}
if (categoriesLoading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Загружаем категории каталога...</p>
</div>
);
}
if (categoriesError) {
console.error('Ошибка загрузки категорий:', categoriesError);
return (
<div className="text-center py-8">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Ошибка загрузки категорий</h3>
<p className="text-gray-600 mb-4">Не удалось загрузить категории каталога</p>
<p className="text-sm text-gray-500">
{categoriesError.message}
</p>
</div>
);
}
const categories = categoriesData?.laximoCategories || [];
if (categories.length === 0) {
return (
<div className="text-center py-8">
<div className="text-gray-400 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Категории не найдены</h3>
<p className="text-gray-600">
Для данного автомобиля категории каталога недоступны
</p>
</div>
);
}
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((category) => (
<button
key={category.quickgroupid}
onClick={() => handleCategorySelect(category.quickgroupid, category.name)}
className="bg-white border border-gray-200 rounded-lg p-4 text-left hover:border-red-300 hover:shadow-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 mb-1">
{category.name}
</h4>
<p className="text-sm text-gray-500">
Нажмите для просмотра узлов
</p>
</div>
<div className="ml-3 text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-400" 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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Информация о категориях
</h4>
<p className="text-sm text-blue-700 mt-1">
Категории узлов оригинального каталога представляют структуру каталога производителя.
Каждая категория содержит узлы (агрегаты), которые в свою очередь содержат конкретные детали.
</p>
</div>
</div>
</div>
</div>
);
};
export default CategoriesSection;

View File

@ -0,0 +1,386 @@
import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { useFavorites } from "@/contexts/FavoritesContext";
const INITIAL_OFFERS_LIMIT = 5;
interface CoreProductCardOffer {
id?: string;
productId?: string;
offerKey?: string;
pcs: string;
days: string;
recommended?: boolean;
price: string;
count: string;
isExternal?: boolean;
currency?: string;
warehouse?: string;
supplier?: string;
deliveryTime?: number;
}
interface CoreProductCardProps {
brand: string;
article: string;
name: string;
image?: string;
offers: CoreProductCardOffer[];
showMoreText?: string;
isAnalog?: boolean;
isLoadingOffers?: boolean;
onLoadOffers?: () => void;
}
const CoreProductCard: React.FC<CoreProductCardProps> = ({
brand,
article,
name,
image,
offers,
showMoreText,
isAnalog = false,
isLoadingOffers = false,
onLoadOffers
}) => {
const { addItem } = useCart();
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites();
const [visibleOffersCount, setVisibleOffersCount] = useState(INITIAL_OFFERS_LIMIT);
const [quantities, setQuantities] = useState<{ [key: number]: number }>(
offers.reduce((acc, _, index) => ({ ...acc, [index]: 1 }), {})
);
const [quantityErrors, setQuantityErrors] = useState<{ [key: number]: string }>({});
const displayedOffers = offers.slice(0, visibleOffersCount);
const hasMoreOffers = visibleOffersCount < offers.length;
// Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(
offers[0]?.productId,
offers[0]?.offerKey,
article,
brand
);
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
return parseFloat(cleanPrice) || 0;
};
// Функция для парсинга времени доставки
const parseDeliveryTime = (daysStr: string): string => {
const match = daysStr.match(/\d+/);
return match ? `${match[0]} дней` : daysStr;
};
// Функция для парсинга количества в наличии
const parseStock = (stockStr: string): number => {
const match = stockStr.match(/\d+/);
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) {
window.alert(`Максимум ${availableStock} шт.`);
return;
}
setQuantities(prev => ({ ...prev, [index]: num }));
};
const handleAddToCart = (offer: CoreProductCardOffer, index: number) => {
const quantity = quantities[index] || 1;
const availableStock = parseStock(offer.pcs);
// Проверяем наличие
if (quantity > availableStock) {
alert(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return;
}
const numericPrice = parsePrice(offer.price);
addItem({
productId: offer.productId,
offerKey: offer.offerKey,
name: name,
description: `${brand} ${article} - ${name}`,
brand: brand,
article: article,
price: numericPrice,
currency: offer.currency || 'RUB',
quantity: quantity,
deliveryTime: parseDeliveryTime(offer.days),
warehouse: offer.warehouse || 'Склад',
supplier: offer.supplier || (offer.isExternal ? 'AutoEuro' : 'Protek'),
isExternal: offer.isExternal || false,
image: image,
});
// Показываем уведомление о добавлении
alert(`Товар "${brand} ${article}" добавлен в корзину (${quantity} шт.)`);
};
// Обработчик клика по сердечку
const handleFavoriteClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isItemFavorite) {
// Создаем ID для удаления
const id = `${offers[0]?.productId || offers[0]?.offerKey || ''}:${article}:${brand}`;
removeFromFavorites(id);
} else {
// Добавляем в избранное
const bestOffer = offers[0]; // Берем первое предложение как лучшее
const numericPrice = bestOffer ? parsePrice(bestOffer.price) : 0;
addToFavorites({
productId: bestOffer?.productId,
offerKey: bestOffer?.offerKey,
name: name,
brand: brand,
article: article,
price: numericPrice,
currency: bestOffer?.currency || 'RUB',
image: image
});
}
};
if (isLoadingOffers) {
return (
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-48-copy items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600"></div>
<p className="mt-2 text-gray-500">Загрузка предложений...</p>
</div>
</div>
);
}
if (!offers || offers.length === 0) {
return (
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
{image && (
<div className="div-block-20">
<img src={image} loading="lazy" alt={name} className="image-10" />
</div>
)}
</div>
<div className="w-layout-vflex flex-block-48-copy items-center justify-center">
{onLoadOffers ? (
<button
onClick={onLoadOffers}
className="bg-blue-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Загрузить предложения
</button>
) : (
<p className="text-gray-500">Предложений не найдено.</p>
)}
</div>
</div>
);
}
return (
<>
<div className="w-layout-hflex core-product-search-s1">
<div className="w-layout-vflex core-product-s1">
<div className="w-layout-vflex flex-block-47">
<div className="div-block-19">
<img src="/images/info.svg" loading="lazy" alt="info" className="image-9" />
</div>
<div className="w-layout-vflex flex-block-50">
<div className="w-layout-hflex flex-block-79">
<h3 className="heading-10 name">{brand}</h3>
<h3 className="heading-10">{article}</h3>
<div
className="favorite-icon w-embed"
onClick={handleFavoriteClick}
style={{ cursor: 'pointer', marginLeft: '10px', color: isItemFavorite ? '#e53935' : undefined }}
>
<svg width="24" height="24" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z"
fill={isItemFavorite ? "#e53935" : "currentColor"}
/>
</svg>
</div>
</div>
<div className="text-block-21">{name}</div>
</div>
</div>
{image && (
<div className="div-block-20">
<img src={image} loading="lazy" alt={name} className="image-10" />
</div>
)}
</div>
<div className="w-layout-vflex flex-block-48-copy">
<div className="w-layout-hflex sort-list-s1">
<div className="w-layout-hflex flex-block-49">
<div className="sort-item first">Наличие</div>
<div className="sort-item">Доставка</div>
</div>
<div className="sort-item price">Цена</div>
</div>
<div className="w-layout-vflex product-list-search-s1">
{displayedOffers.map((offer, idx) => {
const isLast = idx === displayedOffers.length - 1;
return (
<div
className="w-layout-hflex product-item-search-s1"
key={idx}
style={isLast ? { borderBottom: 'none' } : undefined}
>
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-s1">
<div className="pcs-search-s1">{offer.pcs}</div>
<div className="pcs-search">{offer.days}</div>
</div>
<div className="w-layout-hflex info-block-product-card-search-s1">
{offer.recommended && (
<>
<div className="w-layout-hflex item-recommend">
<img src="/images/ri_refund-fill.svg" loading="lazy" alt="" />
</div>
<div className="text-block-25-s1">Рекомендуем</div>
</>
)}
</div>
<div className="price-s1">{offer.price}</div>
</div>
<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"
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) - 1).toString())}
style={{ cursor: 'pointer' }}
aria-label="Уменьшить количество"
>
<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 className="input-pcs">
<input
type="number"
min={1}
max={parseStock(offer.pcs)}
value={quantities[idx] || 1}
onChange={e => handleQuantityInput(idx, e.target.value)}
className="text-block-26 w-full text-center outline-none"
aria-label="Количество"
/>
</div>
<button
type="button"
className="minus-plus"
onClick={() => handleQuantityInput(idx, ((quantities[idx] || 1) + 1).toString())}
style={{ cursor: 'pointer' }}
aria-label="Увеличить количество"
>
<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>
<button
type="button"
onClick={() => handleAddToCart(offer, idx)}
className="button-icon w-inline-block"
style={{ cursor: 'pointer' }}
aria-label="Добавить в корзину"
>
<div className="div-block-26">
<img loading="lazy" src="/images/cart_icon.svg" alt="В корзину" className="image-11" />
</div>
</button>
</div>
</div>
</div>
);
})}
</div>
{hasMoreOffers || visibleOffersCount > INITIAL_OFFERS_LIMIT ? (
<div
className="w-layout-hflex show-more-search"
onClick={() => {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
}}
style={{ cursor: 'pointer' }}
tabIndex={0}
role="button"
aria-label={hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть предложения'}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
if (hasMoreOffers) {
setVisibleOffersCount(prev => Math.min(prev + 10, offers.length));
} else {
setVisibleOffersCount(INITIAL_OFFERS_LIMIT);
}
}
}}
>
<div className="text-block-27">
{hasMoreOffers ? `Еще ${offers.length - visibleOffersCount} предложений` : 'Скрыть'}
</div>
<img
src="/images/arrow_drop_down.svg"
loading="lazy"
alt=""
className={`transition-transform duration-200 ${!hasMoreOffers ? 'rotate-180' : ''}`}
/>
</div>
) : null}
</div>
</div>
</>
);
};
export default CoreProductCard;

View File

@ -0,0 +1,62 @@
import React from "react";
import { useFavorites } from "@/contexts/FavoritesContext";
const FavoriteInfo = () => {
const { favorites } = useFavorites();
const getCountText = (count: number) => {
const lastDigit = count % 10;
const lastTwoDigits = count % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
return `${count} товаров`;
}
if (lastDigit === 1) {
return `${count} товар`;
} else if (lastDigit >= 2 && lastDigit <= 4) {
return `${count} товара`;
} else {
return `${count} товаров`;
}
};
return (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="/catalog" className="link-block w-inline-block">
<div>Каталог</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Избранное</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Избранное</h1>
<div className="text-block-4">
{favorites.length > 0
? `Вы добавили ${getCountText(favorites.length)} в избранное`
: 'Нет избранных товаров'
}
</div>
</div>
<div className="w-layout-hflex flex-block-11">
<img src="/images/qwestions.svg" loading="lazy" alt="" className="image-4" />
<div className="text-block-5">Как пользоваться избранным?</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default FavoriteInfo;

View File

@ -0,0 +1,283 @@
import React, { useState } from "react";
import Filters, { FilterConfig } from "./Filters";
import { useFavorites } from "@/contexts/FavoritesContext";
interface FavoriteListProps {
filters: FilterConfig[];
filterValues?: {[key: string]: any};
onFilterChange?: (type: string, value: any) => void;
searchQuery?: string;
onSearchChange?: (value: string) => void;
sortBy?: 'name' | 'brand' | 'price' | 'date';
sortOrder?: 'asc' | 'desc';
onSortChange?: (sortBy: 'name' | 'brand' | 'price' | 'date') => void;
onSortOrderChange?: (sortOrder: 'asc' | 'desc') => void;
}
const FavoriteList: React.FC<FavoriteListProps> = ({
filters,
filterValues = {},
onFilterChange,
searchQuery = '',
onSearchChange,
sortBy = 'date',
sortOrder = 'desc',
onSortChange,
onSortOrderChange
}) => {
const { favorites, removeFromFavorites, clearFavorites } = useFavorites();
const handleRemove = (id: string) => {
removeFromFavorites(id);
};
const handleRemoveAll = () => {
clearFavorites();
};
// Состояние для hover на иконке удаления всех
const [removeAllHover, setRemoveAllHover] = useState(false);
// Состояние для hover на корзине отдельного товара
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Применяем фильтры к избранным товарам
const filteredFavorites = favorites.filter(item => {
// Фильтр по поисковому запросу
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesSearch =
item.name.toLowerCase().includes(query) ||
item.brand.toLowerCase().includes(query) ||
item.article.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// Фильтр по производителю
const selectedBrands = filterValues['Производитель'] || [];
if (selectedBrands.length > 0 && !selectedBrands.includes(item.brand)) {
return false;
}
// Фильтр по цене
const priceRange = filterValues['Цена (₽)'];
if (priceRange && item.price) {
const [minPrice, maxPrice] = priceRange;
if (item.price < minPrice || item.price > maxPrice) {
return false;
}
}
return true;
});
// Применяем сортировку
const sortedFavorites = [...filteredFavorites].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'brand':
comparison = a.brand.localeCompare(b.brand);
break;
case 'price':
const priceA = a.price || 0;
const priceB = b.price || 0;
comparison = priceA - priceB;
break;
case 'date':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
default:
comparison = 0;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const formatPrice = (price?: number, currency?: string) => {
if (!price) {
return 'Цена не указана';
}
if (currency === 'RUB') {
return `от ${price.toLocaleString('ru-RU')}`;
}
return `от ${price} ${currency || ''}`;
};
const handleSortClick = (newSortBy: 'name' | 'brand' | 'price' | 'date') => {
if (sortBy === newSortBy) {
// Если тот же столбец, меняем порядок
onSortOrderChange?.(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
// Если новый столбец, устанавливаем его и порядок по умолчанию
onSortChange?.(newSortBy);
onSortOrderChange?.(newSortBy === 'price' ? 'asc' : 'desc');
}
};
// SVG-галочки для сортировки — всегда видны у всех колонок
const getSortIcon = (columnSort: 'name' | 'brand' | 'price' | 'date') => {
const isActive = sortBy === columnSort;
const isAsc = sortOrder === 'asc';
const color = isActive ? 'var(--_button---primary)' : '#94a3b8';
return (
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" style={{marginLeft: 2}}>
<path
d={isActive ? (isAsc ? 'M6 12l4-4 4 4' : 'M6 8l4 4 4-4') : 'M6 8l4 4 4-4'}
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
return (
<div className="w-layout-hflex core-product-card">
<Filters
filters={filters}
onFilterChange={onFilterChange || (() => {})}
filterValues={filterValues}
searchQuery={searchQuery}
onSearchChange={onSearchChange || (() => {})}
/>
<div className="w-layout-vflex flex-block-48">
<div className="w-layout-vflex product-list-cart">
{/* Информация о результатах фильтрации */}
{(searchQuery || Object.values(filterValues).some(v => Array.isArray(v) ? v.length > 0 : v)) && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-700">
Найдено {sortedFavorites.length} из {favorites.length} товаров
{searchQuery && (
<span> по запросу "{searchQuery}"</span>
)}
</div>
</div>
)}
<div className="w-layout-hflex heading-list">
<div className="w-layout-hflex flex-block-61">
<div
className="sort-item-brand cursor-pointer hover:text-blue-600 flex items-center gap-1"
onClick={() => handleSortClick('brand')}
>
Производитель {getSortIcon('brand')}
</div>
<div className="sort-item-brand-copy">Артикул</div>
<div
className="sort-item-name cursor-pointer hover:text-blue-600 flex items-center gap-1"
onClick={() => handleSortClick('name')}
>
Наименование {getSortIcon('name')}
</div>
<div className="sort-item-comments">Комментарий</div>
</div>
{favorites.length > 0 && (
<div
className="w-layout-hflex select-all-block"
onClick={handleRemoveAll}
style={{ cursor: 'pointer', color: removeAllHover ? '#ec1c24' : undefined, transition: 'color 0.2s' }}
onMouseEnter={() => setRemoveAllHover(true)}
onMouseLeave={() => setRemoveAllHover(false)}
>
<div className="text-block-30">Удалить</div>
<svg
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="image-13"
>
<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={removeAllHover ? "#ec1c24" : "#D0D0D0"}
style={{ transition: 'fill 0.2s' }}
/>
</svg>
</div>
)}
</div>
{sortedFavorites.map((item) => {
return (
<div className="div-block-21" key={item.id}>
<div className="w-layout-hflex favorite-item">
<div className="w-layout-hflex info-block-search">
<div className="w-layout-hflex block-detail">
<h4 className="brandname">{item.brand}</h4>
<h4 className="brandname">{item.article}</h4>
<div className="productname_f">{item.name}</div>
</div>
<div className="comments_f w-form">
<form className="form-copy">
<input
className="text-field-copy w-input"
maxLength={256}
name="Search-5"
data-name="Search 5"
placeholder="Комментарий"
type="text"
id={`Search-5-${item.id}`}
/>
</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-copy">
<h4
className="heading-9-copy-copy cursor-pointer hover:text-blue-600 flex items-center gap-1"
onClick={() => handleSortClick('price')}
>
{formatPrice(item.price, item.currency)} {getSortIcon('price')}
</h4>
<div className="w-layout-hflex control-element-copy">
{/* Корзина с hover-эффектом для удаления товара */}
<span
style={{ display: 'inline-flex' }}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
>
<svg
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="image-13"
style={{ cursor: 'pointer', transition: 'fill 0.2s' }}
onClick={() => handleRemove(item.id)}
>
<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={hoveredId === item.id ? "#ec1c24" : "#D0D0D0"}
style={{ transition: 'fill 0.2s' }}
/>
</svg>
</span>
</div>
</div>
</div>
</div>
);
})}
{sortedFavorites.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: '#888' }}>
{favorites.length === 0 ? 'Нет избранных товаров' : 'Нет товаров, соответствующих фильтрам'}
</div>
)}
</div>
</div>
</div>
);
};
export default FavoriteList;

105
src/components/Filters.tsx Normal file
View File

@ -0,0 +1,105 @@
import React from "react";
import FilterDropdown from "./filters/FilterDropdown";
import FilterRange from "./filters/FilterRange";
// Типизация для фильтра
export type FilterConfig =
| {
type: "dropdown";
title: string;
options: string[];
multi?: boolean;
showAll?: boolean;
defaultOpen?: boolean;
hasMore?: boolean;
onShowMore?: () => void;
}
| {
type: "range";
title: string;
min: number;
max: number;
};
interface FiltersProps {
filters: FilterConfig[];
onFilterChange: (type: string, value: any) => void;
filterValues?: {
[key: string]: any;
};
searchQuery?: string;
onSearchChange?: (value: string) => void;
isLoading?: boolean;
}
const Filters: React.FC<FiltersProps> = ({
filters,
onFilterChange,
filterValues = {},
searchQuery = '',
onSearchChange,
isLoading = false
}) => (
<div className="w-layout-vflex flex-block-12">
{/* Поиск - показываем только если есть обработчик */}
{onSearchChange && (
<div className="div-block-2">
<div className="form-block">
<form className="form" onSubmit={e => e.preventDefault()}>
<a href="#" className="link-block-3 w-inline-block">
<span className="code-embed-6 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.8834 13.8833" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</a>
<input
className="text-field w-input"
maxLength={256}
name="Search"
placeholder="Введите код запчасти или VIN номер автомобиля"
type="text"
id="Search-4"
required
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
/>
</form>
</div>
</div>
)}
{/* Фильтры из пропса */}
{filters.map((filter, idx) => {
if (filter.type === "dropdown") {
return (
<FilterDropdown
key={filter.title + idx}
title={filter.title}
options={filter.options}
multi={filter.multi}
showAll={filter.showAll}
defaultOpen={filter.defaultOpen}
selectedValues={(filterValues && filterValues[filter.title]) || []}
onChange={(values) => onFilterChange(filter.title, values)}
/>
);
}
if (filter.type === "range") {
return (
<FilterRange
key={filter.title + idx}
title={filter.title}
min={filter.min}
max={filter.max}
value={(filterValues && filterValues[filter.title]) || null}
onChange={(value) => onFilterChange(filter.title, value)}
/>
);
}
return null;
})}
</div>
);
export default Filters;

View File

@ -0,0 +1,170 @@
import React, { useEffect, useState } from "react";
import FilterDropdown from "./filters/FilterDropdown";
import FilterRange from "./filters/FilterRange";
import type { FilterConfig } from "./Filters";
interface FiltersPanelMobileProps {
filters: FilterConfig[];
open: boolean;
onClose: () => void;
onApply?: () => void;
searchQuery: string;
onSearchChange: (value: string) => void;
filterValues?: {[key: string]: any};
onFilterChange?: (type: string, value: any) => void;
}
const FiltersPanelMobile: React.FC<FiltersPanelMobileProps> = ({
filters,
open,
onClose,
onApply,
searchQuery,
onSearchChange,
filterValues = {},
onFilterChange
}) => {
const [localFilterValues, setLocalFilterValues] = useState<{[key: string]: any}>({});
// Синхронизируем локальные значения с внешними при открытии панели
useEffect(() => {
if (open) {
setLocalFilterValues(filterValues);
}
}, [open, filterValues]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleLocalFilterChange = (type: string, value: any) => {
setLocalFilterValues(prev => ({
...prev,
[type]: value
}));
};
const handleApply = () => {
// Применяем все локальные фильтры к основному состоянию
Object.entries(localFilterValues).forEach(([key, value]) => {
onFilterChange?.(key, value);
});
onApply?.();
};
const handleClearFilters = () => {
setLocalFilterValues({});
onSearchChange('');
// Сбрасываем фильтры в родительском компоненте
Object.keys(filterValues).forEach(key => {
onFilterChange?.(key, []);
});
};
return (
<>
{/* Overlay */}
<div
className={`filters-panel-mobile-overlay${open ? " open" : ""}`}
onClick={onClose}
style={{ zIndex: 1000 }}
/>
{/* Drawer */}
<div
className={`filters-panel-mobile-drawer${open ? " open" : ""}`}
aria-hidden={!open}
style={{ zIndex: 1001 }}
>
{/* Header */}
<div className="filters-panel-mobile-header">
<span className="filters-panel-mobile-title">Фильтры</span>
<button className="filters-panel-mobile-close" onClick={onClose} aria-label="Закрыть фильтры">&times;</button>
</div>
{/* Search */}
<div className="filters-panel-mobile-content-search">
<div className="div-block-2">
<div className="form-block">
<form className="form" onSubmit={e => e.preventDefault()}>
<a href="#" className="link-block-3 w-inline-block">
<span className="code-embed-6 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.8834 13.8833" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</a>
<input
className="text-field w-input"
maxLength={256}
name="Search"
placeholder="Поиск по названию, бренду или артикулу"
type="text"
id="Search-4"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
/>
</form>
</div>
</div>
</div>
{/* Filters */}
<div className="filters-panel-mobile-content">
{filters.map((filter, idx) => {
if (filter.type === "dropdown") {
return (
<FilterDropdown
key={filter.title + idx}
title={filter.title}
options={filter.options}
multi={filter.multi}
showAll={filter.showAll}
selectedValues={localFilterValues[filter.title] || []}
onChange={(values) => handleLocalFilterChange(filter.title, values)}
isMobile
/>
);
}
if (filter.type === "range") {
return (
<FilterRange
key={filter.title + idx}
title={filter.title}
min={filter.min}
max={filter.max}
value={localFilterValues[filter.title] || null}
onChange={(value) => handleLocalFilterChange(filter.title, value)}
isMobile
/>
);
}
return null;
})}
</div>
{/* Apply Button */}
<div className="flex gap-2 p-4">
<button
className="filters-panel-mobile-clear flex-1 bg-gray-200 text-gray-700 py-3 px-4 rounded-lg font-medium"
onClick={handleClearFilters}
>
Сбросить
</button>
<button
className="filters-panel-mobile-apply flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium"
onClick={handleApply}
>
Показать
</button>
</div>
</div>
</>
);
};
export default FiltersPanelMobile;

View File

@ -0,0 +1,83 @@
import React from "react";
import FilterDropdown from "./filters/FilterDropdown";
import FilterRange from "./filters/FilterRange";
import { FilterConfig } from "./Filters";
interface FiltersWithSearchProps {
filters: FilterConfig[];
searchQuery: string;
onSearchChange: (query: string) => void;
selectedFilters?: Record<string, string[]>;
onFilterChange?: (filterTitle: string, values: string[]) => void;
}
const FiltersWithSearch: React.FC<FiltersWithSearchProps> = ({
filters,
searchQuery,
onSearchChange,
selectedFilters = {},
onFilterChange
}) => {
const emptyArray: string[] = []; // Стабильная ссылка на пустой массив
return (
<div className="w-layout-vflex flex-block-12">
{/* Поиск всегда первый */}
<div className="div-block-2">
<div className="form-block">
<form className="form" onSubmit={e => e.preventDefault()}>
<a href="#" className="link-block-3 w-inline-block">
<span className="code-embed-6 w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 17.5L13.8834 13.8833" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</a>
<input
className="text-field w-input"
maxLength={256}
name="Search"
placeholder="Поиск по названию, артикулу, бренду..."
type="text"
id="Search-4"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</form>
</div>
</div>
{/* Фильтры из пропса */}
{filters.map((filter, idx) => {
if (filter.type === "dropdown" && filter.options) {
return (
<FilterDropdown
key={filter.title + idx}
title={filter.title}
options={filter.options}
multi={filter.multi}
showAll={filter.showAll}
hasMore={(filter as any).hasMore}
onShowMore={(filter as any).onShowMore}
selectedValues={selectedFilters[filter.title] || emptyArray}
onChange={(values: string[]) => onFilterChange?.(filter.title, values)}
/>
);
}
if (filter.type === "range") {
return (
<FilterRange
key={filter.title + idx}
title={filter.title}
min={filter.min}
max={filter.max}
/>
);
}
return null;
})}
</div>
);
};
export default FiltersWithSearch;

104
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,104 @@
const Footer = () => (
<footer className="section-2">
<div className="w-layout-blockcontainer container footer w-container">
<div className="w-layout-vflex flex-block-20">
<div className="w-layout-hflex flex-block-18-copy-copy">
<div className="w-layout-vflex flex-block-19">
<img src="/images/logo_gor.svg" loading="lazy" width="320" alt="" className="image-15" />
<div className="text-block-15">Пн-Пт 9:00 18:00, <br />Сб 10:00 16:00, Вс выходной</div>
<a href="#" className="link-block-5 w-inline-block">
<div className="w-layout-hflex flex-block-3">
<img src="/images/phone_icon.svg" loading="lazy" alt="" className="image-23" />
<div className="phone">+7 (495) 260-20-60</div>
</div>
</a>
</div>
<div className="w-layout-hflex flex-block-22">
<div className="w-layout-vflex flex-block-23">
<div className="w-layout-hflex flex-block-86">
<div className="text-block-17">Покупателям</div>
</div>
<a href="#" className="link">Оплата</a>
<a href="#" className="link">Возврат</a>
<a href="#" className="link">Доставка</a>
<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>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Оплата</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Возврат</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Доставка</a>
</nav>
</div>
</div>
<div className="w-layout-vflex flex-block-23">
<div className="w-layout-hflex flex-block-86">
<div className="text-block-17">Сотрудничество</div>
</div>
<a href="#" className="link">Поставщикам</a>
<a href="#" className="link">Дилерская сеть</a>
<a href="#" className="link">Оптовым покупателям</a>
<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>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Дилерская сеть</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Оптовым покупателям</a>
</nav>
</div>
</div>
<div className="w-layout-vflex flex-block-23">
<div className="w-layout-hflex flex-block-86">
<div className="text-block-17">PROTEK</div>
</div>
<a href="#" className="link">Вакансии</a>
<a href="#" className="link">О компании</a>
<a href="#" className="link">Контакты</a>
<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>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Вакансии</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">О компании</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Контакты</a>
</nav>
</div>
</div>
<div className="w-layout-vflex flex-block-23">
<div className="w-layout-hflex flex-block-86">
<div className="text-block-17">Оферта</div>
</div>
<a href="#" className="link">Поставщикам</a>
<a href="#" className="link">Поставщикам</a>
<a href="#" className="link">Поставщикам</a>
<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>
<nav className="dropdown-list-3 w-dropdown-list">
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
<a href="#" className="dropdown-link-2 w-dropdown-link">Поставщикам</a>
</nav>
</div>
</div>
</div>
</div>
<div className="w-layout-hflex flex-block-18-copy">
<div className="w-layout-hflex flex-block-21"><img src="/images/mastercard.svg" loading="lazy" alt="" /><img src="/images/visa.svg" loading="lazy" alt="" /><img src="/images/mir.svg" loading="lazy" alt="" /></div>
<div className="text-block-16">© 2025 Protek. Все права защищены.</div>
</div>
</div>
</div>
</footer>
);
export default Footer;

View File

@ -0,0 +1,204 @@
import React, { useState } from 'react';
import { useLazyQuery } from '@apollo/client';
import { LaximoFulltextSearchResult, LaximoFulltextDetail, LaximoOEMResult } from '@/types/laximo';
import { SEARCH_LAXIMO_FULLTEXT, SEARCH_LAXIMO_OEM } from '@/lib/graphql';
import PartDetailCard from './PartDetailCard';
interface FulltextSearchSectionProps {
catalogCode: string;
vehicleId: string;
ssd: string;
}
const FulltextSearchSection: React.FC<FulltextSearchSectionProps> = ({
catalogCode,
vehicleId,
ssd
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [executeSearch, { data, loading, error }] = useLazyQuery(SEARCH_LAXIMO_FULLTEXT, {
errorPolicy: 'all'
});
const handleSearch = () => {
if (!searchQuery.trim()) {
return;
}
if (!ssd || ssd.trim() === '') {
console.error('SSD обязателен для поиска по названию');
return;
}
executeSearch({
variables: {
catalogCode,
vehicleId,
searchQuery: searchQuery.trim(),
ssd
}
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const searchResults: LaximoFulltextSearchResult | null = data?.laximoFulltextSearch || null;
return (
<div className="space-y-6">
{/* Форма поиска */}
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Поиск деталей по названию
</h3>
<div className="flex gap-3">
<div className="flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Введите название детали (например: фильтр масляный)"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<button
onClick={handleSearch}
disabled={!searchQuery.trim() || loading || !ssd || ssd.trim() === ''}
className="px-6 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Поиск...' : 'Найти'}
</button>
</div>
<p className="text-sm text-gray-600 mt-2">
Введите название детали для поиска в каталоге.
Попробуйте русские термины: "фильтр", "тормозной", "амортизатор"
или английские: "filter", "brake", "shock"
</p>
{(!ssd || ssd.trim() === '') && (
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex">
<svg className="h-5 w-5 text-yellow-400 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Полнотекстовый поиск недоступен
</h3>
<p className="text-sm text-yellow-700 mt-1">
Для поиска по названию деталей необходимо сначала выбрать конкретный автомобиль через поиск по VIN или мастер подбора.
</p>
</div>
</div>
</div>
)}
</div>
{/* Ошибка */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Ошибка поиска
</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error.message}</p>
</div>
</div>
</div>
</div>
)}
{/* Результаты поиска */}
{searchResults && (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Результаты поиска: "{searchQuery}"
</h3>
<p className="text-sm text-gray-600 mt-1">
Найдено {searchResults.details.length} деталей
</p>
</div>
{searchResults.details.length > 0 ? (
<div className="space-y-4 p-6">
<div className="text-sm text-blue-700 bg-blue-50 border border-blue-200 rounded-lg p-3">
💡 Нажмите на карточку детали для поиска предложений и цен. Используйте кнопку "Показать применимость" для просмотра применения в автомобиле.
</div>
{searchResults.details.map((detail, index) => (
<PartDetailCard
key={`${detail.oem}-${index}`}
oem={detail.oem}
name={detail.name}
brand={detail.brand}
description={detail.description}
catalogCode={catalogCode}
vehicleId={vehicleId}
ssd={ssd}
/>
))}
</div>
) : (
<div className="px-6 py-8 text-center">
<svg className="w-12 h-12 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-gray-600">
По запросу "{searchQuery}" ничего не найдено
</p>
<p className="text-sm text-gray-500 mt-1">
Попробуйте изменить поисковый запрос
</p>
</div>
)}
</div>
)}
{/* Подсказки */}
{!searchResults && !loading && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Советы по поиску
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc pl-5 space-y-1">
<li>Используйте ключевые слова: "фильтр", "масляный", "воздушный", "тормозной"</li>
<li>Попробуйте английские термины: "filter", "oil", "air", "brake", "shock"</li>
<li>Можно использовать частичные названия: "амортизатор", "сцепление"</li>
<li>Поиск ведется по названиям деталей в оригинальном каталоге</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default FulltextSearchSection;

View File

@ -0,0 +1,334 @@
import React, { useState } from 'react';
import { useRouter } from 'next/router';
import { useQuery } from '@apollo/client';
import { GET_LAXIMO_QUICK_DETAIL } from '@/lib/graphql';
import { LaximoQuickDetail, LaximoUnit, LaximoDetail } from '@/types/laximo';
import BrandSelectionModal from './BrandSelectionModal';
interface GroupDetailsSectionProps {
catalogCode: string;
vehicleId: string;
quickGroupId: string;
groupName: string;
ssd: string;
onBack: () => void;
}
interface DetailCardProps {
detail: LaximoDetail;
}
const DetailCard: React.FC<DetailCardProps> = ({ detail }) => {
const [isBrandModalOpen, setIsBrandModalOpen] = useState(false);
const handleDetailClick = () => {
const articleNumber = detail.oem;
console.log('🔍 Клик по детали для выбора бренда:', { articleNumber, name: detail.name });
setIsBrandModalOpen(true);
};
const handleCloseBrandModal = () => {
setIsBrandModalOpen(false);
};
const handleAddToCart = () => {
// TODO: Реализовать добавление в корзину
console.log('Добавить в корзину:', detail.oem);
alert(`Функция "Добавить в корзину" будет реализована в ближайшее время.\еталь: ${detail.name}\nOEM: ${detail.oem}`);
};
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="text-lg font-semibold text-gray-900 mb-1">{detail.name}</h4>
<div className="flex items-center space-x-2 text-sm text-gray-600">
<span className="font-medium">OEM:</span>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-mono">
{detail.oem}
</span>
</div>
</div>
{detail.brand && (
<div className="flex-shrink-0 ml-4">
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">
{detail.brand}
</span>
</div>
)}
</div>
{detail.description && (
<p className="text-gray-700 text-sm mb-3">{detail.description}</p>
)}
{detail.applicablemodels && (
<div className="mb-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Применимые модели:
</span>
<p className="text-sm text-gray-700 mt-1">{detail.applicablemodels}</p>
</div>
)}
{detail.note && (
<div className="mb-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Примечание:
</span>
<p className="text-sm text-gray-700 mt-1">{detail.note}</p>
</div>
)}
{detail.attributes && detail.attributes.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-wrap gap-2">
{detail.attributes.map((attr, index) => (
<div key={index} className="text-xs">
<span className="text-gray-500">{attr.name || attr.key}:</span>
<span className="ml-1 text-gray-700">{attr.value}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-4 flex items-center justify-between">
<button
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
onClick={handleDetailClick}
>
Найти предложения
</button>
<button
className="text-gray-500 hover:text-gray-700 text-sm transition-colors"
onClick={handleAddToCart}
>
Добавить в корзину
</button>
</div>
<BrandSelectionModal
isOpen={isBrandModalOpen}
onClose={handleCloseBrandModal}
articleNumber={detail.oem}
detailName={detail.name}
/>
</div>
);
};
interface UnitSectionProps {
unit: LaximoUnit;
}
const UnitSection: React.FC<UnitSectionProps> = ({ unit }) => {
return (
<div className="mb-8">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">{unit.name}</h3>
{unit.code && (
<p className="text-sm text-gray-600 mt-1">Код: {unit.code}</p>
)}
{unit.description && (
<p className="text-sm text-gray-700 mt-2">{unit.description}</p>
)}
</div>
{unit.details && unit.details.length > 0 && (
<div className="text-sm text-gray-500">
{unit.details.length} {unit.details.length === 1 ? 'деталь' : 'деталей'}
</div>
)}
</div>
</div>
{unit.details && unit.details.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{unit.details.map((detail) => (
<DetailCard key={detail.detailid} detail={detail} />
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0H4m0 0l4-4m0 8l4-4" />
</svg>
<p>В этом узле пока нет доступных деталей</p>
</div>
)}
</div>
);
};
const GroupDetailsSection: React.FC<GroupDetailsSectionProps> = ({
catalogCode,
vehicleId,
quickGroupId,
groupName,
ssd,
onBack
}) => {
console.log('🔍 GroupDetailsSection получил параметры:', {
catalogCode,
vehicleId,
quickGroupId,
quickGroupIdType: typeof quickGroupId,
quickGroupIdLength: quickGroupId?.length,
groupName,
ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует'
});
const { data, loading, error } = useQuery<{ laximoQuickDetail: LaximoQuickDetail }>(
GET_LAXIMO_QUICK_DETAIL,
{
variables: {
catalogCode,
vehicleId,
quickGroupId,
ssd
},
skip: !catalogCode || !vehicleId || !quickGroupId || !ssd,
errorPolicy: 'all'
}
);
if (loading) {
return (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-4 text-lg text-gray-600">Загружаем детали группы...</p>
<p className="text-sm text-gray-500 mt-1">{groupName}</p>
</div>
);
}
if (error) {
console.error('Ошибка загрузки деталей группы:', error);
// Определяем тип ошибки для показа соответствующего сообщения
const isInvalidParameterError = error.message.includes('E_INVALIDPARAMETER') ||
error.message.includes('INVALIDPARAMETER') ||
error.message.includes('QuickGroupId');
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
{isInvalidParameterError ? (
<>
<h3 className="text-lg font-medium text-red-600 mb-2">Неподдерживаемая группа</h3>
<p className="text-gray-600 mb-4">
Группа "{groupName}" (ID: {quickGroupId}) не поддерживается API Laximo для данного автомобиля.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6 text-left max-w-md mx-auto">
<h4 className="font-medium text-yellow-800 mb-2">💡 Рекомендации:</h4>
<ul className="text-sm text-yellow-700 space-y-1">
<li> Попробуйте использовать "Категории каталога"</li>
<li> Воспользуйтесь поиском по артикулу</li>
<li> Попробуйте поиск по названию детали</li>
<li> Используйте "Группы быстрого поиска"</li>
</ul>
</div>
</>
) : (
<>
<h3 className="text-lg font-medium text-red-600 mb-2">Ошибка загрузки деталей</h3>
<p className="text-gray-600 mb-4">Не удалось загрузить детали для группы "{groupName}"</p>
<p className="text-sm text-gray-500 mb-4">Ошибка: {error.message}</p>
</>
)}
<button
onClick={onBack}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors"
>
Вернуться к группам
</button>
</div>
);
}
if (!data?.laximoQuickDetail) {
return (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0H4m0 0l4-4m0 8l4-4" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-600 mb-2">Детали не найдены</h3>
<p className="text-gray-500 mb-4">Для группы "{groupName}" не найдено доступных деталей</p>
<button
onClick={onBack}
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"
>
Вернуться к группам
</button>
</div>
);
}
const quickDetail = data.laximoQuickDetail;
const totalDetails = quickDetail.units?.reduce((total, unit) => total + (unit.details?.length || 0), 0) || 0;
return (
<div className="space-y-6">
{/* Шапка */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="flex items-center text-blue-600 hover:text-blue-800 mb-2 transition-colors"
>
<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="M15 19l-7-7 7-7" />
</svg>
Вернуться к группам
</button>
<h2 className="text-2xl font-bold text-gray-900">{quickDetail.name || groupName}</h2>
<p className="text-gray-600 mt-1">ID группы: {quickDetail.quickgroupid}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Найдено</p>
<p className="text-2xl font-bold text-blue-600">{totalDetails}</p>
<p className="text-sm text-gray-500">{totalDetails === 1 ? 'деталь' : 'деталей'}</p>
</div>
</div>
</div>
{/* Список узлов и деталей */}
{quickDetail.units && quickDetail.units.length > 0 ? (
<div>
{quickDetail.units.map((unit) => (
<UnitSection key={unit.unitid} unit={unit} />
))}
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0H4m0 0l4-4m0 8l4-4" />
</svg>
</div>
<h3 className="text-xl font-medium text-gray-600 mb-2">Узлы не найдены</h3>
<p className="text-gray-500">В этой группе пока нет доступных узлов и деталей</p>
</div>
)}
</div>
);
};
export default GroupDetailsSection;

742
src/components/Header.tsx Normal file
View File

@ -0,0 +1,742 @@
import React, { useState, useEffect, useRef } from "react";
import { useRouter } from "next/router";
import { useLazyQuery } from '@apollo/client';
import BottomHead from "@/components/BottomHead";
import AuthModal from "@/components/auth/AuthModal";
import type { Client } from "@/types/auth";
import { useIsClient } from "@/lib/useIsomorphicLayoutEffect";
import { FIND_LAXIMO_VEHICLE, DOC_FIND_OEM, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL, FIND_LAXIMO_VEHICLES_BY_PART_NUMBER } from '@/lib/graphql';
import { LaximoVehicleSearchResult, LaximoDocFindOEMResult, LaximoVehiclesByPartResult } from '@/types/laximo';
import Link from "next/link";
import CartButton from './CartButton';
interface HeaderProps {
onOpenAuthModal?: () => void;
}
const Header: React.FC<HeaderProps> = ({ onOpenAuthModal = () => console.log('Auth modal action not provided') }) => {
const [menuOpen, setMenuOpen] = useState(false);
const [currentUser, setCurrentUser] = useState<Client | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<LaximoVehicleSearchResult[]>([]);
const [showResults, setShowResults] = useState(false);
const [oemSearchResults, setOemSearchResults] = useState<LaximoDocFindOEMResult | null>(null);
const [vehiclesByPartResults, setVehiclesByPartResults] = useState<LaximoVehiclesByPartResult | null>(null);
const [searchType, setSearchType] = useState<'vin' | 'oem' | 'plate' | 'text'>('text');
const [oemSearchMode, setOemSearchMode] = useState<'parts' | 'vehicles'>('parts');
const router = useRouter();
const searchFormRef = useRef<HTMLFormElement>(null);
const searchDropdownRef = useRef<HTMLDivElement>(null);
const isClient = useIsClient();
// Эффект для восстановления поискового запроса из URL
useEffect(() => {
if (!router.isReady) return;
// Если мы находимся на странице 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') {
setSearchQuery(article);
}
}
// Если мы находимся на странице search, восстанавливаем поисковый запрос
else if (router.pathname === '/search') {
const { q } = router.query;
if (q && typeof q === 'string') {
setSearchQuery(q);
}
}
// Если мы находимся на странице vehicle-search-results, восстанавливаем поисковый запрос
else if (router.pathname === '/vehicle-search-results') {
const { q } = router.query;
if (q && typeof q === 'string') {
setSearchQuery(q);
}
}
// Для других страниц очищаем поисковый запрос
else {
setSearchQuery('');
}
}, [router.isReady, router.pathname, router.query]);
// Query для поиска по артикулу через Doc FindOEM
const [findOEMParts] = useLazyQuery(DOC_FIND_OEM, {
onCompleted: (data) => {
const result = data.laximoDocFindOEM;
console.log('🔍 Найдено деталей по артикулу:', result?.details?.length || 0);
setOemSearchResults(result);
setSearchResults([]);
setIsSearching(false);
setShowResults(true);
},
onError: (error) => {
console.error('❌ Ошибка поиска по артикулу:', error);
setOemSearchResults(null);
setSearchResults([]);
setIsSearching(false);
setShowResults(true);
}
});
// Query для поиска автомобилей по артикулу
const [findVehiclesByPartNumber] = useLazyQuery(FIND_LAXIMO_VEHICLES_BY_PART_NUMBER, {
onCompleted: (data) => {
const result = data.laximoFindVehiclesByPartNumber;
console.log('🔍 Найдено автомобилей по артикулу:', result?.totalVehicles || 0);
setVehiclesByPartResults(result);
setSearchResults([]);
setOemSearchResults(null);
setIsSearching(false);
setShowResults(true);
},
onError: (error) => {
console.error('❌ Ошибка поиска автомобилей по артикулу:', error);
setVehiclesByPartResults(null);
setSearchResults([]);
setOemSearchResults(null);
setIsSearching(false);
setShowResults(true);
}
});
// Закрытие результатов при клике вне области
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchDropdownRef.current && !searchDropdownRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Проверяем авторизацию при загрузке компонента (только на клиенте)
useEffect(() => {
if (!isClient) return;
const token = localStorage.getItem('authToken');
const userData = localStorage.getItem('userData');
if (token && userData) {
try {
const user = JSON.parse(userData);
setCurrentUser(user);
} catch (error) {
console.error('Ошибка парсинга данных пользователя:', error);
localStorage.removeItem('authToken');
localStorage.removeItem('userData');
}
}
}, [isClient]);
useEffect(() => {
const bottomHead = document.querySelector('.bottom_head');
if (!bottomHead) return;
const onScroll = () => {
if (window.scrollY > 0) {
bottomHead.classList.add('scrolled');
} else {
bottomHead.classList.remove('scrolled');
}
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Скрытие top_head при скролле
useEffect(() => {
const topHead = document.querySelector('.top_head');
if (!topHead) return;
const onScroll = () => {
if (window.scrollY > 0) {
topHead.classList.add('hide-top-head');
} else {
topHead.classList.remove('hide-top-head');
}
};
window.addEventListener('scroll', onScroll);
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Проверяем, является ли строка VIN номером
const isVinNumber = (query: string): boolean => {
const cleanQuery = query.trim().toUpperCase();
// VIN состоит из 17 символов, содержит буквы и цифры, исключая I, O, Q
return /^[A-HJ-NPR-Z0-9]{17}$/.test(cleanQuery);
};
// Проверяем, является ли строка артикулом (OEM номером)
const isOEMNumber = (query: string): boolean => {
const cleanQuery = query.trim().toUpperCase();
// Артикул обычно содержит буквы и цифры, может содержать дефисы, точки
// Длина от 3 до 20 символов, не должен быть VIN номером или госномером
return /^[A-Z0-9\-\.]{3,20}$/.test(cleanQuery) && !isVinNumber(cleanQuery) && !isPlateNumber(cleanQuery);
};
// Проверяем, является ли строка госномером РФ
const isPlateNumber = (query: string): boolean => {
const cleanQuery = query.trim().toUpperCase().replace(/\s+/g, '');
// Российские госномера: А123БВ77, А123БВ777, АА123А77, АА123А777, А123АА77, А123АА777
// Убираем пробелы и дефисы для проверки
const platePatterns = [
/^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\d{2,3}$/, // А123БВ77, А123БВ777
/^[АВЕКМНОРСТУХ]{2}\d{3}[АВЕКМНОРСТУХ]\d{2,3}$/, // АА123А77, АА123А777
/^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\d{2,3}$/, // А123АА77, А123АА777
];
return platePatterns.some(pattern => pattern.test(cleanQuery));
};
// Определяем тип поиска
const getSearchType = (query: string): 'vin' | 'oem' | 'plate' | 'text' => {
if (isVinNumber(query)) return 'vin';
if (isPlateNumber(query)) return 'plate';
if (isOEMNumber(query)) return 'oem';
return 'text';
};
// Список популярных каталогов для поиска по VIN
const popularCatalogs = ['VW', 'AUDI', 'BMW', 'MERCEDES', 'FORD', 'TOYOTA', 'NISSAN', 'HYUNDAI', 'KIA'];
// Обработчик поиска по VIN больше не используется (переходим на отдельную страницу)
/*
const handleVinSearch = async (vin: string) => {
setIsSearching(true);
setSearchResults([]);
setOemSearchResults(null);
setVehiclesByPartResults(null);
console.log('🔍 Поиск по VIN глобально:', vin);
// Выполняем глобальный поиск без указания каталога
try {
await findVehicleInCatalogs({
variables: {
catalogCode: '', // Пустой код каталога для глобального поиска
vin: vin
}
});
} catch (error) {
console.error('❌ Ошибка глобального поиска по VIN:', error);
}
};
*/
const handleOEMSearch = async (oemNumber: string) => {
setIsSearching(true);
setSearchResults([]);
setOemSearchResults(null);
setVehiclesByPartResults(null);
console.log('🔍 Поиск по артикулу через Doc FindOEM:', oemNumber);
try {
await findOEMParts({
variables: {
oemNumber: oemNumber.trim().toUpperCase()
}
});
} catch (error) {
console.error('❌ Ошибка поиска по артикулу:', error);
}
};
// Обработчик поиска по госномеру больше не используется (переходим на отдельную страницу)
/*
const handlePlateSearch = async (plateNumber: string) => {
setIsSearching(true);
setSearchResults([]);
setOemSearchResults(null);
setVehiclesByPartResults(null);
// Очищаем госномер от пробелов и приводим к верхнему регистру
const cleanPlateNumber = plateNumber.trim().toUpperCase().replace(/\s+/g, '');
console.log('🔍 Поиск по госномеру:', cleanPlateNumber);
try {
await findVehicleByPlate({
variables: {
plateNumber: cleanPlateNumber
}
});
} catch (error) {
console.error('❌ Ошибка поиска по госномеру:', error);
}
};
*/
const handlePartVehicleSearch = async (partNumber: string) => {
setIsSearching(true);
setSearchResults([]);
setOemSearchResults(null);
setVehiclesByPartResults(null);
console.log('🔍 Поиск автомобилей по артикулу:', partNumber);
try {
await findVehiclesByPartNumber({
variables: {
partNumber: partNumber.trim().toUpperCase()
}
});
} catch (error) {
console.error('❌ Ошибка поиска автомобилей по артикулу:', error);
}
};
const handleSearchSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!searchQuery.trim()) return;
const currentSearchType = getSearchType(searchQuery);
setSearchType(currentSearchType);
if (currentSearchType === 'vin') {
// Переходим на страницу результатов поиска по VIN
router.push(`/vehicle-search-results?q=${encodeURIComponent(searchQuery.trim().toUpperCase())}`);
} else if (currentSearchType === 'plate') {
// Переходим на страницу результатов поиска по госномеру
router.push(`/vehicle-search-results?q=${encodeURIComponent(searchQuery.trim().toUpperCase())}`);
} else if (currentSearchType === 'oem') {
// Если это артикул, переходим на новую страницу поиска с режимом запчастей
router.push(`/search?q=${encodeURIComponent(searchQuery.trim().toUpperCase())}&mode=parts`);
} else {
// Для текстового поиска также перенаправляем на новую страницу поиска
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}&mode=parts`);
}
};
const handleVehicleSelect = (vehicle: LaximoVehicleSearchResult) => {
setShowResults(false);
// Переходим на страницу автомобиля - используем catalog вместо brand
const catalogCode = (vehicle as any).catalog || vehicle.brand.toLowerCase();
console.log('🚗 Переход на страницу автомобиля:', { catalogCode, vehicleId: vehicle.vehicleid, ssd: vehicle.ssd });
// Если переход происходит из поиска автомобилей по артикулу, передаем артикул для автоматического поиска
const currentOEMNumber = oemSearchMode === 'vehicles' ? searchQuery.trim().toUpperCase() : '';
const url = `/vehicle-search/${catalogCode}/${vehicle.vehicleid}?ssd=${vehicle.ssd || ''}${currentOEMNumber ? `&oemNumber=${encodeURIComponent(currentOEMNumber)}` : ''}`;
setSearchQuery('');
router.push(url);
};
return (
<>
<section className="top_head">
<div className="w-layout-blockcontainer container nav w-container">
<div data-animation="default" data-collapse="medium" data-duration="400" data-easing="ease" data-easing2="ease" role="banner" className="navbar w-nav">
<Link href="/" className="brand w-nav-brand"><img src="/images/logo.svg" loading="lazy" alt="" className="image-24" /></Link>
<nav role="navigation" className="nav-menu w-nav-menu">
<Link href="/about" className="nav-link w-nav-link">О компании</Link>
<Link href="/payments-method" className="nav-link w-nav-link">Оплата и доставка</Link>
<Link href="/" className="nav-link w-nav-link">Гарантия и возврат</Link>
<Link href="/payments-method" className="nav-link w-nav-link">Покупателям</Link>
<Link href="/wholesale" className="nav-link w-nav-link">Оптовым клиентам</Link>
<Link href="/contacts" className="nav-link w-nav-link">Контакты</Link>
</nav>
<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="phone-copy">+7 (495) 260-20-60</div>
</div>
</div>
<div className="w-layout-hflex flex-block"><img src="/images/tg_icon.svg" loading="lazy" alt="" className="icon_messenger" /><img src="/images/wa_icon.svg" loading="lazy" alt="" className="icon_messenger" /></div>
</div>
</div>
</div>
</section>
<section className="bottom_head">
<div className="w-layout-blockcontainer container nav w-container">
<div className="w-layout-hflex flex-block-93">
<div data-animation="default" data-collapse="all" data-duration="400" data-easing="ease-in" data-easing2="ease" role="banner" className="navbar-2 w-nav">
<div
className={`menu-button w-nav-button${menuOpen ? " w--open" : ""}`}
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">
<path d="M0 0H30V3H0V0Z" fill="currentColor"></path>
<path d="M0 7.5H30V10.5H0V7.5Z" fill="currentColor"></path>
<path d="M0 15H30V18H0V15Z" fill="currentColor"></path>
</svg></div>
</div>
</div>
<div className="form-block w-form" style={{ position: 'relative' }}>
<form
id="custom-search-form"
name="custom-search-form"
data-custom-form="true"
className="form"
autoComplete="off"
onSubmit={handleSearchSubmit}
ref={searchFormRef}
>
<div className="link-block-3 w-inline-block" style={{cursor: 'pointer'}} onClick={() => searchFormRef.current?.requestSubmit()}>
<div className="code-embed-6 w-embed">
{isSearching ? (
<svg className="animate-spin w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 17.5L13.8834 13.8833" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
)}
</div>
</div>
<input
className="text-field w-input"
maxLength={256}
name="customSearch"
data-custom-input="true"
placeholder="Введите код запчасти, VIN номер или госномер автомобиля"
type="text"
id="customSearchInput"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isSearching}
/>
</form>
{/* Результаты поиска VIN */}
{showResults && searchResults.length > 0 && (searchType === 'vin' || searchType === 'plate') && (
<div
ref={searchDropdownRef}
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-80 overflow-y-auto"
>
<div className="p-3 border-b border-gray-100">
<h3 className="text-sm font-medium text-gray-900">
{searchType === 'vin' ? 'Найденные автомобили по VIN' : 'Найденные автомобили по госномеру'}
</h3>
</div>
{searchResults.map((vehicle, index) => (
<button
key={`${vehicle.vehicleid}-${index}`}
onClick={() => handleVehicleSelect(vehicle)}
className="w-full text-left p-3 hover:bg-gray-50 border-b border-gray-100 last:border-b-0 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-gray-900">
{vehicle.brand} {vehicle.model}
</h4>
<p className="text-sm text-gray-600">
{vehicle.modification} {vehicle.year} {vehicle.bodytype}
</p>
{vehicle.engine && (
<p className="text-xs text-gray-500">
Двигатель: {vehicle.engine}
</p>
)}
</div>
<div className="text-right">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
)}
{/* Результаты поиска по артикулу */}
{showResults && searchType === 'oem' && (
<div
ref={searchDropdownRef}
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50 max-h-96 overflow-y-auto"
>
{/* Переключатель режимов поиска */}
<div className="p-3 border-b border-gray-100 bg-gray-50">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-900">Поиск по артикулу: {searchQuery}</h3>
</div>
<div className="flex space-x-1 bg-gray-200 rounded-lg p-1">
<button
onClick={() => {
setOemSearchMode('parts');
if (oemSearchMode !== 'parts') {
handleOEMSearch(searchQuery.trim().toUpperCase());
}
}}
className={`flex-1 px-3 py-1 text-xs font-medium rounded-md transition-colors ${
oemSearchMode === 'parts'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
🔧 Найти детали
</button>
<button
onClick={() => {
setOemSearchMode('vehicles');
if (oemSearchMode !== 'vehicles') {
handlePartVehicleSearch(searchQuery.trim().toUpperCase());
}
}}
className={`flex-1 px-3 py-1 text-xs font-medium rounded-md transition-colors ${
oemSearchMode === 'vehicles'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
🚗 Найти автомобили
</button>
</div>
</div>
{/* Результаты поиска деталей */}
{oemSearchMode === 'parts' && oemSearchResults && oemSearchResults.details.length > 0 && (
<>
<div className="p-3 border-b border-gray-100">
<p className="text-xs text-gray-600">Найдено {oemSearchResults.details.length} деталей</p>
</div>
{oemSearchResults.details.slice(0, 5).map((detail, index) => (
<div
key={`${detail.detailid}-${index}`}
className="p-3 border-b border-gray-100 last:border-b-0"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 text-sm">
{detail.name}
</h4>
<p className="text-xs text-gray-600 mt-1">
<span className="font-medium">OEM:</span> {detail.formattedoem}
</p>
<p className="text-xs text-gray-600">
<span className="font-medium">Производитель:</span> {detail.manufacturer}
</p>
{detail.replacements.length > 0 && (
<p className="text-xs text-blue-600 mt-1">
+{detail.replacements.length} аналогов
</p>
)}
</div>
<div className="text-right ml-2">
<button
onClick={() => {
// Переходим на страницу поиска по артикулу
router.push(`/search?q=${encodeURIComponent(detail.formattedoem)}&mode=parts`);
setShowResults(false);
setSearchQuery('');
}}
className="text-xs text-blue-600 hover:text-blue-800"
>
Подробнее
</button>
</div>
</div>
</div>
))}
{oemSearchResults.details.length > 5 && (
<div className="p-3 text-center border-t border-gray-100">
<button
onClick={() => {
router.push(`/search?q=${encodeURIComponent(searchQuery)}&mode=parts`);
setShowResults(false);
setSearchQuery('');
}}
className="text-sm text-blue-600 hover:text-blue-800"
>
Показать все {oemSearchResults.details.length} деталей
</button>
</div>
)}
</>
)}
{/* Результаты поиска автомобилей по артикулу */}
{oemSearchMode === 'vehicles' && vehiclesByPartResults && vehiclesByPartResults.totalVehicles > 0 && (
<>
<div className="p-3 border-b border-gray-100">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-600">
Найдено {vehiclesByPartResults.totalVehicles} автомобилей в {vehiclesByPartResults.catalogs.length} каталогах
</p>
<button
onClick={() => {
// Переходим на страницу со всеми автомобилями по артикулу
const cleanPartNumber = searchQuery.trim();
router.push(`/vehicles-by-part?partNumber=${encodeURIComponent(cleanPartNumber)}`);
setShowResults(false);
setSearchQuery('');
}}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
Показать все
</button>
</div>
</div>
{vehiclesByPartResults.catalogs.map((catalog, catalogIndex) => (
<div key={catalog.catalogCode} className="border-b border-gray-100 last:border-b-0">
<div className="p-3 bg-gray-50">
<h4 className="text-sm font-medium text-gray-800">
{catalog.brand} ({catalog.vehicleCount} автомобилей)
</h4>
</div>
{catalog.vehicles.slice(0, 3).map((vehicle, vehicleIndex) => (
<button
key={`${vehicle.vehicleid}-${catalogIndex}-${vehicleIndex}`}
onClick={() => handleVehicleSelect(vehicle)}
className="w-full p-3 text-left hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h5 className="font-medium text-gray-900 text-sm">
{vehicle.name || `${vehicle.brand} ${vehicle.model}`}
</h5>
<p className="text-xs text-gray-600 mt-1">
{vehicle.modification}
</p>
{vehicle.year && (
<p className="text-xs text-gray-500">
Год: {vehicle.year}
</p>
)}
{vehicle.engine && (
<p className="text-xs text-gray-500">
Двигатель: {vehicle.engine}
</p>
)}
</div>
<div className="text-right">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
{catalog.vehicles.length > 3 && (
<div className="p-2 text-center bg-gray-50">
<button
onClick={() => {
// Переходим на страницу со всеми автомобилями по артикулу
console.log('Показать все автомобили в каталоге:', catalog.catalogCode);
// Используем оригинальный артикул без лишних символов
const cleanPartNumber = searchQuery.trim();
router.push(`/vehicles-by-part?partNumber=${encodeURIComponent(cleanPartNumber)}&catalogCode=${catalog.catalogCode}`);
setShowResults(false);
setSearchQuery('');
}}
className="text-xs text-blue-600 hover:text-blue-800"
>
Показать все {catalog.vehicles.length} автомобилей в {catalog.brand}
</button>
</div>
)}
</div>
))}
</>
)}
{/* Сообщения об отсутствии результатов */}
{oemSearchMode === 'parts' && (!oemSearchResults || oemSearchResults.details.length === 0) && !isSearching && (
<div className="p-4 text-center">
<div className="text-yellow-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">Детали не найдены</h3>
<p className="text-xs text-gray-600">
Детали с артикулом {searchQuery} не найдены в базе данных
</p>
</div>
)}
{oemSearchMode === 'vehicles' && (!vehiclesByPartResults || vehiclesByPartResults.totalVehicles === 0) && !isSearching && (
<div className="p-4 text-center">
<div className="text-yellow-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">Автомобили не найдены</h3>
<p className="text-xs text-gray-600">
Автомобили с артикулом {searchQuery} не найдены в каталогах
</p>
</div>
)}
</div>
)}
{/* Сообщение о том, что VIN/госномер не найден */}
{showResults && searchResults.length === 0 && (searchType === 'vin' || searchType === 'plate') && !isSearching && (
<div
ref={searchDropdownRef}
className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg mt-2 z-50"
>
<div className="p-4 text-center">
<div className="text-yellow-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-1">
{searchType === 'vin' ? 'VIN не найден' : 'Госномер не найден'}
</h3>
<p className="text-xs text-gray-600">
{searchType === 'vin'
? `Автомобиль с VIN ${searchQuery} не найден в доступных каталогах`
: `Автомобиль с госномером ${searchQuery} не найден в базе данных`
}
</p>
</div>
</div>
)}
<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 className="w-layout-hflex flex-block-76">
<Link href="/profile-gar" className="button_h w-inline-block">
<div className="code-embed-7 w-embed"><svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M27 10.8V24H24.6V13.2H5.4V24H3V10.8L15 6L27 10.8ZM23.4 14.4H6.6V16.8H23.4V14.4ZM23.4 18H6.6V20.4H23.4V18Z" fill="currentColor" /><path d="M6.6 21.6H23.4V24H6.6V21.6Z" fill="currentColor" /></svg></div>
<div className="text-block-2">Добавить в гараж</div>
</Link>
<Link href="/favorite" className="button_h w-inline-block">
<div className="code-embed-7 w-embed"><svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z" fill="currentColor" /></svg></div>
<div className="text-block-2">Избранное</div>
</Link>
<button
onClick={() => {
if (currentUser) {
router.push('/profile-orders');
} else {
onOpenAuthModal();
}
}}
className="button_h login w-inline-block"
style={{ cursor: 'pointer' }}
>
<div className="code-embed-8 w-embed"><svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15 3C8.376 3 3 8.376 3 15C3 21.624 8.376 27 15 27C21.624 27 27 21.624 27 15C27 8.376 21.624 3 15 3ZM15 7.8C17.316 7.8 19.2 9.684 19.2 12C19.2 14.316 17.316 16.2 15 16.2C12.684 16.2 10.8 14.316 10.8 12C10.8 9.684 12.684 7.8 15 7.8ZM15 24.6C12.564 24.6 9.684 23.616 7.632 21.144C9.73419 19.4955 12.3285 18.5995 15 18.5995C17.6715 18.5995 20.2658 19.4955 22.368 21.144C20.316 23.616 17.436 24.6 15 24.6Z" fill="currentColor" /></svg></div>
<div className="text-block-2">{currentUser ? 'Личный кабинет' : 'Войти'}</div>
</button>
<CartButton />
</div>
</div>
</div>
</section>
<BottomHead menuOpen={menuOpen} onClose={() => setMenuOpen(false)} />
</>
);
};
export default Header;

24
src/components/Help.tsx Normal file
View File

@ -0,0 +1,24 @@
import React from "react";
const Help = () => (
<div className="div-block-11">
<div className="w-layout-vflex flex-block-30">
<h3 className="heading-6">Мы будем рады помочь вам с оформлением заказа!</h3>
<div className="text-block-19">Если у вас возникли вопросы, пожалуйста, оставьте свой номер телефона. Наш менеджер свяжется с вами и поможет оформить заказ, или решить любой вопрос!</div>
</div>
<div className="form-block-3 w-form">
<form id="email-form" name="email-form" data-name="Email Form" method="get" className="form-3-copy" data-wf-page-id="6836cad8b1a5806f12459deb" data-wf-element-id="112346f5-b373-dbe0-71b5-88818a3c0556">
<input className="text-field-3 w-input" maxLength={256} name="name-6" data-name="Name 6" placeholder="+7 (999) 999-99-99" type="text" id="name-6" />
<input type="submit" data-wait="Please wait..." className="submit-button w-button" value="Отправить номер" />
</form>
<div className="w-form-done">
<div>Thank you! Your submission has been received!</div>
</div>
<div className="w-form-fail">
<div>Oops! Something went wrong while submitting the form.</div>
</div>
</div>
</div>
);
export default Help;

View File

@ -0,0 +1,29 @@
import React from "react";
interface InfoOrder1Props {
children: React.ReactNode;
}
const InfoOrder1: React.FC<InfoOrder1Props> = ({ children }) => (
<div
style={{
fontFamily: 'Onest, sans-serif',
fontWeight: 400,
fontSize: '14px',
padding: '20px',
height: '91px',
color: '#000814',
borderRadius: '12px',
background: '#fff',
boxShadow: '0 0 15px rgba(0,0,0,0.25)',
width: '223px',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box',
}}
>
{children}
</div>
);
export default InfoOrder1;

View File

@ -0,0 +1,48 @@
import React from "react";
interface InfoSearchProps {
brand: string;
articleNumber: string;
name: string;
offersCount: number;
minPrice: string;
}
const InfoSearch: React.FC<InfoSearchProps> = ({
brand,
articleNumber,
name,
offersCount,
minPrice,
}) => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<div className="text-block-53">{brand} {articleNumber}</div>
{/* <div className="fsfav">
<div className="icon-setting w-embed">
<svg width="currentwidth" height="currentheight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z" fill="currentColor"></path>
</svg>
</div>
</div> */}
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">{name}</h1>
<div className="text-block-4">
Найдено {offersCount} предложений от {minPrice}
</div>
</div>
{/* <div className="w-layout-hflex flex-block-11">
<img src="/images/qwestions.svg" loading="lazy" alt="" className="image-4" />
<div className="text-block-5">Как правильно подобрать запчасть?</div>
</div> */}
</div>
</div>
</div>
</section>
);
export default InfoSearch;

143
src/components/LKMenu.tsx Normal file
View File

@ -0,0 +1,143 @@
import * as React from "react";
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useIsClient } from '@/lib/useIsomorphicLayoutEffect';
const menuItems = [
{ label: 'Заказы', href: '/profile-orders', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/22ecd7e6251abe04521d03f0ac09f73018a8c2c8?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'История поиска', href: '/profile-history', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/e7688217cca08e8c080ec07f80bf1142429d899c?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Уведомления', href: '/profile-announcement', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/7505ecbdf10660110c88e1641f43b4618fef292d?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Оповещения', href: '/profile-notification', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/f7a4dd35c3365eb1f1e7292f9b6194b8a3083c4f?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Адреса доставки', href: '/profile-addresses', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/1faca7190a7dd71a66fd3cf0127a8c6e45eac5e6?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Гараж', href: '/profile-gar', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/783501855b4cb8be4ac47a0733e298c3f3ccfc5e?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Настройки аккаунта', href: '/profile-set', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/b39907028aa6baf08adc313aed84d1294f2be013?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
];
const financeItems = [
{ label: 'Баланс', href: '/profile-balance', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/135ee20623aaa1f29816106bd0ca1a627976969d?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Реквизиты', href: '/profile-req', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/0890fb36a7fb89b3942f93be72ac0e79d93bc530?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
// { label: 'Взаиморасчеты', href: '/profile-settlements', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/74b08742b16c7daefb4d895173a6d749eb61fd94?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
{ label: 'Акты сверки', href: '/profile-acts', icon: 'https://cdn.builder.io/api/v1/image/assets/TEMP/22ecd7e6251abe04521d03f0ac09f73018a8c2c8?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920' },
];
function normalizePath(path: string) {
return path.replace(/\/+$/, '');
}
const LKMenu = React.forwardRef<HTMLDivElement>((props, ref) => {
const router = useRouter();
const isClient = useIsClient();
const handleLogout = () => {
if (isClient) {
localStorage.removeItem('authToken');
localStorage.removeItem('userData');
window.location.href = '/';
}
};
return (
<div ref={ref} className="flex flex-col max-w-xs max-md:w-full max-md:max-w-full w-full text-xl font-semibold leading-none text-gray-950">
<div className="flex flex-col px-4 pt-4 pb-6 w-full bg-white rounded-3xl">
<div className="gap-2.5 self-start px-2.5 pt-2.5 text-gray-950">
Личный кабинет
</div>
<div className="flex flex-col mt-3 w-full text-base leading-snug text-gray-600">
{menuItems
.filter(item => !['Уведомления', 'Оповещения'].includes(item.label)) // Временно скрываем эти пункты
.map((item) => {
const isActive = normalizePath(router.asPath) === normalizePath(item.href);
return (
<Link href={item.href} key={item.href}>
<div
className={`flex gap-2.5 items-center px-2.5 py-2 w-full whitespace-nowrap rounded-lg ${
isActive ? 'bg-slate-200' : 'bg-white'
}`}
>
<img
loading="lazy"
src={item.icon}
className="object-contain shrink-0 self-stretch my-auto w-5 aspect-square"
alt={item.label}
/>
<div className="self-stretch my-auto text-gray-600">{item.label}</div>
</div>
</Link>
);
})}
</div>
<div className="gap-2.5 self-start px-2.5 pt-2.5 mt-3 whitespace-nowrap text-gray-950">
Финансы
</div>
<div className="flex flex-col mt-3 w-full text-base leading-snug text-gray-600">
{financeItems.map((item) => {
const isActive = normalizePath(router.asPath) === normalizePath(item.href);
return (
<Link href={item.href} key={item.href}>
<div
className={`flex gap-2.5 items-center px-2.5 py-2 w-full whitespace-nowrap rounded-lg ${
isActive ? 'bg-slate-200' : 'bg-white'
}`}
>
<img
loading="lazy"
src={item.icon}
className="object-contain shrink-0 self-stretch my-auto w-5 aspect-square"
alt={item.label}
/>
<div className="self-stretch my-auto text-gray-600">{item.label}</div>
</div>
</Link>
);
})}
</div>
{/* Кнопка выхода */}
<div className="mt-3">
<button
onClick={handleLogout}
className="flex gap-2.5 items-center px-2.5 py-2 w-full text-base leading-snug text-gray-600 rounded-lg bg-white hover:bg-slate-200 font-normal"
tabIndex={0}
aria-label="Выйти из аккаунта"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="object-contain shrink-0 self-stretch my-auto w-5 aspect-square text-gray-600"
>
<path
d="M7 17L3 17C2.46957 17 1.96086 16.7893 1.58579 16.4142C1.21071 16.0391 1 15.5304 1 15L1 5C1 4.46957 1.21071 3.96086 1.58579 3.58579C1.96086 3.21071 2.46957 3 3 3L7 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 13L19 10L14 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 10L7 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="self-stretch my-auto text-gray-600 text-[16px]">Выйти</div>
</button>
</div>
</div>
</div>
);
});
LKMenu.displayName = 'LKMenu';
export default LKMenu;

View File

@ -0,0 +1,494 @@
import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { GET_LAXIMO_CATALOG_INFO, GET_LAXIMO_QUICK_GROUPS_WITH_XML } from '@/lib/graphql';
import { LaximoCatalogInfo, LaximoQuickGroup } from '@/types/laximo';
interface LaximoDiagnosticProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
}
const LaximoDiagnostic: React.FC<LaximoDiagnosticProps> = ({
catalogCode,
vehicleId,
ssd
}) => {
const [expanded, setExpanded] = useState(false);
const [showRawData, setShowRawData] = useState(false);
const [showRawXML, setShowRawXML] = useState(false);
const [copySuccess, setCopySuccess] = useState<string | null>(null);
const handleCopyToClipboard = async (content: string, type: string) => {
try {
await navigator.clipboard.writeText(content);
setCopySuccess(`${type} скопирован в буфер обмена!`);
setTimeout(() => setCopySuccess(null), 3000);
} catch (err) {
console.error('Ошибка копирования в буфер обмена:', err);
setCopySuccess(`Ошибка копирования ${type}`);
setTimeout(() => setCopySuccess(null), 3000);
}
};
// Получаем информацию о каталоге
const { data: catalogData, loading: catalogLoading, error: catalogError } = useQuery<{ laximoCatalogInfo: LaximoCatalogInfo }>(
GET_LAXIMO_CATALOG_INFO,
{
variables: { catalogCode },
errorPolicy: 'all'
}
);
// Получаем группы быстрого поиска с RAW XML
const { data: quickGroupsData, loading: quickGroupsLoading, error: quickGroupsError } = useQuery<{
laximoQuickGroupsWithXML: {
groups: LaximoQuickGroup[],
rawXML: string
}
}>(
GET_LAXIMO_QUICK_GROUPS_WITH_XML,
{
variables: {
catalogCode,
vehicleId,
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !expanded,
errorPolicy: 'all'
}
);
const catalogInfo = catalogData?.laximoCatalogInfo;
const quickGroups = quickGroupsData?.laximoQuickGroupsWithXML?.groups || [];
const rawXML = quickGroupsData?.laximoQuickGroupsWithXML?.rawXML || '';
if (!expanded) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<button
onClick={() => setExpanded(true)}
className="flex items-center space-x-2 text-gray-700 hover:text-gray-900"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-medium">🔧 Показать диагностику Laximo</span>
</button>
</div>
);
}
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">🔧 Диагностика Laximo</h3>
<button
onClick={() => setExpanded(false)}
className="text-gray-500 hover:text-gray-700"
>
<svg className="w-5 h-5" 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>
{/* Уведомление о копировании */}
{copySuccess && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-green-800">{copySuccess}</span>
</div>
</div>
)}
<div className="space-y-4">
{/* Информация о каталоге */}
<div className="bg-white rounded-lg border p-4">
<h4 className="font-medium text-gray-900 mb-3">📋 Информация о каталоге</h4>
{catalogLoading && (
<div className="text-sm text-gray-500">Загрузка информации о каталоге...</div>
)}
{catalogError && (
<div className="text-sm text-red-600">
Ошибка загрузки каталога: {catalogError.message}
</div>
)}
{catalogInfo && (
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="font-medium text-gray-700">Код:</span>
<span className="ml-2">{catalogInfo.code}</span>
</div>
<div>
<span className="font-medium text-gray-700">Название:</span>
<span className="ml-2">{catalogInfo.name}</span>
</div>
<div>
<span className="font-medium text-gray-700">Бренд:</span>
<span className="ml-2">{catalogInfo.brand}</span>
</div>
<div>
<span className="font-medium text-gray-700">QuickGroups:</span>
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
catalogInfo.supportquickgroups
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{catalogInfo.supportquickgroups ? 'Поддерживается' : 'Не поддерживается'}
</span>
</div>
</div>
{/* Поддерживаемые функции */}
<div className="mt-3">
<span className="font-medium text-gray-700">Поддерживаемые функции:</span>
<div className="flex flex-wrap gap-2 mt-1">
{catalogInfo.features.map((feature, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
>
{feature.name}
{feature.example && (
<span className="ml-1 text-blue-600">({feature.example})</span>
)}
</span>
))}
</div>
</div>
</div>
)}
</div>
{/* Параметры запроса */}
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">🔗 Параметры запроса</h4>
<button
onClick={() => {
const diagnosticInfo = {
catalogCode,
vehicleId,
ssd: ssd ? ssd : 'отсутствует',
ssdLength: ssd?.length || 0,
catalogInfo: catalogInfo ? {
code: catalogInfo.code,
name: catalogInfo.name,
brand: catalogInfo.brand,
supportquickgroups: catalogInfo.supportquickgroups,
features: catalogInfo.features
} : 'не загружена',
quickGroupsCount: quickGroups.length,
timestamp: new Date().toISOString()
};
handleCopyToClipboard(JSON.stringify(diagnosticInfo, null, 2), 'Диагностическая информация');
}}
className="text-sm px-3 py-1 bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors"
>
📋 Скопировать все
</button>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium text-gray-700">Каталог:</span>
<span className="ml-2 font-mono">{catalogCode}</span>
</div>
<div>
<span className="font-medium text-gray-700">ID автомобиля:</span>
<span className="ml-2 font-mono">{vehicleId}</span>
</div>
<div>
<span className="font-medium text-gray-700">SSD:</span>
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
ssd && ssd.trim() !== ''
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{ssd && ssd.trim() !== ''
? `Доступен (${ssd.length} символов)`
: 'Отсутствует'
}
</span>
</div>
{ssd && ssd.trim() !== '' && (
<div className="mt-2">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-700">SSD (первые 100 символов):</span>
<button
onClick={() => handleCopyToClipboard(ssd, 'SSD')}
className="text-xs px-2 py-1 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
>
📋 Скопировать полный SSD
</button>
</div>
<div className="mt-1 p-2 bg-gray-100 rounded text-xs font-mono break-all">
{ssd.substring(0, 100)}...
</div>
</div>
)}
</div>
</div>
{/* Результат запроса групп быстрого поиска */}
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900"> Группы быстрого поиска</h4>
{quickGroups.length > 0 && (
<div className="flex space-x-2 flex-wrap">
<button
onClick={() => {
setShowRawData(!showRawData);
setShowRawXML(false);
}}
className={`text-sm px-3 py-1 rounded transition-colors ${
showRawData
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
}`}
>
🔍 JSON данные
</button>
<button
onClick={() => {
setShowRawXML(!showRawXML);
setShowRawData(false);
}}
className={`text-sm px-3 py-1 rounded transition-colors ${
showRawXML
? 'bg-green-500 text-white'
: 'bg-green-100 text-green-700 hover:bg-green-200'
}`}
>
📄 RAW XML
</button>
{(showRawData || showRawXML) && (
<>
<button
onClick={() => {
setShowRawData(false);
setShowRawXML(false);
}}
className="text-sm px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
📊 Таблица
</button>
<button
onClick={() => {
const content = showRawXML ? rawXML : JSON.stringify(quickGroups, null, 2);
const type = showRawXML ? 'RAW XML' : 'JSON данные';
handleCopyToClipboard(content, type);
}}
className="text-sm px-3 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 transition-colors"
>
📋 Скопировать
</button>
</>
)}
</div>
)}
</div>
{quickGroupsLoading && (
<div className="space-y-2">
<div className="text-sm text-gray-500">Загрузка групп быстрого поиска...</div>
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<div className="text-xs text-blue-800">
<div>🌐 Выполняется SOAP запрос к Laximo API...</div>
<div>📝 Команда: ListQuickGroup:Locale=ru_RU|Catalog={catalogCode}|VehicleId={vehicleId}|ssd=...</div>
<div>🔗 URL: https://ws.laximo.ru/ec.Kito.WebCatalog/services/Catalog.CatalogHttpSoap11Endpoint/</div>
<div> Ожидание ответа от сервера...</div>
</div>
</div>
</div>
)}
{quickGroupsError && (
<div className="space-y-2">
<div className="text-sm text-red-600">
Ошибка загрузки групп: {quickGroupsError.message}
</div>
<div className="bg-red-50 border border-red-200 rounded p-3">
<div className="text-xs text-red-800">
<div> SOAP запрос завершился с ошибкой</div>
<div>🔍 Проверьте консоль браузера для детальной информации</div>
<div>💡 Убедитесь, что SSD корректен и каталог поддерживает quickgroups</div>
</div>
</div>
</div>
)}
{quickGroups.length > 0 ? (
<div className="space-y-4">
<div className="text-sm">
<span className="font-medium text-gray-700">Всего групп верхнего уровня:</span>
<span className="ml-2 px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">
{quickGroups.length}
</span>
</div>
{showRawXML ? (
/* RAW XML ответ от Laximo */
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700 mb-2">
📄 RAW XML ответ от Laximo SOAP API (оригинальный):
</div>
<div className="bg-gray-900 text-yellow-400 rounded-lg p-4 max-h-96 overflow-auto">
<pre className="text-xs whitespace-pre-wrap font-mono">
{rawXML || 'XML данные недоступны'}
</pre>
</div>
{/* Информация о XML */}
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<div className="text-sm font-medium text-yellow-900 mb-2">📋 Информация о XML:</div>
<div className="text-xs text-yellow-800 space-y-1">
<div> Длина XML: <strong>{rawXML.length} символов</strong></div>
<div> Кодировка: UTF-8</div>
<div> Формат: SOAP XML Response</div>
<div> API: Laximo WebCatalog</div>
<div> Команда: ListQuickGroup:Locale=ru_RU|Catalog=...|VehicleId=...|ssd=...</div>
</div>
</div>
</div>
) : showRawData ? (
/* RAW JSON данные от Laximo */
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700 mb-2">
🔍 Обработанные JSON данные от Laximo API (ListQuickGroup):
</div>
<div className="bg-gray-900 text-green-400 rounded-lg p-4 max-h-96 overflow-auto">
<pre className="text-xs whitespace-pre-wrap font-mono">
{JSON.stringify(quickGroups, null, 2)}
</pre>
</div>
{/* Дополнительная информация о структуре */}
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<div className="text-sm font-medium text-blue-900 mb-2">📊 Анализ структуры данных:</div>
<div className="text-xs text-blue-800 space-y-1">
<div> Общее количество групп: <strong>{quickGroups.length}</strong></div>
<div> Групп с поддержкой деталей (link=true): <strong>{quickGroups.filter(g => g.link).length}</strong></div>
<div> Групп с дочерними элементами: <strong>{quickGroups.filter(g => g.children && g.children.length > 0).length}</strong></div>
<div> Общее количество всех групп (включая дочерние): <strong>{
quickGroups.reduce((total, group) => {
const countChildren = (g: LaximoQuickGroup): number => {
let count = 1;
if (g.children) {
count += g.children.reduce((childTotal, child) => childTotal + countChildren(child), 0);
}
return count;
};
return total + countChildren(group);
}, 0)
}</strong></div>
</div>
</div>
{/* Backend Debug Info */}
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<div className="text-sm font-medium text-yellow-900 mb-2">🔧 Backend Debug Info:</div>
<div className="text-xs text-yellow-800 space-y-1">
<div>💡 <strong>Для полной отладки откройте консоль сервера (backend)</strong></div>
<div>📥 В консоли сервера будет виден полный RAW XML ответ от Laximo SOAP API</div>
<div>🌐 SOAP URL: https://ws.laximo.ru/ec.Kito.WebCatalog/services/Catalog.CatalogHttpSoap11Endpoint/</div>
<div>📝 Команда: ListQuickGroup:Locale=ru_RU|Catalog={catalogCode}|VehicleId={vehicleId}|ssd=...</div>
<div>🔐 Login используется из переменной окружения LAXIMO_LOGIN</div>
<div>🎯 SOAPAction: "urn:QueryDataLogin"</div>
<div>📦 Content-Type: text/xml; charset=utf-8</div>
</div>
</div>
</div>
) : (
/* Табличное представление */
<div className="max-h-64 overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-1">ID</th>
<th className="text-left py-1">Название</th>
<th className="text-left py-1">Link</th>
<th className="text-left py-1">Дочерние</th>
<th className="text-left py-1">Code</th>
<th className="text-left py-1">Image</th>
</tr>
</thead>
<tbody>
{quickGroups.map((group, index) => (
<tr key={index} className="border-b">
<td className="py-1 font-mono">{group.quickgroupid}</td>
<td className="py-1">{group.name}</td>
<td className="py-1">
<span className={`px-1 py-0.5 rounded text-xs ${
group.link
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{group.link ? 'true' : 'false'}
</span>
</td>
<td className="py-1">{group.children?.length || 0}</td>
<td className="py-1 font-mono text-xs">{group.code || '-'}</td>
<td className="py-1">
{group.imageurl ? (
<span className="text-green-600 text-xs"></span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : (
!quickGroupsLoading && !quickGroupsError && (
<div className="text-sm text-gray-500">
Группы быстрого поиска не найдены
</div>
)
)}
</div>
{/* Рекомендации */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">💡 Рекомендации</h4>
<div className="text-sm text-blue-800 space-y-1">
{catalogInfo?.supportquickgroups ? (
<div> Каталог поддерживает группы быстрого поиска</div>
) : (
<div> Каталог не поддерживает группы быстрого поиска - используйте категории</div>
)}
{ssd && ssd.trim() !== '' ? (
<div> SSD доступен - все функции активны</div>
) : (
<div> SSD отсутствует - некоторые функции могут быть недоступны</div>
)}
{quickGroups.length > 0 ? (
<div> Группы быстрого поиска загружены успешно</div>
) : (
quickGroupsError ? (
<div> Ошибка загрузки групп быстрого поиска</div>
) : (
<div> Группы быстрого поиска пусты или еще загружаются</div>
)
)}
</div>
</div>
</div>
</div>
);
};
export default LaximoDiagnostic;

39
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,39 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import Header from "./Header";
import AuthModal from "./auth/AuthModal";
import MobileMenuBottomSection from "./MobileMenuBottomSection";
const Layout = ({ children }: { children: React.ReactNode }) => {
const [authModalOpen, setAuthModalOpen] = useState(false);
const router = useRouter();
const handleAuthSuccess = (client: any, token?: string) => {
// Сохраняем токен и пользователя в localStorage
if (typeof window !== "undefined") {
if (token) {
localStorage.setItem('authToken', token);
}
localStorage.setItem('userData', JSON.stringify(client));
}
setAuthModalOpen(false);
router.push('/profile-orders');
};
return (
<>
<header className="section-4">
<Header onOpenAuthModal={() => setAuthModalOpen(true)} />
<AuthModal
isOpen={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={handleAuthSuccess}
/>
</header>
<main className="pt-[132px]">{children}</main>
<MobileMenuBottomSection onOpenAuthModal={() => setAuthModalOpen(true)} />
</>
);
};
export default Layout;

View File

@ -0,0 +1,28 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
className?: string;
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
text = 'Загружаем...',
className = ''
}) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
};
return (
<div className={`flex items-center space-x-2 ${className}`}>
<div className={`animate-spin rounded-full border-b-2 border-blue-600 ${sizeClasses[size]}`}></div>
{text && <span className="text-gray-600">{text}</span>}
</div>
);
};
export default LoadingSpinner;

View File

@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import Head from 'next/head';
interface MaintenanceModeProps {
onPasswordCorrect: () => void;
}
const MaintenanceMode: React.FC<MaintenanceModeProps> = ({ onPasswordCorrect }) => {
const [password, setPassword] = useState('');
const [isShaking, setIsShaking] = useState(false);
const [progress, setProgress] = useState(0);
const correctPassword = 'protek2024'; // Замените на ваш пароль
// Дебаг информация (удалите в продакшене)
useEffect(() => {
console.log('Maintenance Mode Environment Variable:', process.env.NEXT_PUBLIC_MAINTENANCE_MODE);
}, []);
useEffect(() => {
const interval = setInterval(() => {
setProgress(prev => prev >= 100 ? 0 : prev + 1);
}, 100);
return () => clearInterval(interval);
}, []);
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password === correctPassword) {
onPasswordCorrect();
} else {
setIsShaking(true);
setPassword('');
setTimeout(() => setIsShaking(false), 500);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handlePasswordSubmit(e);
}
};
return (
<>
<Head>
<title>Техническое обслуживание - Protek Auto</title>
<meta name="description" content="Сайт временно недоступен из-за технического обслуживания" />
</Head>
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-gray-800 flex items-center justify-center p-4 overflow-hidden relative">
{/* Анимированный фон */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-4 h-4 bg-yellow-400 rounded-full animate-pulse"></div>
<div className="absolute top-32 right-16 w-6 h-6 bg-red-400 rounded-full animate-bounce"></div>
<div className="absolute bottom-24 left-20 w-3 h-3 bg-green-400 rounded-full animate-ping"></div>
<div className="absolute bottom-40 right-12 w-5 h-5 bg-blue-400 rounded-full animate-pulse"></div>
</div>
<div className="max-w-2xl mx-auto text-center relative z-10">
{/* Логотип и автомобиль */}
<div className="mb-8">
<div className="relative">
{/* Анимированный автомобиль */}
<div className="mb-6 relative">
<div className="inline-block">
<svg
className="w-32 h-20 mx-auto text-blue-400 animate-bounce"
fill="currentColor"
viewBox="0 0 512 512"
>
<path d="M135.2 117.4L109.1 192H402.9l-26.1-74.6C372.3 104.6 360.2 96 346.6 96H165.4c-13.6 0-25.7 8.6-30.2 21.4zM39.6 196.8L74.8 96.3C88.3 57.8 124.6 32 165.4 32H346.6c40.8 0 77.1 25.8 90.6 64.3l35.2 100.5c23.2 9.6 39.6 32.5 39.6 59.2V304c0 8.8-7.2 16-16 16H448c-8.8 0-16-7.2-16-16V288H80v16c0 8.8-7.2 16-16 16H16c-8.8 0-16-7.2-16-16V256c0-26.7 16.4-49.6 39.6-59.2zM128 288a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm288 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64z"/>
</svg>
</div>
{/* Дым из выхлопной трубы */}
<div className="absolute -right-8 top-6">
<div className="w-4 h-4 bg-gray-400 rounded-full opacity-60 animate-ping"></div>
<div className="w-3 h-3 bg-gray-300 rounded-full opacity-40 animate-pulse absolute -top-2 -right-2"></div>
</div>
</div>
<h1 className="text-5xl font-bold text-white mb-4 tracking-wide">
PROTEK AUTO
</h1>
{/* Инструменты */}
<div className="flex justify-center items-center gap-4 mb-6">
<svg className="w-8 h-8 text-yellow-400 animate-spin" fill="currentColor" viewBox="0 0 512 512">
<path d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4h54.1l109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109V104c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.4 408.6 7.4 428.9 19.9 441.4l51.2 51.2c12.5 12.5 32.8 12.5 45.3 0l44.9-44.9c8.5-8.5 8.5-22.4 0-30.9l-65.4-65.4c-8.5-8.5-22.4-8.5-30.9 0L19.9 396.1z"/>
</svg>
<svg className="w-8 h-8 text-red-400 animate-pulse" fill="currentColor" viewBox="0 0 512 512">
<path d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4h54.1l109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109V104c0-7.5-3.5-14.5-9.4-19L78.6 5z"/>
</svg>
<svg className="w-8 h-8 text-green-400 animate-bounce" fill="currentColor" viewBox="0 0 512 512">
<path d="M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.7 2.9C246.8 1 251.4 0 256 0z"/>
</svg>
</div>
</div>
</div>
{/* Текст состояния */}
<div className="mb-8">
<h2 className="text-3xl font-semibold text-white mb-4">
🔧 Техническое обслуживание
</h2>
<p className="text-xl text-gray-300 mb-2">
Наш автосервис временно закрыт на профилактику
</p>
<p className="text-lg text-gray-400">
Мы настраиваем двигатель для лучшей производительности
</p>
</div>
{/* Прогресс бар */}
<div className="mb-8">
<div className="bg-gray-700 rounded-full h-4 mb-4 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-green-400 h-full rounded-full transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
></div>
</div>
<p className="text-sm text-gray-400">Прогресс обслуживания: {progress}%</p>
</div>
{/* Форма ввода пароля */}
<div className="bg-gray-800/50 backdrop-blur-sm rounded-2xl p-8 border border-gray-700">
<h3 className="text-xl font-semibold text-white mb-4">
🔐 Код доступа для техперсонала
</h3>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="relative">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Введите код доступа..."
className={`w-full px-6 py-4 bg-gray-700 border-2 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 transition-all duration-300 ${
isShaking ? 'animate-pulse border-red-500 bg-red-900/20' : 'border-gray-600'
}`}
autoComplete="off"
tabIndex={0}
aria-label="Введите пароль для доступа к сайту"
/>
{/* Индикатор безопасности */}
<div className="absolute right-4 top-1/2 transform -translate-y-1/2">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
</div>
</div>
<button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-4 focus:ring-blue-500/50"
tabIndex={0}
aria-label="Войти в систему"
>
🚀 Запустить двигатель
</button>
</form>
{isShaking && (
<p className="text-red-400 text-sm mt-4 animate-pulse">
Неверный код доступа! Попробуйте еще раз.
</p>
)}
</div>
{/* Контактная информация */}
<div className="mt-8 text-center">
<p className="text-gray-400 text-sm">
По вопросам обращайтесь к администратору системы
</p>
<div className="flex justify-center items-center gap-4 mt-4">
<span className="text-2xl">🛠</span>
<span className="text-2xl"></span>
<span className="text-2xl">🔧</span>
</div>
</div>
</div>
{/* Дополнительные анимационные элементы */}
<div className="absolute bottom-10 left-1/2 transform -translate-x-1/2">
<div className="flex space-x-2">
<div className="w-3 h-3 bg-blue-400 rounded-full animate-bounce"></div>
<div className="w-3 h-3 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-3 h-3 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
</>
);
};
export default MaintenanceMode;

View File

@ -0,0 +1,85 @@
import React from 'react';
import MobileMenuButton from './MobileMenuButton';
import { useFavorites } from '@/contexts/FavoritesContext';
import { useCart } from '@/contexts/CartContext';
interface MobileMenuBottomSectionProps {
onOpenAuthModal?: () => void;
}
const GarageIcon = (
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M27 10.8V24H24.6V13.2H5.4V24H3V10.8L15 6L27 10.8ZM23.4 14.4H6.6V16.8H23.4V14.4ZM23.4 18H6.6V20.4H23.4V18Z" fill="currentColor"></path>
<path d="M6.6 21.6H23.4V24H6.6V21.6Z" fill="currentColor"></path>
</svg>
);
const FavoriteIcon = (
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z" fill="currentColor"></path>
</svg>
);
const CartIcon = (
<svg width="30" height="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"></path>
</svg>
);
const CabinetIcon = (
<svg width="30" height="30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 3C8.376 3 3 8.376 3 15C3 21.624 8.376 27 15 27C21.624 27 27 21.624 27 15C27 8.376 21.624 3 15 3ZM15 7.8C17.316 7.8 19.2 9.684 19.2 12C19.2 14.316 17.316 16.2 15 16.2C12.684 16.2 10.8 14.316 10.8 12C10.8 9.684 12.684 7.8 15 7.8ZM15 24.6C12.564 24.6 9.684 23.616 7.632 21.144C9.73419 19.4955 12.3285 18.5995 15 18.5995C17.6715 18.5995 20.2658 19.4955 22.368 21.144C20.316 23.616 17.436 24.6 15 24.6Z" fill="currentColor"></path>
</svg>
);
const MobileMenuBottomSection: React.FC<MobileMenuBottomSectionProps> = ({
onOpenAuthModal = () => console.log('Auth modal action not provided')
}) => {
const { favorites } = useFavorites();
const { state: cartState } = useCart();
const favoriteCounter = favorites.length > 0 ? (
<div className="text-block-39">{favorites.length}</div>
) : undefined;
const cartCounter = cartState.items.length > 0 ? (
<div className="text-block-39">{cartState.items.length}</div>
) : undefined;
return (
<nav className="mobile-menu-buttom-section">
<div className="w-layout-blockcontainer mobile-menu-bottom w-container">
<div className="w-layout-hflex flex-block-87">
<MobileMenuButton icon={GarageIcon} label="Гараж" href="/profile-gar" />
<MobileMenuButton
icon={FavoriteIcon}
label="Избранное"
href="/favorite"
counter={favoriteCounter}
status={favorites.length > 0 ? "warning" : undefined}
/>
<MobileMenuButton
icon={CartIcon}
label="Корзина"
href="/cart"
counter={cartCounter}
status={cartState.items.length > 0 ? "danger" : undefined}
/>
<button
type="button"
className="button-for-mobile-menu-block w-inline-block"
onClick={onOpenAuthModal}
>
<div className="block-for-moble-menu-icon">
<div className="icon-setting w-embed">{CabinetIcon}</div>
<div className="pcs-info pcs-info--success"><div className="text-block-39">!</div></div>
</div>
<div className="name-mobile-menu-item">Кабинет</div>
</button>
</div>
</div>
</nav>
);
};
export default MobileMenuBottomSection;

View File

@ -0,0 +1,24 @@
import React from 'react';
import Link from 'next/link';
interface MobileMenuButtonProps {
icon: React.ReactNode;
label: string;
counter?: React.ReactNode;
href?: string;
status?: 'default' | 'success' | 'warning' | 'danger';
}
const MobileMenuButton: React.FC<MobileMenuButtonProps> = ({ icon, label, counter, href = '#', status = 'default' }) => (
<Link href={href} className="button-for-mobile-menu-block w-inline-block">
<div className="block-for-moble-menu-icon">
<div className="icon-setting w-embed">{icon}</div>
{counter && (
<div className={`pcs-info${status !== 'default' ? ' pcs-info--' + status : ''}`}>{counter}</div>
)}
</div>
<div className="name-mobile-menu-item">{label}</div>
</Link>
);
export default MobileMenuButton;

View File

@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { useLazyQuery } from '@apollo/client';
import { SEARCH_LAXIMO_OEM } from '@/lib/graphql';
import { LaximoOEMResult, LaximoOEMCategory, LaximoOEMUnit, LaximoOEMDetail } from '@/types/laximo';
interface OEMSearchSectionProps {
catalogCode: string;
vehicleId: string;
ssd: string;
initialOEMNumber?: string;
}
interface OEMDetailCardProps {
detail: LaximoOEMDetail;
categoryName: string;
unitName: string;
}
const OEMDetailCard: React.FC<OEMDetailCardProps> = ({ detail, categoryName, unitName }) => {
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="text-lg font-semibold text-gray-900 mb-1">{detail.name}</h4>
<div className="text-sm text-gray-600 mb-2">
<span className="font-medium">OEM:</span>
<span className="font-mono bg-gray-100 px-2 py-1 rounded ml-2">{detail.oem}</span>
</div>
</div>
<div className="flex flex-col items-end ml-4">
{detail.brand && (
<span className="text-sm font-medium text-blue-600 mb-1">{detail.brand}</span>
)}
{detail.amount && (
<span className="text-xs text-gray-500">Кол-во: {detail.amount}</span>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span className="font-medium text-gray-700">Категория:</span>
<p className="text-gray-600">{categoryName}</p>
</div>
<div>
<span className="font-medium text-gray-700">Узел:</span>
<p className="text-gray-600">{unitName}</p>
</div>
</div>
{detail.range && (
<div className="mt-3 text-sm">
<span className="font-medium text-gray-700">Период применения:</span>
<p className="text-gray-600">{detail.range}</p>
</div>
)}
{detail.attributes && detail.attributes.length > 0 && (
<div className="mt-3">
<span className="font-medium text-gray-700 text-sm">Характеристики:</span>
<div className="mt-1 space-y-1">
{detail.attributes.map((attr, index) => (
<div key={index} className="text-xs text-gray-600">
<span className="font-medium">{attr.name || attr.key}:</span> {attr.value}
</div>
))}
</div>
</div>
)}
<div className="mt-4 flex gap-2">
<button className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors">
Добавить в корзину
</button>
<button className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50 transition-colors">
Найти аналоги
</button>
</div>
</div>
);
};
interface UnitSectionProps {
unit: LaximoOEMUnit;
categoryName: string;
}
const UnitSection: React.FC<UnitSectionProps> = ({ unit, categoryName }) => {
return (
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
{unit.imageurl && (
<img
src={unit.imageurl.replace('%size%', '100')}
alt={unit.name}
className="w-16 h-16 object-contain border rounded"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
<div>
<h3 className="text-lg font-semibold text-gray-900">{unit.name}</h3>
{unit.code && (
<p className="text-sm text-gray-600">Код: {unit.code}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{unit.details.map((detail, index) => (
<OEMDetailCard
key={`${detail.detailid}-${index}`}
detail={detail}
categoryName={categoryName}
unitName={unit.name}
/>
))}
</div>
</div>
);
};
const OEMSearchSection: React.FC<OEMSearchSectionProps> = ({
catalogCode,
vehicleId,
ssd,
initialOEMNumber
}) => {
const [oemNumber, setOemNumber] = useState(initialOEMNumber || '');
const [searchOEMNumber, setSearchOEMNumber] = useState(initialOEMNumber || '');
const [executeSearch, { data, loading, error }] = useLazyQuery(SEARCH_LAXIMO_OEM, {
errorPolicy: 'all'
});
const handleSearch = () => {
if (oemNumber.trim()) {
console.log('🔍 Начинаем поиск OEM:', {
catalogCode,
vehicleId,
oemNumber: oemNumber.trim(),
ssd: ssd ? `${ssd.substring(0, 30)}...` : 'отсутствует'
});
setSearchOEMNumber(oemNumber.trim());
// Попробуем прямой fetch запрос для диагностики
const testFetch = async () => {
try {
console.log('🚀 Выполняем прямой fetch запрос...');
const response = await fetch('http://localhost:3000/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query TestOEM($catalogCode: String!, $vehicleId: String!, $oemNumber: String!, $ssd: String!) {
laximoOEMSearch(catalogCode: $catalogCode, vehicleId: $vehicleId, oemNumber: $oemNumber, ssd: $ssd) {
oemNumber
}
}
`,
variables: {
catalogCode,
vehicleId,
oemNumber: oemNumber.trim(),
ssd
}
})
});
const result = await response.json();
console.log('✅ Прямой fetch результат:', result);
if (result.errors) {
console.error('❌ GraphQL ошибки:', result.errors);
}
} catch (err) {
console.error('❌ Fetch ошибка:', err);
}
};
testFetch();
executeSearch({
variables: {
catalogCode,
vehicleId,
oemNumber: oemNumber.trim(),
ssd
}
});
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// Автоматически выполняем поиск при наличии initialOEMNumber
useEffect(() => {
if (initialOEMNumber && initialOEMNumber.trim() && catalogCode && vehicleId && ssd) {
const cleanOEM = initialOEMNumber.trim();
console.log('🔍 Автоматический поиск OEM при загрузке:', cleanOEM);
setOemNumber(cleanOEM);
handleSearch();
}
}, [initialOEMNumber]);
const searchResults: LaximoOEMResult | null = data?.laximoOEMSearch || null;
return (
<div className="space-y-6">
{/* Форма поиска */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Поиск деталей по артикулу (OEM номеру)
</h2>
{initialOEMNumber && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
🔍 Автоматический поиск по артикулу <span className="font-mono font-semibold">{initialOEMNumber}</span> из результатов поиска автомобилей
</p>
</div>
)}
<div className="flex gap-3">
<div className="flex-1">
<input
type="text"
value={oemNumber}
onChange={(e) => setOemNumber(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Введите OEM номер (например: 14G857507)"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<button
onClick={handleSearch}
disabled={!oemNumber.trim() || loading}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Поиск...' : 'Найти'}
</button>
</div>
<p className="text-sm text-gray-600 mt-2">
Поиск покажет, где в указанном автомобиле используется данная деталь
</p>
</div>
{/* Результаты поиска */}
{loading && searchOEMNumber && (
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-600">Поиск детали по номеру {searchOEMNumber}...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium mb-2">Ошибка поиска</h3>
<p className="text-red-700 text-sm">
Не удалось выполнить поиск по номеру "{searchOEMNumber}": {error.message}
</p>
{(() => { console.log('❌ GraphQL Error:', error); return null; })()}
</div>
)}
{searchResults && (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Результаты поиска: {searchResults.oemNumber}
</h2>
<p className="text-gray-600">
Найдено {searchResults.categories.length} категорий с {
searchResults.categories.reduce((total, cat) => total + cat.units.length, 0)
} узлами
</p>
</div>
{searchResults.categories.map((category) => (
<div key={category.categoryid} className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 border-b border-gray-200 pb-2">
📂 {category.name}
</h2>
{category.units.map((unit) => (
<UnitSection
key={unit.unitid}
unit={unit}
categoryName={category.name}
/>
))}
</div>
))}
</div>
)}
{searchOEMNumber && !loading && !searchResults && !error && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="text-yellow-800 font-medium mb-2">Детали не найдены</h3>
<p className="text-yellow-700 text-sm">
По номеру "{searchOEMNumber}" ничего не найдено в данном автомобиле.
Проверьте правильность номера или попробуйте использовать группы быстрого поиска.
</p>
</div>
)}
</div>
);
};
export default OEMSearchSection;

View File

@ -0,0 +1,90 @@
import React from 'react';
interface OrderTabsProps {
activeTab: string;
onTabChange: (tab: string) => void;
}
const OrderTabs: React.FC<OrderTabsProps> = ({ activeTab, onTabChange }) => {
const tabs = [
{ id: 'all', label: 'Все' },
{ id: 'current', label: 'Текущие' },
{ id: 'completed', label: 'Выполненные' },
{ id: 'cancelled', label: 'Отмененные' }
];
return (
<div className="order-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
>
<div className="tab-content">
{tab.label}
</div>
</button>
))}
<style jsx>{`
.order-tabs {
display: flex;
gap: 20px;
flex: 1;
}
.tab-button {
flex: 1;
background: #E6EDF6;
border: none;
border-radius: 12px;
padding: 0;
cursor: pointer;
transition: all 0.2s;
min-height: 48px;
}
.tab-button.active {
background: #EC1C24;
}
.tab-button:hover {
transform: translateY(-1px);
}
.tab-content {
padding: 14px 22px;
font-size: 18px;
font-weight: 500;
line-height: 1.2;
color: #000814;
border-radius: 12px;
width: 100%;
}
.tab-button.active .tab-content {
color: white;
}
@media (max-width: 768px) {
.order-tabs {
flex-direction: column;
gap: 10px;
}
.tab-button {
flex: none;
}
.tab-content {
font-size: 16px;
padding: 12px 18px;
}
}
`}</style>
</div>
);
};
export default OrderTabs;

View File

@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { useRouter } from 'next/router';
import { useLazyQuery } from '@apollo/client';
import { LaximoOEMResult } from '@/types/laximo';
import { SEARCH_LAXIMO_OEM } from '@/lib/graphql';
import BrandSelectionModal from './BrandSelectionModal';
interface PartDetailCardProps {
oem: string;
name: string;
brand?: string;
description?: string;
catalogCode: string;
vehicleId: string;
ssd: string;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
const PartDetailCard: React.FC<PartDetailCardProps> = ({
oem,
name,
brand,
description,
catalogCode,
vehicleId,
ssd,
isExpanded = false,
onToggleExpand
}) => {
const router = useRouter();
const [localExpanded, setLocalExpanded] = useState(false);
const [isBrandModalOpen, setIsBrandModalOpen] = useState(false);
// Используем локальное состояние если нет внешнего контроля
const expanded = onToggleExpand ? isExpanded : localExpanded;
const toggleExpand = onToggleExpand || (() => setLocalExpanded(!localExpanded));
const [executeOEMSearch, { data, loading, error }] = useLazyQuery(SEARCH_LAXIMO_OEM, {
errorPolicy: 'all'
});
const handleToggleExpand = () => {
toggleExpand();
// Загружаем данные только при первом раскрытии
if (!expanded && !data && !loading) {
executeOEMSearch({
variables: { catalogCode, vehicleId, oemNumber: oem, ssd }
});
}
};
const handleFindOffers = () => {
console.log('🔍 Выбрана деталь для поиска предложений:', name, 'OEM:', oem);
// Показываем модал выбора бренда
setIsBrandModalOpen(true);
};
const handleCloseBrandModal = () => {
setIsBrandModalOpen(false);
};
const handleOpenFullInfo = () => {
// Переход на отдельную страницу с детальной информацией о детали
const url = `/vehicle-search/${catalogCode}/${vehicleId}/part/${oem}?use_storage=1&ssd_length=${ssd.length}`;
router.push(url);
};
const oemResult: LaximoOEMResult | null = data?.laximoOEMSearch || null;
const totalUnits = oemResult?.categories.reduce((total, cat) => total + cat.units.length, 0) || 0;
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md hover:border-red-300 transition-all duration-200 cursor-pointer">
{/* Основная информация - кликабельная область */}
<div
className="p-4 hover:bg-gray-50 transition-colors"
onClick={handleFindOffers}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2 hover:text-red-600 transition-colors">
{name}
</h3>
<div className="flex flex-wrap items-center gap-4 mb-3">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">OEM:</span>
<span className="font-mono text-sm font-medium text-red-600 bg-red-50 px-2 py-1 rounded">
{oem}
</span>
</div>
{brand && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Бренд:</span>
<span className="text-sm font-medium text-blue-600">{brand}</span>
</div>
)}
</div>
{description && (
<p className="text-sm text-gray-600 mb-3">{description}</p>
)}
{/* Подсказка о переходе */}
<div className="text-sm text-gray-500 italic">
Нажмите, чтобы найти предложения для этой детали
</div>
{/* Краткая информация о применимости */}
{oemResult && (
<div className="text-sm text-gray-600 mt-2">
<span className="font-medium">Применимость:</span>
<span className="ml-1">
{oemResult.categories.length} категорий, {totalUnits} узлов
</span>
</div>
)}
</div>
{/* Иконка перехода */}
<div className="ml-4 text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
{/* Дополнительные действия */}
<div className="px-4 pb-4 flex flex-wrap gap-2 border-t border-gray-100">
<button
onClick={(e) => {
e.stopPropagation();
handleToggleExpand();
}}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
>
<svg
className={`w-4 h-4 mr-2 transform transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
{expanded ? 'Скрыть применимость' : 'Показать применимость'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleOpenFullInfo();
}}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Подробно
</button>
</div>
{/* Развернутая информация */}
{expanded && (
<div className="border-t border-gray-200 bg-gray-50">
<div className="p-4">
<h4 className="text-sm font-medium text-gray-900 mb-3">
Применимость в автомобиле:
</h4>
{loading && (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-red-600"></div>
<span className="ml-2 text-sm text-gray-600">Загружаем информацию...</span>
</div>
)}
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg p-3">
Ошибка загрузки: {error.message}
</div>
)}
{oemResult && (
<div className="space-y-3">
{oemResult.categories.map((category) => (
<div key={category.categoryid} className="bg-white rounded-lg border border-gray-200 p-3">
<h5 className="text-sm font-semibold text-gray-900 mb-2 flex items-center">
<svg className="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{category.name}
</h5>
<div className="space-y-2">
{category.units.map((unit) => (
<div key={unit.unitid} className="ml-4 border-l-2 border-gray-200 pl-3">
<div className="text-sm font-medium text-gray-800 flex items-center">
<svg className="w-3 h-3 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{unit.name}
{unit.code && (
<span className="ml-2 text-xs text-gray-500">({unit.code})</span>
)}
</div>
{unit.details.map((detail, index) => (
<div key={`${detail.detailid}-${index}`} className="ml-4 mt-1">
<div className="text-xs text-gray-600 flex items-start">
<svg className="w-3 h-3 mr-1 mt-0.5 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1">
<div>{detail.name}</div>
{detail.amount && (
<div className="mt-1">
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-xs">
Количество: {detail.amount}
</span>
</div>
)}
{detail.range && (
<div className="mt-1 text-xs text-gray-500">
Период: {detail.range}
</div>
)}
</div>
</div>
</div>
))}
</div>
))}
</div>
</div>
))}
</div>
)}
{!loading && !error && !oemResult && (
<div className="text-sm text-gray-500 text-center py-4">
Информация о применимости не найдена
</div>
)}
</div>
</div>
)}
{/* Модал выбора бренда */}
<BrandSelectionModal
isOpen={isBrandModalOpen}
onClose={handleCloseBrandModal}
articleNumber={oem}
detailName={name}
/>
</div>
);
};
export default PartDetailCard;

View File

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { useLazyQuery } from '@apollo/client';
import { FIND_LAXIMO_APPLICABLE_VEHICLES } from '@/lib/graphql';
import { LaximoVehicleSearchResult } from '@/types/laximo';
interface PartSearchFormProps {
catalogCode: string;
onVehiclesFound: (vehicles: LaximoVehicleSearchResult[]) => void;
onSearchStart?: () => void;
isLoading: boolean;
placeholder?: string;
}
const PartSearchForm: React.FC<PartSearchFormProps> = ({
catalogCode,
onVehiclesFound,
onSearchStart,
isLoading,
placeholder = '1J0853665BB41'
}) => {
const [partNumber, setPartNumber] = useState('');
// Запрос для поиска автомобилей по артикулу в каталоге
const [findApplicableVehicles, { loading: searchLoading }] = useLazyQuery(FIND_LAXIMO_APPLICABLE_VEHICLES, {
onCompleted: (data) => {
const vehicles = data.laximoFindApplicableVehicles || [];
console.log('✅ Найдено автомобилей по артикулу:', vehicles.length);
onVehiclesFound(vehicles);
},
onError: (error) => {
console.error('❌ Ошибка поиска автомобилей по артикулу:', error);
onVehiclesFound([]);
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (partNumber.trim()) {
console.log('🔍 Поиск автомобилей по артикулу:', partNumber, 'в каталоге:', catalogCode);
onSearchStart?.();
findApplicableVehicles({
variables: {
catalogCode,
partNumber: partNumber.trim().toUpperCase()
}
});
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
setPartNumber(value);
};
const loading = isLoading || searchLoading;
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
value={partNumber}
onChange={handleInputChange}
placeholder={placeholder}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 text-lg font-mono"
disabled={loading}
/>
<p className="mt-2 text-sm text-gray-500">
Введите артикул (OEM номер) детали для поиска применимых автомобилей в каталоге
</p>
<p className="text-xs text-gray-400 mt-1">
Например: {placeholder}
</p>
</div>
<button
type="submit"
disabled={loading || !partNumber.trim()}
className="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 min-w-[120px]"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Поиск...
</>
) : (
<>
<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>
</form>
</div>
);
};
export default PartSearchForm;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
interface PartsSearchFormProps {
onSearch: (partNumber: string) => void;
isLoading: boolean;
}
const PartsSearchForm: React.FC<PartsSearchFormProps> = ({
onSearch,
isLoading
}) => {
const [partNumber, setPartNumber] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (partNumber.trim()) {
onSearch(partNumber.trim());
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
value={partNumber}
onChange={(e) => setPartNumber(e.target.value)}
placeholder="Введите артикул (OEM)"
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 text-lg"
disabled={isLoading}
/>
<p className="mt-2 text-sm text-gray-500">
Введите артикул оригинальной детали для поиска применимых автомобилей
</p>
</div>
<button
type="submit"
disabled={isLoading || !partNumber.trim()}
className="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 min-w-[120px]"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Поиск...
</>
) : (
<>
<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>
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Примеры артикулов:</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-xs">
<span>11427525333</span>
<span>51718208317</span>
<span>32414037820</span>
<span>12317565026</span>
<span>51177286520</span>
<span>63127165711</span>
</div>
</div>
</form>
);
};
export default PartsSearchForm;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
interface PlateSearchFormProps {
onSearch: (plateNumber: string) => void;
isLoading: boolean;
placeholder?: string;
}
const PlateSearchForm: React.FC<PlateSearchFormProps> = ({
onSearch,
isLoading,
placeholder = 'А123БВ177'
}) => {
const [plateNumber, setPlateNumber] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (plateNumber.trim()) {
onSearch(plateNumber.trim().toUpperCase());
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Убираем пробелы и дефисы, приводим к верхнему регистру
const value = e.target.value.replace(/[\s-]/g, '').toUpperCase();
setPlateNumber(value);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
value={plateNumber}
onChange={handleInputChange}
placeholder={placeholder}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 text-lg font-mono"
disabled={isLoading}
maxLength={9}
/>
<p className="mt-2 text-sm text-gray-500">
Введите государственный номер автомобиля без пробелов и дефисов
</p>
<p className="text-xs text-gray-400 mt-1">
Поддерживаются все виды государственных номеров, действующие в РФ
</p>
</div>
<button
type="submit"
disabled={isLoading || !plateNumber.trim()}
className="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 min-w-[120px]"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Поиск...
</>
) : (
<>
<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>
</form>
);
};
export default PlateSearchForm;

View File

@ -0,0 +1,47 @@
import React from "react";
interface ProductCardProps {
image: string;
discount: string;
price: string;
oldPrice: string;
title: string;
brand: string;
size?: "normal" | "small";
}
const ProductCard: React.FC<ProductCardProps> = ({ image, discount, price, oldPrice, title, brand, size = "normal" }) => (
<div className={size === "small" ? "w-layout-vflex flex-block-15 small-card" : "w-layout-vflex flex-block-15"}>
<div className="div-block-4">
<img
src={image}
loading="lazy"
width={size === "small" ? 150 : 210}
height={size === "small" ? 135 : 190}
alt=""
className={size === "small" ? "image-5 small-img" : "image-5"}
/>
<div className="text-block-7">{discount}</div>
</div>
<div className="div-block-3">
<div className="w-layout-hflex flex-block-16">
<div className="text-block-8">{price}</div>
<div className="text-block-9">{oldPrice}</div>
</div>
<div className="text-block-10">{title}</div>
<div className="text-block-11">{brand}</div>
</div>
<a href="#" className="link-block-4-copy w-inline-block">
<div className="div-block-25">
<span className="icon-setting w-embed">
<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>
</div>
<div className="text-block-6">Купить</div>
</a>
</div>
);
export default ProductCard;

View File

@ -0,0 +1,151 @@
import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
interface ProductListCardProps {
id?: string;
productId?: string;
offerKey?: string;
image: string;
title: string;
brand: string;
price: string;
oldPrice?: string;
discount?: string;
rating?: number;
stock?: string;
delivery?: string;
address?: string;
recommended?: boolean;
isExternal?: boolean;
currency?: string;
deliveryTime?: string;
warehouse?: string;
supplier?: string;
}
const ProductListCard: React.FC<ProductListCardProps> = ({
id,
productId,
offerKey,
image,
title,
brand,
price,
oldPrice,
discount,
rating = 4.8,
stock = "444 шт",
delivery = "Сегодня с 18:00",
address = "Москва ЦС (Новая Рига)",
recommended = false,
isExternal = false,
currency = "RUB",
deliveryTime,
warehouse,
supplier,
}) => {
const [count, setCount] = useState(1);
const { addItem } = useCart();
// Функция для парсинга цены из строки
const parsePrice = (priceStr: string): number => {
const cleanPrice = priceStr.replace(/[^\d.,]/g, '').replace(',', '.');
return parseFloat(cleanPrice) || 0;
};
// Функция для парсинга количества в наличии
const parseStock = (stockStr: string): number => {
const match = stockStr.match(/\d+/);
return match ? parseInt(match[0]) : 0;
};
const handleAddToCart = () => {
const availableStock = parseStock(stock);
// Проверяем наличие
if (count > availableStock) {
alert(`Недостаточно товара в наличии. Доступно: ${availableStock} шт.`);
return;
}
const numericPrice = parsePrice(price);
const numericOldPrice = oldPrice ? parsePrice(oldPrice) : undefined;
addItem({
productId: productId,
offerKey: offerKey,
name: title,
description: `${brand} - ${title}`,
brand: brand,
price: numericPrice,
originalPrice: numericOldPrice,
currency: currency,
quantity: count,
deliveryTime: deliveryTime || delivery,
warehouse: warehouse || address,
supplier: supplier,
isExternal: isExternal,
image: image,
});
// Показываем уведомление о добавлении
alert(`Товар "${title}" добавлен в корзину (${count} шт.)`);
};
return (
<div className="w-layout-hflex product-item-search">
<div className="w-layout-hflex flex-block-81">
<div className="w-layout-hflex info-block-search-copy">
<div className="w-layout-hflex raiting">
<img src="/images/Star-1.svg" alt="Рейтинг" className="image-8" />
<div className="text-block-22">{rating}</div>
</div>
<div className="pcs-search">{stock}</div>
<div className="pcs-search">{delivery}</div>
</div>
<div className="w-layout-hflex info-block-product-card-search">
{recommended && (
<>
<div className="w-layout-hflex item-recommend">
<img src="/images/ri_refund-fill.svg" alt="Рекомендуем" />
</div>
<div className="text-block-25">Рекомендуем</div>
</>
)}
</div>
<div className="price">{price}</div>
</div>
<div className="w-layout-hflex add-to-cart-block">
<div className="w-layout-hflex flex-block-82">
<div className="w-layout-hflex pcs">
<div className="minus-plus" onClick={() => setCount(Math.max(1, count - 1))}>
<img src="/images/minus_icon.svg" alt="-" />
</div>
<div className="input-pcs">
<div className="text-block-26">{count}</div>
</div>
<div className="minus-plus" onClick={() => {
const availableStock = parseStock(stock);
if (count < availableStock) {
setCount(count + 1);
} else {
alert(`Максимальное количество: ${availableStock} шт.`);
}
}}>
<img src="/images/plus_icon.svg" alt="+" />
</div>
</div>
<button
onClick={handleAddToCart}
className="button-icon w-inline-block"
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
<img src="/images/cart_icon.svg" alt="В корзину" className="image-11" />
</button>
</div>
</div>
</div>
);
};
export default ProductListCard;

View File

@ -0,0 +1,291 @@
import React from 'react';
import { useIsClient } from '@/lib/useIsomorphicLayoutEffect';
interface ProfileSidebarProps {
activeItem: string;
}
const ProfileSidebar: React.FC<ProfileSidebarProps> = ({ activeItem }) => {
const isClient = useIsClient();
const menuItems = [
{ id: 'orders', icon: 'order', label: 'Заказы', href: '/profile-orders' },
{ id: 'history', icon: 'history', label: 'История поиска', href: '/profile-history' },
{ id: 'notifications', icon: 'bell', label: 'Уведомления', href: '/profile-notifications' },
{ id: 'messages', icon: 'message', label: 'Оповещения', href: '/profile-messages' },
{ id: 'addresses', icon: 'location', label: 'Адреса доставки', href: '/profile-addresses' },
{ id: 'garage', icon: 'garage', label: 'Гараж', href: '/profile-garage' },
{ id: 'settings', icon: 'settings', label: 'Настройки аккаунта', href: '/profile-settings' }
];
const handleLogout = () => {
if (isClient) {
localStorage.removeItem('authToken');
localStorage.removeItem('userData');
window.location.href = '/';
}
};
const financeItems = [
{ id: 'balance', icon: 'wallet', label: 'Баланс', href: '/profile-balance' },
{ id: 'requisites', icon: 'case', label: 'Реквизиты', href: '/profile-requisites' },
{ id: 'mutual', icon: 'finance_check', label: 'Взаиморасчеты', href: '/profile-mutual' },
{ id: 'acts', icon: 'order', label: 'Акты сверки', href: '/profile-acts' }
];
const renderIcon = (iconType: string, isActive: boolean) => {
const color = isActive ? '#424F60' : '#424F60';
switch (iconType) {
case 'order':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2L6 16H14L17 2" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case 'history':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="8" stroke={color} strokeWidth="2"/>
<path d="m10 6 0 4 3 3" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
case 'bell':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 8C6 5.79086 7.79086 4 10 4C12.2091 4 14 5.79086 14 8C14 11 15 12 15 12H5C5 12 6 11 6 8Z" stroke={color} strokeWidth="2"/>
<path d="M9 16C9.26522 16.3333 9.63043 16.5 10 16.5C10.3696 16.5 10.7348 16.3333 11 16" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
case 'message':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12L12 8M8 8L12 12M18 4H2V14H6L10 18L14 14H18V4Z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case 'location':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 11C11.1046 11 12 10.1046 12 9C12 7.89543 11.1046 7 10 7C8.89543 7 8 7.89543 8 9C8 10.1046 8.89543 11 10 11Z" stroke={color} strokeWidth="2"/>
<path d="M17 9C17 13.5 10 19 10 19C10 19 3 13.5 3 9C3 5.68629 6.31371 2 10 2C13.6863 2 17 5.68629 17 9Z" stroke={color} strokeWidth="2"/>
</svg>
);
case 'garage':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 8L10 3L18 8V18H14V12H6V18H2V8Z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case 'settings':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="3" stroke={color} strokeWidth="2"/>
<path d="M10 1V3M10 17V19M18.66 9L16.66 10M3.34 10L1.34 9M15.66 4.34L14.24 5.76M5.76 14.24L4.34 15.66M15.66 15.66L14.24 14.24M5.76 5.76L4.34 4.34" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
case 'wallet':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="5" width="16" height="12" rx="2" stroke={color} strokeWidth="2"/>
<path d="M2 7H18M5 3H15" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
case 'case':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="7" width="16" height="10" rx="2" stroke={color} strokeWidth="2"/>
<path d="M6 7V5C6 3.89543 6.89543 3 8 3H12C13.1046 3 14 3.89543 14 5V7M2 11H18" stroke={color} strokeWidth="2" strokeLinecap="round"/>
</svg>
);
case 'finance_check':
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="3" width="16" height="14" rx="2" stroke={color} strokeWidth="2"/>
<path d="M8 12L10 14L15 9M2 7H18" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
default:
return null;
}
};
return (
<div className="profile-sidebar">
<div className="sidebar-section">
<div className="sidebar-header">
<h3 className="sidebar-title">Личный кабинет</h3>
</div>
<div className="sidebar-menu">
{menuItems
.filter(item => !['notifications', 'messages'].includes(item.id)) // Временно скрываем уведомления и оповещения
.map((item) => (
<a
key={item.id}
href={item.href}
className={`sidebar-item ${activeItem === item.id ? 'active' : ''}`}
>
<div className="sidebar-icon">
{renderIcon(item.icon, activeItem === item.id)}
</div>
<span className="sidebar-label">{item.label}</span>
</a>
))}
</div>
</div>
<div className="sidebar-section">
<div className="sidebar-header">
<h3 className="sidebar-title">Финансы</h3>
</div>
<div className="sidebar-menu">
{financeItems.map((item) => (
<a
key={item.id}
href={item.href}
className={`sidebar-item ${activeItem === item.id ? 'active' : ''}`}
>
<div className="sidebar-icon">
{renderIcon(item.icon, activeItem === item.id)}
</div>
<span className="sidebar-label">{item.label}</span>
</a>
))}
</div>
</div>
{/* Кнопка выхода */}
<div className="logout-section">
<button onClick={handleLogout} className="logout-button">
<div className="sidebar-icon">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="#424F60" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 17L21 12L16 7" stroke="#424F60" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke="#424F60" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<span className="sidebar-label">Выйти</span>
</button>
</div>
<style jsx>{`
.profile-sidebar {
width: 320px;
background: white;
border-radius: 20px;
padding: 15px 15px 25px;
display: flex;
flex-direction: column;
gap: 12px;
height: fit-content;
align-self: flex-start;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 0;
}
.sidebar-header {
padding: 10px 10px 0px;
}
.sidebar-title {
font-size: 20px;
font-weight: 600;
color: #000814;
margin: 0;
}
.sidebar-menu {
display: flex;
flex-direction: column;
gap: 3px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
text-decoration: none;
color: #424F60;
font-size: 16px;
font-weight: 400;
transition: all 0.2s;
width: 290px;
}
.sidebar-item:hover {
background: #E6EDF6;
}
.sidebar-item.active {
background: #E6EDF6;
color: #424F60;
}
.sidebar-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-label {
flex: 1;
}
.logout-section {
margin-top: auto;
padding-top: 20px;
border-top: 1px solid #E6EDF6;
}
.logout-button {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: none;
border: none;
cursor: pointer;
color: #424F60;
font-size: 16px;
font-weight: 400;
transition: all 0.2s;
width: 290px;
text-align: left;
}
.logout-button:hover {
background: #E6EDF6;
color: #EC1C24;
}
.logout-button:hover svg path {
stroke: #EC1C24;
}
@media (max-width: 768px) {
.profile-sidebar {
width: 100%;
}
.sidebar-item {
width: 100%;
}
.logout-button {
width: 100%;
}
}
`}</style>
</div>
);
};
export default ProfileSidebar;

View File

@ -0,0 +1,603 @@
import React, { useState } from 'react';
import { useRouter } from 'next/router';
import { useQuery } from '@apollo/client';
import { GET_LAXIMO_QUICK_GROUPS, GET_LAXIMO_QUICK_DETAIL } from '@/lib/graphql';
import { LaximoQuickGroup, LaximoQuickDetail, LaximoUnit } from '@/types/laximo';
import BrandSelectionModal from './BrandSelectionModal';
import UnitDetailsSection from './UnitDetailsSection';
interface QuickGroupsSectionProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
}
interface QuickGroupItemProps {
group: LaximoQuickGroup;
level: number;
onGroupClick: (group: LaximoQuickGroup) => void;
}
const QuickGroupItem: React.FC<QuickGroupItemProps> = ({ group, level, onGroupClick }) => {
const [isExpanded, setIsExpanded] = useState(false);
const hasChildren = group.children && group.children.length > 0;
const canShowDetails = group.link; // Только группы с link=true могут показывать детали
const handleGroupClick = () => {
if (canShowDetails) {
onGroupClick(group);
} else if (hasChildren) {
setIsExpanded(!isExpanded);
}
};
return (
<div className="w-full">
<div
onClick={handleGroupClick}
className={`
flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors
${canShowDetails
? 'bg-white hover:bg-red-50 border-gray-200 hover:border-red-300'
: hasChildren
? 'bg-gray-50 hover:bg-gray-100 border-gray-200'
: 'bg-gray-100 border-gray-200 cursor-not-allowed'
}
${level > 0 ? 'ml-4' : ''}
`}
>
<div className="flex items-center space-x-3">
{hasChildren && (
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
<div>
<h3 className={`font-medium ${canShowDetails ? 'text-gray-900' : 'text-gray-600'}`}>
{group.name}
</h3>
<p className="text-sm text-gray-500">
ID: {group.quickgroupid}
{canShowDetails && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Доступен поиск
</span>
)}
</p>
</div>
</div>
{canShowDetails && (
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
{/* Дочерние группы */}
{hasChildren && isExpanded && (
<div className="mt-2 space-y-2">
{group.children!.map((childGroup) => (
<QuickGroupItem
key={childGroup.quickgroupid}
group={childGroup}
level={level + 1}
onGroupClick={onGroupClick}
/>
))}
</div>
)}
</div>
);
};
interface QuickDetailSectionProps {
catalogCode: string;
vehicleId: string;
selectedGroup: LaximoQuickGroup;
ssd: string;
onBack: () => void;
}
const QuickDetailSection: React.FC<QuickDetailSectionProps> = ({
catalogCode,
vehicleId,
selectedGroup,
ssd,
onBack
}) => {
console.log('🚀 QuickDetailSection рендерится с параметрами:', { catalogCode, vehicleId, selectedGroup, ssd });
const router = useRouter();
const [isBrandModalOpen, setIsBrandModalOpen] = useState(false);
const [selectedDetail, setSelectedDetail] = useState<any>(null);
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set());
const [selectedUnit, setSelectedUnit] = useState<LaximoUnit | null>(null);
const handleDetailClick = (detail: any) => {
const articleNumber = detail.oem;
console.log('🔍 Клик по детали из QuickGroups для выбора бренда:', { articleNumber, name: detail.name });
setSelectedDetail(detail);
setIsBrandModalOpen(true);
};
const handleCloseBrandModal = () => {
setIsBrandModalOpen(false);
setSelectedDetail(null);
};
const toggleUnitExpansion = (unitId: string) => {
const newExpanded = new Set(expandedUnits);
if (newExpanded.has(unitId)) {
newExpanded.delete(unitId);
} else {
newExpanded.add(unitId);
}
setExpandedUnits(newExpanded);
};
const handleUnitClick = (unit: LaximoUnit) => {
console.log('🔍 Выбран узел для детального просмотра:', unit.name, 'ID:', unit.unitid);
setSelectedUnit(unit);
};
const handleBackFromUnit = () => {
setSelectedUnit(null);
};
const { data: quickDetailData, loading: quickDetailLoading, error: quickDetailError } = useQuery<{ laximoQuickDetail: LaximoQuickDetail }>(
GET_LAXIMO_QUICK_DETAIL,
{
variables: {
catalogCode,
vehicleId,
quickGroupId: selectedGroup.quickgroupid,
ssd
},
skip: !catalogCode || !vehicleId || !selectedGroup.quickgroupid || !ssd,
errorPolicy: 'all',
fetchPolicy: 'cache-and-network' // Принудительно запрашиваем данные
}
);
const quickDetail = quickDetailData?.laximoQuickDetail;
// Добавляем отладочную информацию
console.log('🔍 QuickDetailSection Debug:');
console.log('📊 quickDetailData:', quickDetailData);
console.log('📋 quickDetail:', quickDetail);
console.log('🏗️ quickDetail.units:', quickDetail?.units);
console.log('⚙️ Variables:', { catalogCode, vehicleId, quickGroupId: selectedGroup.quickgroupid, ssd });
// Если выбран узел для детального просмотра, показываем UnitDetailsSection
if (selectedUnit) {
return (
<UnitDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
ssd={ssd}
unitId={selectedUnit.unitid}
unitName={selectedUnit.name}
onBack={handleBackFromUnit}
/>
);
}
if (quickDetailLoading) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Назад к группам</span>
</button>
</div>
<div className="bg-white rounded-lg border p-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-2 text-gray-600">Загружаем детали...</p>
</div>
</div>
);
}
if (quickDetailError) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Назад к группам</span>
</button>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h3 className="text-lg font-medium text-red-600 mb-2">Ошибка загрузки деталей</h3>
<p className="text-red-700">Не удалось загрузить детали для группы "{selectedGroup.name}"</p>
<p className="text-sm text-red-600 mt-2">Ошибка: {quickDetailError.message}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Навигация */}
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center space-x-2 text-gray-600 hover:text-gray-900"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Назад к группам</span>
</button>
<div className="text-sm text-gray-500">
Группа: {selectedGroup.quickgroupid}
</div>
</div>
{/* Заголовок */}
<div className="bg-white rounded-lg border p-6">
<h2 className="text-xl font-bold text-gray-900 mb-2">
{selectedGroup.name}
</h2>
<p className="text-gray-600">
Детали и узлы в группе быстрого поиска
</p>
</div>
{/* Детали */}
{quickDetail && quickDetail.units ? (
<div className="space-y-4">
{quickDetail.units.map((unit) => (
<div key={unit.unitid} className="bg-white rounded-lg border p-6">
<div className="flex items-start space-x-6 mb-4">
{/* Изображение узла */}
{(unit.imageurl || unit.largeimageurl) && (() => {
const finalImageUrl = unit.largeimageurl ? unit.largeimageurl.replace('%size%', '250') : unit.imageurl?.replace('%size%', '250') || '';
console.log('🖼️ Загружаем изображение:', finalImageUrl);
console.log('🔍 Raw URLs:', { imageurl: unit.imageurl, largeimageurl: unit.largeimageurl });
return (
<div className="flex-shrink-0">
<div className="text-xs text-gray-500 mb-2 p-2 bg-yellow-100 rounded">
Debug: {finalImageUrl}
</div>
<img
src={finalImageUrl}
alt={unit.name}
className="w-48 h-48 object-contain bg-gray-50 rounded-lg border border-gray-200 hover:border-red-300 transition-colors cursor-pointer"
onLoad={() => {
console.log('✅ Изображение загружено успешно:', finalImageUrl);
}}
onError={(e) => {
console.error('❌ Ошибка загрузки изображения:', finalImageUrl);
console.error('❌ Event:', e);
const img = e.target as HTMLImageElement;
img.style.border = '2px solid red';
img.alt = 'Ошибка загрузки';
}}
onClick={() => {
// Открываем изображение в новой вкладке
const imageUrl = unit.largeimageurl ? unit.largeimageurl.replace('%size%', '400') : unit.imageurl?.replace('%size%', '400') || '';
if (imageUrl) {
window.open(imageUrl, '_blank');
}
}}
/>
</div>
);
})()}
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<button
onClick={() => toggleUnitExpansion(unit.unitid)}
className="flex-1 text-left hover:bg-gray-50 p-2 rounded-lg transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
{unit.name}
{unit.details && unit.details.length > 0 && (
<svg
className={`w-5 h-5 ml-2 transform transition-transform ${
expandedUnits.has(unit.unitid) ? 'rotate-90' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</h3>
{unit.code && (
<p className="text-sm text-gray-500">Код: {unit.code}</p>
)}
{unit.details && unit.details.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
{unit.details.length} деталей Нажмите для {expandedUnits.has(unit.unitid) ? 'скрытия' : 'показа'}
</p>
)}
</div>
<div className="flex flex-col space-y-2">
{unit.unitid && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
ID: {unit.unitid}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation(); // Предотвращаем всплытие события
handleUnitClick(unit);
}}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-sm font-medium transition-colors"
>
Подробнее
</button>
</div>
</div>
</button>
</div>
</div>
</div>
{unit.details && unit.details.length > 0 && expandedUnits.has(unit.unitid) && (
<div className="border-t pt-4">
<h4 className="text-sm font-medium text-gray-900 mb-3">Детали узла "{unit.name}":</h4>
<div className="space-y-3">
{unit.details.map((detail) => (
<div key={detail.detailid} className="p-4 bg-gray-50 rounded-lg border border-gray-200 hover:border-red-300 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<h5 className="font-medium text-gray-900 mb-2">{detail.name}</h5>
<div className="space-y-1">
<p className="text-sm text-gray-600">
<span className="font-medium">OEM:</span>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-mono ml-2">
{detail.oem}
</span>
</p>
{detail.brand && (
<p className="text-sm text-gray-600">
<span className="font-medium">Бренд:</span>
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium ml-2">
{detail.brand}
</span>
</p>
)}
{detail.note && (
<p className="text-sm text-gray-600">
<span className="font-medium">Примечание:</span> {detail.note}
</p>
)}
</div>
{detail.attributes && detail.attributes.length > 0 && (
<div className="mt-2 space-y-1">
{detail.attributes.map((attr, index) => (
<p key={index} className="text-xs text-gray-500">
<span className="font-medium">{attr.name || attr.key}:</span> {attr.value}
</p>
))}
</div>
)}
</div>
<div className="flex flex-col space-y-2 ml-4">
<button
onClick={() => handleDetailClick(detail)}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm font-medium transition-colors"
>
Найти предложения
</button>
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-200 text-gray-800 text-center">
{detail.detailid}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
) : (
<div className="bg-white rounded-lg border p-6 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2M4 13h2m8-8v2m0 6v2" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Нет доступных деталей</h3>
<p className="mt-1 text-sm text-gray-500">
В данной группе не найдено деталей или узлов.
</p>
</div>
)}
{selectedDetail && (
<BrandSelectionModal
isOpen={isBrandModalOpen}
onClose={handleCloseBrandModal}
articleNumber={selectedDetail.oem}
detailName={selectedDetail.name}
/>
)}
</div>
);
};
const QuickGroupsSection: React.FC<QuickGroupsSectionProps> = ({
catalogCode,
vehicleId,
ssd
}) => {
const [selectedGroup, setSelectedGroup] = useState<LaximoQuickGroup | null>(null);
// Получаем список групп быстрого поиска
const { data: quickGroupsData, loading: quickGroupsLoading, error: quickGroupsError } = useQuery<{ laximoQuickGroups: LaximoQuickGroup[] }>(
GET_LAXIMO_QUICK_GROUPS,
{
variables: {
catalogCode,
vehicleId,
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || !vehicleId,
errorPolicy: 'all'
}
);
const handleGroupClick = (group: LaximoQuickGroup) => {
if (!ssd || ssd.trim() === '') {
alert('Ошибка: Для поиска деталей необходимы данные автомобиля (SSD). Пожалуйста, выберите автомобиль заново.');
return;
}
console.log('🔍 Открываем детали группы быстрого поиска:', group.quickgroupid);
setSelectedGroup(group);
};
const handleBackToGroups = () => {
setSelectedGroup(null);
};
// Если выбрана группа для просмотра деталей
if (selectedGroup && ssd) {
return (
<QuickDetailSection
catalogCode={catalogCode}
vehicleId={vehicleId}
selectedGroup={selectedGroup}
ssd={ssd}
onBack={handleBackToGroups}
/>
);
}
if (quickGroupsLoading) {
return (
<div className="space-y-4">
<div className="bg-white rounded-lg border p-6">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</div>
</div>
);
}
if (quickGroupsError) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Ошибка загрузки групп быстрого поиска
</h3>
<div className="mt-2 text-sm text-red-700">
<p>Не удалось загрузить группы быстрого поиска для данного автомобиля.</p>
<p className="mt-1">Ошибка: {quickGroupsError.message}</p>
</div>
</div>
</div>
</div>
);
}
const quickGroups = quickGroupsData?.laximoQuickGroups || [];
if (quickGroups.length === 0) {
return (
<div className="bg-white rounded-lg border p-6 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Группы быстрого поиска недоступны</h3>
<p className="mt-1 text-sm text-gray-500">
Для данного автомобиля не найдено групп быстрого поиска.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Группы быстрого поиска
</h2>
<p className="text-gray-600 text-sm">
Выберите группу для поиска запчастей. Доступны только группы с активным поиском деталей.
</p>
</div>
<div className="space-y-3">
{quickGroups.map((group) => (
<QuickGroupItem
key={group.quickgroupid}
group={group}
level={0}
onGroupClick={handleGroupClick}
/>
))}
</div>
{/* Информационная панель */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Информация о группах быстрого поиска
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc list-inside space-y-1">
<li>Зеленая метка "Доступен поиск" указывает на возможность поиска деталей в группе</li>
<li>Группы без метки служат для организации структуры каталога</li>
<li>Нажмите на группу с активным поиском для просмотра деталей</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
};
export default QuickGroupsSection;

View File

@ -0,0 +1,22 @@
import React from 'react';
const SearchForm: React.FC = () => (
<div className="w-container">
<h1>Search results</h1>
<form action="/search" className="w-form">
<label htmlFor="search">Search</label>
<input
className="w-input"
maxLength={256}
name="query"
placeholder="Search…"
type="search"
id="search"
required
/>
<input type="submit" className="w-button" value="Search" />
</form>
</div>
);
export default SearchForm;

View File

@ -0,0 +1,24 @@
const ThankInfo = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Спасибо за покупку</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Спасибо за покупку!</h1>
</div>
</div>
</div>
</div>
</section>
);
export default ThankInfo;

View File

@ -0,0 +1,688 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@apollo/client';
import { useRouter } from 'next/router';
import { GET_LAXIMO_UNIT_INFO, GET_LAXIMO_UNIT_DETAILS, GET_LAXIMO_UNIT_IMAGE_MAP } from '@/lib/graphql';
import { LaximoUnitInfo, LaximoUnitDetail, LaximoUnitImageMap, LaximoImageCoordinate } from '@/types/laximo';
import BrandSelectionModal from './BrandSelectionModal';
interface UnitDetailsSectionProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
unitId: string;
unitName: string;
onBack: () => void;
}
const UnitDetailsSection: React.FC<UnitDetailsSectionProps> = ({
catalogCode,
vehicleId,
ssd,
unitId,
unitName,
onBack
}) => {
const router = useRouter();
const [selectedImageSize, setSelectedImageSize] = useState<string>('250');
const [imageScale, setImageScale] = useState<{ x: number; y: number }>({ x: 1, y: 1 });
const [imageLoadTimeout, setImageLoadTimeout] = useState<NodeJS.Timeout | null>(null);
const [isBrandModalOpen, setIsBrandModalOpen] = useState(false);
const [selectedDetail, setSelectedDetail] = useState<LaximoUnitDetail | null>(null);
// Получаем информацию об узле
const { data: unitInfoData, loading: unitInfoLoading, error: unitInfoError } = useQuery<{ laximoUnitInfo: LaximoUnitInfo }>(
GET_LAXIMO_UNIT_INFO,
{
variables: {
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
},
skip: !catalogCode || !vehicleId || !unitId,
errorPolicy: 'all'
}
);
// Получаем детали узла
const { data: unitDetailsData, loading: unitDetailsLoading, error: unitDetailsError } = useQuery<{ laximoUnitDetails: LaximoUnitDetail[] }>(
GET_LAXIMO_UNIT_DETAILS,
{
variables: {
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
},
skip: !catalogCode || !vehicleId || !unitId,
errorPolicy: 'all'
}
);
// Получаем карту изображений узла
const { data: unitImageMapData, loading: unitImageMapLoading, error: unitImageMapError } = useQuery<{ laximoUnitImageMap: LaximoUnitImageMap }>(
GET_LAXIMO_UNIT_IMAGE_MAP,
{
variables: {
catalogCode,
vehicleId,
unitId,
ssd: ssd || ''
},
skip: !catalogCode || !vehicleId || !unitId,
errorPolicy: 'all'
}
);
// Используем данные из API или показываем сообщение о загрузке
const unitInfo = unitInfoData?.laximoUnitInfo;
console.log('📊 Данные узла из GraphQL:', { unitInfoData, unitInfo });
// Эффект для установки таймаута загрузки изображения
useEffect(() => {
if (unitInfo?.imageurl) {
console.log('🔄 Начинаем загрузку изображения:', getImageUrl(unitInfo.imageurl, selectedImageSize));
// Устанавливаем таймаут на 10 секунд
const timeout = setTimeout(() => {
console.warn('⚠️ Таймаут загрузки изображения (10 сек)');
const placeholder = document.getElementById('image-placeholder');
if (placeholder) {
placeholder.style.display = 'block';
}
}, 10000);
setImageLoadTimeout(timeout);
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
}
}, [unitInfo?.imageurl, selectedImageSize]);
const unitDetails = unitDetailsData?.laximoUnitDetails || [];
const unitImageMap = unitImageMapData?.laximoUnitImageMap;
const handleDetailClick = (detail: LaximoUnitDetail) => {
console.log('🔍 Выбрана деталь для выбора бренда:', detail.name, 'OEM:', detail.oem);
if (detail.oem) {
setSelectedDetail(detail);
setIsBrandModalOpen(true);
}
};
const handleCloseBrandModal = () => {
setIsBrandModalOpen(false);
setSelectedDetail(null);
};
const handleCoordinateClick = (coord: LaximoImageCoordinate) => {
console.log('🖱️ Клик по интерактивной области:', coord.codeonimage);
// Сначала пытаемся найти деталь в списке
const detail = unitDetails.find(d =>
d.detailid === coord.detailid ||
d.codeonimage === coord.codeonimage ||
d.detailid === coord.codeonimage
);
if (detail && detail.oem) {
console.log('✅ Найдена деталь для выбора бренда:', detail.name, 'OEM:', detail.oem);
// Показываем модал выбора бренда
setSelectedDetail(detail);
setIsBrandModalOpen(true);
} else {
// Если деталь не найдена в списке, переходим к общему поиску по коду на изображении
console.log('⚠️ Деталь не найдена в списке, переходим к поиску по коду:', coord.codeonimage);
router.push(`/search-result?q=${coord.codeonimage}&catalog=${catalogCode}&vehicle=${vehicleId}`);
}
};
const getImageUrl = (baseUrl: string, size: string) => {
// Декодируем HTML-сущности и заменяем размер
const decodedUrl = baseUrl
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace('%size%', size);
console.log('🔗 Преобразование URL:', {
original: baseUrl,
decoded: decodedUrl,
size: size
});
return decodedUrl;
};
const imageSizes = [
{ value: '150', label: 'Маленькое' },
{ value: '200', label: 'Среднее' },
{ value: '250', label: 'Большое' },
{ value: 'source', label: 'Оригинал' }
];
// Показываем загрузку если загружаются основные данные
if (unitInfoLoading || unitDetailsLoading) {
return (
<div>
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к узлам
</button>
<h3 className="text-lg font-medium text-gray-900">
{unitName}
</h3>
</div>
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Загружаем детали узла...</p>
</div>
</div>
);
}
// Показываем ошибку если есть критические ошибки
if (unitInfoError && unitDetailsError) {
return (
<div>
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к узлам
</button>
<h3 className="text-lg font-medium text-gray-900">
{unitName}
</h3>
</div>
<div className="text-center py-8">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Ошибка загрузки деталей узла</h3>
<p className="text-gray-600 mb-4">Не удалось загрузить информацию об узле</p>
<p className="text-sm text-gray-500">
{unitInfoError?.message || unitDetailsError?.message}
</p>
</div>
</div>
);
}
// Показываем заглушку если детали не загружены (временное решение)
if (!unitDetailsLoading && unitDetails.length === 0) {
console.log('⚠️ Детали узла не загружены - показываем заглушку')
}
// Если данные об узле не загружены, показываем сообщение
if (!unitInfo) {
return (
<div>
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к узлам
</button>
<h3 className="text-lg font-medium text-gray-900">
{unitName}
</h3>
</div>
<div className="text-center py-8">
<div className="text-gray-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-gray-500">Информация об узле не найдена</p>
<p className="text-sm text-gray-400 mt-1">Попробуйте обновить страницу</p>
</div>
</div>
);
}
return (
<div>
{/* Навигация */}
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к узлам
</button>
<h3 className="text-lg font-medium text-gray-900">
{unitInfo.name}
</h3>
</div>
{/* Информация об узле */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Изображение узла */}
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Размер изображения:
</label>
<select
value={selectedImageSize}
onChange={(e) => {
setSelectedImageSize(e.target.value);
// Сбрасываем масштаб при изменении размера
setImageScale({ x: 1, y: 1 });
}}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-red-500 focus:border-red-500"
>
{imageSizes.map((size) => (
<option key={size.value} value={size.value}>
{size.label}
</option>
))}
</select>
</div>
{unitInfo.imageurl && (
<div className="bg-gray-50 rounded-lg p-4 text-center">
{/* Отладочная информация для изображения */}
{process.env.NODE_ENV === 'development' && (
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-left">
<p><strong>URL изображения:</strong></p>
<p>Базовый: {unitInfo.imageurl}</p>
<p>Итоговый: {getImageUrl(unitInfo.imageurl, selectedImageSize)}</p>
<div className="mt-2 space-x-2">
<button
onClick={() => {
if (unitInfo.imageurl) {
window.open(getImageUrl(unitInfo.imageurl, selectedImageSize), '_blank');
}
}}
className="px-2 py-1 bg-blue-500 text-white rounded text-xs"
>
Открыть в новой вкладке
</button>
<button
onClick={() => {
const img = document.getElementById('unit-image') as HTMLImageElement;
if (img) {
console.log('🔄 Принудительная перезагрузка изображения');
img.src = img.src + '?t=' + Date.now();
}
}}
className="px-2 py-1 bg-green-500 text-white rounded text-xs"
>
Перезагрузить
</button>
</div>
</div>
)}
<div className="relative inline-block">
<img
id="unit-image"
src={getImageUrl(unitInfo.imageurl, selectedImageSize)}
alt={unitInfo.name}
className="max-w-full h-auto mx-auto rounded"
onLoad={(e) => {
// Очищаем таймаут если изображение загрузилось
if (imageLoadTimeout) {
clearTimeout(imageLoadTimeout);
setImageLoadTimeout(null);
}
// Обновляем масштаб интерактивных областей при загрузке изображения
const img = e.currentTarget;
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
const displayWidth = img.offsetWidth;
const displayHeight = img.offsetHeight;
const scaleX = displayWidth / naturalWidth;
const scaleY = displayHeight / naturalHeight;
setImageScale({ x: scaleX, y: scaleY });
console.log('✅ Изображение успешно загружено:', {
src: img.src,
natural: { width: naturalWidth, height: naturalHeight },
display: { width: displayWidth, height: displayHeight },
scale: { x: scaleX, y: scaleY }
});
// Скрываем placeholder если он был показан
const placeholder = document.getElementById('image-placeholder');
if (placeholder) {
placeholder.style.display = 'none';
}
}}
onError={(e) => {
const target = e.currentTarget;
console.error('❌ Ошибка загрузки изображения:', {
src: target.src,
error: e,
naturalWidth: target.naturalWidth,
naturalHeight: target.naturalHeight
});
target.style.display = 'none';
const placeholder = document.getElementById('image-placeholder');
if (placeholder) {
placeholder.style.display = 'block';
}
}}
/>
{/* Интерактивные области изображения */}
{unitImageMap?.coordinates && unitImageMap.coordinates.map((coord, index) => {
const detail = unitDetails.find(d => d.detailid === coord.detailid || d.codeonimage === coord.codeonimage);
// Применяем масштаб к координатам
const scaledX = coord.x * imageScale.x;
const scaledY = coord.y * imageScale.y;
const scaledWidth = coord.width * imageScale.x;
const scaledHeight = coord.height * imageScale.y;
// Создаем уникальный ключ для каждой области
const uniqueKey = `coord-${unitId}-${index}-${coord.x}-${coord.y}`;
return (
<div
key={uniqueKey}
className="absolute border-2 border-red-500 bg-red-500 bg-opacity-20 hover:bg-opacity-40 cursor-pointer transition-all duration-200"
style={{
left: `${scaledX}px`,
top: `${scaledY}px`,
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
borderRadius: coord.shape === 'circle' ? '50%' : '0'
}}
onClick={() => handleCoordinateClick(coord)}
title={detail ? `${coord.codeonimage}: ${detail.name}` : `Деталь ${coord.codeonimage}`}
>
<div className="absolute -top-6 left-1/2 transform -translate-x-1/2 bg-red-600 text-white text-xs px-2 py-1 rounded font-bold">
{coord.codeonimage}
</div>
</div>
);
})}
</div>
<div className="hidden bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-8" id="image-placeholder">
<div className="text-gray-400 mb-2">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm text-gray-500">Изображение недоступно</p>
{process.env.NODE_ENV === 'development' && (
<p className="text-xs text-gray-400 mt-2">
URL: {getImageUrl(unitInfo.imageurl, selectedImageSize)}
</p>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Схема узла с номерами деталей
{unitImageMap?.coordinates && unitImageMap.coordinates.length > 0 && (
<span className="text-green-600 ml-2">
{unitImageMap.coordinates.length} интерактивных областей
</span>
)}
{(!unitImageMap?.coordinates || unitImageMap.coordinates.length === 0) && (
<span className="text-yellow-600 ml-2">
Интерактивные области не найдены
</span>
)}
</p>
{/* Отладочная информация */}
{process.env.NODE_ENV === 'development' && unitImageMap && (
<div className="mt-2 p-2 bg-gray-100 rounded text-xs">
<p><strong>Отладка:</strong></p>
<p>Unit ID: {unitImageMap.unitid}</p>
<p>Координат: {unitImageMap.coordinates?.length || 0}</p>
<p>Масштаб: x={imageScale.x.toFixed(3)}, y={imageScale.y.toFixed(3)}</p>
{unitImageMap.coordinates?.map((coord, i) => (
<p key={`debug-coord-${unitId}-${i}`}>
Область {i+1}: код={coord.codeonimage}, x={coord.x}, y={coord.y}, w={coord.width}, h={coord.height}
</p>
))}
</div>
)}
</div>
)}
</div>
{/* Информация об узле */}
<div>
<h4 className="text-lg font-semibold text-gray-900 mb-4">
Информация об узле
</h4>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">ID узла:</dt>
<dd className="text-sm text-gray-900">{unitInfo.unitid}</dd>
</div>
{unitInfo.code && (
<div>
<dt className="text-sm font-medium text-gray-500">Код:</dt>
<dd className="text-sm text-gray-900">{unitInfo.code}</dd>
</div>
)}
{unitInfo.description && (
<div>
<dt className="text-sm font-medium text-gray-500">Описание:</dt>
<dd className="text-sm text-gray-900">{unitInfo.description}</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-gray-500">Каталог:</dt>
<dd className="text-sm text-gray-900">{catalogCode}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Автомобиль:</dt>
<dd className="text-sm text-gray-900">ID: {vehicleId}</dd>
</div>
</dl>
{/* Дополнительные атрибуты узла */}
{unitInfo.attributes && unitInfo.attributes.length > 0 && (
<div className="mt-6">
<h5 className="text-sm font-medium text-gray-900 mb-3">Дополнительная информация</h5>
<dl className="space-y-2">
{unitInfo.attributes.map((attr, attrIndex) => (
<div key={`unit-attr-${unitId}-${attrIndex}-${attr.key}`} className="flex">
<dt className="text-sm text-gray-500 w-1/3">{attr.name || attr.key}:</dt>
<dd className="text-sm text-gray-900 w-2/3">{attr.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
</div>
</div>
{/* Список деталей */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-semibold text-gray-900">
Детали узла ({unitDetails.length})
</h4>
{unitDetailsLoading && (
<div className="flex items-center text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600 mr-2"></div>
Загружаем детали...
</div>
)}
</div>
{unitDetailsError && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Ошибка загрузки деталей: {unitDetailsError.message}
</p>
</div>
)}
{unitDetails.length === 0 && !unitDetailsLoading ? (
<div className="text-center py-8">
<div className="text-gray-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-gray-500">Детали узла не найдены</p>
{/* Отладочная информация для деталей */}
{process.env.NODE_ENV === 'development' && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-xs text-left">
<p><strong>Отладка деталей:</strong></p>
<p>Ошибка загрузки: {unitDetailsError?.message || 'нет'}</p>
<p>Загружается: {unitDetailsLoading ? 'да' : 'нет'}</p>
<p>Количество деталей: {unitDetails.length}</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
{unitDetails.map((detail, index) => (
<div
key={`detail-${unitId}-${index}-${detail.detailid}`}
className="border border-gray-200 rounded-lg p-4 hover:border-red-300 hover:shadow-md transition-all duration-200 cursor-pointer"
onClick={() => handleDetailClick(detail)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
{detail.codeonimage && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-600 text-white text-xs font-bold rounded-full">
{detail.codeonimage}
</span>
)}
<h5 className="font-medium text-gray-900">{detail.name}</h5>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
{detail.oem && (
<div>
<span className="text-gray-500">OEM:</span>
<span className="ml-1 font-medium text-gray-900">{detail.oem}</span>
</div>
)}
{detail.brand && (
<div>
<span className="text-gray-500">Бренд:</span>
<span className="ml-1 font-medium text-gray-900">{detail.brand}</span>
</div>
)}
{detail.price && (
<div>
<span className="text-gray-500">Цена:</span>
<span className="ml-1 font-medium text-green-600">{detail.price} </span>
</div>
)}
{detail.availability && (
<div>
<span className="text-gray-500">Наличие:</span>
<span className={`ml-1 font-medium ${detail.availability === 'В наличии' ? 'text-green-600' : 'text-orange-600'}`}>
{detail.availability}
</span>
</div>
)}
</div>
{detail.note && (
<p className="text-sm text-gray-600 mt-2">{detail.note}</p>
)}
{/* Дополнительные атрибуты детали */}
{detail.attributes && detail.attributes.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<h6 className="text-xs font-medium text-gray-700 mb-2">Дополнительные характеристики:</h6>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{detail.attributes.map((attr, attrIndex) => (
<div key={`attr-${unitId}-${index}-${attrIndex}-${attr.key}`} className="flex">
<span className="text-gray-500 w-1/2">{attr.name || attr.key}:</span>
<span className="text-gray-700 w-1/2">{attr.value}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="ml-4 text-gray-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Информационное сообщение */}
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-green-900">
Полная интеграция с Laximo API
</h4>
<p className="text-sm text-green-700 mt-1">
Компонент использует официальные API Laximo: GetUnitInfo для информации об узле,
ListDetailByUnit для получения деталей и ListImageMapByUnit для интерактивной карты изображений.
Нажмите на номера деталей на схеме или в списке для подробной информации.
</p>
</div>
</div>
</div>
{/* Модал выбора бренда */}
{selectedDetail && (
<BrandSelectionModal
isOpen={isBrandModalOpen}
onClose={handleCloseBrandModal}
articleNumber={selectedDetail.oem || ''}
detailName={selectedDetail.name}
/>
)}
</div>
);
};
export default UnitDetailsSection;

View File

@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery, useApolloClient } from '@apollo/client';
import { GET_LAXIMO_UNITS } from '@/lib/graphql/laximo';
import { useRouter } from 'next/router';
import UnitDetailsSection from './UnitDetailsSection';
interface UnitsSectionProps {
catalogCode: string;
vehicleId: string;
ssd?: string;
categoryId: string;
categoryName: string;
onBack: () => void;
}
interface LaximoUnit {
quickgroupid: string; // unitid в API
name: string;
link: boolean;
code?: string;
imageurl?: string;
largeimageurl?: string;
}
const UnitsSection: React.FC<UnitsSectionProps> = ({
catalogCode,
vehicleId,
ssd,
categoryId,
categoryName,
onBack
}) => {
const router = useRouter();
const apolloClient = useApolloClient();
const [selectedUnit, setSelectedUnit] = useState<{ unitId: string; unitName: string } | null>(null);
// Функция для правильного формирования URL изображения
const getImageUrl = (baseUrl: string, size: string = '250') => {
if (!baseUrl) return '';
// Декодируем HTML-сущности и заменяем размер
const decodedUrl = baseUrl
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace('%size%', size);
return decodedUrl;
};
// Получаем список узлов для выбранной категории
const { data: unitsData, loading: unitsLoading, error: unitsError } = useQuery<{ laximoUnits: LaximoUnit[] }>(
GET_LAXIMO_UNITS,
{
variables: {
catalogCode,
vehicleId,
categoryId,
...(ssd && ssd.trim() !== '' && { ssd })
},
skip: !catalogCode || !vehicleId || !categoryId,
errorPolicy: 'all',
fetchPolicy: 'no-cache', // Полностью отключаем кэширование для гарантии свежих данных
notifyOnNetworkStatusChange: true
}
);
const handleUnitSelect = (unitId: string, unitName: string) => {
console.log('Выбран узел:', { unitId, unitName });
setSelectedUnit({ unitId, unitName });
};
const handleBackToUnits = () => {
setSelectedUnit(null);
};
const handleClearCache = async () => {
console.log('🧹 Очищаем кэш Apollo Client...');
try {
await apolloClient.clearStore();
console.log('✅ Кэш очищен успешно');
// Принудительный refetch данных
window.location.reload();
} catch (error) {
console.error('❌ Ошибка очистки кэша:', error);
}
};
// Если выбран узел, показываем детали узла
if (selectedUnit) {
return (
<UnitDetailsSection
catalogCode={catalogCode}
vehicleId={vehicleId}
ssd={ssd}
unitId={selectedUnit.unitId}
unitName={selectedUnit.unitName}
onBack={handleBackToUnits}
/>
);
}
if (unitsLoading) {
return (
<div>
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к категориям
</button>
<h3 className="text-lg font-medium text-gray-900">
{categoryName}
</h3>
</div>
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Загружаем узлы категории...</p>
</div>
</div>
);
}
if (unitsError) {
console.error('Ошибка загрузки узлов:', unitsError);
return (
<div>
<div className="flex items-center mb-6">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к категориям
</button>
<h3 className="text-lg font-medium text-gray-900">
{categoryName}
</h3>
</div>
<div className="text-center py-8">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Ошибка загрузки узлов</h3>
<p className="text-gray-600 mb-4">Не удалось загрузить узлы категории</p>
<p className="text-sm text-gray-500">
{unitsError.message}
</p>
</div>
</div>
);
}
const units = unitsData?.laximoUnits || [];
// Отладочная информация
console.log('🔍 UnitsSection: RAW данные от Apollo:', unitsData);
console.log('🔍 UnitsSection: полученные данные узлов:', {
categoryId,
categoryName,
unitsCount: units.length,
units: units.map(unit => ({
id: unit.quickgroupid,
name: unit.name,
code: unit.code,
hasImageUrl: !!unit.imageurl,
imageUrl: unit.imageurl || 'отсутствует'
}))
});
// Дополнительная отладка первого узла
if (units.length > 0) {
console.log('🔍 Первый узел (полные данные):', units[0]);
console.log('🔍 Все поля первого узла:', Object.keys(units[0]));
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mr-4 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Назад к категориям
</button>
<h3 className="text-lg font-medium text-gray-900">
{categoryName}
</h3>
</div>
{/* Кнопка отладки - очистка кэша */}
<button
onClick={handleClearCache}
className="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
title="Очистить кэш Apollo и перезагрузить"
>
🧹 Очистить кэш
</button>
</div>
{units.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-400 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Узлы не найдены</h3>
<p className="text-gray-600">
В данной категории узлы недоступны
</p>
</div>
) : (
<>
<p className="text-sm text-gray-600 mb-6">
Найдено узлов: {units.length}. Выберите узел для просмотра деталей.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{units.map((unit) => (
<button
key={unit.quickgroupid}
onClick={() => handleUnitSelect(unit.quickgroupid, unit.name)}
className="bg-white border border-gray-200 rounded-lg overflow-hidden text-left hover:border-red-300 hover:shadow-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 group"
>
{/* Изображение узла */}
{unit.imageurl ? (
<div className="relative h-48 bg-gray-50 border-b border-gray-200">
<img
src={getImageUrl(unit.imageurl || '', '250')}
alt={unit.name}
className="w-full h-full object-contain p-4 group-hover:scale-105 transition-transform duration-200"
onError={(e) => {
console.log('❌ Ошибка загрузки изображения:', {
originalUrl: unit.imageurl || 'отсутствует',
processedUrl: getImageUrl(unit.imageurl || '', '250'),
unitName: unit.name
});
const parent = e.currentTarget.parentElement;
if (parent) {
parent.classList.add('hidden');
// Показываем заглушку
const nextSibling = parent.nextElementSibling;
if (nextSibling && nextSibling.classList.contains('hidden')) {
nextSibling.classList.remove('hidden');
}
}
}}
onLoad={(e) => {
console.log('✅ Изображение успешно загружено:', {
src: e.currentTarget.src,
unitName: unit.name
});
}}
/>
{/* Индикатор увеличения */}
<div className="absolute top-2 right-2 bg-black bg-opacity-50 text-white p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-4 h-4" 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 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
) : null}
{/* Заглушка для отсутствующего изображения */}
<div className={`${unit.imageurl ? 'hidden' : ''} h-48 bg-gray-100 border-b border-gray-200 flex items-center justify-center`}>
<div className="text-center text-gray-400">
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p className="text-sm">Изображение недоступно</p>
</div>
</div>
{/* Информация об узле */}
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-2 overflow-hidden text-ellipsis group-hover:text-red-600 transition-colors" style={{display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical'}}>
{unit.name}
</h4>
{unit.code && (
<p className="text-sm text-gray-500 mb-2 font-mono bg-gray-50 px-2 py-1 rounded">
Код: {unit.code}
</p>
)}
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Нажмите для просмотра деталей
</div>
</div>
<div className="ml-3 text-gray-400 group-hover:text-red-500 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</button>
))}
</div>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-400" 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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Готово к использованию
</h4>
<p className="text-sm text-blue-700 mt-1">
Нажмите на любой узел, чтобы просмотреть его детали с изображениями и схемами.
Функция полностью реализована согласно API Laximo.
</p>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default UnitsSection;

View File

@ -0,0 +1,271 @@
import React, { useState } from 'react';
import { LaximoCatalogInfo } from '@/types/laximo';
import QuickGroupsSection from './QuickGroupsSection';
import CategoriesSection from './CategoriesSection';
import FulltextSearchSection from './FulltextSearchSection';
interface LaximoVehicleInfo {
vehicleid: string;
name: string;
ssd: string;
brand: string;
catalog: string;
attributes: Array<{
key: string;
name: string;
value: string;
}>;
}
interface VehiclePartsSearchSectionProps {
catalogInfo: LaximoCatalogInfo;
vehicleInfo: LaximoVehicleInfo;
searchType: 'quickgroups' | 'categories' | 'fulltext';
onSearchTypeChange: (type: 'quickgroups' | 'categories' | 'fulltext') => void;
}
const VehiclePartsSearchSection: React.FC<VehiclePartsSearchSectionProps> = ({
catalogInfo,
vehicleInfo,
searchType,
onSearchTypeChange
}) => {
// Проверяем поддержку функций согласно документации Laximo
const supportsQuickGroups = catalogInfo.features.some(f => f.name === 'quickgroups');
const supportsFullTextSearch = catalogInfo.features.some(f => f.name === 'fulltextsearch');
console.log('🔧 VehiclePartsSearchSection - Поддерживаемые функции:');
console.log('📋 Все features:', catalogInfo.features.map(f => f.name));
console.log('🚀 quickgroups поддерживается:', supportsQuickGroups);
console.log('🔍 fulltextsearch поддерживается:', supportsFullTextSearch);
const searchOptions = [
{
id: 'quickgroups' as const,
name: 'Группы быстрого поиска',
description: 'Поиск запчастей по группам быстрого поиска Laximo (ListQuickGroup)',
enabled: supportsQuickGroups,
requiresSSD: true,
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)
},
{
id: 'categories' as const,
name: 'Категории узлов каталога',
description: 'Поиск через структуру оригинального каталога (ListCategories)',
enabled: true, // Always available according to documentation
requiresSSD: false,
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)
},
{
id: 'fulltext' as const,
name: 'Поиск деталей по названию',
description: 'Введите часть названия детали (SearchVehicleDetails)',
enabled: supportsFullTextSearch,
requiresSSD: true,
icon: (
<svg className="w-6 h-6" 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>
)
}
];
// Если текущий тип поиска не поддерживается, переключаемся на поддерживаемый
React.useEffect(() => {
const currentOption = searchOptions.find(option => option.id === searchType);
if (!currentOption?.enabled) {
// Приоритет: quickgroups -> categories -> fulltext
if (supportsQuickGroups && vehicleInfo.ssd) {
onSearchTypeChange('quickgroups');
} else {
onSearchTypeChange('categories'); // categories всегда доступны
}
}
}, [catalogInfo, vehicleInfo, searchType, onSearchTypeChange, supportsQuickGroups]);
const handleSearchTypeChange = (type: 'quickgroups' | 'categories' | 'fulltext') => {
const option = searchOptions.find(opt => opt.id === type);
if (!option?.enabled) {
console.warn(`Тип поиска ${type} не поддерживается каталогом ${catalogInfo.code}`);
return;
}
if (option.requiresSSD && (!vehicleInfo.ssd || vehicleInfo.ssd.trim() === '')) {
alert(`Для использования "${option.name}" необходимы данные автомобиля (SSD). Пожалуйста, выберите автомобиль заново.`);
return;
}
console.log(`🔄 Переключение на тип поиска: ${type}`);
onSearchTypeChange(type);
};
return (
<div className="space-y-6">
{/* Заголовок с информацией о каталоге */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Способы поиска запчастей
</h2>
<p className="text-sm text-gray-600 mt-1">
Выберите предпочтительный способ поиска для каталога {catalogInfo.name}
</p>
</div>
{/* Индикатор поддерживаемых функций */}
<div className="text-right">
<div className="text-xs text-gray-500 mb-1">Поддерживаемые функции:</div>
<div className="flex space-x-2">
{supportsQuickGroups && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
QuickGroups
</span>
)}
{supportsFullTextSearch && (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
FullText
</span>
)}
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800">
Categories
</span>
</div>
</div>
</div>
{/* Селектор типов поиска */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{searchOptions.map((option) => (
<button
key={option.id}
onClick={() => handleSearchTypeChange(option.id)}
disabled={!option.enabled}
className={`
relative p-4 border rounded-lg text-left transition-all duration-200
${searchType === option.id && option.enabled
? 'border-red-500 bg-red-50 ring-2 ring-red-200'
: option.enabled
? 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
: 'border-gray-200 bg-gray-50 cursor-not-allowed opacity-60'
}
`}
>
<div className="flex items-start space-x-3">
<div className={`flex-shrink-0 ${option.enabled ? 'text-gray-600' : 'text-gray-400'}`}>
{option.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className={`text-sm font-medium ${option.enabled ? 'text-gray-900' : 'text-gray-500'}`}>
{option.name}
</h3>
<p className={`text-xs mt-1 ${option.enabled ? 'text-gray-600' : 'text-gray-400'}`}>
{option.description}
</p>
{/* Индикаторы требований */}
<div className="mt-2 flex items-center space-x-2">
{option.requiresSSD && (
<span className={`text-xs px-2 py-0.5 rounded ${
vehicleInfo.ssd
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{vehicleInfo.ssd ? '✓ SSD доступен' : '⚠ Требует SSD'}
</span>
)}
{!option.enabled && (
<span className="text-xs px-2 py-0.5 rounded bg-red-100 text-red-700">
Не поддерживается
</span>
)}
</div>
</div>
</div>
{/* Индикатор выбранного состояния */}
{searchType === option.id && option.enabled && (
<div className="absolute top-2 right-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
))}
</div>
</div>
{/* Отображение выбранного компонента поиска */}
<div className="min-h-[400px]">
{searchType === 'quickgroups' && supportsQuickGroups && (
<QuickGroupsSection
catalogCode={vehicleInfo.catalog}
vehicleId={vehicleInfo.vehicleid}
ssd={vehicleInfo.ssd}
/>
)}
{searchType === 'categories' && (
<CategoriesSection
catalogCode={vehicleInfo.catalog}
vehicleId={vehicleInfo.vehicleid}
ssd={vehicleInfo.ssd}
/>
)}
{searchType === 'fulltext' && supportsFullTextSearch && (
<FulltextSearchSection
catalogCode={vehicleInfo.catalog}
vehicleId={vehicleInfo.vehicleid}
ssd={vehicleInfo.ssd}
/>
)}
</div>
{/* Информационная панель */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<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 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Информация о способах поиска
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc list-inside space-y-1">
<li><strong>Группы быстрого поиска</strong> - используют функцию Laximo ListQuickGroup для быстрого доступа к категориям</li>
<li><strong>Категории узлов каталога</strong> - навигация по структуре оригинального каталога производителя</li>
<li><strong>Поиск по названию</strong> - полнотекстовый поиск деталей по их наименованию</li>
</ul>
{vehicleInfo.ssd ? (
<p className="mt-2 text-green-700">
Данные автомобиля (SSD) доступны - все функции активны
</p>
) : (
<p className="mt-2 text-yellow-700">
Некоторые функции требуют данных автомобиля (SSD)
</p>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default VehiclePartsSearchSection;

View File

@ -0,0 +1,144 @@
import React from 'react';
import { useRouter } from 'next/router';
import { LaximoVehicleSearchResult, LaximoCatalogInfo } from '@/types/laximo';
interface VehicleSearchResultsProps {
results: LaximoVehicleSearchResult[];
catalogInfo: LaximoCatalogInfo;
}
const VehicleSearchResults: React.FC<VehicleSearchResultsProps> = ({
results,
catalogInfo
}) => {
const router = useRouter();
const handleSelectVehicle = (vehicle: LaximoVehicleSearchResult) => {
console.log('🚗 handleSelectVehicle вызвана для:', vehicle);
// Формируем SSD из данных vehicle или берем из router query
const routerSsd = Array.isArray(router.query.ssd) ? router.query.ssd[0] : router.query.ssd;
const ssd = vehicle.ssd || routerSsd || '';
const brand = router.query.brand || catalogInfo.code;
console.log('🚗 Selected vehicle:', vehicle);
console.log('🔧 Vehicle SSD:', vehicle.ssd ? `${vehicle.ssd.substring(0, 50)}...` : 'отсутствует');
console.log('🔧 Router SSD:', routerSsd ? `${routerSsd.substring(0, 50)}...` : 'отсутствует');
console.log('🔧 Final SSD to pass:', ssd ? `${ssd.substring(0, 50)}...` : 'отсутствует');
console.log('🔧 SSD length:', ssd.length);
console.log('🔧 Brand для навигации:', brand);
console.log('🔧 Vehicle ID:', vehicle.vehicleid);
// Переходим на страницу автомобиля с SSD
if (ssd && ssd.trim() !== '') {
// Всегда используем localStorage для SSD, так как VW SSD очень длинные
console.log('💾 Сохраняем SSD в localStorage для безопасной передачи');
const vehicleKey = `vehicle_ssd_${brand}_${vehicle.vehicleid}`;
console.log('💾 Ключ localStorage:', vehicleKey);
localStorage.setItem(vehicleKey, ssd);
console.log('💾 SSD сохранен в localStorage');
const targetUrl = `/vehicle-search/${brand}/${vehicle.vehicleid}?use_storage=1&ssd_length=${ssd.length}`;
console.log('🔗 Переходим по URL:', targetUrl);
router.push(targetUrl);
} else {
console.log('⚠️ SSD отсутствует, переходим без него');
router.push(`/vehicle-search/${brand}/${vehicle.vehicleid}`);
}
};
if (results.length === 0) {
return null;
}
return (
<div className="bg-white rounded-2xl md:my-8">
<div className="mb-2">
<h4 className="text-lg font-medium text-gray-900">
Найденные автомобили ({results.length})
</h4>
</div>
<div className="flex flex-col gap-4">
{results.map((vehicle, index) => (
<div
key={vehicle.vehicleid || index}
className="pt-3 pb-3 bg-white border-b border-gray-200 hover:bg-neutral-50 transition-colors cursor-pointer flex flex-col sm:flex-row sm:items-center gap-4"
onClick={() => handleSelectVehicle(vehicle)}
>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h4 className="text-lg font-semibold text-gray-900 truncate">
{vehicle.name || `${vehicle.brand || 'Unknown'} ${vehicle.model || 'Vehicle'}`}
</h4>
{vehicle.year && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700">
{vehicle.year}
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm mb-2">
{vehicle.modification && (
<div>
<span className="text-gray-500">Модификация:</span>
<span className="ml-2 font-medium text-gray-900">{vehicle.modification}</span>
</div>
)}
{vehicle.bodytype && (
<div>
<span className="text-gray-500">Тип кузова:</span>
<span className="ml-2 font-medium text-gray-900">{vehicle.bodytype}</span>
</div>
)}
{vehicle.engine && (
<div>
<span className="text-gray-500">Двигатель:</span>
<span className="ml-2 font-medium text-gray-900">{vehicle.engine}</span>
</div>
)}
</div>
{vehicle.notes && (
<div className="mt-3 p-3 bg-yellow-50 rounded-lg">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-800">
<span className="font-medium">Примечание:</span> {vehicle.notes}
</p>
</div>
</div>
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center justify-end">
<button
onClick={e => {
e.stopPropagation();
handleSelectVehicle(vehicle);
}}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 shadow transition"
style={{ color: '#fff' }}
>
Выбрать
</button>
</div>
</div>
))}
</div>
<div className="bg-gray-50 rounded-xl pt-4 pb-4 mt-6 flex flex-col sm:flex-row items-center justify-between text-sm text-gray-600">
<span>Показано {results.length} результат{results.length === 1 ? '' : results.length < 5 ? 'а' : 'ов'}</span>
<span>Кликните на автомобиль для подбора запчастей</span>
</div>
</div>
);
};
export default VehicleSearchResults;

View File

@ -0,0 +1,300 @@
import React, { useState } from 'react';
import { useLazyQuery } from '@apollo/client';
import { FIND_LAXIMO_VEHICLE, FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL } from '@/lib/graphql';
import { LaximoCatalogInfo, LaximoWizardStep, LaximoVehicleSearchResult } from '@/types/laximo';
import VinSearchForm from './VinSearchForm';
import PlateSearchForm from './PlateSearchForm';
import PartSearchForm from './PartSearchForm';
import WizardSearchForm from './WizardSearchForm';
import VehicleSearchResults from './VehicleSearchResults';
interface VehicleSearchSectionProps {
catalogInfo: LaximoCatalogInfo;
searchType: 'vin' | 'wizard' | 'parts' | 'plate';
onSearchTypeChange: (type: 'vin' | 'wizard' | 'parts' | 'plate') => void;
}
const VehicleSearchSection: React.FC<VehicleSearchSectionProps> = ({
catalogInfo,
searchType,
onSearchTypeChange
}) => {
const [searchResults, setSearchResults] = useState<LaximoVehicleSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
// Query для поиска по VIN
const [findVehicle] = useLazyQuery(FIND_LAXIMO_VEHICLE, {
onCompleted: (data) => {
setSearchResults(data.laximoFindVehicle || []);
setIsSearching(false);
setHasSearched(true);
},
onError: (error) => {
console.error('Ошибка поиска автомобиля:', error);
setSearchResults([]);
setIsSearching(false);
setHasSearched(true);
}
});
// Query для поиска по госномеру
const [findVehicleByPlate] = useLazyQuery(FIND_LAXIMO_VEHICLE_BY_PLATE_GLOBAL, {
onCompleted: (data) => {
setSearchResults(data.laximoFindVehicleByPlateGlobal || []);
setIsSearching(false);
setHasSearched(true);
},
onError: (error) => {
console.error('Ошибка поиска автомобиля по госномеру:', error);
setSearchResults([]);
setIsSearching(false);
setHasSearched(true);
}
});
const handleVinSearch = async (vin: string) => {
if (!vin.trim()) return;
setIsSearching(true);
setSearchResults([]);
setHasSearched(false);
await findVehicle({
variables: {
catalogCode: '', // Пустой для глобального поиска
vin: vin.trim()
}
});
};
const handlePlateSearch = async (plateNumber: string) => {
if (!plateNumber.trim()) return;
setIsSearching(true);
setSearchResults([]);
setHasSearched(false);
await findVehicleByPlate({
variables: {
plateNumber: plateNumber.trim()
}
});
};
const handleWizardVehicleFound = (vehicles: LaximoVehicleSearchResult[]) => {
setSearchResults(vehicles);
setIsSearching(false);
setHasSearched(true);
};
const handlePartsSearchStart = () => {
setIsSearching(true);
setSearchResults([]);
setHasSearched(false);
};
const handlePartsVehicleFound = (vehicles: LaximoVehicleSearchResult[]) => {
console.log('🔍 Найдено автомобилей по артикулу:', vehicles.length);
setSearchResults(vehicles);
setIsSearching(false);
setHasSearched(true);
};
const searchTabs = [
{
id: 'vin' as const,
name: 'Поиск по VIN/Frame',
description: 'Введите VIN или номер кузова автомобиля',
enabled: catalogInfo.supportvinsearch,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
},
{
id: 'wizard' as const,
name: 'Поиск автомобиля по параметрам',
description: 'Выберите серию и тип кузова',
enabled: catalogInfo.supportparameteridentification2,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
)
},
{
id: 'parts' as const,
name: 'Поиск автомобилей по детали',
description: 'Введите артикул (OEM)',
enabled: catalogInfo.supportdetailapplicability,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)
},
{
id: 'plate' as const,
name: 'Поиск по государственному номеру',
description: 'Введите государственный номер автомобиля',
enabled: catalogInfo.supportplateidentification ?? true,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
</svg>
)
}
];
const availableTabs = searchTabs.filter(tab => tab.enabled);
return (
<div className="space-y-6">
{/* Tabs */}
<div className="bg-white rounded-lg shadow-sm border">
<div className="border-b">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{availableTabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
onSearchTypeChange(tab.id);
setSearchResults([]);
setHasSearched(false);
setIsSearching(false);
}}
className={`
group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm
${searchType === tab.id
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
{tab.icon}
<span className="ml-2">{tab.name}</span>
</button>
))}
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{searchType === 'vin' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Поиск по VIN/Frame
</h3>
<p className="text-sm text-gray-600 mb-4">
{catalogInfo.vinexample
? `Введите VIN или номер кузова автомобиля, например: ${catalogInfo.vinexample}`
: 'Введите VIN или номер кузова автомобиля'
}
</p>
<VinSearchForm
onSearch={handleVinSearch}
isLoading={isSearching}
placeholder={catalogInfo.vinexample || 'WBS21CS0709X59107'}
/>
</div>
)}
{searchType === 'wizard' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Поиск автомобиля по параметрам
</h3>
<p className="text-sm text-gray-600 mb-4">
Выберите серию и тип кузова для поиска автомобиля
</p>
<WizardSearchForm
catalogCode={catalogInfo.code}
onVehicleFound={handleWizardVehicleFound}
/>
</div>
)}
{searchType === 'parts' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Поиск автомобилей по детали
</h3>
<p className="text-sm text-gray-600 mb-4">
Введите артикул (OEM) для поиска применимых автомобилей
</p>
<PartSearchForm
catalogCode={catalogInfo.code}
onVehiclesFound={handlePartsVehicleFound}
onSearchStart={handlePartsSearchStart}
isLoading={isSearching}
/>
</div>
)}
{searchType === 'plate' && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Поиск по государственному номеру
</h3>
<p className="text-sm text-gray-600 mb-4">
Введите государственный номер автомобиля
</p>
<PlateSearchForm
onSearch={handlePlateSearch}
isLoading={isSearching}
placeholder={catalogInfo.plateexample || 'А123АА777'}
/>
</div>
)}
</div>
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<VehicleSearchResults
results={searchResults}
catalogInfo={catalogInfo}
/>
)}
{/* No Results */}
{!isSearching && searchResults.length === 0 && !hasSearched && (
<div className="bg-white rounded-lg shadow-sm border p-8 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-gray-600">
Выполните поиск, чтобы найти подходящий автомобиль для подбора запчастей
</p>
</div>
)}
{/* Search completed but no results */}
{!isSearching && searchResults.length === 0 && hasSearched && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-8 text-center">
<div className="text-yellow-400 mb-4">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 14.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Автомобили не найдены</h3>
<p className="text-gray-600 mb-4">
{searchType === 'vin' && 'По указанному VIN/Frame номеру не найдено автомобилей в данном каталоге.'}
{searchType === 'parts' && 'По указанному артикулу не найдено применимых автомобилей в данном каталоге.'}
{searchType === 'plate' && 'По указанному государственному номеру не найдено автомобилей в данном каталоге.'}
{searchType === 'wizard' && 'По заданным параметрам не найдено автомобилей в данном каталоге.'}
</p>
<p className="text-sm text-gray-500">
Попробуйте изменить параметры поиска или выберите другой каталог.
</p>
</div>
)}
</div>
);
};
export default VehicleSearchSection;

View File

@ -0,0 +1,68 @@
import React, { useState } from 'react';
interface VinSearchFormProps {
onSearch: (vin: string) => void;
isLoading: boolean;
placeholder?: string;
}
const VinSearchForm: React.FC<VinSearchFormProps> = ({
onSearch,
isLoading,
placeholder = 'WBS21CS0709X59107'
}) => {
const [vin, setVin] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (vin.trim()) {
onSearch(vin.trim());
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
value={vin}
onChange={(e) => setVin(e.target.value.toUpperCase())}
placeholder={placeholder}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 text-lg font-mono"
disabled={isLoading}
maxLength={17}
/>
<p className="mt-2 text-sm text-gray-500">
Введите VIN код или номер кузова автомобиля
</p>
</div>
<button
type="submit"
disabled={isLoading || !vin.trim()}
className="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 min-w-[120px]"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Поиск...
</>
) : (
<>
<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>
</form>
);
};
export default VinSearchForm;

View File

@ -0,0 +1,343 @@
import React, { useState, useEffect, useRef } from 'react';
import { useLazyQuery } from '@apollo/client';
import { LaximoWizardStep, LaximoVehicleSearchResult } from '@/types/laximo';
import { GET_LAXIMO_WIZARD2, FIND_LAXIMO_VEHICLE_BY_WIZARD } from '@/lib/graphql';
import { Combobox } from '@headlessui/react';
interface WizardSearchFormProps {
catalogCode: string;
onVehicleFound: (vehicles: LaximoVehicleSearchResult[]) => void;
}
const WizardSearchForm: React.FC<WizardSearchFormProps> = ({
catalogCode,
onVehicleFound
}) => {
const [wizardSteps, setWizardSteps] = useState<LaximoWizardStep[]>([]);
const [selectedParams, setSelectedParams] = useState<Record<string, { key: string; value: string }>>({});
const [currentSsd, setCurrentSsd] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>('');
const [queries, setQueries] = useState<Record<string, string>>({});
const buttonRefs = useRef<Record<string, React.RefObject<HTMLButtonElement | null>>>({});
const [showSearchButton, setShowSearchButton] = React.useState(true);
const [getWizard2] = useLazyQuery(GET_LAXIMO_WIZARD2, {
onCompleted: (data) => {
if (data.laximoWizard2) {
setWizardSteps(data.laximoWizard2);
setIsLoading(false);
}
},
onError: (error) => {
setError('Ошибка загрузки параметров поиска');
setIsLoading(false);
console.error('Error loading wizard:', error);
}
});
const [findVehicleByWizard] = useLazyQuery(FIND_LAXIMO_VEHICLE_BY_WIZARD, {
onCompleted: (data) => {
if (data.laximoFindVehicleByWizard) {
onVehicleFound(data.laximoFindVehicleByWizard);
setIsLoading(false);
}
},
onError: (error) => {
setError('Ошибка поиска автомобилей');
setIsLoading(false);
console.error('Error finding vehicles:', error);
}
});
// Загружаем начальные параметры при монтировании
useEffect(() => {
if (catalogCode) {
setIsLoading(true);
setError('');
setSelectedParams({});
setCurrentSsd('');
getWizard2({
variables: {
catalogCode,
ssd: ''
}
});
}
}, [catalogCode, getWizard2]);
// При каждом рендере wizardSteps гарантируем наличие ref для каждого шага
wizardSteps.forEach(step => {
if (!buttonRefs.current[step.conditionid]) {
buttonRefs.current[step.conditionid] = React.createRef<HTMLButtonElement>();
}
});
// --- Автовыбор единственного варианта для всех шагов ---
React.useEffect(() => {
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) {
handleParamSelect(step, options[0].key, options[0].value);
}
});
// eslint-disable-next-line
}, [wizardSteps, selectedParams]);
// Обработка выбора параметра
const handleParamSelect = async (step: LaximoWizardStep, optionKey: string, optionValue: string) => {
setIsLoading(true);
setError('');
// Обновляем выбранные параметры
const newSelectedParams = {
...selectedParams,
[step.conditionid]: { key: optionKey, value: optionValue }
};
setSelectedParams(newSelectedParams);
// Устанавливаем новый SSD
const newSsd = optionKey;
setCurrentSsd(newSsd);
try {
// Загружаем обновленные шаги wizard с новым SSD
await getWizard2({
variables: {
catalogCode,
ssd: newSsd
}
});
} catch (error) {
setError('Ошибка обновления параметров');
setIsLoading(false);
}
};
// Сброс параметра
const handleParamReset = async (step: LaximoWizardStep) => {
setIsLoading(true);
setError('');
// Убираем параметр из выбранных
const newSelectedParams = { ...selectedParams };
delete newSelectedParams[step.conditionid];
setSelectedParams(newSelectedParams);
// Используем SSD для сброса параметра, если он есть
const resetSsd = step.ssd || '';
setCurrentSsd(resetSsd);
try {
// Загружаем обновленные шаги wizard
await getWizard2({
variables: {
catalogCode,
ssd: resetSsd
}
});
} catch (error) {
setError('Ошибка сброса параметра');
setIsLoading(false);
}
};
// Поиск автомобилей по выбранным параметрам
const handleFindVehicles = () => {
if (!currentSsd) {
setError('Выберите хотя бы один параметр для поиска');
return;
}
setIsLoading(true);
setError('');
findVehicleByWizard({
variables: {
catalogCode,
ssd: currentSsd
}
});
};
// Проверяем можно ли искать автомобили
const canListVehicles = wizardSteps.some(step =>
step.allowlistvehicles && (step.determined || selectedParams[step.conditionid])
);
// Скрывать кнопку и блок после поиска, показывать при изменении параметров
React.useEffect(() => {
setShowSearchButton(true);
}, [selectedParams, queries]);
if (error) {
return (
<div className="text-center py-8">
<div className="text-red-600 mb-4">{error}</div>
<button
onClick={() => {
setError('');
setIsLoading(true);
getWizard2({
variables: { catalogCode, ssd: currentSsd }
});
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Попробовать снова
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-lg font-medium text-blue-900 mb-2">
Поиск автомобиля по параметрам
</h3>
<p className="text-blue-700 text-sm">
Выберите параметры автомобиля шаг за шагом. После выбора достаточного количества параметров станет доступен поиск автомобилей.
</p>
</div> */}
{/* Индикатор загрузки */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<span className="ml-3 text-gray-600">Загружаем параметры...</span>
</div>
)}
{/* Шаги wizard */}
{!isLoading && (
<div className="flex flex-row flex-wrap gap-4 pb-2">
{wizardSteps.map((step, index) => {
const options = step.options || [];
const query = queries[step.conditionid] || '';
const filteredOptions = query
? options.filter(option => option.value.toLowerCase().includes(query.toLowerCase()))
: options;
const buttonRef = buttonRefs.current[step.conditionid];
// Определяем выбранный ключ
const selectedKey = selectedParams[step.conditionid]?.key || (step.determined ? options.find(o => o.value === step.value)?.key : '');
// Определяем отображаемый label
const selectedLabel =
options.find(o => o.key === selectedKey)?.value ||
selectedParams[step.conditionid]?.value ||
step.value ||
'';
// Если единственный вариант уже выбран — не рендерим селект
if (options.length === 1 && (selectedKey === options[0].key || step.determined)) {
return null;
}
return (
<div key={`${step.conditionid}-${index}`} className="space-y-3 min-w-[320px] max-w-[320px] flex-1">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-gray-900">{step.name}</h4>
</div>
</div>
{/* Combobox для выбора опции (всегда показываем, кроме случая с единственным вариантом) */}
<div className={`w-full max-w-[450px] relative transition-colors duration-200 ${selectedLabel ? 'bg-gray-50 border border-gray-200' : ''}`}>
<Combobox
value={selectedKey}
onChange={key => {
const option = options.find(o => o.key === key);
if (option) handleParamSelect(step, option.key, option.value);
}}
disabled={isLoading || options.length === 0}
>
<div className="relative">
<Combobox.Input
id={`wizard-combobox-${step.conditionid}`}
className={`w-full px-6 py-4 rounded text-sm text-gray-950 placeholder:text-neutral-500 outline-none focus:shadow-none transition-colors pr-12 ${selectedLabel ? 'bg-gray-50 border-gray-200' : 'bg-white border border-stone-300'}`}
displayValue={() => selectedLabel}
onChange={e => setQueries(q => ({ ...q, [step.conditionid]: e.target.value }))}
placeholder="Начните вводить..."
autoComplete="off"
disabled={options.length === 0}
/>
{selectedLabel ? (
<button
type="button"
className="absolute inset-y-0 right-0 w-12 flex items-center justify-center text-gray-400 hover:text-red-600 focus:outline-none"
aria-label="Сбросить"
tabIndex={0}
onClick={() => handleParamReset(step)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
) : (
<Combobox.Button className="absolute inset-y-0 right-0 w-12 flex items-center justify-center focus:outline-none">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9l6 6 6-6" />
</svg>
</Combobox.Button>
)}
<Combobox.Options
className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full max-h-60 overflow-auto scrollbar-none"
style={{ scrollbarWidth: 'none' }}
data-hide-scrollbar
>
{filteredOptions.length === 0 ? null : filteredOptions.map(option => (
<Combobox.Option
key={option.key}
value={option.key}
className={({ active, selected }) =>
`px-6 py-4 cursor-pointer hover:!bg-[rgb(236,28,36)] hover:!text-white text-sm transition-colors ${selected ? 'bg-red-50 font-semibold text-gray-950' : 'text-neutral-500'}`
}
>
{option.value}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
</div>
</div>
);
})}
</div>
)}
{/* Кнопка поиска автомобилей */}
{!isLoading && canListVehicles && showSearchButton && (
<div className="pt-4 border-t">
<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"
>
<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>
</div>
)}
{/* Информация о недостаточности параметров */}
{!isLoading && !canListVehicles && wizardSteps.length > 0 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 text-sm">
Выберите больше параметров для поиска автомобилей
</p>
</div>
)}
</div>
);
};
export default WizardSearchForm;

View File

@ -0,0 +1,13 @@
import React from "react";
const AboutHelp = () => (
<div className="div-block-11">
<div className="w-layout-vflex flex-block-30">
<h3 className="heading-6">Мы всегда рады помочь</h3>
<div className="text-block-19">Если вам нужна помощь с подбором автозапчастей, то воспользуйтесь формой VIN-запроса. Введите идентификационный номер (VIN) вашего автомобиля и мы найдём нужную деталь.</div>
</div>
<a href="#" className="submit-button w-button">Отправить VIN-запрос</a>
</div>
);
export default AboutHelp;

View File

@ -0,0 +1,17 @@
import React from "react";
import Link from 'next/link';
const AboutIntro = () => (
<div className="w-layout-hflex flex-block-69">
<div className="w-layout-vflex flex-block-68">
<div className="text-block-36">
Наши клиенты как крупные поставщики, так и небольшие магазины, сервисные центры и СТО получают возможность эффективно находить своих покупателей. Мы обеспечиваем своевременную поставку подходящих запчастей по привлекательным ценам.<br /><br />Мы стремимся сделать процесс покупок и продаж максимально выгодным и надёжным для всех участников. Контроль качества продукции, гарантия оплаты и соблюдение условий возврата в соответствии с законодательством наши приоритеты.
</div>
<Link href="/catalog" legacyBehavior>
<a className="submit-button w-button">Перейти в каталог запчастей</a>
</Link>
</div>
<img src="/images/auto_protek.png" loading="lazy" sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 830px" srcSet="/images/auto_protek-p-500.png 500w, /images/auto_protek-p-800.png 800w, /images/auto_protek.png 830w" alt="" className="image-14" />
</div>
);
export default AboutIntro;

View File

@ -0,0 +1,31 @@
import React from "react";
const AboutOffers = () => (
<div className="w-layout-hflex flex-block-69-copy">
<h2 className="heading-13">Мы предлагаем</h2>
<div className="w-layout-vflex flex-block-68-copy">
<div className="w-layout-vflex flex-block-70">
<img src="/images/img1.png" loading="lazy" alt="" />
<h3 className="heading-14">Оригинальные запчасти и аналоги</h3>
<div className="text-block-37">От расходников до комплектующих для сложного ремонта</div>
</div>
<div className="w-layout-vflex flex-block-70">
<img src="/images/img2.png" loading="lazy" alt="" />
<h3 className="heading-14">Широкий ассортимент</h3>
<div className="text-block-37">От запчастей для популярных моделей до редких деталей</div>
</div>
<div className="w-layout-vflex flex-block-70">
<img src="/images/img3.png" loading="lazy" alt="" />
<h3 className="heading-14">Собственное наличие</h3>
<div className="text-block-37">Мы гарантируем быстрое и качественное обслуживание</div>
</div>
<div className="w-layout-vflex flex-block-70">
<img src="/images/img4.png" loading="lazy" alt="" />
<h3 className="heading-14">Конкурентные цены</h3>
<div className="text-block-37">Благодаря прямым поставкам из Китая, Индии, Турции и Европы</div>
</div>
</div>
</div>
);
export default AboutOffers;

View File

@ -0,0 +1,48 @@
import React from "react";
const AboutProtekInfo = () => (
<div className="w-layout-hflex flex-block-69-copy">
<h2 className="heading-13">PROTEK это</h2>
<div className="w-layout-hflex flex-block-71">
<div className="text-block-38">Электронные каталоги для подбора. Подбор конкретной автозапчасти с помощью электронных каталогов. Кроссировка по оригинальным номерам и возможность выбора артикула от разных производителей по разным ценам и уровню качества.</div>
<div className="text-block-38">Сотрудничаем только с известными поставщиками и брендами, что страхует наших партнёров от приобретения контрафакта, что очень важно, с нами вы застрахованы от подделок</div>
</div>
<div className="w-layout-vflex flex-block-72">
<div className="w-layout-vflex flex-block-73">
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.77051H17C17 4.01051 14.76 1.77051 12 1.77051C9.24 1.77051 7 4.01051 7 6.77051H5C3.9 6.77051 3 7.67051 3 8.77051V20.7705C3 21.8705 3.9 22.7705 5 22.7705H19C20.1 22.7705 21 21.8705 21 20.7705V8.77051C21 7.67051 20.1 6.77051 19 6.77051ZM12 3.77051C13.66 3.77051 15 5.11051 15 6.77051H9C9 5.11051 10.34 3.77051 12 3.77051ZM12 13.7705C10.8913 13.772 9.81368 13.4041 8.93725 12.7251C8.06083 12.046 7.43551 11.0944 7.16 10.0205C6.99 9.39051 7.48 8.77051 8.13 8.77051C8.6 8.77051 8.98 9.11051 9.11 9.57051C9.28387 10.2036 9.66083 10.7621 10.1829 11.1602C10.7051 11.5582 11.3434 11.7738 12 11.7738C12.6566 11.7738 13.2949 11.5582 13.8171 11.1602C14.3392 10.7621 14.7161 10.2036 14.89 9.57051C15.02 9.11051 15.4 8.77051 15.87 8.77051C16.52 8.77051 17 9.39051 16.84 10.0205C16.5645 11.0944 15.9392 12.046 15.0627 12.7251C14.1863 13.4041 13.1087 13.772 12 13.7705Z" fill="currentColor" />
</svg>
</div>
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Оптово-розничная компания</h3>
<div className="text-block-38">Мы работаем и с розничными клиентами, и с оптовыми покупателями</div>
</div>
</div>
<div className="w-layout-vflex flex-block-73">
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.77051H17C17 4.01051 14.76 1.77051 12 1.77051C9.24 1.77051 7 4.01051 7 6.77051H5C3.9 6.77051 3 7.67051 3 8.77051V20.7705C3 21.8705 3.9 22.7705 5 22.7705H19C20.1 22.7705 21 21.8705 21 20.7705V8.77051C21 7.67051 20.1 6.77051 19 6.77051ZM12 3.77051C13.66 3.77051 15 5.11051 15 6.77051H9C9 5.11051 10.34 3.77051 12 3.77051ZM12 13.7705C10.8913 13.772 9.81368 13.4041 8.93725 12.7251C8.06083 12.046 7.43551 11.0944 7.16 10.0205C6.99 9.39051 7.48 8.77051 8.13 8.77051C8.6 8.77051 8.98 9.11051 9.11 9.57051C9.28387 10.2036 9.66083 10.7621 10.1829 11.1602C10.7051 11.5582 11.3434 11.7738 12 11.7738C12.6566 11.7738 13.2949 11.5582 13.8171 11.1602C14.3392 10.7621 14.7161 10.2036 14.89 9.57051C15.02 9.11051 15.4 8.77051 15.87 8.77051C16.52 8.77051 17 9.39051 16.84 10.0205C16.5645 11.0944 15.9392 12.046 15.0627 12.7251C14.1863 13.4041 13.1087 13.772 12 13.7705Z" fill="currentColor" />
</svg>
</div>
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Надежный партнер</h3>
<div className="text-block-38">Мы ценим долгосрочные отношения и стремимся к взаимовыгодному сотрудничеству</div>
</div>
</div>
<div className="w-layout-vflex flex-block-73">
<div className="w-embed">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.77051H17C17 4.01051 14.76 1.77051 12 1.77051C9.24 1.77051 7 4.01051 7 6.77051H5C3.9 6.77051 3 7.67051 3 8.77051V20.7705C3 21.8705 3.9 22.7705 5 22.7705H19C20.1 22.7705 21 21.8705 21 20.7705V8.77051C21 7.67051 20.1 6.77051 19 6.77051ZM12 3.77051C13.66 3.77051 15 5.11051 15 6.77051H9C9 5.11051 10.34 3.77051 12 3.77051ZM12 13.7705C10.8913 13.772 9.81368 13.4041 8.93725 12.7251C8.06083 12.046 7.43551 11.0944 7.16 10.0205C6.99 9.39051 7.48 8.77051 8.13 8.77051C8.6 8.77051 8.98 9.11051 9.11 9.57051C9.28387 10.2036 9.66083 10.7621 10.1829 11.1602C10.7051 11.5582 11.3434 11.7738 12 11.7738C12.6566 11.7738 13.2949 11.5582 13.8171 11.1602C14.3392 10.7621 14.7161 10.2036 14.89 9.57051C15.02 9.11051 15.4 8.77051 15.87 8.77051C16.52 8.77051 17 9.39051 16.84 10.0205C16.5645 11.0944 15.9392 12.046 15.0627 12.7251C14.1863 13.4041 13.1087 13.772 12 13.7705Z" fill="currentColor" />
</svg>
</div>
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Гарантия качества</h3>
<div className="text-block-38">Мы предлагаем только качественные запчасти от проверенных поставщиков</div>
</div>
</div>
</div>
</div>
);
export default AboutProtekInfo;

View File

@ -0,0 +1,175 @@
import React, { useState } from 'react'
import { ApolloProvider } from '@apollo/client'
import { apolloClient } from '@/lib/apollo'
import PhoneInput from './PhoneInput'
import CodeVerification from './CodeVerification'
import UserRegistration from './UserRegistration'
import type { AuthState, AuthStep, ClientAuthResponse, VerificationResponse } from '@/types/auth'
interface AuthModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (client: any, token?: string) => void
}
const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onClose, onSuccess }) => {
const [authState, setAuthState] = useState<AuthState>({
step: 'phone',
phone: '',
sessionId: '',
isExistingClient: false
})
const [error, setError] = useState('')
const handlePhoneSuccess = (data: ClientAuthResponse) => {
setError('')
// Всегда переходим к вводу кода, независимо от того, существует клиент или нет
setAuthState(prev => ({
...prev,
step: 'code',
sessionId: data.sessionId,
client: data.client,
isExistingClient: data.exists
}))
}
const handleCodeSuccess = (data: VerificationResponse) => {
if (data.success && data.client) {
onSuccess(data.client, data.token)
onClose()
}
}
const handleRegistrationSuccess = (data: VerificationResponse) => {
if (data.success && data.client) {
onSuccess(data.client, data.token)
onClose()
}
}
const handleError = (errorMessage: string) => {
setError(errorMessage)
}
const handleBack = () => {
setAuthState(prev => ({
...prev,
step: 'phone'
}))
setError('')
}
const handleGoToRegistration = () => {
setAuthState(prev => ({
...prev,
step: 'registration'
}))
setError('')
}
const handleClose = () => {
setAuthState({
step: 'phone',
phone: '',
sessionId: '',
isExistingClient: false
})
setError('')
onClose()
}
if (!isOpen) return null
const renderStep = () => {
switch (authState.step) {
case 'phone':
return (
<PhoneInput
onSuccess={(data, phone) => {
setAuthState(prev => ({
...prev,
phone: phone,
sessionId: data.sessionId,
client: data.client,
isExistingClient: data.exists
}))
handlePhoneSuccess(data)
}}
onError={handleError}
onRegister={handleGoToRegistration}
/>
)
case 'code':
return (
<CodeVerification
phone={authState.phone}
sessionId={authState.sessionId}
isExistingClient={authState.isExistingClient}
onSuccess={handleCodeSuccess}
onError={handleError}
onBack={handleBack}
onRegister={handleGoToRegistration}
/>
)
case 'registration':
return (
<UserRegistration
phone={authState.phone}
sessionId={authState.sessionId}
onSuccess={handleRegistrationSuccess}
onError={handleError}
/>
)
default:
return null
}
}
return (
<ApolloProvider client={apolloClient}>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/10 z-index-40 margin-top-[132px] transition-opacity duration-200"
aria-label="Затемнение фона"
tabIndex={-1}
onClick={handleClose}
/>
{/* Модальное окно */}
<div className="flex relative w-full bg-white mx-auto z-50">
<div className="flex relative flex-col gap-4 items-start px-32 py-10 w-full bg-white max-w-[1920px] min-h-[320px] max-md:px-16 max-md:py-8 max-sm:gap-8 max-sm:p-5 mx-auto z-50"
style={{ marginTop: 0, position: 'relative' }}
>
{/* Кнопка закрытия */}
<button
onClick={handleClose}
className="absolute right-8 top-8 p-2 hover:opacity-70 focus:outline-none"
aria-label="Закрыть окно авторизации"
tabIndex={0}
>
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<path d="M8 23.75L6.25 22L13.25 15L6.25 8L8 6.25L15 13.25L22 6.25L23.75 8L16.75 15L23.75 22L22 23.75L15 16.75L8 23.75Z" fill="#000814"/>
</svg>
</button>
{/* Заголовок */}
<div className="flex relative justify-between items-start w-full max-sm:flex-col max-sm:gap-5">
<div className="relative text-5xl font-bold uppercase leading-[62.4px] text-gray-950 max-md:text-5xl max-sm:self-start max-sm:text-3xl">
ВХОД
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded">
<p className="text-red-800 m-0">{error}</p>
</div>
)}
{/* Контент */}
<div className="flex relative flex-col gap-5 items-start self-stretch w-full">
{renderStep()}
</div>
</div>
</div>
</ApolloProvider>
)
}
export default AuthModal

View File

@ -0,0 +1,159 @@
import React, { useState, useRef, useEffect } from 'react'
import { useMutation } from '@apollo/client'
import { SEND_SMS_CODE, VERIFY_CODE } from '@/lib/graphql'
import type { SMSCodeResponse, VerificationResponse } from '@/types/auth'
interface CodeVerificationProps {
phone: string
sessionId: string
isExistingClient: boolean
onSuccess: (data: VerificationResponse) => void
onError: (error: string) => void
onBack: () => void
onRegister: () => void
}
const CodeVerification: React.FC<CodeVerificationProps> = ({
phone,
sessionId,
isExistingClient,
onSuccess,
onError,
onBack,
onRegister
}) => {
const [code, setCode] = useState(['', '', '', '', ''])
const [isLoading, setIsLoading] = useState(false)
const [smsCode, setSmsCode] = useState('')
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const [sendSMS] = useMutation<{ sendSMSCode: SMSCodeResponse }>(SEND_SMS_CODE)
const [verifyCode] = useMutation<{ verifyCode: VerificationResponse }>(VERIFY_CODE)
// SMS код уже отправлен в PhoneInput, здесь только показываем
useEffect(() => {
console.log('CodeVerification mounted for', isExistingClient ? 'existing' : 'new', 'client')
}, [])
const handleCodeChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return
const newCode = [...code]
newCode[index] = value.slice(-1)
setCode(newCode)
// Автоматически переходим к следующему полю
if (value && index < 4) {
inputRefs.current[index + 1]?.focus()
}
// Если все поля заполнены, отправляем код
if (newCode.every(digit => digit !== '') && !isLoading) {
handleVerify(newCode.join(''))
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handleVerify = async (codeString?: string) => {
const finalCode = codeString || code.join('')
if (finalCode.length !== 5) {
onError('Введите полный код')
return
}
setIsLoading(true)
try {
const { data } = await verifyCode({
variables: {
phone,
code: finalCode,
sessionId
}
})
if (data?.verifyCode?.success) {
if (data.verifyCode.client) {
// Если клиент существует - авторизуем
onSuccess(data.verifyCode)
} else {
// Если клиент новый - переходим к регистрации
onRegister()
}
}
} catch (error) {
console.error('Ошибка верификации:', error)
onError('Неверный код')
setCode(['', '', '', '', ''])
inputRefs.current[0]?.focus()
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Введите код из СМС</label>
<div className="flex gap-5 items-center w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
{/* 5 полей для цифр */}
<div className="flex gap-3">
{code.map((digit, index) => (
<input
key={index}
ref={el => { inputRefs.current[index] = el }}
type="text"
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="w-[62px] h-[62px] px-4 py-3 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none text-center"
maxLength={1}
disabled={isLoading}
aria-label={`Цифра ${index + 1}`}
/>
))}
</div>
{/* Кнопка "Войти" */}
<button
type="button"
onClick={() => handleVerify()}
disabled={isLoading || code.some(digit => digit === '')}
style={{ color: 'white' }}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[62px] max-sm:px-6 max-sm:py-4"
aria-label="Войти"
tabIndex={0}
>
{isLoading ? 'Проверяем...' : 'Войти'}
</button>
</div>
{/* Кнопка "Ввести другой номер" под вводом кода */}
<button
type="button"
onClick={onBack}
className="flex gap-3 items-center hover:opacity-70 mt-2"
aria-label="Ввести другой номер"
tabIndex={0}
>
<svg width="40" height="13" viewBox="0 0 40 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.469669 5.96967C0.176777 6.26256 0.176777 6.73743 0.469669 7.03033L5.24264 11.8033C5.53553 12.0962 6.01041 12.0962 6.3033 11.8033C6.59619 11.5104 6.59619 11.0355 6.3033 10.7426L2.06066 6.5L6.3033 2.25736C6.5962 1.96446 6.5962 1.48959 6.3033 1.1967C6.01041 0.903803 5.53553 0.903803 5.24264 1.1967L0.469669 5.96967ZM40 5.75L1 5.75L1 7.25L40 7.25L40 5.75Z" fill="#424F60"/>
</svg>
<span className="text-lg leading-[1.4] font-normal font-[Onest,sans-serif] text-[#424F60]">Ввести другой номер</span>
</button>
{/* Отладочная информация */}
{smsCode && (
<div className="mt-4 px-4 py-3 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-800 m-0 font-[Onest,sans-serif]">
<strong>Код для тестирования:</strong> {smsCode}
</p>
</div>
)}
</div>
)
}
export default CodeVerification

View File

@ -0,0 +1,142 @@
import React, { useState } from 'react'
import { useMutation } from '@apollo/client'
import { CHECK_CLIENT_BY_PHONE, SEND_SMS_CODE } from '@/lib/graphql'
import type { ClientAuthResponse, SMSCodeResponse } from '@/types/auth'
interface PhoneInputProps {
onSuccess: (data: ClientAuthResponse, phone: string) => void
onError: (error: string) => void
onRegister: () => void
}
const PhoneInput: React.FC<PhoneInputProps> = ({ onSuccess, onError, onRegister }) => {
const [phone, setPhone] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [checkClient] = useMutation<{ checkClientByPhone: ClientAuthResponse }>(CHECK_CLIENT_BY_PHONE)
const [sendSMSCode] = useMutation<{ sendSMSCode: SMSCodeResponse }>(SEND_SMS_CODE)
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
// Убираем все кроме цифр
let digitsOnly = value.replace(/\D/g, '')
// Если начинается с 7, убираем её
if (digitsOnly.startsWith('7')) {
digitsOnly = digitsOnly.substring(1)
}
// Ограничиваем до 10 цифр
if (digitsOnly.length <= 10) {
// Форматируем номер
let formatted = digitsOnly
if (digitsOnly.length >= 1) {
formatted = digitsOnly.replace(/(\d{1,3})(\d{0,3})(\d{0,2})(\d{0,2})/, (match, p1, p2, p3, p4) => {
let result = `(${p1}`
if (p2) result += `) ${p2}`
if (p3) result += `-${p3}`
if (p4) result += `-${p4}`
return result
})
}
setPhone(formatted)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const cleanPhone = '+7' + phone.replace(/\D/g, '')
if (phone.replace(/\D/g, '').length !== 10) {
onError('Введите корректный номер телефона')
return
}
setIsLoading(true)
try {
// Сначала проверяем существует ли клиент
const { data: clientData } = await checkClient({
variables: { phone: cleanPhone }
})
if (clientData?.checkClientByPhone) {
// Затем отправляем SMS код
const { data: smsData } = await sendSMSCode({
variables: {
phone: cleanPhone,
sessionId: clientData.checkClientByPhone.sessionId
}
})
if (smsData?.sendSMSCode?.success) {
console.log('SMS код отправлен! Код:', smsData.sendSMSCode.code)
onSuccess(clientData.checkClientByPhone, cleanPhone)
} else {
onError('Не удалось отправить SMS код')
}
}
} catch (error) {
console.error('Ошибка проверки телефона:', error)
onError('Произошла ошибка при проверке номера')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif] "
style={{
fontSize: '22px',
lineHeight: '1.4',
fontWeight: 400,
fontFamily: 'Onest, sans-serif',
color: '#000814'
}}>Введите номер телефона</label>
<form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<div className="flex gap-5 items-center w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
<input
type="tel"
value={`+7 ${phone}`}
onChange={handlePhoneChange}
placeholder="+7 (999) 999-99-99"
className="max-w-[360px] w-full h-[70px] px-[30px] py-[20px] text-[20px] leading-[1.4] font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none min-w-0 max-md:w-[300px] max-sm:w-full"
disabled={isLoading}
required
aria-label="Введите номер телефона"
/>
<button
type="submit"
disabled={isLoading || phone.replace(/\D/g, '').length !== 10}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[70px] max-sm:px-6 max-sm:py-4"
style={{ color: 'white' }}
aria-label="Получить код"
tabIndex={0}
>
{isLoading ? 'Проверяем...' : 'Получить код'}
</button>
</div>
</form>
{/* <button
type="button"
onClick={onRegister}
className="flex gap-5 justify-center items-center px-7 py-5 w-80 rounded-xl border border-red-700 border-solid cursor-pointer max-md:self-center max-md:px-6 max-md:py-5 max-md:w-[280px] max-sm:px-5 max-sm:py-4 max-sm:w-full"
aria-label="Зарегистрироваться"
tabIndex={0}
>
<span className="text-xl font-medium leading-7 text-center text-gray-950 max-md:text-lg max-sm:text-base">Зарегистрироваться</span>
<span aria-hidden="true">
<svg width="31" height="16" viewBox="0 0 31 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="arrow-icon" style={{width:'30px',height:'16px',flexShrink:0}}>
<path d="M30.7071 8.70711C31.0976 8.31659 31.0976 7.68342 30.7071 7.2929L24.3431 0.928936C23.9526 0.538412 23.3195 0.538412 22.9289 0.928936C22.5384 1.31946 22.5384 1.95263 22.9289 2.34315L28.5858 8L22.9289 13.6569C22.5384 14.0474 22.5384 14.6805 22.9289 15.0711C23.3195 15.4616 23.9526 15.4616 24.3431 15.0711L30.7071 8.70711ZM0 8L-1.74846e-07 9L30 9.00001L30 8.00001L30 7.00001L1.74846e-07 7L0 8Z" fill="#000814"/>
</svg>
</span>
</button> */}
</div>
)
}
export default PhoneInput

View File

@ -0,0 +1,105 @@
import React, { useState } from 'react'
import { useMutation } from '@apollo/client'
import { REGISTER_NEW_CLIENT } from '@/lib/graphql'
import type { VerificationResponse } from '@/types/auth'
interface UserRegistrationProps {
phone: string
sessionId: string
onSuccess: (data: VerificationResponse) => void
onError: (error: string) => void
}
const UserRegistration: React.FC<UserRegistrationProps> = ({
phone,
sessionId,
onSuccess,
onError
}) => {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [registerClient] = useMutation<{ registerNewClient: VerificationResponse }>(REGISTER_NEW_CLIENT)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim()) {
onError('Введите имя')
return
}
if (!lastName.trim()) {
onError('Введите фамилию')
return
}
setIsLoading(true)
try {
const fullName = `${firstName.trim()} ${lastName.trim()}`
const { data } = await registerClient({
variables: {
phone,
name: fullName,
sessionId
}
})
if (data?.registerNewClient) {
onSuccess(data.registerNewClient)
}
} catch (error) {
onError('Не удалось зарегистрировать пользователя')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<div className="flex gap-5 items-end w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
{/* Имя */}
<div className="flex flex-col gap-3 max-w-[360px] w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Введите имя</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Иван"
className="max-w-[360px] w-full h-[62px] px-6 py-4 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none"
disabled={isLoading}
required
/>
</div>
{/* Фамилия */}
<div className="flex flex-col gap-3 max-w-[360px] w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Фамилию</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Иванов"
className="max-w-[360px] w-full h-[62px] px-6 py-4 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none"
disabled={isLoading}
required
/>
</div>
{/* Кнопка */}
<button
type="submit"
disabled={isLoading || !firstName.trim() || !lastName.trim()}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[70px] max-sm:px-6 max-sm:py-4"
style={{
color: 'white'
}}
aria-label="Сохранить"
tabIndex={0}
>
{isLoading ? 'Сохраняем...' : 'Сохранить'}
{/* <img src="/images/Arrow_right.svg" alt="" className="ml-2 w-6 h-6" /> */}
</button>
</div>
</form>
</div>
)
}
export default UserRegistration

View File

@ -0,0 +1,108 @@
import React from "react";
import { useFavorites } from "@/contexts/FavoritesContext";
interface InfoCardProps {
brand?: string;
articleNumber?: string;
name?: string;
productId?: string;
offerKey?: string;
price?: number;
currency?: string;
image?: string;
}
export default function InfoCard({
brand,
articleNumber,
name,
productId,
offerKey,
price = 0,
currency = 'RUB',
image
}: InfoCardProps) {
const { addToFavorites, removeFromFavorites, isFavorite } = useFavorites();
// Проверяем, есть ли товар в избранном
const isItemFavorite = isFavorite(productId, offerKey, articleNumber, brand);
// Обработчик клика по сердечку
const handleFavoriteClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isItemFavorite) {
// Создаем ID для удаления
const id = `${productId || offerKey || ''}:${articleNumber}:${brand}`;
removeFromFavorites(id);
} else {
// Добавляем в избранное
addToFavorites({
productId,
offerKey,
name: name || "Название товара",
brand: brand || "БРЕНД",
article: articleNumber || "АРТИКУЛ",
price,
currency,
image
});
}
};
return (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="/catalog" className="link-block w-inline-block">
<div>Каталог</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block w-inline-block">
<div>Автозапчасти</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>{name || "Деталь"} </div>
</a>
</div>
<div className="w-layout-hflex flex-block-bi">
<div className="w-layout-hflex headingbi">
<h1 className="heading-bi">{name || "Название товара"} {brand || "БРЕНД"}</h1>
<div
className="div-block-127"
onClick={handleFavoriteClick}
style={{
cursor: 'pointer',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.1)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
>
<div className="icon-setting w-embed">
<svg width="currentwidth" height="currentheight" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 25L13.405 23.5613C7.74 18.4714 4 15.1035 4 10.9946C4 7.6267 6.662 5 10.05 5C11.964 5 13.801 5.88283 15 7.26703C16.199 5.88283 18.036 5 19.95 5C23.338 5 26 7.6267 26 10.9946C26 15.1035 22.26 18.4714 16.595 23.5613L15 25Z"
fill={isItemFavorite ? "#e53935" : "currentColor"}
style={{ color: isItemFavorite ? "#e53935" : undefined }}
/>
</svg>
</div>
</div>
</div>
<div className="w-layout-hflex rightbi">
<div className="text-block-5-copy">{brand || "БРЕНД"} <strong className="bold-text">{articleNumber || "АРТИКУЛ"}</strong></div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,106 @@
import React, { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { toast } from "react-hot-toast";
interface ProductBuyBlockProps {
offer?: any;
}
const ProductBuyBlock = ({ offer }: ProductBuyBlockProps) => {
const [quantity, setQuantity] = useState(1);
const { addItem } = useCart();
if (!offer) {
return (
<div className="w-layout-hflex add-to-cart-block-copy">
<div className="text-center py-4">
<p className="text-gray-500">Загрузка...</p>
</div>
</div>
);
}
const handleQuantityChange = (delta: number) => {
const newQuantity = Math.max(1, Math.min(offer.quantity || 999, quantity + delta));
setQuantity(newQuantity);
};
// Обработчик добавления в корзину
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
if (!offer.price || offer.price <= 0) {
toast.error('Цена товара не найдена');
return;
}
// Добавляем товар в корзину
addItem({
productId: offer.id ? String(offer.id) : undefined,
offerKey: offer.offerKey || undefined,
name: offer.name || `${offer.brand} ${offer.articleNumber}`,
description: offer.name || `${offer.brand} ${offer.articleNumber}`,
price: offer.price,
currency: 'RUB',
quantity: quantity,
image: offer.image || undefined,
brand: offer.brand,
article: offer.articleNumber,
supplier: offer.supplier || (offer.type === 'external' ? 'AutoEuro' : 'Внутренний'),
deliveryTime: offer.deliveryTime ? String(offer.deliveryTime) + ' дней' : '1 день',
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: '🛒',
}
);
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
toast.error('Ошибка при добавлении товара в корзину');
}
};
const totalPrice = offer.price * quantity;
return (
<div className="w-layout-hflex add-to-cart-block-copy">
<div className="pcs-card">{offer.quantity || 0} шт</div>
<div className="price opencard">{totalPrice.toLocaleString('ru-RU')} </div>
<div className="w-layout-hflex pcs-copy">
<div className="minus-plus" onClick={() => handleQuantityChange(-1)}>
<img loading="lazy" src="images/minus_icon.svg" alt="" />
</div>
<div className="input-pcs">
<div className="text-block-26">{quantity}</div>
</div>
<div className="minus-plus" onClick={() => handleQuantityChange(1)}>
<img loading="lazy" src="images/plus_icon.svg" alt="" />
</div>
</div>
<div
className="button-icon w-inline-block"
onClick={handleAddToCart}
style={{
cursor: 'pointer',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
>
<img loading="lazy" src="images/cart_icon.svg" alt="" className="image-11" />
</div>
</div>
);
};
export default ProductBuyBlock;

View File

@ -0,0 +1,139 @@
import React from "react";
interface ProductCharacteristicsProps {
result?: any;
}
const ProductCharacteristics = ({ result }: ProductCharacteristicsProps) => {
// Функция для рендеринга характеристик из нашей базы данных
const renderInternalCharacteristics = () => {
if (!result?.characteristics || result.characteristics.length === 0) return null;
return (
<div className="w-layout-vflex flex-block-53">
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
Характеристики товара:
</span>
</div>
{result.characteristics.map((char: any, index: number) => (
<div key={index} className="w-layout-hflex flex-block-55">
<span className="text-block-29">{char.characteristic.name}:</span>
<span className="text-block-28">{char.value}</span>
</div>
))}
</div>
);
};
// Функция для рендеринга характеристик из Parts Index
const renderPartsIndexCharacteristics = () => {
if (!result?.partsIndexCharacteristics || result.partsIndexCharacteristics.length === 0) return null;
return (
<div className="w-layout-vflex flex-block-53">
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
Дополнительные характеристики:
</span>
</div>
{result.partsIndexCharacteristics.map((char: any, index: number) => (
<div key={index} className="w-layout-hflex flex-block-55">
<span className="text-block-29">{char.name}:</span>
<span className="text-block-28">{char.value}</span>
</div>
))}
</div>
);
};
// Функция для рендеринга изображений из нашей базы данных
const renderInternalImages = () => {
if (!result?.images || result.images.length === 0) return null;
return (
<div className="w-layout-vflex flex-block-53" style={{ marginTop: '20px' }}>
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29" style={{ fontWeight: 'bold', color: '#333' }}>
Изображения товара:
</span>
</div>
<div className="w-layout-hflex" style={{ flexWrap: 'wrap', gap: '10px', marginTop: '10px' }}>
{result.images.slice(0, 6).map((image: any, index: number) => (
<div key={image.id || index} style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
width: '120px',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9f9f9'
}}>
<img
src={image.url}
alt={image.alt || `${result?.brand} ${result?.articleNumber} - изображение ${index + 1}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
cursor: 'pointer'
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onClick={() => window.open(image.url, '_blank')}
/>
</div>
))}
</div>
</div>
);
};
return (
<>
<div className="w-layout-hflex flex-block-52">
{result && (
<>
<div className="w-layout-vflex flex-block-53">
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Бренд:</span>
<span className="text-block-28">{result.brand}</span>
</div>
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Артикул:</span>
<span className="text-block-28">{result.articleNumber}</span>
</div>
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Название:</span>
<span className="text-block-28">{result.name}</span>
</div>
{result?.description && (
<div className="w-layout-hflex flex-block-55">
<span className="text-block-29">Описание:</span>
<span className="text-block-28" style={{ maxWidth: '400px', wordWrap: 'break-word' }}>
{result.description}
</span>
</div>
)}
</div>
{/* Характеристики из нашей базы данных */}
{renderInternalCharacteristics()}
{/* Дополнительные характеристики из Parts Index */}
{renderPartsIndexCharacteristics()}
</>
)}
</div>
{/* Изображения из нашей базы данных */}
{renderInternalImages()}
</>
);
};
export default ProductCharacteristics;

View File

@ -0,0 +1,33 @@
import React, { useState } from "react";
interface ProductDescriptionTabsProps {
result?: any;
}
const tabList = [
{ key: 'description', label: 'Описание' },
{ key: 'characteristics', label: 'Характеристики' },
// { key: 'reviews', label: 'Отзывы' },
// { key: 'analogs', label: 'Аналоги' }
];
const ProductDescriptionTabs = ({ result }: ProductDescriptionTabsProps) => {
const [activeTab, setActiveTab] = useState<'description' | 'characteristics' | 'reviews' | 'analogs'>('characteristics');
return (
<div className="w-layout-hflex flex-block-51">
{tabList.map(tab => (
<div
key={tab.key}
className={activeTab === tab.key ? 'tab_card-activ' : 'tab_card'}
onClick={() => setActiveTab(tab.key as typeof activeTab)}
style={{ cursor: 'pointer' }}
>
{tab.label}
</div>
))}
</div>
);
};
export default ProductDescriptionTabs;

View File

@ -0,0 +1,123 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
interface ProductImageGalleryProps {
imageUrl?: string;
images?: string[]; // если появятся несколько картинок
partsIndexImages?: string[]; // изображения из Parts Index
}
export default function ProductImageGallery({ imageUrl, images, partsIndexImages }: ProductImageGalleryProps) {
// Убираем defaultImage - больше не используем заглушку
// const defaultImage = "/images/image-10.png";
// Объединяем все доступные изображения
const allImages = [
...(partsIndexImages && partsIndexImages.length > 0 ? partsIndexImages : []),
...(images && images.length > 0 ? images : []),
...(imageUrl ? [imageUrl] : [])
];
// Если нет изображений, показываем заглушку с текстом
const galleryImages = allImages.length > 0 ? allImages : [];
const [selectedImage, setSelectedImage] = useState(galleryImages[0] || '');
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
// Обновляем selectedImage при изменении galleryImages
useEffect(() => {
if (galleryImages.length > 0 && !selectedImage) {
setSelectedImage(galleryImages[0]);
}
}, [galleryImages, selectedImage]);
// Закрытие overlay по ESC
useEffect(() => {
if (!isOverlayOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOverlayOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOverlayOpen]);
// Клик вне картинки
const overlayRef = useRef<HTMLDivElement>(null);
const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) setIsOverlayOpen(false);
}, []);
// Если нет изображений, показываем заглушку
if (galleryImages.length === 0) {
return (
<div className="w-layout-vflex core-product-copy-copy">
<div className="div-block-20 flex items-center justify-center bg-gray-100 rounded-lg" style={{ minHeight: '300px' }}>
<div className="text-center text-gray-500">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm">Изображения товара не найдены</p>
</div>
</div>
</div>
);
}
return (
<div className="w-layout-vflex core-product-copy-copy">
{/* Основная картинка */}
<div className="div-block-20 cursor-zoom-in" onClick={() => setIsOverlayOpen(true)} tabIndex={0} aria-label="Открыть изображение в полный экран" role="button" onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && setIsOverlayOpen(true)}>
<img src={selectedImage} loading="lazy" alt="Изображение товара" className="image-10-copy" />
</div>
{/* Миниатюры */}
<div className="w-layout-hflex flex-block-56 mt-2 gap-2">
{galleryImages.map((img, idx) => (
<img
key={img + idx}
src={img}
loading="lazy"
alt={`Миниатюра ${idx + 1}`}
className={`small-img cursor-pointer border ${selectedImage === img ? 'border-blue-500' : 'border-transparent'} rounded transition`}
onClick={() => {
setSelectedImage(img);
setIsOverlayOpen(true);
}}
tabIndex={0}
aria-label={`Показать изображение ${idx + 1}`}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
setSelectedImage(img);
setIsOverlayOpen(true);
}
}}
/>
))}
</div>
{/* Overlay для просмотра картинки */}
{isOverlayOpen && selectedImage && (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-fade-in"
onClick={handleOverlayClick}
aria-modal="true"
role="dialog"
>
<button
onClick={() => setIsOverlayOpen(false)}
className="absolute top-6 right-6 text-white bg-black/40 rounded-full p-2 hover:bg-black/70 focus:outline-none"
aria-label="Закрыть просмотр изображения"
tabIndex={0}
>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M8 24L24 8M8 8l16 16" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
<img
src={selectedImage}
alt="Просмотр товара"
className="max-h-[90vh] max-w-[90vw] rounded-lg shadow-2xl border-4 border-white"
draggable={false}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,76 @@
import React from "react";
interface ProductInfoProps {
offer?: any;
}
const ProductInfo: React.FC<ProductInfoProps> = ({ offer }) => {
if (!offer) {
return (
<div className="w-layout-hflex info-block-search">
<div className="text-center py-4 text-gray-500">
Нет данных о предложении
</div>
</div>
);
}
// Форматируем срок доставки
const formatDeliveryTime = (deliveryTime: number | string) => {
const days = typeof deliveryTime === 'string' ? parseInt(deliveryTime) : deliveryTime;
if (!days || days === 0) {
return "Сегодня";
} else if (days === 1) {
return "Завтра";
} else if (days <= 3) {
return `${days} дня`;
} else if (days <= 7) {
return `${days} дней`;
} else {
return `${days} дней`;
}
};
return (
<div className="w-layout-hflex info-block-search">
{/* Иконки рекомендации (если есть) */}
<div className="w-layout-hflex info-block-product-card-search">
{offer.recommended && (
<>
<div className="w-layout-hflex item-recommend-copy">
<img loading="lazy" src="/images/ri_refund-fill_1.svg" alt="Рекомендуем" />
</div>
<div className="w-layout-hflex item-recommend-copy">
<img loading="lazy" src="/images/mdi_approve.svg" alt="Проверено" />
</div>
<div className="w-layout-hflex item-recommend-copy">
<img loading="lazy" src="/images/ri_refund-fill.svg" alt="Гарантия" className="image-16" />
</div>
</>
)}
</div>
{/* Срок доставки */}
<div className="delivery-time-search">
{formatDeliveryTime(offer.deliveryTime || offer.deliveryDays || 0)}
</div>
{/* Шанс отказа */}
{offer.rejects !== undefined && offer.rejects > 0 && (
<div className="rejects-info" style={{ fontSize: '12px', color: '#666', marginLeft: '8px' }}>
Шанс отказа: {offer.rejects}%
</div>
)}
{/* Название склада */}
{offer.warehouseName && (
<div className="warehouse-info" style={{ fontSize: '12px', color: '#666', marginLeft: '8px' }}>
Склад: {offer.warehouseName}
</div>
)}
</div>
);
};
export default ProductInfo;

View File

@ -0,0 +1,19 @@
import React from "react";
import ProductInfo from "./ProductInfo";
import ProductBuyBlock from "./ProductBuyBlock";
interface ProductItemCardProps {
isLast?: boolean;
offer?: any;
}
const ProductItemCard = ({ isLast = false, offer }: ProductItemCardProps) => {
return (
<div className={`w-layout-hflex product-item-card${isLast ? " last" : ""}`}>
<ProductInfo offer={offer} />
<ProductBuyBlock offer={offer} />
</div>
);
};
export default ProductItemCard;

View File

@ -0,0 +1,43 @@
import React from "react";
import ProductItemCard from "./ProductItemCard";
import ProductListSkeleton from "./ProductListSkeleton";
interface ProductListProps {
offers?: any[];
isLoading?: boolean;
}
const ProductList = ({ offers = [], isLoading = false }: ProductListProps) => {
// Показываем скелетон во время загрузки
if (isLoading) {
return <ProductListSkeleton count={4} />;
}
// Фильтруем предложения - показываем только те, у которых есть цена
const validOffers = offers.filter(offer => offer && offer.price && offer.price > 0);
// Если нет валидных предложений
if (validOffers.length === 0) {
return (
<div className="w-layout-vflex product-list-search">
<div className="text-center py-8">
<p className="text-gray-500">Предложения с ценами не найдены</p>
</div>
</div>
);
}
return (
<div className="w-layout-vflex product-list-search">
{validOffers.map((offer, idx) => (
<ProductItemCard
key={`${offer.type}-${offer.id || idx}`}
offer={offer}
isLast={idx === validOffers.length - 1}
/>
))}
</div>
);
};
export default ProductList;

View File

@ -0,0 +1,66 @@
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
interface ProductListSkeletonProps {
count?: number;
}
const ProductListSkeleton: React.FC<ProductListSkeletonProps> = ({ count = 4 }) => {
return (
<div className="w-layout-vflex product-list-search">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className={`w-layout-hflex product-item-card${index === count - 1 ? " last" : ""}`}>
{/* ProductInfo скелетон */}
<div className="w-layout-hflex info-block-search">
<div className="w-layout-hflex info-block-product-card-search">
{/* Иконки рекомендации */}
<div className="w-layout-hflex item-recommend-copy">
<Skeleton width={24} height={24} />
</div>
<div className="w-layout-hflex item-recommend-copy">
<Skeleton width={24} height={24} />
</div>
<div className="w-layout-hflex item-recommend-copy">
<Skeleton width={24} height={24} />
</div>
</div>
{/* Срок доставки */}
<div className="delivery-time-search">
<Skeleton width={80} height={20} />
</div>
</div>
{/* ProductBuyBlock скелетон */}
<div className="w-layout-hflex add-to-cart-block-copy">
<div className="pcs-card">
<Skeleton width={50} height={20} />
</div>
<div className="price opencard">
<Skeleton width={100} height={24} />
</div>
<div className="w-layout-hflex pcs-copy">
<div className="minus-plus">
<Skeleton width={20} height={20} />
</div>
<div className="input-pcs">
<div className="text-block-26">
<Skeleton width={30} height={20} />
</div>
</div>
<div className="minus-plus">
<Skeleton width={20} height={20} />
</div>
</div>
<div className="button-icon w-inline-block">
<Skeleton width={32} height={32} />
</div>
</div>
</div>
))}
</div>
);
};
export default ProductListSkeleton;

View File

@ -0,0 +1,49 @@
import React from "react";
interface ProductSortHeaderProps {
brand?: string;
articleNumber?: string;
name?: string;
sortBy: string;
onSortChange: (sortBy: string) => void;
}
const sortOptions = [
{ key: "delivery", label: "Доставка" },
{ key: "quantity", label: "Количество" },
{ key: "price", label: "Цена" }
];
const ProductSortHeader: React.FC<ProductSortHeaderProps> = ({
brand,
articleNumber,
name,
sortBy,
onSortChange
}) => {
const handleClick = (key: string) => {
if (sortBy === key) {
onSortChange(""); // сброс сортировки
} else {
onSortChange(key);
}
};
return (
<div className="w-layout-hflex sort-list-card">
{sortOptions.map(option => (
<div
key={option.key}
className={`sort-item${sortBy === option.key ? " active" : ""}`}
onClick={() => handleClick(option.key)}
style={{ cursor: "pointer" }}
>
{option.label}
</div>
))}
</div>
);
};
export default ProductSortHeader;

View File

@ -0,0 +1,27 @@
import React from "react";
interface ShowMoreOffersProps {
hasMoreOffers?: boolean;
onShowMore?: () => void;
remainingCount?: number;
}
const ShowMoreOffers = ({ hasMoreOffers = false, onShowMore, remainingCount = 0 }: ShowMoreOffersProps) => {
if (!hasMoreOffers || remainingCount <= 0) {
return null;
}
return (
<div className="w-layout-hflex show-more-search">
<button
onClick={onShowMore}
className="text-block-27"
>
Показать еще предложения ({remainingCount})
</button>
<img src="images/arrow_drop_down.svg" loading="lazy" alt="" />
</div>
);
};
export default ShowMoreOffers;

View File

@ -0,0 +1,26 @@
import React from "react";
const InfoContacts = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Контактная информация</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Контактная информация</h1>
</div>
</div>
</div>
</div>
</section>
);
export default InfoContacts;

View File

@ -0,0 +1,27 @@
import React from "react";
const LegalContacts = () => (
<div className="w-layout-vflex desc-wholesale">
<div className="w-layout-hflex flex-block-74-copy">
<h3 className="heading-14">Реквизиты ООО «Протек»</h3>
<div className="w-layout-hflex flex-block-75">
<div className="text-block-36-copy">ИНН</div>
<div className="text-block-36">5007117840</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="text-block-36-copy">ОГРН</div>
<div className="text-block-36">1225000146282</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="text-block-36-copy">КПП</div>
<div className="text-block-36">500701001</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="text-block-36-copy">Почтовый адрес</div>
<div className="text-block-36">Московская обл., г. Дмитров, ул. Чекистская 6, комната 4</div>
</div>
</div>
</div>
);
export default LegalContacts;

View File

@ -0,0 +1,19 @@
import React from "react";
const MapContacts = () => (
<div className="w-layout-vflex map-contacts">
<div className="map w-widget w-widget-map" style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
<iframe
src="https://yandex.ru/map-widget/v1/?ll=37.532502%2C56.339223&mode=whatshere&whatshere%5Bpoint%5D=37.532502%2C56.339223&whatshere%5Bzoom%5D=17&z=16"
width="100%"
height="100%"
frameBorder="0"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0 }}
title="Карта"
></iframe>
</div>
</div>
);
export default MapContacts;

View File

@ -0,0 +1,30 @@
import React from "react";
const OrderContacts = () => (
<div className="w-layout-vflex desc-wholesale">
<div className="w-layout-hflex flex-block-74-copy">
<h3 className="heading-14">Сделать заказ или уточнить наличие</h3>
<div className="w-layout-hflex flex-block-75-copy">
<div className="phone-contacts 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="text-block-36">+7 (495) 260-20-60</div>
<div className="text-block-36-copy">ПН-ПТ 9:00 18:00, Сб 10:00 16:00, ВС Выходной</div>
</div>
<h3 className="heading-14">Адрес</h3>
<div className="w-layout-hflex flex-block-75-copy">
<div className="phone-contacts w-embed">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2C11.8565 2 13.637 2.72245 14.9497 4.00841C16.2625 5.29437 17 7.03852 17 8.85714C17 11.696 14.7911 14.6705 10.4667 17.8476C10.332 17.9465 10.1683 18 10 18C9.83171 18 9.66796 17.9465 9.53333 17.8476C5.20889 14.6705 3 11.696 3 8.85714C3 7.03852 3.7375 5.29437 5.05025 4.00841C6.36301 2.72245 8.14348 2 10 2ZM10 6.57143C9.38116 6.57143 8.78767 6.81224 8.35008 7.2409C7.9125 7.66955 7.66667 8.25093 7.66667 8.85714C7.66667 9.46335 7.9125 10.0447 8.35008 10.4734C8.78767 10.902 9.38116 11.1429 10 11.1429C10.6188 11.1429 11.2123 10.902 11.6499 10.4734C12.0875 10.0447 12.3333 9.46335 12.3333 8.85714C12.3333 8.25093 12.0875 7.66955 11.6499 7.2409C11.2123 6.81224 10.6188 6.57143 10 6.57143Z" fill="currentColor" />
</svg>
</div>
<div className="text-block-36">Московская обл., г. Дмитров, ул. Чекистская 6, комната 4</div>
</div>
<a href="#" className="submit-button-copy w-button">Обратная связь</a>
</div>
</div>
);
export default OrderContacts;

View File

@ -0,0 +1,372 @@
import React, { useState, useEffect, useRef } from 'react';
import { useQuery } from '@apollo/client';
import {
YANDEX_PICKUP_POINTS_BY_CITY,
YANDEX_PICKUP_POINTS_BY_COORDINATES,
YandexPickupPoint
} from '@/lib/graphql/yandex-delivery';
interface PickupPointSelectorProps {
selectedPoint?: YandexPickupPoint;
onPointSelect: (point: YandexPickupPoint) => void;
onCityChange?: (cityName: string) => void;
placeholder?: string;
className?: string;
typeFilter?: string;
}
const PickupPointSelector: React.FC<PickupPointSelectorProps> = ({
selectedPoint,
onPointSelect,
onCityChange,
placeholder = "Выберите пункт выдачи",
className = "",
typeFilter
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [location, setLocation] = useState<{ lat: number; lng: number } | null>(null);
const [cityName, setCityName] = useState('Москва'); // По умолчанию Москва (где есть ПВЗ)
const [showCitySelector, setShowCitySelector] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Запрос ПВЗ по городу
const { data: cityData, loading: cityLoading, error: cityError } = useQuery(YANDEX_PICKUP_POINTS_BY_CITY, {
variables: { cityName },
skip: !cityName,
errorPolicy: 'all' // Продолжаем работу даже при ошибках
});
// Запрос ПВЗ по координатам (если есть геолокация)
const { data: coordinatesData, loading: coordinatesLoading, error: coordinatesError } = useQuery(YANDEX_PICKUP_POINTS_BY_COORDINATES, {
variables: {
latitude: location?.lat,
longitude: location?.lng,
radiusKm: 10
},
skip: !location,
errorPolicy: 'all' // Продолжаем работу даже при ошибках
});
// Определяем какие данные использовать
const pickupPoints = coordinatesData?.yandexPickupPointsByCoordinates ||
cityData?.yandexPickupPointsByCity ||
[];
const loading = cityLoading || coordinatesLoading;
const hasError = cityError || coordinatesError;
// Координаты городов для центрирования карты
const cityCoordinates: Record<string, [number, number]> = {
'Москва': [55.7558, 37.6176],
'Санкт-Петербург': [59.9311, 30.3609],
'Новосибирск': [55.0084, 82.9357],
'Екатеринбург': [56.8431, 60.6454],
'Казань': [55.8304, 49.0661],
'Нижний Новгород': [56.2965, 43.9361],
'Челябинск': [55.1644, 61.4368],
'Самара': [53.2001, 50.15],
'Омск': [54.9885, 73.3242],
'Ростов-на-Дону': [47.2357, 39.7015],
'Уфа': [54.7388, 55.9721],
'Красноярск': [56.0184, 92.8672],
'Воронеж': [51.6720, 39.1843],
'Пермь': [58.0105, 56.2502],
'Волгоград': [48.7080, 44.5133],
'Краснодар': [45.0355, 38.9753],
'Саратов': [51.5924, 46.0348],
'Тюмень': [57.1522, 65.5272],
'Тольятти': [53.5303, 49.3461],
'Ижевск': [56.8527, 53.2118],
'Барнаул': [53.3606, 83.7636],
'Ульяновск': [54.3142, 48.4031],
'Иркутск': [52.2978, 104.2964],
'Хабаровск': [48.4827, 135.0839],
'Ярославль': [57.6261, 39.8845],
'Владивосток': [43.1056, 131.8735],
'Махачкала': [42.9849, 47.5047],
'Томск': [56.4977, 84.9744],
'Оренбург': [51.7727, 55.0988],
'Кемерово': [55.3331, 86.0833],
'Новокузнецк': [53.7557, 87.1099],
'Рязань': [54.6269, 39.6916],
'Набережные Челны': [55.7558, 52.4069],
'Астрахань': [46.3497, 48.0408],
'Пенза': [53.2001, 45.0000],
'Липецк': [52.6031, 39.5708],
'Тула': [54.1961, 37.6182],
'Киров': [58.6035, 49.6679],
'Чебоксары': [56.1439, 47.2517],
'Калининград': [54.7065, 20.5110],
'Брянск': [53.2434, 34.3640],
'Курск': [51.7373, 36.1873],
'Иваново': [57.0000, 40.9737],
'Магнитогорск': [53.4078, 59.0647],
'Тверь': [56.8587, 35.9176],
'Ставрополь': [45.0428, 41.9734],
'Симферополь': [44.9572, 34.1108],
'Белгород': [50.5951, 36.5804],
'Архангельск': [64.5401, 40.5433],
'Владимир': [56.1366, 40.3966],
'Сочи': [43.6028, 39.7342],
'Курган': [55.4500, 65.3333],
'Смоленск': [54.7818, 32.0401],
'Калуга': [54.5293, 36.2754],
'Чита': [52.0307, 113.5006],
'Орёл': [52.9651, 36.0785],
'Волжский': [48.7854, 44.7759],
'Череповец': [59.1374, 37.9097],
'Владикавказ': [43.0370, 44.6830],
'Мурманск': [68.9792, 33.0925],
'Сургут': [61.2500, 73.4167],
'Вологда': [59.2239, 39.8840],
'Тамбов': [52.7319, 41.4520],
'Стерлитамак': [53.6241, 55.9504],
'Грозный': [43.3181, 45.6942],
'Якутск': [62.0355, 129.6755],
'Кострома': [57.7665, 40.9265],
'Комсомольск-на-Амуре': [50.5496, 137.0067],
'Петрозаводск': [61.7849, 34.3469],
'Таганрог': [47.2362, 38.8969],
'Нижневартовск': [60.9344, 76.5531],
'Йошкар-Ола': [56.6372, 47.8753],
'Братск': [56.1326, 101.6140],
'Новороссийск': [44.7209, 37.7677],
'Дзержинск': [56.2342, 43.4582],
'Шахты': [47.7090, 40.2060],
'Нижнекамск': [55.6367, 51.8209],
'Орск': [51.2045, 58.5434],
'Ангарск': [52.5406, 103.8887],
'Старый Оскол': [51.2965, 37.8411],
'Великий Новгород': [58.5218, 31.2756],
'Благовещенск': [50.2941, 127.5405],
'Прокопьевск': [53.9058, 86.7194],
'Химки': [55.8970, 37.4296],
'Энгельс': [51.4827, 46.1124],
'Рыбинск': [58.0446, 38.8486],
'Балашиха': [55.7969, 37.9386],
'Подольск': [55.4297, 37.5547],
'Королёв': [55.9226, 37.8251],
'Петропавловск-Камчатский': [53.0446, 158.6483],
'Мытищи': [55.9116, 37.7307],
'Люберцы': [55.6758, 37.8939],
'Магадан': [59.5638, 150.8063],
'Норильск': [69.3558, 88.1893],
'Южно-Сахалинск': [46.9588, 142.7386]
};
// Популярные города с ПВЗ Яндекса (расширенный список)
const availableCities = Object.keys(cityCoordinates).sort();
// Фильтрация ПВЗ по поисковому запросу и типу
const filteredPoints = pickupPoints.filter((point: YandexPickupPoint) => {
const matchesSearch = point.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
point.address.fullAddress.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = !typeFilter || point.type === typeFilter;
return matchesSearch && matchesType;
});
// Получение геолокации
const handleGetLocation = () => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
lat: position.coords.latitude,
lng: position.coords.longitude
});
},
(error) => {
console.error('Ошибка получения геолокации:', error);
// Fallback на Калининград
setLocation({ lat: 54.7104, lng: 20.4522 });
}
);
} else {
// Fallback на Калининград
setLocation({ lat: 54.7104, lng: 20.4522 });
}
};
// Закрытие дропдаунов при клике вне них
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setShowCitySelector(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Автоматическая загрузка геолокации убрана - пользователь может выбрать город или нажать кнопку геолокации
const handlePointSelect = (point: YandexPickupPoint) => {
onPointSelect(point);
setIsOpen(false);
setSearchTerm('');
};
return (
<div className={`relative ${className}`} ref={dropdownRef}>
{/* Выбор города */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Город для поиска ПВЗ:
</label>
<div className="relative">
<button
onClick={() => setShowCitySelector(!showCitySelector)}
className="w-full gap-2.5 px-6 py-3 text-base leading-6 bg-white rounded border border-solid border-stone-300 h-[45px] text-gray-700 outline-none flex items-center justify-between hover:border-gray-400 transition-colors"
>
<span>{cityName}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`transition-transform ${showCitySelector ? 'rotate-180' : ''}`}
>
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{/* Дропдаун с городами */}
{showCitySelector && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{availableCities.map((city) => (
<div
key={city}
onClick={() => {
setCityName(city);
setShowCitySelector(false);
setLocation(null); // Сбрасываем геолокацию при выборе города
onCityChange?.(city); // Уведомляем родительский компонент
}}
className={`px-4 py-2 cursor-pointer hover:bg-gray-50 transition-colors ${
cityName === city ? 'bg-red-50 text-red-600 font-medium' : 'text-gray-700'
}`}
>
{city}
</div>
))}
</div>
)}
</div>
</div>
{/* Поле ввода */}
<div className="relative">
<input
type="text"
value={selectedPoint ? selectedPoint.name : searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder={`${placeholder} в г. ${cityName}`}
className="w-full gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 outline-none pr-20"
/>
{/* Кнопка геолокации */}
<button
onClick={() => {
handleGetLocation();
setIsOpen(true);
}}
className="absolute right-2 top-2 p-2 text-gray-400 hover:text-gray-600 transition-colors"
title="Определить местоположение"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</button>
</div>
{/* Дропдаун с ПВЗ */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-500">
Загрузка пунктов выдачи...
</div>
) : hasError ? (
<div className="p-4 text-center text-red-500">
<div className="mb-2">Ошибка загрузки пунктов выдачи</div>
<div className="text-sm text-gray-500 mb-2">
{cityError?.message || coordinatesError?.message || 'Неизвестная ошибка'}
</div>
<button
onClick={() => {
// Перезагружаем данные
window.location.reload();
}}
className="text-red-600 hover:text-red-700 underline text-sm"
>
Попробовать снова
</button>
</div>
) : filteredPoints.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{searchTerm ? (
`Пункты выдачи не найдены по запросу "${searchTerm}"`
) : (
<div>
<div className="mb-2">Нет доступных пунктов выдачи в г. {cityName}</div>
<div className="text-xs text-gray-400">
Попробуйте выбрать другой город или использовать геолокацию
</div>
</div>
)}
</div>
) : (
<div className="py-2">
{/* Заголовок с количеством */}
<div className="px-4 py-2 bg-gray-50 border-b text-sm text-gray-600 font-medium">
{location
? `Найдено ${filteredPoints.length} ПВЗ рядом с вами`
: `Найдено ${filteredPoints.length} ПВЗ в г. ${cityName}`
}
</div>
{filteredPoints.map((point: YandexPickupPoint) => (
<div
key={point.id}
onClick={() => handlePointSelect(point)}
className={`px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors ${
selectedPoint?.id === point.id ? 'bg-red-50 border-l-4 border-red-500' : ''
}`}
>
<div className="font-medium text-gray-900 mb-1">
{point.name}
</div>
<div className="text-sm text-gray-600 mb-1">
{point.address.fullAddress}
</div>
<div className="text-xs text-gray-500">
{point.contact.phone} {point.typeLabel}
</div>
{point.formattedSchedule && (
<div className="text-xs text-gray-500 mt-1">
{point.formattedSchedule}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
export default PickupPointSelector;

View File

@ -0,0 +1,172 @@
import React, { useEffect, useRef, useState } from 'react';
import { YandexPickupPoint } from '@/lib/graphql/yandex-delivery';
interface YandexPickupPointsMapProps {
pickupPoints: YandexPickupPoint[];
selectedPoint?: YandexPickupPoint;
onPointSelect: (point: YandexPickupPoint) => void;
center?: [number, number];
zoom?: number;
className?: string;
}
declare global {
interface Window {
ymaps: any;
selectPickupPoint?: (pointId: string) => void;
}
}
const YandexPickupPointsMap: React.FC<YandexPickupPointsMapProps> = ({
pickupPoints,
selectedPoint,
onPointSelect,
center = [55.76, 37.64], // Москва по умолчанию
zoom = 10,
className = "w-full h-full"
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<any>(null);
const [clusterer, setClusterer] = useState<any>(null);
const [isLoaded, setIsLoaded] = useState(false);
// Загрузка Яндекс карт API
useEffect(() => {
if (window.ymaps) {
setIsLoaded(true);
return;
}
const script = document.createElement('script');
script.src = `https://api-maps.yandex.ru/2.1/?apikey=${process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY}&lang=ru_RU`;
script.onload = () => {
window.ymaps.ready(() => {
setIsLoaded(true);
});
};
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, []);
// Инициализация карты
useEffect(() => {
if (!isLoaded || !mapRef.current || map) return;
const ymap = new window.ymaps.Map(mapRef.current, {
center,
zoom,
controls: ['zoomControl', 'searchControl', 'trafficControl', 'fullscreenControl']
});
const clstr = new window.ymaps.Clusterer({
preset: 'islands#redClusterIcons',
groupByCoordinates: false,
clusterDisableClickZoom: false,
clusterHideIconOnBalloonOpen: false,
geoObjectHideIconOnBalloonOpen: false
});
ymap.geoObjects.add(clstr);
setMap(ymap);
setClusterer(clstr);
}, [isLoaded, center, zoom, map]);
// Обновление точек на карте
useEffect(() => {
if (!map || !clusterer) return;
clusterer.removeAll();
const placemarks = pickupPoints.map(point => {
const placemark = new window.ymaps.Placemark(
[point.position.latitude, point.position.longitude],
{
balloonContentHeader: `<strong>${point.name}</strong>`,
balloonContentBody: `
<div style="max-width: 300px;">
<p><strong>Адрес:</strong> ${point.address.fullAddress}</p>
<p><strong>Тип:</strong> ${point.typeLabel}</p>
<p><strong>Телефон:</strong> ${point.contact.phone}</p>
<p><strong>Режим работы:</strong><br/>${point.formattedSchedule}</p>
${point.instruction ? `<p><strong>Инструкция:</strong> ${point.instruction}</p>` : ''}
<button
onclick="window.selectPickupPoint('${point.id}')"
style="
background: #dc2626;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
margin-top: 10px;
font-size: 14px;
"
>
Выбрать этот пункт
</button>
</div>
`,
hintContent: point.name
},
{
preset: selectedPoint?.id === point.id ? 'islands#redIcon' : 'islands#blueIcon',
iconColor: selectedPoint?.id === point.id ? '#dc2626' : '#3b82f6'
}
);
placemark.events.add('click', () => {
onPointSelect(point);
});
return placemark;
});
clusterer.add(placemarks);
// Если есть точки, подгоняем карту под них
if (pickupPoints.length > 0) {
map.setBounds(clusterer.getBounds(), {
checkZoomRange: true,
zoomMargin: 20
});
}
}, [map, clusterer, pickupPoints, selectedPoint, onPointSelect]);
// Глобальная функция для выбора точки из балуна
useEffect(() => {
window.selectPickupPoint = (pointId: string) => {
const point = pickupPoints.find(p => p.id === pointId);
if (point) {
onPointSelect(point);
}
};
return () => {
delete window.selectPickupPoint;
};
}, [pickupPoints, onPointSelect]);
// Центрирование на выбранной точке
useEffect(() => {
if (map && selectedPoint) {
map.setCenter([selectedPoint.position.latitude, selectedPoint.position.longitude], 15);
}
}, [map, selectedPoint]);
if (!isLoaded) {
return (
<div className={`${className} bg-gray-100 flex items-center justify-center`}>
<div className="text-gray-600">Загрузка карты...</div>
</div>
);
}
return <div ref={mapRef} className={className} />;
};
export default YandexPickupPointsMap;

View File

@ -0,0 +1,168 @@
import React, { useState, useEffect } from "react";
interface FilterDropdownProps {
title?: string; // Делаем необязательным
options: string[];
multi?: boolean;
showAll?: boolean;
defaultOpen?: boolean; // Открыт ли по умолчанию
hasMore?: boolean; // Есть ли еще опции для загрузки
onShowMore?: () => void; // Обработчик "Показать еще"
isMobile?: boolean; // Добавляем флаг для мобильной версии
selectedValues?: string[]; // Выбранные значения
onChange?: (values: string[]) => void; // Обработчик изменений
}
const FilterDropdown: React.FC<FilterDropdownProps> = ({
title,
options,
multi = true,
showAll = false,
defaultOpen = false,
hasMore = false,
onShowMore,
isMobile = false,
selectedValues = [],
onChange
}) => {
const [open, setOpen] = useState(isMobile || defaultOpen); // На мобилке или если defaultOpen - сразу открыт
const [showAllOptions, setShowAllOptions] = useState(false);
const [selected, setSelected] = useState<string[]>(selectedValues);
const visibleOptions = showAll && !showAllOptions ? options.slice(0, 4) : options;
useEffect(() => {
// Сравниваем содержимое массивов, а не ссылки
if (JSON.stringify(selected) !== JSON.stringify(selectedValues)) {
setSelected(selectedValues);
}
}, [selectedValues, selected]);
const handleSelect = (option: string) => {
let newSelected: string[];
if (multi) {
newSelected = selected.includes(option)
? selected.filter(o => o !== option)
: [...selected, option];
} else {
newSelected = selected.includes(option) ? [] : [option];
}
setSelected(newSelected);
// Вызываем колбэк только если значения действительно изменились
if (onChange && JSON.stringify(newSelected) !== JSON.stringify(selected)) {
onChange(newSelected);
}
};
// Мобильная версия - всегда открытый список
if (isMobile) {
return (
<div className="filter-block-mobile">
<div className="dropdown w-dropdown w--open">
<div className="dropdown-toggle w-dropdown-toggle" style={{ cursor: 'default', background: 'none', boxShadow: 'none' }}>
<h4 className="heading-2">{title}</h4>
</div>
<nav className="dropdown-list w-dropdown-list" style={{ display: 'block', position: 'static', boxShadow: 'none', background: 'transparent', padding: 0 }}>
<div className="w-layout-vflex flex-block-17">
{visibleOptions.map(option => (
<div className="div-block-8" key={option} onClick={() => handleSelect(option)}>
<div className={`div-block-7${selected.includes(option) ? " active" : ""}`}>
{selected.includes(option) && (
<svg width="16" height="12" viewBox="0 0 16 12" fill="none">
<path d="M5.33333 12L0 6.89362L1.86667 5.10638L5.33333 8.42553L14.1333 0L16 1.78723L5.33333 12Z" fill="currentColor" />
</svg>
)}
</div>
<div className="text-block-12">{option}</div>
</div>
))}
{((showAll && options.length > 4) || hasMore) && (
<div className="w-layout-vflex flex-block-17">
<div
className="div-block-8"
onClick={() => {
if (hasMore && onShowMore) {
onShowMore();
} else {
setShowAllOptions(!showAllOptions);
}
}}
style={{ cursor: 'pointer' }}
>
<div className="text-block-13">
{hasMore ? "Показать еще" : (showAllOptions ? "Скрыть" : "Показать все")}
</div>
<img
loading="lazy"
src="/images/arrow_drop_down.svg"
alt=""
style={{ marginLeft: 4, transform: showAllOptions ? 'rotate(180deg)' : undefined }}
/>
</div>
</div>
)}
</div>
</nav>
</div>
</div>
);
}
// Десктопная версия - классический dropdown
return (
<div className={`dropdown w-dropdown${open ? " w--open" : ""}`}>
<div className="dropdown-toggle w-dropdown-toggle" onClick={() => setOpen(!open)}>
<h4 className="heading-2">
{title} {selectedValues.length > 0 && `(${selectedValues.length})`}
</h4>
<div className="icon-3 w-icon-dropdown-toggle"></div>
</div>
{open && (
<nav className="dropdown-list w-dropdown-list">
<div className="w-layout-vflex flex-block-17">
{visibleOptions.map(option => (
<div className="div-block-8" key={option} onClick={() => handleSelect(option)}>
<div className={`div-block-7${selected.includes(option) ? " active" : ""}`}>
{selected.includes(option) && (
<svg width="16" height="12" viewBox="0 0 16 12" fill="none">
<path d="M5.33333 12L0 6.89362L1.86667 5.10638L5.33333 8.42553L14.1333 0L16 1.78723L5.33333 12Z" fill="currentColor" />
</svg>
)}
</div>
<div className="text-block-12">{option}</div>
</div>
))}
</div>
{((showAll && options.length > 4) || hasMore) && (
<div className="w-layout-vflex flex-block-17">
<div
className="div-block-8"
onClick={() => {
if (hasMore && onShowMore) {
onShowMore();
} else {
setShowAllOptions(!showAllOptions);
}
}}
style={{ cursor: 'pointer' }}
>
<div className="text-block-13">
{hasMore ? "Показать еще" : (showAllOptions ? "Скрыть" : "Показать все")}
</div>
<img
loading="lazy"
src="/images/arrow_drop_down.svg"
alt=""
style={{ marginLeft: 4, transform: showAllOptions ? 'rotate(180deg)' : undefined }}
/>
</div>
</div>
)}
</nav>
)}
</div>
);
};
export default FilterDropdown;

View File

@ -0,0 +1,279 @@
import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
interface FilterRangeProps {
title: string;
min?: number;
max?: number;
isMobile?: boolean; // Добавляем флаг для мобильной версии
value?: [number, number] | null; // Текущее значение диапазона
onChange?: (value: [number, number]) => void;
}
const DEFAULT_MIN = 1;
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 [dragging, setDragging] = useState<null | "from" | "to">(null);
const [trackWidth, setTrackWidth] = useState(0);
const [open, setOpen] = useState(true);
const trackRef = useRef<HTMLDivElement>(null);
// Обновляем локальное состояние при изменении внешнего значения
useEffect(() => {
if (value) {
setFrom(value[0]);
setTo(value[1]);
} else {
setFrom(min);
setTo(max);
}
}, [value, min, max]);
// Обновляем ширину полосы при монтировании и ресайзе
useLayoutEffect(() => {
const updateWidth = () => {
if (trackRef.current) setTrackWidth(trackRef.current.offsetWidth);
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, []);
// Перевод значения в px и обратно
const valueToPx = (value: number) => trackWidth ? ((value - min) / (max - min)) * trackWidth : 0;
const pxToValue = (px: number) => trackWidth ? Math.round((px / trackWidth) * (max - min) + min) : min;
// Drag logic
const onMouseDown = (type: "from" | "to") => (e: React.MouseEvent) => {
setDragging(type);
e.preventDefault();
};
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => {
if (!trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
let x = e.clientX - rect.left;
x = clamp(x, 0, trackWidth);
const value = clamp(pxToValue(x), min, max);
if (dragging === "from") {
setFrom(v => clamp(Math.min(value, to), min, to));
} else {
setTo(v => clamp(Math.max(value, from), from, max));
}
};
const onUp = () => {
setDragging(null);
if (onChange) {
onChange([from, to]);
}
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [dragging, from, to, min, max, trackWidth, onChange]);
// 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));
};
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));
};
const handleInputBlur = () => {
if (onChange) {
onChange([from, to]);
}
};
// px позиции для точек
const pxFrom = valueToPx(from);
const pxTo = valueToPx(to);
// Мобильная версия - без dropdown
if (isMobile) {
return (
<div className="filter-block-mobile">
<div className="dropdown w-dropdown w--open">
<div className="dropdown-toggle w-dropdown-toggle" style={{ cursor: 'default', background: 'none', boxShadow: 'none' }}>
<h4 className="heading-2">{title}</h4>
</div>
<nav className="dropdown-list w-dropdown-list" style={{ display: 'block', position: 'static', boxShadow: 'none', background: 'transparent', padding: 0 }}>
<div className="form-block-2">
<form className="form-2" onSubmit={e => e.preventDefault()} style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<div className="div-block-5" style={{ position: 'relative', flex: 1 }}>
<label htmlFor="from" className="field-label" style={{ position: 'absolute', left: 3, top: '50%', transform: 'translateY(-50%)', color: '#888', fontSize: 15, pointerEvents: 'none' }}>от</label>
<input
className="text-field-2 w-input"
maxLength={6}
name="from"
placeholder={String(min)}
type="text"
id="from"
value={from}
onChange={handleFromInput}
onBlur={handleInputBlur}
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
/>
</div>
<div className="div-block-5" style={{ position: 'relative', flex: 1 }}>
<label htmlFor="to" className="field-label" style={{ position: 'absolute', left: 3, top: '50%', transform: 'translateY(-50%)', color: '#888', fontSize: 15, pointerEvents: 'none' }}>до</label>
<input
className="text-field-2 w-input"
maxLength={6}
name="to"
placeholder={String(max)}
type="text"
id="to"
value={to}
onChange={handleToInput}
onBlur={handleInputBlur}
style={{ padding: '8px 10px 8px 36px', fontSize: 16, width: '100%' }}
/>
</div>
</form>
</div>
<div className="div-block-6" style={{ position: 'relative', height: 32, marginTop: 12 }} ref={trackRef}>
<div className="track" style={{ position: 'absolute', top: 14, left: 0, right: 0, height: 4, borderRadius: 2 }}></div>
<div
className="track fill"
style={{
position: 'absolute',
top: 14,
left: pxFrom,
width: pxTo - pxFrom - 20,
height: 4,
borderRadius: 2,
zIndex: 2,
}}
></div>
<div
className="start"
style={{
position: 'absolute',
top: 6,
left: pxFrom ,
zIndex: 3,
cursor: 'pointer'
}}
onMouseDown={onMouseDown("from")}
></div>
<div
className="start end"
style={{
position: 'absolute',
top: 6,
left: pxTo - 20,
zIndex: 3,
cursor: 'pointer'
}}
onMouseDown={onMouseDown("to")}
></div>
</div>
<div className="range-values" style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 14, color: '#888' }}>
<span>{min}</span>
<span>{max}</span>
</div>
</nav>
</div>
</div>
);
}
// Десктопная версия - с dropdown
return (
<div className={`dropdown w-dropdown${open ? " w--open" : ""}`}>
<div className="dropdown-toggle w-dropdown-toggle" onClick={() => setOpen(o => !o)} tabIndex={0} aria-expanded={open} aria-label={`Фильтр диапазона ${title}`}>
<h4 className="heading-2">{title}</h4>
<div className="icon-3 w-icon-dropdown-toggle"></div>
</div>
{open && (
<nav className="dropdown-list w-dropdown-list">
<div className="form-block-2">
<form className="form-2" onSubmit={e => e.preventDefault()}>
<div className="div-block-5">
<label htmlFor="from" className="field-label">от</label>
<input
className="text-field-2 w-input"
maxLength={6}
name="from"
placeholder={String(min)}
type="text"
id="from"
value={from}
onChange={handleFromInput}
onBlur={handleInputBlur}
/>
</div>
<div className="div-block-5">
<label htmlFor="to" className="field-label">до</label>
<input
className="text-field-2 w-input"
maxLength={6}
name="to"
placeholder={String(max)}
type="text"
id="to"
value={to}
onChange={handleToInput}
onBlur={handleInputBlur}
/>
</div>
</form>
</div>
<div className="div-block-6" style={{ position: "relative", height: 32, marginTop: 12 }} ref={trackRef}>
<div className="track" style={{ position: "absolute", top: 14, left: 0, right: 0, height: 4, borderRadius: 2 }}></div>
<div
className="track fill"
style={{
position: "absolute",
top: 14,
left: pxFrom,
width: pxTo - pxFrom,
height: 4,
borderRadius: 2,
zIndex: 2,
}}
></div>
<div
className="start"
style={{
position: "absolute",
top: 6,
left: pxFrom - 8,
zIndex: 3,
cursor: "pointer"
}}
onMouseDown={onMouseDown("from")}
></div>
<div
className="start end"
style={{
position: "absolute",
top: 6,
left: pxTo - 8,
zIndex: 3,
cursor: "pointer"
}}
onMouseDown={onMouseDown("to")}
></div>
</div>
</nav>
)}
</div>
);
};
export default FilterRange;

View File

@ -0,0 +1,54 @@
import React from "react";
import Link from "next/link";
const AvailableParts = () => (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-5">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Автозапчасти в наличии</h2>
<div className="w-layout-hflex flex-block-29">
<Link href="/catalog" className="text-block-18">
Ко всем автозапчастям
</Link>
<img src="/images/Arrow_right.svg" loading="lazy" alt="" />
</div>
</div>
<div className="w-layout-hflex flex-block-6">
<Link href="/catalog" className="div-block-12" id="w-node-bc394713-4b8e-44e3-8ddf-3edc1c31a743-3b3232bc">
<h1 className="heading-7">Аксессуары</h1>
<img src="/images/IMG_1.png" loading="lazy" alt="" className="image-22" />
</Link>
<Link href="/catalog" className="div-block-12-copy">
<h1 className="heading-7">Воздушные фильтры</h1>
<img src="/images/IMG_2.png" loading="lazy" alt="" className="image-22" />
</Link>
<Link href="/catalog" className="div-block-12">
<h1 className="heading-7">Шины</h1>
<img src="/images/IMG_3.png" loading="lazy" alt="" className="image-22" />
</Link>
<Link href="/catalog" className="div-block-123">
<h1 className="heading-7-white">Аккумуляторы</h1>
<img src="/images/IMG_4.png" loading="lazy" alt="" className="image-22" />
</Link>
<div className="w-layout-hflex flex-block-35" id="w-node-_8908a890-8c8f-e12c-999f-08d5da3bcc01-3b3232bc">
<Link href="/catalog" className="div-block-12 small">
<h1 className="heading-7">Диски</h1>
<img src="/images/IMG_5.png" loading="lazy" alt="" className="image-22" />
</Link>
<Link href="/catalog" className="div-block-12 small">
<h1 className="heading-7">Свечи</h1>
<img src="/images/IMG_6.png" loading="lazy" alt="" className="image-22" />
</Link>
<Link href="/catalog" className="div-block-red small">
<h1 className="heading-7-white">Масла</h1>
<img src="/images/IMG_7.png" loading="lazy" alt="" className="image-22" />
</Link>
</div>
</div>
</div>
</div>
</section>
);
export default AvailableParts;

View File

@ -0,0 +1,30 @@
import React from "react";
const CarPartsSelectionForm = () => (
<div className="w-layout-vflex flex-block-28">
<h3 className="heading-5">Подбор по автомобилю</h3>
<div className="form-block-4 w-form">
<form id="email-form" name="email-form" data-name="Email Form" method="get" data-wf-page-id="6800f7e35fcfd4ca3b3232bc" data-wf-element-id="035eb944-3f18-512d-416f-afd9dcaf7b45">
{[7, 5, 4, 3].map((field) => (
<select id={`field-${field}`} name={`field-${field}`} data-name={`Field ${field}`} className="select w-select" key={field}>
<option value="">Год выпуска</option>
<option value="First">First choice</option>
<option value="Second">Second choice</option>
<option value="Third">Third choice</option>
</select>
))}
<div className="div-block-10">
<input type="submit" data-wait="Please wait..." className="submit-button w-button" value="Подобрать автозапчасть" />
</div>
</form>
<div className="w-form-done">
<div>Thank you! Your submission has been received!</div>
</div>
<div className="w-form-fail">
<div>Oops! Something went wrong while submitting the form.</div>
</div>
</div>
</div>
);
export default CarPartsSelectionForm;

View File

@ -0,0 +1,149 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { useQuery } from "@apollo/client";
import { GET_LAXIMO_BRANDS } from "@/lib/graphql";
import { LaximoBrand } from "@/types/laximo";
const tabs = [
"Техническое обслуживание",
"Легковые",
"Грузовые",
"Коммерческие",
];
const CatalogSection = () => {
const [activeTab, setActiveTab] = useState(0);
const router = useRouter();
const { data, loading, error } = useQuery<{ laximoBrands: LaximoBrand[] }>(GET_LAXIMO_BRANDS, {
errorPolicy: 'all'
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
router.push("/catalog");
};
// Статические данные автомобильных брендов
const staticBrands = [
{ name: "Audi" },
{ name: "BMW" },
{ name: "Cadillac" },
{ name: "Chevrolet" },
{ name: "Citroen" },
{ name: "Fiat" },
{ name: "Mazda" }
];
// Определяем какие данные использовать
let brands = staticBrands;
if (data?.laximoBrands && data.laximoBrands.length > 0) {
// Если есть данные от Laximo API, используем их
brands = data.laximoBrands.map(brand => ({
name: brand.name,
code: brand.code
}));
} else if (error) {
console.warn('Laximo API недоступен, используются статические данные:', error.message);
}
const handleBrandClick = (brand: { name: string; code?: string }) => {
if (brand.code) {
router.push(`/brands?selected=${brand.code}`);
} else {
console.warn('Brand code not available for', brand.name);
}
};
if (loading) {
return (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-5">
<h2 className="heading-4">Каталоги автозапчастей</h2>
<div className="text-center">Загрузка брендов...</div>
</div>
</div>
</section>
);
}
return (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-5">
<h2 className="heading-4">Каталоги автозапчастей</h2>
<div className="w-layout-hflex flex-block-6-copy">
<div className="w-layout-hflex flex-block-24">
<div className="w-layout-hflex flex-block-25">
{tabs.map((tab, idx) => (
<div
className={activeTab === idx ? "tab_card-activ" : "tab_card"}
key={idx}
onClick={() => setActiveTab(idx)}
style={{ cursor: "pointer" }}
>
{tab}
</div>
))}
</div>
<div className="w-layout-hflex flex-block-27">
{[...Array(7)].map((_, colIdx) => (
<div className="w-layout-vflex flex-block-26" key={colIdx}>
{brands.slice(colIdx * Math.ceil(brands.length / 7), (colIdx + 1) * Math.ceil(brands.length / 7)).map((brand, idx) => (
<button
onClick={() => handleBrandClick(brand)}
className="link-block-6 w-inline-block text-left"
key={idx}
style={{ background: 'none', border: 'none', padding: 0 }}
>
<div>{brand.name}</div>
</button>
))}
</div>
))}
</div>
<button
onClick={() => router.push('/brands')}
className="w-layout-hflex flex-block-29 cursor-pointer hover:opacity-80 transition-opacity"
style={{ background: 'none', border: 'none', padding: 0 }}
>
<div className="text-block-18">Все марки</div>
<img src="/images/Arrow_right.svg" loading="lazy" alt="" />
</button>
</div>
<div className="w-layout-vflex flex-block-28">
<h3 className="heading-5">Подбор по автомобилю</h3>
<div className="form-block-4 w-form">
<form id="email-form" name="email-form" data-name="Email Form" method="get" data-wf-page-id="6800f7e35fcfd4ca3b3232bc" data-wf-element-id="035eb944-3f18-512d-416f-afd9dcaf7b45" onSubmit={handleSubmit}>
{[7, 5, 4, 3].map((field) => (
<select id={`field-${field}`} name={`field-${field}`} data-name={`Field ${field}`} className="select w-select" key={field}>
<option value="">Год выпуска</option>
<option value="First">First choice</option>
<option value="Second">Second choice</option>
<option value="Third">Third choice</option>
</select>
))}
<div className="div-block-10">
<input type="submit" data-wait="Please wait..." className="submit-button w-button" value="Подобрать автозапчасть" />
</div>
</form>
<div className="w-form-done">
<div>Thank you! Your submission has been received!</div>
</div>
<div className="w-form-fail">
<div>Oops! Something went wrong while submitting the form.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default CatalogSection;

View File

@ -0,0 +1,38 @@
import React from "react";
const CatalogTabs = () => (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex flex-block-5">
<h2 className="heading-4">Каталоги автозапчастей</h2>
<div className="w-layout-hflex flex-block-6">
<div className="w-layout-hflex flex-block-24">
<div className="w-layout-hflex flex-block-25">
<div className="tab_c">Техническое обслуживание</div>
<div className="tab_c">Легковые</div>
<div className="tab_c">Грузовые</div>
<div className="tab_c">Коммерческие</div>
</div>
<div className="w-layout-hflex flex-block-27">
{[...Array(7)].map((_, i) => (
<div className="w-layout-vflex flex-block-26" key={i}>
{["Audi", "BMW", "Cadillac", "Chevrolet", "Citroen", "Fiat", "Mazda"].map((brand) => (
<a href={`/brand?selected=${encodeURIComponent(brand)}`} className="link-block-6 w-inline-block" key={brand}>
<div>{brand}</div>
</a>
))}
</div>
))}
</div>
<div className="w-layout-hflex flex-block-29">
<div className="text-block-18">Все марки</div>
<img src="/images/Arrow_right.svg" loading="lazy" alt="" />
</div>
</div>
</div>
</div>
</div>
</section>
);
export default CatalogTabs;

View File

@ -0,0 +1,133 @@
import React, { useEffect } from "react";
const HeroSlider = () => {
useEffect(() => {
if (typeof window !== "undefined" && window.Webflow && window.Webflow.require) {
if (window.Webflow.destroy) {
window.Webflow.destroy();
}
if (window.Webflow.ready) {
window.Webflow.ready();
}
}
}, []);
return (
<section className="section-5">
<div className="w-layout-blockcontainer container w-container">
<div data-delay="4000" data-animation="slide" className="slider w-slider" data-autoplay="false" data-easing="ease"
data-hide-arrows="false" data-disable-swipe="false" data-autoplay-limit="0" data-nav-spacing="3"
data-duration="500" data-infinite="true">
<div className="mask w-slider-mask">
<div className="slide w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">ШИРОКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
</div>
</div>
<div className="w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">УЗКИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
</div>
</div>
<div className="w-slide">
<div className="w-layout-vflex flex-block-100">
<div className="div-block-35"><img src="/images/imgfb.png" loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/imgfb-p-500.png 500w, /images/imgfb-p-800.png 800w, /images/imgfb.png 1027w" alt=""
className="image-21" /></div>
<div className="w-layout-vflex flex-block-99">
<h2 className="heading-17">ЛУЧШИЙ ВЫБОР АВТОЗАПЧАСТЕЙ</h2>
<div className="text-block-51">Сотрудничаем только с проверенными поставщиками.Постоянно обновляем
ассортимент, чтобы предложить самые лучшие и актуальные детали.</div>
</div>
<div className="w-layout-hflex flex-block-101">
<div className="w-layout-hflex flex-block-102"><img src="/images/1.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Быстрая доставка по всей стране</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/2.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Высокое качество продукции</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/3.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Выгодные цены</div>
</div>
<div className="w-layout-hflex flex-block-102"><img src="/images/4.png" loading="lazy" alt=""
className="image-20" />
<div className="text-block-52">Профессиональная консультация</div>
</div>
</div>
</div>
</div>
</div>
<div className="left-arrow w-slider-arrow-left">
<div className="div-block-34">
<div className="icon-2 w-icon-slider-left"></div>
</div>
</div>
<div className="right-arrow w-slider-arrow-right">
<div className="div-block-34">
<div className="icon-2 w-icon-slider-right"></div>
</div>
</div>
<div className="slide-nav w-slider-nav w-slider-nav-invert w-round"></div>
</div>
</div>
</section>
);
};
export default HeroSlider;

View File

@ -0,0 +1,53 @@
import React from "react";
import NewsCard from "@/components/news/NewsCard";
import Link from "next/link";
const NewsAndPromos = () => (
<section>
<div className="w-layout-blockcontainer container w-container">
<div className="w-layout-vflex news-index-block">
<div className="w-layout-hflex flex-block-31">
<h2 className="heading-4">Новости и акции</h2>
<div className="w-layout-hflex flex-block-29">
<Link href="/news" className="text-block-18" style={{display: 'flex', alignItems: 'center'}}>
Ко всем новостям
<img src="/images/Arrow_right.svg" loading="lazy" alt="" style={{marginLeft: 8}} />
</Link>
</div>
</div>
<div className="w-layout-hflex flex-block-6-copy-copy">
<NewsCard
title="Kia Syros будет выделяться необычным стилем"
description="Компания Kia готова представить новый кроссовер Syros"
category="Новости компании"
date="17.12.2024"
image="/images/news_img.png"
/>
<NewsCard
title="Kia Syros будет выделяться необычным стилем"
description="Компания Kia готова представить новый кроссовер Syros"
category="Новости компании"
date="17.12.2024"
image="/images/news_img.png"
/>
<NewsCard
title="Kia Syros будет выделяться необычным стилем"
description="Компания Kia готова представить новый кроссовер Syros"
category="Новости компании"
date="17.12.2024"
image="/images/news_img.png"
/>
<NewsCard
title="Kia Syros будет выделяться необычным стилем"
description="Компания Kia готова представить новый кроссовер Syros"
category="Новости компании"
date="17.12.2024"
image="/images/news_img.png"
/>
</div>
</div>
</div>
</section>
);
export default NewsAndPromos;

View File

@ -0,0 +1,36 @@
import React from "react";
import Link from "next/link";
const ContentNews = () => (
<div className="w-layout-vflex contentnews">
<div className="w-layout-hflex flex-block-74-copy">
<h2 className="heading-14">Объявлен старт продаж электрических насосов</h2>
<div>
Бренд вывел на рынок сразу широкий ассортимент, уже на старте продаж - более 100 артикулов и включает в себя позиции для брендов-лидеров автомобильного рынка, например: артикул 77WPE080 для Mercedes-Benz S-CLASS (W221, C216), артикул 77WPE096 Land Rover DISCOVERY V (L462) / Jaguar F-PACE (X761), артикул 77WPE014 Audi Q5 (8RB) / Volkswagen TOUAREG (7P5, 7P6).
</div>
<img
src="/images/image.png"
loading="lazy"
sizes="(max-width: 767px) 100vw, (max-width: 991px) 728px, 940px"
srcSet="/images/image.png 500w, /images/image.png 800w, /images/image.png 1080w, /images/image.png 1150w"
alt=""
className="image-19"
/>
<h3 className="h3nrws">Преимущества электрических насосов охлаждающей жидкости MasterKit Electro:</h3>
<ul role="list" className="list">
<li className="list-item">Отличная производительность за счёт применения компонентов известных мировых брендов.</li>
<li className="list-item">Герметичность и устойчивость к коррозии</li>
<li className="list-item">Высококачественные материалы компонентов, обеспечивающие долгий срок службы</li>
<li className="list-item">Широкий ассортимент более 100 артикулов</li>
</ul>
<div>
На электрические насосы системы охлаждения MasterKit Electro предоставляется гарантия 1 год или 30.000 км пробега, в зависимости от того, что наступит раньше. Все новинки уже внесены в каталог подбора продукции и доступны для заказа.
</div>
<Link href="/card" className="submit-button-copy w-button">
Перейти к товару
</Link>
</div>
</div>
);
export default ContentNews;

View File

@ -0,0 +1,47 @@
import React from "react";
const InfoNewsOpen = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="/news" className="link-block w-inline-block">
<div>Новости</div>
</a>
<div className="text-block-3"></div>
<a href="/news" className="link-block w-inline-block">
<div>Новости компании</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>MasterKit Electro начал продажи новинки электрических насосов</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">MasterKit Electro начал продажи новинки электрических насосов</h1>
</div>
</div>
<div className="w-layout-hflex flex-block-98">
<div className="w-layout-hflex flex-block-33">
<div className="w-layout-hflex flex-block-32">
<div className="div-block-13"></div>
<div className="text-block-20">Новости компании</div>
</div>
<div className="w-layout-hflex flex-block-34">
<div className="div-block-14"></div>
<img src="/images/time-line.svg" loading="lazy" alt="" className="image-6" />
<div className="text-block-20">17.12.2024</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
export default InfoNewsOpen;

View File

@ -0,0 +1,26 @@
import React from "react";
const InfoNews = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Новости</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Новости</h1>
</div>
</div>
</div>
</div>
</section>
);
export default InfoNews;

View File

@ -0,0 +1,32 @@
import React from "react";
type NewsCardProps = {
title: string;
description: string;
category: string;
date: string;
image: string;
};
const NewsCard = ({ title, description, category, date, image }: NewsCardProps) => (
<div className="news">
<h3 className="heading_news">{title}</h3>
<div className="text-block-20">{description}</div>
<div className="w-layout-hflex flex-block-33">
<div className="w-layout-hflex flex-block-32">
<div className="div-block-13"></div>
<div className="text-block-20">{category}</div>
</div>
<div className="w-layout-hflex flex-block-34">
<div className="div-block-14"></div>
<img src="/images/time-line.svg" loading="lazy" alt="" className="image-6" />
<div className="text-block-20">{date}</div>
</div>
</div>
<div className="div-block-15">
<img src={image} loading="lazy" alt="" height="Auto" className="image-7" />
</div>
</div>
);
export default NewsCard;

View File

@ -0,0 +1,37 @@
import React, { useState } from "react";
const categories = [
"Все",
"Новости компании",
"Новые поступления",
"Другое"
];
const NewsMenu = () => {
const [active, setActive] = useState<number | null>(0);
const handleClick = (idx: number) => {
setActive(active === idx ? null : idx);
};
return (
<div className="w-layout-hflex menu-category">
{categories.map((cat, idx) => (
<div
key={cat}
className={
idx === active
? "tab-menu-category-activ"
: "tab-menu-category"
}
onClick={() => handleClick(idx)}
style={{ cursor: "pointer" }}
>
{cat}
</div>
))}
</div>
);
};
export default NewsMenu;

View File

@ -0,0 +1,39 @@
import React from "react";
const DeliveryInfo = () => (
<div className="w-layout-hflex flex-block-69-copy">
<h2 className="heading-13">Доставка</h2>
<div className="text-block-38">Мы заботимся о вашем времени и комфорте, поэтому предлагаем удобные варианты доставки</div>
<div className="w-layout-hflex flex-block-83">
<div className="w-layout-hflex flex-block-71">
<h4 className="heading-15">В пределах Москвы и области</h4>
<div className="div-block-24">
<div className="text-block-41">Собственная доставка на следующий день после заказа</div>
</div>
<div className="div-block-24">
<div className="text-block-42">Стоимость доставки зависит от суммы заказа</div>
</div>
<div className="div-block-24">
<div className="text-block-43">Подробности уточняйте у менеджера</div>
</div>
</div>
<div className="w-layout-hflex flex-block-71">
<h4 className="heading-15">В регионы</h4>
<div className="div-block-24">
<div className="text-block-44">СДЕК, ПОЧТА, Деловые линии</div>
</div>
<div className="div-block-24">
<div className="text-block-45">Доставка транспортными компаниями</div>
</div>
<div className="div-block-24">
<div className="text-block-46">Заказы, оформленные до 12:00, доставляем в тот же день</div>
</div>
<div className="div-block-24">
<div className="text-block-46">Доставка в ТК бесплатно</div>
</div>
</div>
</div>
</div>
);
export default DeliveryInfo;

View File

@ -0,0 +1,26 @@
import React from "react";
const InfoPayments = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="#" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Оплата и доставка</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Оплата и доставка</h1>
</div>
</div>
</div>
</div>
</section>
);
export default InfoPayments;

View File

@ -0,0 +1,13 @@
import React from "react";
const PaymentsCompony = () => (
<div className="w-layout-hflex flex-block-94">
<div className="div-block-31"><img src="/images/Group-9.png" loading="lazy" alt="" className="image-18" /></div>
<div className="div-block-31"><img src="/images/Group.png" loading="lazy" alt="" className="image-18" /></div>
<div className="div-block-31"><img src="/images/Group-8.png" loading="lazy" alt="" className="image-18" /></div>
<div className="div-block-31"><img src="/images/Group-1.png" loading="lazy" alt="" className="image-18" /></div>
<div className="div-block-31"><img src="/images/Clip-path-group.png" loading="lazy" alt="" className="image-18" /></div>
</div>
);
export default PaymentsCompony;

View File

@ -0,0 +1,55 @@
import React from "react";
const PaymentsDetails = () => (
<div className="w-layout-vflex flex-block-72">
<div className="w-layout-vflex flex-block-73-copy-copy">
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Для физических лиц</h3>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">Наличными</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">Банковской картой</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">По QR-коду через мобильное приложение банка</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-73-copy-copy">
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Для юридических лиц</h3>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">Безналичный расчет с НДС</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">НДС 20%</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor"/></svg></div>
<div className="text-block-36">Доплата при оплате с НДС не требуется</div>
</div>
</div>
</div>
<div className="w-layout-vflex flex-block-73-copy-copy-red">
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14-white">Важно</h3>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3-white w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/></svg></div>
<div className="text-block-36-white">100% предоплата для всех заказов</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3-white w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="currentColor"/></svg></div>
<div className="text-block-36-white">Предоплата меньшего размера только для предзаказа</div>
</div>
</div>
</div>
</div>
);
export default PaymentsDetails;

View File

@ -0,0 +1,91 @@
import React from "react";
const AddressDetails = ({ onClose, onBack, address, setAddress }) => (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-5 max-md:pb-8">
<div className="flex relative flex-col gap-8 items-start h-[730px] w-[420px] max-md:w-full max-md:h-auto max-sm:gap-5">
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4">
<div className="flex relative gap-2.5 justify-center items-center self-stretch pr-10 max-md:pr-5">
<div className="text-3xl font-bold leading-9 flex-[1_0_0] text-gray-950 max-md:text-2xl max-sm:text-2xl">
Пункт выдачи
</div>
<div onClick={onClose} className="cursor-pointer absolute right-0 top-1">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M1.8 18L0 16.2L7.2 9L0 1.8L1.8 0L9 7.2L16.2 0L18 1.8L10.8 9L18 16.2L16.2 18L9 10.8L1.8 18Z" fill="#000814"/>
</svg>
</div>
</div>
<div className="flex relative gap-2.5 items-center self-stretch max-md:flex-wrap">
<input
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Адрес"
className="gap-2.5 self-stretch px-6 py-4 mt-3.5 w-full text-lg leading-snug whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[55px] text-neutral-500 max-md:px-5 outline-none"
/>
</div>
</div>
<div className="relative gap-2 self-stretch text-base font-bold leading-5 text-gray-950">
Калининград, Улица Космонавта Леонова 12
</div>
<div className="flex relative flex-col gap-2.5 items-start">
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Доставка для юридических лиц
</div>
</div>
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Возврат товаров
</div>
</div>
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Срок хранения заказа - 15 дней
</div>
</div>
</div>
<div className="flex relative flex-col gap-2 items-start self-stretch">
<div className="self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base">
Режим работы
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Понедельник-пятница
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
<span>09:00 - 18:00</span>
<br />
<span>13:00 - 14:00 (перерыв)</span>
</div>
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Суббота
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
09:00 - 14:00
</div>
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Воскресенье
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Выходной
</div>
</div>
</div>
</div>
<div
className="cursor-pointer relative gap-2.5 self-stretch px-5 py-3.5 text-base leading-5 text-center text-white bg-red-600 rounded-xl h-[50px] max-sm:px-4 max-sm:py-3 max-sm:h-12 max-sm:text-base"
onClick={onBack}
>
Добавить адрес доставки
</div>
</div>
);
export default AddressDetails;

View File

@ -0,0 +1,219 @@
import React, { useState } from "react";
import CustomCheckbox from './CustomCheckbox';
const Tabs = ({ deliveryType, setDeliveryType }) => (
<div className="flex items-center mt-5 w-full text-lg font-medium text-center whitespace-nowrap rounded-xl bg-slate-200">
<div
className={`flex flex-1 shrink gap-5 items-center self-stretch my-auto rounded-xl basis-0 cursor-pointer ${deliveryType === "pickup" ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
onClick={() => setDeliveryType("pickup")}
>
<div className="flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5">
Самовывоз
</div>
</div>
<div
className={`flex flex-1 shrink gap-5 items-center self-stretch my-auto rounded-xl basis-0 cursor-pointer ${deliveryType === "courier" ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
onClick={() => setDeliveryType("courier")}
>
<div className="flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5">
Курьером
</div>
</div>
</div>
);
const AddressForm = ({ onDetectLocation, address, setAddress, onBack }) => {
const [deliveryType, setDeliveryType] = useState("pickup"); // "pickup" или "courier"
const [isPrivateHouse, setIsPrivateHouse] = useState(false);
const [placeName, setPlaceName] = useState("");
const [city, setCity] = useState("");
const [houseNumber, setHouseNumber] = useState("");
const [apartment, setApartment] = useState("");
const [entrance, setEntrance] = useState("");
const [floor, setFloor] = useState("");
const [intercom, setIntercom] = useState("");
const [courierComment, setCourierComment] = useState("");
const [recipientName, setRecipientName] = useState("");
const [recipientPhone, setRecipientPhone] = useState("");
return (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-5 max-md:pb-8">
<div className="flex flex-col w-full leading-tight">
<div className="text-3xl font-bold text-gray-950">
Способ доставки
</div>
<Tabs deliveryType={deliveryType} setDeliveryType={setDeliveryType} />
</div>
{deliveryType === "pickup" && (
<div className="flex flex-col mt-10 w-full">
<div className="flex flex-col w-full">
<div className="text-lg font-bold leading-tight text-gray-950">
Куда доставить заказ?
</div>
<div className="mt-2 text-sm leading-snug text-gray-400 pb-2">
Выберите пункт выдачи на карте или используйте поиск
</div>
</div>
<input
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Адрес"
className="gap-2.5 self-stretch px-6 py-4 mt-3.5 w-full text-lg leading-snug whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[55px] text-neutral-500 max-md:px-5 outline-none"
/>
<div
className="cursor-pointer flex gap-1.5 items-center mt-3.5 w-full text-sm leading-snug text-gray-600"
onClick={onDetectLocation}
>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/09d97ef790819abac069b7cd0595eae50a6e5b63?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-square w-[18px]"
/>
<div className="self-stretch my-auto">
Определить местоположение
</div>
</div>
</div>
)}
{deliveryType === "courier" && (
<>
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4 mt-10">
<div className="flex relative gap-2.5 items-center self-stretch max-sm:gap-2">
<input
type="text"
value={placeName}
onChange={e => setPlaceName(e.target.value)}
placeholder="Название, например 'Дом'"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch max-sm:gap-2">
<div
layer-name="Адрес доставки"
className="relative self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base"
>
Адрес доставки
</div>
<div className="flex relative gap-2.5 items-start self-stretch max-sm:gap-2">
<input
type="text"
value={city}
onChange={e => setCity(e.target.value)}
placeholder="Город"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<input
type="text"
value={houseNumber}
onChange={e => setHouseNumber(e.target.value)}
placeholder="Номер дома"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex relative gap-2.5 items-center self-stretch max-sm:gap-2">
<input
type="text"
value={apartment}
onChange={e => setApartment(e.target.value)}
placeholder="Квартира"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<div
layer-name="Check_block"
className="flex relative gap-2.5 items-center flex-[1_0_0] h-[22px] max-sm:gap-2"
>
<CustomCheckbox selected={isPrivateHouse} onSelect={() => setIsPrivateHouse(v => !v)} />
<div
layer-name="Экспресс доставка"
className="relative text-sm font-medium leading-5 text-zinc-900"
>
Частный дом
</div>
</div>
</div>
<div className="flex relative gap-2.5 items-start self-stretch max-sm:gap-2">
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Подъезд</div>
<input
type="text"
value={entrance}
onChange={e => setEntrance(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Этаж</div>
<input
type="text"
value={floor}
onChange={e => setFloor(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Домофон</div>
<input
type="text"
value={intercom}
onChange={e => setIntercom(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
</div>
<div className="flex relative gap-2.5 items-start self-stretch h-[100px] max-sm:gap-2 max-sm:h-20">
<textarea
value={courierComment}
onChange={e => setCourierComment(e.target.value)}
placeholder="Комментарий курьеру"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] text-neutral-500 max-sm:gap-2 max-sm:text-base outline-none"
style={{ resize: 'none' }}
/>
</div>
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch max-sm:gap-2">
<div
layer-name="Данные получателя"
className="relative self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base"
>
Данные получателя
</div>
<input
type="text"
value={recipientName}
onChange={e => setRecipientName(e.target.value)}
placeholder="ФИО"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<input
type="text"
value={recipientPhone}
onChange={e => setRecipientPhone(e.target.value)}
placeholder="Номер телефона"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div
className="cursor-pointer relative gap-2.5 self-stretch px-5 py-3.5 text-base leading-5 text-center text-white bg-red-600 rounded-xl h-[50px] max-sm:px-4 max-sm:py-3 max-sm:h-12 max-sm:text-base"
onClick={onBack}
>
Добавить адрес доставки
</div>
</div>
</>
)}
</div>
);
};
export default AddressForm;

View File

@ -0,0 +1,622 @@
import React, { useState, useRef, useEffect } from "react";
import { useMutation, useLazyQuery } from '@apollo/client';
import CustomCheckbox from './CustomCheckbox';
import PickupPointSelector from '../delivery/PickupPointSelector';
import { YandexPickupPoint } from '@/lib/graphql/yandex-delivery';
import { CREATE_CLIENT_DELIVERY_ADDRESS, UPDATE_CLIENT_DELIVERY_ADDRESS, GET_CLIENT_DELIVERY_ADDRESSES, GET_ADDRESS_SUGGESTIONS } from '@/lib/graphql';
interface AddressFormWithPickupProps {
onDetectLocation: () => void;
address: string;
setAddress: (address: string) => void;
onBack: () => void;
onCityChange: (cityName: string) => void;
onPickupPointSelect: (point: YandexPickupPoint) => void;
selectedPickupPoint?: YandexPickupPoint;
editingAddress?: any; // Для редактирования существующего адреса
}
// Компонент автокомплита адресов
interface AddressAutocompleteProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value, onChange, placeholder }) => {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [getAddressSuggestions] = useLazyQuery(GET_ADDRESS_SUGGESTIONS, {
onCompleted: (data) => {
console.log('Автокомплит: получены данные', data);
if (data.addressSuggestions) {
console.log('Автокомплит: установка предложений', data.addressSuggestions);
setSuggestions(data.addressSuggestions);
setShowSuggestions(true);
}
setIsLoading(false);
},
onError: (error) => {
console.error('Ошибка автокомплита:', error);
setIsLoading(false);
}
});
useEffect(() => {
console.log('Автокомплит: значение изменилось', value);
if (!value || value.length < 3) {
console.log('Автокомплит: значение слишком короткое, очистка');
setSuggestions([]);
setShowSuggestions(false);
return;
}
const delayedSearch = setTimeout(() => {
console.log('Автокомплит: запуск поиска для', value);
setIsLoading(true);
getAddressSuggestions({
variables: { query: value }
});
}, 300);
return () => clearTimeout(delayedSearch);
}, [value, getAddressSuggestions]);
const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion);
setShowSuggestions(false);
};
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-transparent outline-none text-gray-600"
onFocus={() => {
if (suggestions.length > 0) setShowSuggestions(true);
}}
onBlur={() => setShowSuggestions(false)}
/>
{isLoading && (
<div className="absolute right-4 top-1/2 transform -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600"></div>
</div>
)}
{/* Отладочная информация */}
{/* {process.env.NODE_ENV === 'development' && (
<div className="absolute right-16 top-1/2 transform -translate-y-1/2 text-xs text-gray-400">
{suggestions.length > 0 ? `${suggestions.length} подсказок` : 'Нет подсказок'}
</div>
)} */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{suggestions.map((suggestion, index) => (
<div
key={index}
className="px-4 py-3 hover:bg-gray-100 cursor-pointer text-sm border-b border-gray-100 last:border-b-0"
onMouseDown={() => {
handleSuggestionClick(suggestion);
}}
>
{suggestion}
</div>
))}
</div>
)}
</div>
);
};
const Tabs = ({ deliveryType, setDeliveryType }: { deliveryType: string; setDeliveryType: (type: string) => void; }) => (
<div className="flex items-center w-full text-base font-medium text-center whitespace-nowrap rounded-xl bg-slate-100 mb-6">
<button
type="button"
style={deliveryType === 'COURIER' ? { color: '#fff' } : {}}
className={`flex-1 py-3 rounded-xl transition-colors duration-150 ${deliveryType === 'COURIER' ? 'bg-red-600 text-white shadow' : 'text-gray-700 hover:text-red-600'}`}
onClick={() => setDeliveryType('COURIER')}
>
Курьером
</button>
<button
type="button"
style={deliveryType === 'PICKUP' ? { color: '#fff' } : {}}
className={`flex-1 py-3 rounded-xl transition-colors duration-150 ${deliveryType === 'PICKUP' ? 'bg-red-600 text-white shadow' : 'text-gray-700 hover:text-red-600'}`}
onClick={() => setDeliveryType('PICKUP')}
>
Самовывоз
</button>
</div>
);
// Компонент фильтра по типу ПВЗ
const PickupTypeFilter = ({ selectedType, onTypeChange }: {
selectedType: string;
onTypeChange: (type: string) => void;
}) => (
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-700">Тип пункта выдачи *</label>
<div className="flex gap-2">
<div
onClick={() => onTypeChange('pickup_point')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md border transition-colors text-center cursor-pointer select-none ${
selectedType === 'pickup_point'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300'
}`}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onTypeChange('pickup_point'); }}
>
ПВЗ
</div>
<div
onClick={() => onTypeChange('terminal')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md border transition-colors text-center cursor-pointer select-none ${
selectedType === 'terminal'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300'
}`}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onTypeChange('terminal'); }}
>
Постомат
</div>
</div>
</div>
);
// Компонент детальной информации о ПВЗ
const PickupPointDetails = ({ point, onConfirm, onCancel }: {
point: YandexPickupPoint;
onConfirm: () => void;
onCancel: () => void;
}) => (
<div className="flex flex-col gap-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex justify-between items-start">
<h3 className="text-lg font-semibold text-gray-900">Подтверждение выбора ПВЗ</h3>
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="space-y-3">
<div>
<h4 className="font-medium text-gray-900">{point.name}</h4>
<p className="text-sm text-gray-600">{point.address.fullAddress}</p>
<span className="inline-block mt-1 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{point.typeLabel}
</span>
</div>
<div>
<h5 className="font-medium text-gray-900 mb-2">Режим работы:</h5>
<div className="text-sm text-gray-600 whitespace-pre-line">
{point.formattedSchedule}
</div>
</div>
{point.contact.phone && (
<div>
<h5 className="font-medium text-gray-900">Телефон:</h5>
<p className="text-sm text-gray-600">{point.contact.phone}</p>
</div>
)}
{point.instruction && (
<div>
<h5 className="font-medium text-gray-900">Дополнительная информация:</h5>
<p className="text-sm text-gray-600">{point.instruction}</p>
</div>
)}
<div className="flex gap-2 pt-2">
<div className="flex items-center gap-2">
{point.paymentMethods.includes('card_on_receipt') && (
<span className="flex items-center gap-1 text-xs text-green-600">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
Оплата картой
</span>
)}
{point.isYandexBranded && (
<span className="flex items-center gap-1 text-xs text-blue-600">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
Яндекс ПВЗ
</span>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<div
onClick={onCancel}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onCancel(); }}
>
Изменить выбор
</div>
<div
onClick={onConfirm}
style={{ color: '#fff' }}
className="flex-1 px-4 py-2 text-sm font-medium bg-red-600 rounded-md hover:bg-red-700 !text-[#fff] text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onConfirm(); }}
>
Подтвердить выбор
</div>
</div>
</div>
);
const AddressFormWithPickup = ({
onDetectLocation,
address,
setAddress,
onBack,
onCityChange,
onPickupPointSelect,
selectedPickupPoint,
editingAddress
}: AddressFormWithPickupProps) => {
const [deliveryType, setDeliveryType] = useState(editingAddress?.deliveryType || 'COURIER');
const [pickupTypeFilter, setPickupTypeFilter] = useState<string>('pickup_point');
const [showPickupDetails, setShowPickupDetails] = useState(false);
const [formData, setFormData] = useState({
name: editingAddress?.name || '',
address: editingAddress?.address || '',
entrance: editingAddress?.entrance || '',
floor: editingAddress?.floor || '',
apartment: editingAddress?.apartment || '',
intercom: editingAddress?.intercom || '',
deliveryTime: editingAddress?.deliveryTime || '',
contactPhone: editingAddress?.contactPhone || '',
comment: editingAddress?.comment || ''
});
const [createAddress] = useMutation(CREATE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
alert('Адрес доставки сохранен!');
onBack();
},
onError: (error) => {
console.error('Ошибка сохранения адреса:', error);
alert('Ошибка сохранения адреса: ' + error.message);
},
refetchQueries: [{ query: GET_CLIENT_DELIVERY_ADDRESSES }]
});
const [updateAddress] = useMutation(UPDATE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
alert('Адрес доставки обновлен!');
onBack();
},
onError: (error) => {
console.error('Ошибка обновления адреса:', error);
alert('Ошибка обновления адреса: ' + error.message);
},
refetchQueries: [{ query: GET_CLIENT_DELIVERY_ADDRESSES }]
});
const handlePickupPointSelect = (point: YandexPickupPoint) => {
// Проверяем соответствие выбранному типу
if (point.type !== pickupTypeFilter) {
alert(`Выбранный пункт не соответствует типу "${pickupTypeFilter === 'pickup_point' ? 'ПВЗ' : 'Постомат'}". Пожалуйста, выберите другой пункт.`);
return;
}
onPickupPointSelect(point);
setShowPickupDetails(true);
};
const handleSave = async () => {
if (deliveryType === 'COURIER') {
if (!formData.name || !formData.address) {
alert('Пожалуйста, заполните обязательные поля: название и адрес');
return;
}
const addressInput = {
name: formData.name,
address: formData.address,
deliveryType: 'COURIER',
comment: formData.comment,
entrance: formData.entrance || null,
floor: formData.floor || null,
apartment: formData.apartment || null,
intercom: formData.intercom || null,
deliveryTime: formData.deliveryTime || null,
contactPhone: formData.contactPhone || null
};
try {
if (editingAddress) {
// Обновляем существующий адрес
await updateAddress({
variables: {
id: editingAddress.id,
input: addressInput
}
});
} else {
// Создаем новый адрес
await createAddress({
variables: {
input: addressInput
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
} else if (deliveryType === 'PICKUP' && selectedPickupPoint) {
// Для самовывоза показываем детали перед сохранением
if (!showPickupDetails) {
setShowPickupDetails(true);
return;
}
const pickupInput = {
name: selectedPickupPoint.name,
address: selectedPickupPoint.address.fullAddress,
deliveryType: 'PICKUP',
comment: formData.comment || null,
entrance: null,
floor: null,
apartment: null,
intercom: null,
deliveryTime: null,
contactPhone: null
};
try {
if (editingAddress) {
// Обновляем существующий адрес
await updateAddress({
variables: {
id: editingAddress.id,
input: pickupInput
}
});
} else {
// Создаем новый адрес
await createAddress({
variables: {
input: pickupInput
}
});
}
} catch (error) {
console.error('Ошибка сохранения ПВЗ:', error);
}
} else {
alert('Пожалуйста, выберите пункт выдачи соответствующего типа');
}
};
const timeSlots = [
'9:00 - 12:00',
'12:00 - 15:00',
'15:00 - 18:00',
'18:00 - 21:00',
'Любое время'
];
// Желаемое время доставки — кастомный селект
const [isTimeOpen, setIsTimeOpen] = useState(false);
return (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-4 max-md:pb-8 ">
<div className="flex flex-col w-full leading-tight mb-2">
<div className="text-2xl font-bold text-gray-950 mb-2">
{editingAddress ? 'Редактировать адрес' : 'Адрес доставки'}
</div>
<Tabs deliveryType={deliveryType} setDeliveryType={setDeliveryType} />
</div>
{deliveryType === 'COURIER' ? (
<div className="flex flex-col gap-4 w-full">
{/* Название адреса */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Название адреса *</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Например: Дом, Офис, Дача"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
{/* Адрес с автокомплитом */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Адрес доставки *</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<AddressAutocomplete
value={formData.address}
onChange={(value) => setFormData(prev => ({ ...prev, address: value }))}
placeholder="Введите адрес"
/>
</div>
</div>
{/* Дополнительные поля */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Подъезд</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.entrance}
onChange={(e) => setFormData(prev => ({ ...prev, entrance: e.target.value }))}
placeholder="1"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Этаж</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.floor}
onChange={(e) => setFormData(prev => ({ ...prev, floor: e.target.value }))}
placeholder="5"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Квартира/офис</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.apartment}
onChange={(e) => setFormData(prev => ({ ...prev, apartment: e.target.value }))}
placeholder="25"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Домофон</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.intercom}
onChange={(e) => setFormData(prev => ({ ...prev, intercom: e.target.value }))}
placeholder="25К"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
</div>
{/* Время доставки */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Желаемое время доставки</label>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsTimeOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsTimeOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{formData.deliveryTime || 'Выберите время'}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isTimeOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{timeSlots.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === formData.deliveryTime ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setFormData(prev => ({ ...prev, deliveryTime: option })); setIsTimeOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
{/* Контактный телефон */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Контактный телефон</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="tel"
value={formData.contactPhone}
onChange={(e) => setFormData(prev => ({ ...prev, contactPhone: e.target.value }))}
placeholder="+7 (999) 123-45-67"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
{/* Комментарий для курьера */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Комментарий для курьера</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<textarea
value={formData.comment}
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
placeholder="Дополнительная информация для курьера"
rows={3}
className="w-full bg-transparent outline-none text-gray-600 resize-none"
/>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-4 w-full">
<PickupTypeFilter
selectedType={pickupTypeFilter}
onTypeChange={setPickupTypeFilter}
/>
{showPickupDetails && selectedPickupPoint ? (
<PickupPointDetails
point={selectedPickupPoint}
onConfirm={() => {
setShowPickupDetails(false);
handleSave();
}}
onCancel={() => setShowPickupDetails(false)}
/>
) : (
<PickupPointSelector
selectedPoint={selectedPickupPoint}
onPointSelect={handlePickupPointSelect}
onCityChange={onCityChange}
placeholder={`Выберите ${pickupTypeFilter === 'pickup_point' ? 'ПВЗ' : 'постомат'}`}
typeFilter={pickupTypeFilter}
/>
)}
{/* Комментарий для самовывоза */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Комментарий</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<textarea
value={formData.comment}
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
placeholder="Дополнительная информация"
rows={3}
className="w-full bg-transparent outline-none text-gray-600 resize-none"
/>
</div>
</div>
</div>
)}
<div
onClick={handleSave}
style={{ color: '#fff' }}
className="w-full mt-6 mb-6 px-5 py-3.5 text-base font-medium bg-red-600 rounded-xl hover:bg-red-700 transition-colors shadow-md disabled:opacity-60 disabled:cursor-not-allowed !text-[#fff] text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSave(); }}
>
{deliveryType === 'PICKUP' && selectedPickupPoint && !showPickupDetails
? 'Показать детали и сохранить'
: editingAddress ? 'Сохранить изменения' : 'Сохранить адрес доставки'
}
</div>
</div>
);
};
export default AddressFormWithPickup;

View File

@ -0,0 +1,22 @@
import React from "react";
interface CustomCheckboxProps {
selected: boolean;
onSelect: () => void;
}
const CustomCheckbox: React.FC<CustomCheckboxProps> = ({ selected, onSelect }) => (
<div
className={"div-block-7" + (selected ? " active" : "")}
onClick={onSelect}
style={{ width: 24, height: 24, borderRadius: 6, border: '1px solid #D1D5DB', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: selected ? '#EF4444' : '#fff', transition: 'background 0.2s' }}
>
{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>
);
export default CustomCheckbox;

View File

@ -0,0 +1,499 @@
import React from "react";
import { useMutation } from '@apollo/client';
import { CREATE_CLIENT_LEGAL_ENTITY, UPDATE_CLIENT_LEGAL_ENTITY } from '@/lib/graphql';
interface LegalEntityFormBlockProps {
inn: string;
setInn: (v: string) => void;
form: string;
setForm: (v: string) => void;
isFormOpen: boolean;
setIsFormOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
formOptions: string[];
ogrn: string;
setOgrn: (v: string) => void;
kpp: string;
setKpp: (v: string) => void;
jurAddress: string;
setJurAddress: (v: string) => void;
shortName: string;
setShortName: (v: string) => void;
fullName: string;
setFullName: (v: string) => void;
factAddress: string;
setFactAddress: (v: string) => void;
taxSystem: string;
setTaxSystem: (v: string) => void;
isTaxSystemOpen: boolean;
setIsTaxSystemOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
taxSystemOptions: string[];
nds: string;
setNds: (v: string) => void;
isNdsOpen: boolean;
setIsNdsOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
ndsOptions: string[];
ndsPercent: string;
setNdsPercent: (v: string) => void;
accountant: string;
setAccountant: (v: string) => void;
responsible: string;
setResponsible: (v: string) => void;
responsiblePosition: string;
setResponsiblePosition: (v: string) => void;
responsiblePhone: string;
setResponsiblePhone: (v: string) => void;
signatory: string;
setSignatory: (v: string) => void;
editingEntity?: {
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
} | null;
onAdd: () => void;
onCancel: () => void;
}
const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
inn,
setInn,
form,
setForm,
isFormOpen,
setIsFormOpen,
formOptions,
ogrn,
setOgrn,
kpp,
setKpp,
jurAddress,
setJurAddress,
shortName,
setShortName,
fullName,
setFullName,
factAddress,
setFactAddress,
taxSystem,
setTaxSystem,
isTaxSystemOpen,
setIsTaxSystemOpen,
taxSystemOptions,
nds,
setNds,
isNdsOpen,
setIsNdsOpen,
ndsOptions,
ndsPercent,
setNdsPercent,
accountant,
setAccountant,
responsible,
setResponsible,
responsiblePosition,
setResponsiblePosition,
responsiblePhone,
setResponsiblePhone,
signatory,
setSignatory,
editingEntity,
onAdd,
onCancel,
}) => {
const [createLegalEntity, { loading: createLoading }] = useMutation(CREATE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо создано');
onAdd();
},
onError: (error) => {
console.error('Ошибка создания юридического лица:', error);
alert('Ошибка создания юридического лица: ' + error.message);
}
});
const [updateLegalEntity, { loading: updateLoading }] = useMutation(UPDATE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо обновлено');
onAdd();
},
onError: (error) => {
console.error('Ошибка обновления юридического лица:', error);
alert('Ошибка обновления юридического лица: ' + error.message);
}
});
const loading = createLoading || updateLoading;
const handleSave = async () => {
// Валидация
if (!inn || inn.length < 10) {
alert('Введите корректный ИНН');
return;
}
if (!shortName.trim()) {
alert('Введите краткое наименование');
return;
}
if (!jurAddress.trim()) {
alert('Введите юридический адрес');
return;
}
if (form === 'Выбрать') {
alert('Выберите форму организации');
return;
}
if (taxSystem === 'Выбрать') {
alert('Выберите систему налогообложения');
return;
}
try {
// Преобразуем НДС в число
let vatPercent = 20; // по умолчанию
if (nds === 'Без НДС') {
vatPercent = 0;
} else if (nds === 'НДС 10%') {
vatPercent = 10;
} else if (nds === 'НДС 20%') {
vatPercent = 20;
} else if (ndsPercent) {
vatPercent = parseFloat(ndsPercent) || 20;
}
if (editingEntity) {
// Обновляем существующее юридическое лицо
await updateLegalEntity({
variables: {
id: editingEntity.id,
input: {
inn: inn.trim(),
shortName: shortName.trim(),
fullName: fullName.trim() || shortName.trim(),
form: form,
legalAddress: jurAddress.trim(),
actualAddress: factAddress.trim() || null,
taxSystem: taxSystem,
vatPercent: vatPercent,
accountant: accountant.trim() || null,
responsibleName: responsible.trim() || null,
responsiblePosition: responsiblePosition.trim() || null,
responsiblePhone: responsiblePhone.trim() || null,
signatory: signatory.trim() || null,
ogrn: ogrn.trim() || null,
registrationReasonCode: kpp.trim() || null
}
}
});
} else {
// Создаем новое юридическое лицо
await createLegalEntity({
variables: {
input: {
inn: inn.trim(),
shortName: shortName.trim(),
fullName: fullName.trim() || shortName.trim(),
form: form,
legalAddress: jurAddress.trim(),
actualAddress: factAddress.trim() || null,
taxSystem: taxSystem,
vatPercent: vatPercent,
accountant: accountant.trim() || null,
responsibleName: responsible.trim() || null,
responsiblePosition: responsiblePosition.trim() || null,
responsiblePhone: responsiblePhone.trim() || null,
signatory: signatory.trim() || null,
ogrn: ogrn.trim() || null,
registrationReasonCode: kpp.trim() || null
}
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
};
return (
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
{editingEntity ? 'Редактирование юридического лица' : 'Данные юридического лица'}
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full whitespace-nowrap max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">ИНН</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="ИНН"
className="w-full bg-transparent outline-none text-gray-600"
value={inn}
onChange={e => setInn(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Форма</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsFormOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsFormOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{form}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isFormOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{formOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === form ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setForm(option); setIsFormOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">ОГРН</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="ОГРН"
className="w-full bg-transparent outline-none text-neutral-500"
value={ogrn}
onChange={e => setOgrn(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">КПП</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="КПП"
className="w-full bg-transparent outline-none text-neutral-500"
value={kpp}
onChange={e => setKpp(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Юридический адрес</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Юридический адрес"
className="w-full bg-transparent outline-none text-neutral-500"
value={jurAddress}
onChange={e => setJurAddress(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Краткое наименование</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Краткое наименование"
className="w-full bg-transparent outline-none text-neutral-500"
value={shortName}
onChange={e => setShortName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Полное наименование</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Полное наименование"
className="w-full bg-transparent outline-none text-neutral-500"
value={fullName}
onChange={e => setFullName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Фактический адрес</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Фактический адрес"
className="w-full bg-transparent outline-none text-neutral-500"
value={factAddress}
onChange={e => setFactAddress(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Система налогоблажения</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsTaxSystemOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsTaxSystemOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{taxSystem}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isTaxSystemOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{taxSystemOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === taxSystem ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setTaxSystem(option); setIsTaxSystemOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">НДС</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsNdsOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsNdsOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{nds}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isNdsOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{ndsOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === nds ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setNds(option); setIsNdsOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">НДС %</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="НДС %"
className="w-full bg-transparent outline-none text-neutral-500"
value={ndsPercent}
onChange={e => setNdsPercent(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Бухгалтер</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Бухгалтер"
className="w-full bg-transparent outline-none text-neutral-500"
value={accountant}
onChange={e => setAccountant(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Ответственный</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Ответственный"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsible}
onChange={e => setResponsible(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Должность ответственного</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Должность ответственного"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsiblePosition}
onChange={e => setResponsiblePosition(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Телефон ответственного</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Телефон ответственного"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsiblePhone}
onChange={e => setResponsiblePhone(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Подписант</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Подписант"
className="w-full bg-transparent outline-none text-neutral-500"
value={signatory}
onChange={e => setSignatory(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className={`gap-2.5 self-stretch px-5 py-4 rounded-xl min-h-[50px] cursor-pointer text-white ${loading ? 'bg-gray-400' : 'bg-red-600 hover:bg-red-700'}`}
onClick={loading ? undefined : handleSave}
>
{loading ? 'Сохранение...' : (editingEntity ? 'Сохранить изменения' : 'Добавить')}
</div>
<div className="gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50" onClick={onCancel}>
Отменить
</div>
</div>
</div>
);
};
export default LegalEntityFormBlock;

View File

@ -0,0 +1,188 @@
import React from "react";
import Image from "next/image";
import { useRouter } from 'next/router';
import { useMutation } from '@apollo/client';
import { DELETE_CLIENT_LEGAL_ENTITY } from '@/lib/graphql';
interface LegalEntity {
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
bankDetails: Array<{
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
}>;
}
interface LegalEntityListBlockProps {
legalEntities: LegalEntity[];
onRefetch: () => void;
onEdit?: (entity: LegalEntity) => void;
}
const LegalEntityListBlock: React.FC<LegalEntityListBlockProps> = ({ legalEntities, onRefetch, onEdit }) => {
const router = useRouter();
const [deleteLegalEntity] = useMutation(DELETE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо удалено');
onRefetch();
},
onError: (error) => {
console.error('Ошибка удаления юридического лица:', error);
alert('Ошибка удаления юридического лица');
}
});
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Вы уверены, что хотите удалить юридическое лицо "${name}"?`)) {
try {
await deleteLegalEntity({
variables: { id }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
if (legalEntities.length === 0) {
return (
<div className="flex relative flex-col mt-5 gap-8 items-start self-stretch p-8 pl-8 bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4">
<div className="text-3xl font-bold leading-8 text-gray-950 max-md:text-2xl max-sm:text-xl">
Юридические лица
</div>
<div className="text-gray-600">
У вас пока нет добавленных юридических лиц. Нажмите кнопку "Добавить юридическое лицо" для создания первого.
</div>
</div>
);
}
return (
<div
layer-name="Frame 2087324698"
className="flex relative flex-col mt-5 gap-8 items-start self-stretch p-8 pl-8 bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4"
>
<div
layer-name="Юридические лица"
className="text-3xl font-bold leading-8 text-gray-950 max-md:text-2xl max-sm:text-xl"
>
Юридические лица
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch">
{legalEntities.map((entity, idx) => (
<div
key={entity.id}
layer-name="legal"
className="flex relative flex-col gap-8 items-start self-stretch px-5 py-3 rounded-lg bg-slate-50 max-sm:px-4 max-sm:py-2.5"
>
<div className="flex relative justify-between items-center self-stretch max-sm:flex-col max-sm:gap-4 max-sm:items-start">
<div className="flex relative gap-5 items-center max-md:flex-wrap max-md:gap-4 max-sm:flex-col max-sm:gap-2.5 max-sm:items-start">
<div
layer-name={entity.shortName}
className="text-xl font-bold leading-5 text-gray-950 max-md:text-lg max-sm:text-base"
>
{entity.shortName}
</div>
<div
layer-name={`ИНН ${entity.inn}`}
className="text-sm leading-5 text-gray-600"
>
ИНН {entity.inn}
</div>
<div
layer-name="link_control_element"
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
onClick={() => router.push('/profile-requisites')}
>
<div
layer-name="icon-wallet"
className="relative aspect-[1/1] h-[18px] w-[18px]"
>
<div>
<div
dangerouslySetInnerHTML={{
__html:
"<svg id=\"I48:1881;1705:18944;1705:18492;1149:3355\" width=\"16\" height=\"15\" viewBox=\"0 0 16 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"wallet-icon\" style=\"width: 16px; height: 14px; flex-shrink: 0; fill: #424F60; position: absolute; left: 1px; top: 2px\"> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.77778 3.16211C1.77778 3.04608 1.8246 2.9348 1.90795 2.85275C1.9913 2.7707 2.10435 2.72461 2.22222 2.72461H11.5556C11.7913 2.72461 12.0174 2.63242 12.1841 2.46833C12.3508 2.30423 12.4444 2.08167 12.4444 1.84961C12.4444 1.61754 12.3508 1.39499 12.1841 1.23089C12.0174 1.0668 11.7913 0.974609 11.5556 0.974609H2.22222C1.63285 0.974609 1.06762 1.20508 0.650874 1.61531C0.234126 2.02555 0 2.58195 0 3.16211V13.2246C0 13.6887 0.187301 14.1339 0.520699 14.462C0.854097 14.7902 1.30628 14.9746 1.77778 14.9746H14.2222C14.6937 14.9746 15.1459 14.7902 15.4793 14.462C15.8127 14.1339 16 13.6887 16 13.2246V5.34961C16 4.88548 15.8127 4.44036 15.4793 4.11217C15.1459 3.78398 14.6937 3.59961 14.2222 3.59961H2.22222C2.10435 3.59961 1.9913 3.55352 1.90795 3.47147C1.8246 3.38942 1.77778 3.27814 1.77778 3.16211ZM11.1111 10.5996C11.4647 10.5996 11.8039 10.4613 12.0539 10.2152C12.304 9.96905 12.4444 9.63521 12.4444 9.28711C12.4444 8.93901 12.304 8.60517 12.0539 8.35903C11.8039 8.11289 11.4647 7.97461 11.1111 7.97461C10.7575 7.97461 10.4184 8.11289 10.1683 8.35903C9.91825 8.60517 9.77778 8.93901 9.77778 9.28711C9.77778 9.63521 9.91825 9.96905 10.1683 10.2152C10.4184 10.4613 10.7575 10.5996 11.1111 10.5996Z\" fill=\"#424F60\"></path> </svg>",
}}
/>
</div>
</div>
<div
layer-name="Редактировать"
className="text-sm leading-5 text-gray-600"
>
Реквизиты компании
</div>
</div>
</div>
<div className="flex relative gap-5 items-center pr-2.5 max-md:gap-4 max-sm:flex-wrap max-sm:gap-2.5">
<div
role="button"
tabIndex={0}
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
onClick={() => onEdit && onEdit(entity)}
>
<div className="relative h-4 w-[18px]">
<Image
src="/images/edit.svg"
alt="Редактировать"
width={16}
height={16}
className="absolute left-0.5 top-0"
/>
</div>
<div className="text-sm leading-5 text-gray-600">
Редактировать
</div>
</div>
<div
role="button"
tabIndex={0}
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
onClick={() => handleDelete(entity.id, entity.shortName)}
>
<div className="relative h-4 w-[18px]">
<Image
src="/images/delete.svg"
alt="Удалить"
width={16}
height={16}
className="absolute left-0.5 top-0"
/>
</div>
<div className="text-sm leading-5 text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default LegalEntityListBlock;

View File

@ -0,0 +1,194 @@
import * as React from "react";
import LKMenu from '@/components/LKMenu';
import CustomCheckbox from './CustomCheckbox';
const NotificationMane = () => {
const [all, setAll] = React.useState(false);
const [delivery, setDelivery] = React.useState(false);
const [payment, setPayment] = React.useState(false);
const [reserve, setReserve] = React.useState(false);
const [refuse, setRefuse] = React.useState(false);
const [returnItem, setReturnItem] = React.useState(false);
const [upd, setUpd] = React.useState(false);
const [email, setEmail] = React.useState("");
const [division, setDivision] = React.useState("Все");
const divisionOptions = ["Все", "Склад 1", "Склад 2", "Офис"];
const [isDivisionOpen, setIsDivisionOpen] = React.useState(false);
const [address, setAddress] = React.useState("Все");
const addressOptions = ["Все", "Калининград, ул. Понартская, 5", "Москва, ул. Ленина, 10"];
const [isAddressOpen, setIsAddressOpen] = React.useState(false);
const [showAddEmail, setShowAddEmail] = React.useState(true);
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="flex flex-col w-full max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full whitespace-nowrap max-md:max-w-full">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
voronin.p.e@gmail.com
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">Удалить</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Подразделение
</div>
<div className="relative mt-1.5">
<div
className="flex items-center justify-between px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 text-neutral-500 max-md:px-5 max-md:max-w-full cursor-pointer select-none"
onClick={() => setIsDivisionOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsDivisionOpen(false)}
style={{ minHeight: 48 }}
>
<span className="text-neutral-500">{division}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isDivisionOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{divisionOptions.map(option => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === division ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setDivision(option); setIsDivisionOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Адрес доставки
</div>
<div className="relative mt-1.5">
<div
className="flex items-center justify-between px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 text-neutral-500 max-md:px-5 max-md:max-w-full cursor-pointer select-none"
onClick={() => setIsAddressOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsAddressOpen(false)}
style={{ minHeight: 48 }}
>
<span className="text-neutral-500">{address}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isAddressOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{addressOptions.map(option => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === address ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setAddress(option); setIsAddressOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-8 justify-between items-start mt-5 w-full max-md:max-w-full">
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={all} onSelect={() => setAll(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Все оповещения
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={delivery} onSelect={() => setDelivery(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Доставка товара
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={payment} onSelect={() => setPayment(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Поступление оплаты
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={reserve} onSelect={() => setReserve(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Снято с резерва
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={refuse} onSelect={() => setRefuse(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Отказ в поставке
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={returnItem} onSelect={() => setReturnItem(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Возврат товара
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={upd} onSelect={() => setUpd(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
УПД или чек
</div>
</div>
</div>
</div>
<div className="mt-8 w-full border border-solid bg-stone-300 border-stone-300 min-h-[1px] max-md:max-w-full" />
{showAddEmail && (
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
<div className="text-xl font-bold leading-none text-gray-950">
Добавление e-mail для уведомлений
</div>
<div className="flex flex-col mt-5 w-full max-md:max-w-full">
<div className="text-sm leading-snug text-gray-950 max-md:max-w-full">
Адрес электронной почты
</div>
<div className="flex flex-wrap gap-5 items-start mt-1.5 w-full text-base font-medium leading-tight whitespace-nowrap max-md:max-w-full">
<div className="flex-1 shrink gap-2.5 self-stretch px-6 py-4 text-sm leading-snug bg-white rounded border border-solid basis-0 border-stone-300 min-h-[52px] min-w-[240px] text-neutral-500 max-md:px-5 max-md:max-w-full">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Введите e-mail"
className="w-full bg-transparent outline-none text-gray-950 placeholder:text-gray-400"
style={{ border: 'none', padding: 0, margin: 0 }}
/>
</div>
<div className="cursor-pointer gap-2.5 self-stretch px-5 py-4 text-center text-white bg-red-600 rounded-xl min-h-[50px]" onClick={() => setShowAddEmail(false)}>
Готово
</div>
<div className="cursor-pointer gap-2.5 self-stretch px-5 py-4 text-center rounded-xl border border-red-600 border-solid min-h-[50px] text-gray-950" onClick={() => setShowAddEmail(false)}>
Отменить
</div>
</div>
</div>
</div>
)}
<div className="mt-8 w-full border border-solid bg-stone-300 border-stone-300 min-h-[1px] max-md:max-w-full" />
<div className="flex flex-wrap gap-10 justify-between items-start mt-8 w-full text-base font-medium leading-tight text-center max-md:max-w-full">
<div className="gap-2.5 self-stretch px-5 py-4 text-white whitespace-nowrap bg-red-600 rounded-xl min-h-[50px]">
Сохранить
</div>
<div
className={`cursor-pointer gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 border-solid min-h-[50px] min-w-[240px] text-gray-950${showAddEmail ? ' opacity-50 pointer-events-none' : ''}`}
onClick={() => { if (!showAddEmail) setShowAddEmail(true); }}
>
Добавить почту для уведомлений
</div>
</div>
</div>
</div>
);
};
export default NotificationMane;

View File

@ -0,0 +1,168 @@
import * as React from "react";
const selectArrow = (
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" style={{ width: 12, height: 6 }}>
<path d="M1 1L7 7L13 1" stroke="#747474" strokeWidth="2" />
</svg>
);
type CustomSelectProps = {
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
className?: string;
};
const CustomSelect: React.FC<CustomSelectProps> = ({ value, onChange, options, placeholder = "Выбрать", className = "" }) => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={ref} className={`relative w-full ${className}`}>
<div
className="flex justify-between items-center px-6 py-4 bg-white rounded border border-solid border-stone-300 cursor-pointer"
onClick={() => setOpen((o) => !o)}
>
<div className={`text-sm leading-5 ${!value || value === placeholder ? "text-neutral-500" : "text-gray-950"}`}>
{value || placeholder}
</div>
{selectArrow}
</div>
{open && (
<div className="absolute left-0 right-0 mt-1 bg-white border border-stone-300 rounded shadow z-10">
{options.map((opt: string) => (
<div
key={opt}
className={`px-6 py-2 text-sm cursor-pointer hover:bg-slate-100 ${opt === value ? "font-medium text-gray-950" : "text-neutral-500"}`}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
{opt}
</div>
))}
</div>
)}
</div>
);
};
const periodOptions = ["Этот год", "Последний квартал", "Предыдущий год", "Другое"];
const buyerOptions = ["Покупатель 1", "Покупатель 2", "Покупатель 3"];
const sellerOptions = ["ООО 'ПротекАвто'", "Продавец 2", "Продавец 3"];
const ProfileActsMain = () => {
const [period, setPeriod] = React.useState("");
const [buyer, setBuyer] = React.useState("");
const [seller, setSeller] = React.useState(sellerOptions[0]);
const [email, setEmail] = React.useState("");
const tabOptions = ["Этот год", "Последний квартал", "Предыдущий год"];
const [activeTab, setActiveTab] = React.useState(tabOptions[0]);
return (
<>
<div className=" flex relative flex-col gap-8 items-start p-8 mx-auto my-0 w-full bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4">
<div className="flex relative flex-col gap-8 items-start self-stretch max-md:gap-5 max-sm:gap-4">
<div className="flex relative flex-wrap gap-5 items-start self-stretch max-md:flex-col max-md:gap-4 max-sm:gap-2.5">
{tabOptions.map((tab) => (
<div
key={tab}
layer-name="Tabs_button"
className={`flex relative gap-5 items-center self-stretch rounded-xl flex-[1_0_0] min-w-[200px] max-md:gap-4 max-md:w-full max-md:min-w-[unset] max-sm:gap-2.5 ${activeTab === tab ? "" : "bg-slate-200"}`}
onClick={() => setActiveTab(tab)}
style={{ cursor: 'pointer' }}
>
<div className={`flex relative gap-5 justify-center items-center px-6 py-3.5 rounded-xl flex-[1_0_0] ${activeTab === tab ? "bg-red-600" : "bg-slate-200"}`}>
<div
layer-name="Курьером"
className={`relative text-lg font-medium leading-5 text-center max-sm:text-base ${activeTab === tab ? "text-white" : "text-gray-950"}`}
>
{tab}
</div>
</div>
</div>
))}
<CustomSelect
value={period}
onChange={setPeriod}
options={periodOptions}
placeholder="Выбрать период"
className="flex-[1_0_0] min-w-[200px] max-md:w-full max-md:min-w-[unset]"
/>
</div>
</div>
<div className="flex relative flex-wrap gap-5 items-start self-stretch max-md:flex-col max-md:gap-4 max-sm:gap-2.5">
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="Покупатель"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
Покупатель
</div>
<CustomSelect
value={buyer}
onChange={setBuyer}
options={buyerOptions}
placeholder="Выберите"
/>
</div>
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="Продавец"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
Продавец
</div>
<CustomSelect
value={seller}
onChange={setSeller}
options={sellerOptions}
placeholder="Выберите"
/>
</div>
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="E-mail для получения акта сверки"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
E-mail для получения акта сверки
</div>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="@"
className="flex relative gap-2.5 items-center self-stretch px-6 py-4 bg-white rounded border border-solid border-stone-300 h-[52px] max-sm:h-12 text-sm text-gray-950 placeholder-neutral-500 outline-none"
layer-name="Input"
/>
</div>
</div>
<div
layer-name="Button Small"
className="flex relative gap-2.5 justify-center items-center px-5 py-3.5 bg-red-600 rounded-xl cursor-pointer border-[none] h-[50px] max-sm:h-[46px]"
>
<div
layer-name="Button Small"
className="relative text-base font-medium leading-5 text-center text-white"
>
Получить акт сверки
</div>
</div>
</div>
</>
);
}
export default ProfileActsMain;

View File

@ -0,0 +1,98 @@
import React from "react";
type ProfileAddressCardProps = {
type: string;
title: string;
address: string;
storagePeriod?: string;
workTime?: string;
comment?: string;
onEdit?: () => void;
onDelete?: () => void;
onSelectMain?: () => void;
isMain?: boolean;
};
const ProfileAddressCard: React.FC<ProfileAddressCardProps> = ({
type,
title,
address,
storagePeriod,
workTime,
comment,
onEdit,
onDelete,
onSelectMain,
isMain
}) => (
<div className="flex flex-col justify-between items-start self-stretch p-8 bg-white rounded-lg border border-solid border-stone-300 sm:min-w-[340px] min-w-[200px] max-w-[404px] flex-[1_0_0] max-md:max-w-[350px] max-sm:p-5 max-sm:max-w-full">
<div className="flex flex-col gap-1.5 items-start self-stretch pb-8">
<div className="relative text-base leading-6 text-gray-950">{type}</div>
<div className="relative self-stretch text-xl font-bold leading-7 text-gray-950">{title}</div>
<div className="relative self-stretch text-base leading-6 text-gray-950">{address}</div>
</div>
<div className="flex flex-col gap-5 items-start self-stretch">
{storagePeriod && workTime && (
<div className="flex gap-5 items-start self-stretch max-sm:flex-col max-sm:gap-4">
<div className="flex flex-col gap-2 items-start flex-[1_0_0] min-w-[132px] max-sm:min-w-full">
<div className="overflow-hidden relative self-stretch text-sm leading-5 text-gray-600 text-ellipsis">Срок хранения</div>
<div className="overflow-hidden relative self-stretch text-lg font-medium leading-5 text-ellipsis text-gray-950">{storagePeriod}</div>
</div>
<div className="flex flex-col gap-2 items-start flex-[1_0_0] min-w-[132px] max-sm:min-w-full">
<div className="overflow-hidden relative self-stretch text-sm leading-5 text-gray-600 text-ellipsis">Ежедневно</div>
<div className="overflow-hidden relative text-lg font-medium leading-5 text-ellipsis text-gray-950">{workTime}</div>
</div>
</div>
)}
{comment && (
<div className="flex flex-col gap-2 items-start self-stretch min-w-[160px]">
<div className="relative self-stretch text-sm leading-5 text-gray-600">Комментарий</div>
<div className="relative self-stretch text-base leading-5 text-gray-950 break-words">
{comment}
</div>
</div>
)}
<div className="flex justify-between items-start self-stretch">
<div className="flex gap-1.5 items-center cursor-pointer group" onClick={onEdit}>
<img src="/images/edit.svg" alt="edit" width={18} height={18} className="mr-1.5 group-hover:filter-red" />
<div className="relative text-sm leading-5 text-gray-600">Редактировать</div>
</div>
<div className="flex gap-1.5 items-center cursor-pointer group" onClick={onDelete}>
<img src="/images/delete.svg" alt="delete" width={18} height={18} className="mr-1.5 group-hover:filter-red" />
<div className="relative text-sm leading-5 text-gray-600">Удалить</div>
</div>
</div>
{onSelectMain && (
<div className="flex gap-1.5 items-center cursor-pointer mt-4" onClick={onSelectMain}>
<div className="relative flex items-center justify-center aspect-[1/1] h-[18px] w-[18px]">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" style={{ width: 18, height: 18, flexShrink: 0 }}>
<circle cx="9" cy="9" r="8.5" stroke="#EC1C24" />
</svg>
{isMain && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
flexShrink: 0,
}}
>
<circle cx="5" cy="5" r="5" fill="#EC1C24" />
</svg>
)}
</div>
<div className="relative text-sm leading-5 text-gray-600">Выбрать как основной адрес</div>
</div>
)}
</div>
</div>
);
export default ProfileAddressCard;

View File

@ -0,0 +1,41 @@
import React, { useState } from "react";
import AddressForm from "./AddressForm";
import AddressDetails from "./AddressDetails";
interface ProfileAddressWayProps {
onBack: () => void;
}
const ProfileAddressWay = ({ onBack }: ProfileAddressWayProps) => {
const [showDetails, setShowDetails] = useState(false);
const [address, setAddress] = useState("");
return (
<div className="flex relative gap-8 items-start bg-white rounded-2xl flex-[1_0_0] min-h-[860px] max-md:flex-col max-md:gap-5 ">
{/* Левая часть */}
{showDetails ? (
<AddressDetails
onClose={() => setShowDetails(false)}
onBack={onBack}
address={address}
setAddress={setAddress}
/>
) : (
<AddressForm onDetectLocation={() => setShowDetails(true)} address={address} setAddress={setAddress} onBack={onBack} />
)}
{/* Правая часть: карта */}
<div className="flex-1 rounded-2xl overflow-hidden shadow-lg md:w-full ">
<iframe
src="https://yandex.ru/map-widget/v1/?ll=37.532502%2C56.339223&mode=whatshere&whatshere%5Bpoint%5D=37.532502%2C56.339223&whatshere%5Bzoom%5D=17&z=16"
className="w-full h-full min-h-[990px] max-md:min-h-[300px] "
frameBorder="0"
allowFullScreen
title="Карта"
></iframe>
</div>
</div>
);
};
export default ProfileAddressWay;

View File

@ -0,0 +1,239 @@
import React, { useState } from "react";
import AddressFormWithPickup from "./AddressFormWithPickup";
import AddressDetails from "./AddressDetails";
import YandexPickupPointsMap from "../delivery/YandexPickupPointsMap";
import { useLazyQuery } from '@apollo/client';
import {
YANDEX_PICKUP_POINTS_BY_CITY,
YANDEX_PICKUP_POINTS_BY_COORDINATES,
YandexPickupPoint
} from '@/lib/graphql/yandex-delivery';
interface ProfileAddressWayWithMapProps {
onBack: () => void;
editingAddress?: any; // Для редактирования существующего адреса
}
// Координаты городов для центрирования карты
const cityCoordinates: Record<string, [number, number]> = {
'Москва': [55.7558, 37.6176],
'Санкт-Петербург': [59.9311, 30.3609],
'Новосибирск': [55.0084, 82.9357],
'Екатеринбург': [56.8431, 60.6454],
'Казань': [55.8304, 49.0661],
'Нижний Новгород': [56.2965, 43.9361],
'Челябинск': [55.1644, 61.4368],
'Самара': [53.2001, 50.15],
'Омск': [54.9885, 73.3242],
'Ростов-на-Дону': [47.2357, 39.7015],
'Уфа': [54.7388, 55.9721],
'Красноярск': [56.0184, 92.8672],
'Воронеж': [51.6720, 39.1843],
'Пермь': [58.0105, 56.2502],
'Волгоград': [48.7080, 44.5133],
'Краснодар': [45.0355, 38.9753],
'Саратов': [51.5924, 46.0348],
'Тюмень': [57.1522, 65.5272],
'Тольятти': [53.5303, 49.3461],
'Ижевск': [56.8527, 53.2118],
'Барнаул': [53.3606, 83.7636],
'Ульяновск': [54.3142, 48.4031],
'Иркутск': [52.2978, 104.2964],
'Хабаровск': [48.4827, 135.0839],
'Ярославль': [57.6261, 39.8845],
'Владивосток': [43.1056, 131.8735],
'Махачкала': [42.9849, 47.5047],
'Томск': [56.4977, 84.9744],
'Оренбург': [51.7727, 55.0988],
'Кемерово': [55.3331, 86.0833],
'Новокузнецк': [53.7557, 87.1099],
'Рязань': [54.6269, 39.6916],
'Набережные Челны': [55.7558, 52.4069],
'Астрахань': [46.3497, 48.0408],
'Пенза': [53.2001, 45.0000],
'Липецк': [52.6031, 39.5708],
'Тула': [54.1961, 37.6182],
'Киров': [58.6035, 49.6679],
'Чебоксары': [56.1439, 47.2517],
'Калининград': [54.7065, 20.5110],
'Брянск': [53.2434, 34.3640],
'Курск': [51.7373, 36.1873],
'Иваново': [57.0000, 40.9737],
'Магнитогорск': [53.4078, 59.0647],
'Тверь': [56.8587, 35.9176],
'Ставрополь': [45.0428, 41.9734],
'Симферополь': [44.9572, 34.1108],
'Белгород': [50.5951, 36.5804],
'Архангельск': [64.5401, 40.5433],
'Владимир': [56.1366, 40.3966],
'Сочи': [43.6028, 39.7342],
'Курган': [55.4500, 65.3333],
'Смоленск': [54.7818, 32.0401],
'Калуга': [54.5293, 36.2754],
'Чита': [52.0307, 113.5006],
'Орёл': [52.9651, 36.0785],
'Волжский': [48.7854, 44.7759],
'Череповец': [59.1374, 37.9097],
'Владикавказ': [43.0370, 44.6830],
'Мурманск': [68.9792, 33.0925],
'Сургут': [61.2500, 73.4167],
'Вологда': [59.2239, 39.8840],
'Тамбов': [52.7319, 41.4520],
'Стерлитамак': [53.6241, 55.9504],
'Грозный': [43.3181, 45.6942],
'Якутск': [62.0355, 129.6755],
'Кострома': [57.7665, 40.9265],
'Комсомольск-на-Амуре': [50.5496, 137.0067],
'Петрозаводск': [61.7849, 34.3469],
'Таганрог': [47.2362, 38.8969],
'Нижневартовск': [60.9344, 76.5531],
'Йошкар-Ола': [56.6372, 47.8753],
'Братск': [56.1326, 101.6140],
'Новороссийск': [44.7209, 37.7677],
'Дзержинск': [56.2342, 43.4582],
'Шахты': [47.7090, 40.2060],
'Нижнекамск': [55.6367, 51.8209],
'Орск': [51.2045, 58.5434],
'Ангарск': [52.5406, 103.8887],
'Старый Оскол': [51.2965, 37.8411],
'Великий Новгород': [58.5218, 31.2756],
'Благовещенск': [50.2941, 127.5405],
'Прокопьевск': [53.9058, 86.7194],
'Химки': [55.8970, 37.4296],
'Энгельс': [51.4827, 46.1124],
'Рыбинск': [58.0446, 38.8486],
'Балашиха': [55.7969, 37.9386],
'Подольск': [55.4297, 37.5547],
'Королёв': [55.9226, 37.8251],
'Петропавловск-Камчатский': [53.0446, 158.6483],
'Мытищи': [55.9116, 37.7307],
'Люберцы': [55.6758, 37.8939],
'Магадан': [59.5638, 150.8063],
'Норильск': [69.3558, 88.1893],
'Южно-Сахалинск': [46.9588, 142.7386]
};
const ProfileAddressWayWithMap: React.FC<ProfileAddressWayWithMapProps> = ({ onBack, editingAddress }) => {
const [showDetails, setShowDetails] = useState(false);
const [address, setAddress] = useState("");
const [pickupPoints, setPickupPoints] = useState<YandexPickupPoint[]>([]);
const [selectedPickupPoint, setSelectedPickupPoint] = useState<YandexPickupPoint | undefined>();
const [mapCenter, setMapCenter] = useState<[number, number]>([55.7558, 37.6176]); // Москва
const [loadPointsByCity] = useLazyQuery(YANDEX_PICKUP_POINTS_BY_CITY, {
onCompleted: (data) => {
const points = data.yandexPickupPointsByCity || [];
setPickupPoints(points);
// Если есть точки, центрируем карту на первой
if (points.length > 0) {
setMapCenter([points[0].position.latitude, points[0].position.longitude]);
}
},
onError: (error) => {
console.error('Ошибка загрузки ПВЗ по городу:', error);
setPickupPoints([]);
},
errorPolicy: 'all'
});
const [loadPointsByCoordinates] = useLazyQuery(YANDEX_PICKUP_POINTS_BY_COORDINATES, {
onCompleted: (data) => {
const points = data.yandexPickupPointsByCoordinates || [];
setPickupPoints(points);
},
onError: (error) => {
console.error('Ошибка загрузки ПВЗ по координатам:', error);
setPickupPoints([]);
},
errorPolicy: 'all'
});
// Загружаем ПВЗ для Москвы при первой загрузке (где есть много ПВЗ)
React.useEffect(() => {
loadPointsByCity({ variables: { cityName: 'Москва' } });
}, [loadPointsByCity]);
const handlePickupPointSelect = (point: YandexPickupPoint) => {
setSelectedPickupPoint(point);
setAddress(point.address.fullAddress);
setMapCenter([point.position.latitude, point.position.longitude]);
};
const handleDetectLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
setMapCenter([lat, lon]);
loadPointsByCoordinates({
variables: {
latitude: lat,
longitude: lon,
radiusKm: 15
}
});
},
(error) => {
console.error('Ошибка определения местоположения:', error);
alert('Не удалось определить местоположение');
}
);
} else {
alert('Геолокация не поддерживается браузером');
}
};
const handleCityChange = (cityName: string) => {
// Сначала центрируем карту на выбранном городе
const coordinates = cityCoordinates[cityName];
if (coordinates) {
setMapCenter(coordinates);
}
// Затем загружаем ПВЗ для города
loadPointsByCity({ variables: { cityName } });
};
return (
<div className="flex relative gap-8 items-start bg-white rounded-2xl flex-[1_0_0] max-md:flex-col max-md:gap-5">
{/* Левая часть */}
{showDetails ? (
<AddressDetails
onClose={() => setShowDetails(false)}
onBack={onBack}
address={address}
setAddress={setAddress}
/>
) : (
<AddressFormWithPickup
onDetectLocation={handleDetectLocation}
address={address}
setAddress={setAddress}
onBack={onBack}
onCityChange={handleCityChange}
onPickupPointSelect={handlePickupPointSelect}
selectedPickupPoint={selectedPickupPoint}
editingAddress={editingAddress}
/>
)}
{/* Правая часть: карта */}
<div className="flex-1 min-w-0 w-full rounded-2xl md:w-full max-md:h-[320px] max-md:min-h-0">
<YandexPickupPointsMap
pickupPoints={pickupPoints}
selectedPoint={selectedPickupPoint}
onPointSelect={handlePickupPointSelect}
center={mapCenter}
zoom={12}
className="w-full h-[220px] md:min-h-[990px] md:h-full"
/>
</div>
</div>
);
};
export default ProfileAddressWayWithMap;

View File

@ -0,0 +1,155 @@
import * as React from "react";
import { useQuery, useMutation } from "@apollo/client";
import ProfileAddressCard from "./ProfileAddressCard";
import ProfileAddressWayWithMap from "./ProfileAddressWayWithMap";
import { GET_CLIENT_DELIVERY_ADDRESSES, DELETE_CLIENT_DELIVERY_ADDRESS } from "@/lib/graphql";
interface DeliveryAddress {
id: string;
name: string;
address: string;
deliveryType: 'COURIER' | 'PICKUP' | 'POST' | 'TRANSPORT';
comment?: string;
entrance?: string;
floor?: string;
apartment?: string;
intercom?: string;
deliveryTime?: string;
contactPhone?: string;
createdAt: string;
updatedAt: string;
}
const getDeliveryTypeLabel = (type: string) => {
const labels = {
COURIER: 'Доставка курьером',
PICKUP: 'Самовывоз',
POST: 'Почта России',
TRANSPORT: 'Транспортная компания'
};
return labels[type as keyof typeof labels] || type;
};
const ProfileAddressesMain = () => {
const [mainIndex, setMainIndex] = React.useState(0);
const [showWay, setShowWay] = React.useState(false);
const [editingAddress, setEditingAddress] = React.useState<DeliveryAddress | null>(null);
const { data, loading, error, refetch } = useQuery(GET_CLIENT_DELIVERY_ADDRESSES, {
errorPolicy: 'all'
});
const [deleteAddress] = useMutation(DELETE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка удаления адреса:', error);
alert('Ошибка удаления адреса: ' + error.message);
}
});
const handleDeleteAddress = async (addressId: string) => {
if (confirm('Вы уверены, что хотите удалить этот адрес?')) {
try {
await deleteAddress({
variables: { id: addressId }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleEditAddress = (address: DeliveryAddress) => {
setEditingAddress(address);
setShowWay(true);
};
const handleWayClose = () => {
setShowWay(false);
setEditingAddress(null);
refetch(); // Обновляем данные после закрытия формы
};
if (showWay) return (
<ProfileAddressWayWithMap
onBack={handleWayClose}
editingAddress={editingAddress}
/>
);
if (loading) {
return (
<div className="flex relative flex-col gap-8 items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
<div className="text-center text-gray-500">Загрузка адресов...</div>
</div>
);
}
if (error) {
return (
<div className="flex relative flex-col gap-8 items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
<div className="text-center text-red-500">
<div className="mb-2">Ошибка загрузки адресов</div>
<div className="text-sm text-gray-500 mb-4">{error.message}</div>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Попробовать снова
</button>
</div>
</div>
);
}
const addresses = data?.clientMe?.deliveryAddresses || [];
return (
<div className="flex relative flex-col gap-8 w-full items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
{addresses.length > 0 ? (
<div className="flex flex-wrap gap-5 items-start self-stretch">
{addresses.map((addr: DeliveryAddress, idx: number) => (
<ProfileAddressCard
key={addr.id}
type={getDeliveryTypeLabel(addr.deliveryType)}
title={addr.name}
address={addr.address}
comment={addr.comment}
onEdit={() => handleEditAddress(addr)}
onSelectMain={() => setMainIndex(idx)}
onDelete={() => handleDeleteAddress(addr.id)}
isMain={mainIndex === idx}
/>
))}
</div>
) : (
<div
className="flex items-center justify-center w-full h-[380px] max-w-[400px] bg-[#eaf0f8] rounded-2xl text-xl font-semibold text-gray-900 cursor-pointer select-none"
onClick={() => setShowWay(true)}
>
+ Добавить адрес
</div>
)}
{addresses.length > 0 && (
<div
layer-name="Button Small"
className="flex relative gap-2.5 justify-center items-center px-5 py-3.5 bg-red-600 rounded-xl h-[50px] cursor-pointer hover:bg-red-700 transition-colors"
onClick={() => setShowWay(true)}
>
<div
layer-name="Button Small"
className="relative text-base font-medium leading-5 text-center text-white "
>
Добавить адрес доставки
</div>
</div>
)}
</div>
);
};
export default ProfileAddressesMain;

View File

@ -0,0 +1,267 @@
import * as React from "react";
const ProfileAnnouncementMain = () => {
const [search, setSearch] = React.useState("");
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full text-base leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск уведомлений"
className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full bg-transparent outline-none placeholder-gray-400"
/>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/2b8e5dde8809a16af6b9b2f399617f9bd340e40c?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Важное
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center text-red-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/31621e15429b14d49586c2261c65e539112ef134?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-red-600 text-ellipsis">
Больше не важно
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 text-gray-600 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center text-red-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/31621e15429b14d49586c2261c65e539112ef134?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-red-600 text-ellipsis">
Больше не важно
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 text-gray-600 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Все уведомления
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug text-gray-600 max-md:max-w-full">
<div className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default ProfileAnnouncementMain;

View File

@ -0,0 +1,168 @@
import React, { useState } from "react";
type ProfileBalanceCardProps = {
contractId: string;
orgName: string;
contract: string;
balance: string;
limit: string;
limitLeft: string;
ordersSum: string;
days: string;
daysLeft: string;
paid: string;
inputValue: string;
buttonLabel: string;
onTopUp: (contractId: string, amount: number) => Promise<void>;
isOverLimit?: boolean;
isCreatingInvoice?: boolean;
};
const ProfileBalanceCard: React.FC<ProfileBalanceCardProps> = ({
contractId,
orgName,
contract,
balance,
limit,
limitLeft,
ordersSum,
days,
daysLeft,
paid,
inputValue,
buttonLabel,
onTopUp,
isOverLimit = false,
isCreatingInvoice = false
}) => {
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
}
}, [editing]);
const handleBlur = () => setEditing(false);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") setEditing(false);
};
const handleTopUp = async () => {
const amount = parseFloat(value.replace(/[^\d.,]/g, '').replace(',', '.'));
if (isNaN(amount) || amount <= 0) {
alert('Введите корректную сумму для пополнения');
return;
}
if (isCreatingInvoice) {
return; // Не делаем ничего, если уже создается счет
}
setLoading(true);
try {
await onTopUp(contractId, amount);
setValue("");
setEditing(false);
} catch (error) {
console.error('Ошибка пополнения:', error);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 shrink justify-between p-8 bg-white rounded-lg border border-solid basis-0 border-stone-300 max-w-[404px] sm:min-w-[340px] min-w-[200px] max-md:px-5">
<div className="flex flex-col w-full leading-snug text-gray-950">
<div className="text-xl font-bold text-gray-950">{orgName}</div>
<div className="mt-1.5 text-base text-gray-950">{contract}</div>
</div>
<div className="flex flex-col mt-4 w-full">
<div className="flex flex-col w-full">
<div className="flex flex-col w-full">
<div className="text-sm leading-snug text-gray-600">Баланс</div>
<div className={`mt-2 text-2xl font-bold leading-none ${balance.startsWith('-') ? 'text-red-600' : 'text-gray-950'}`}>
{balance}
</div>
</div>
<div className="flex flex-row gap-5 items-end mt-5 w-full max-sm:flex-col">
<div className="flex flex-col flex-1 shrink basis-0">
<div className="flex flex-col min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Лимит отсрочки</div>
<div className="flex flex-col self-start mt-2">
<div className="text-lg font-medium leading-none text-gray-950">{limit}</div>
<div className={`text-sm leading-snug ${isOverLimit ? 'text-red-600' : 'text-gray-600'}`}>
{limitLeft.includes('Не установлен') ? limitLeft : `Осталось ${limitLeft}`}
</div>
</div>
</div>
<div className="flex flex-col mt-5 min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Сумма заказов</div>
<div className="mt-2 text-lg font-medium leading-none text-gray-950">{ordersSum}</div>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0">
<div className="flex flex-col min-w-[160px]">
<div className="text-lg font-medium leading-none text-gray-950">{days}</div>
<div className={`text-sm leading-snug ${daysLeft.includes("Осталось") && balance.startsWith('-') ? "text-red-600" : "text-gray-600"}`}>
{daysLeft}
</div>
</div>
<div className="flex flex-col mt-5 min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Оплачено</div>
<div className="mt-2 text-lg font-medium leading-none text-gray-950">{paid}</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col mt-8 w-full">
{editing ? (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="Введите сумму пополнения"
className="gap-2.5 self-stretch px-6 py-4 w-full text-sm leading-snug bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 outline-none focus:ring-0 focus:border-stone-400 placeholder-neutral-500"
/>
) : (
<div
className={`gap-2.5 self-stretch px-6 py-4 w-full text-sm leading-snug bg-white rounded border border-solid border-stone-300 min-h-[52px] max-md:px-5 cursor-text ${!value ? "text-neutral-500" : "text-gray-950"}`}
onClick={() => setEditing(true)}
>
{value || "Введите сумму пополнения"}
</div>
)}
<div
role="button"
tabIndex={0}
aria-disabled={loading || isCreatingInvoice || !value.trim()}
onClick={() => {
if (!(loading || isCreatingInvoice || !value.trim())) handleTopUp();
}}
onKeyDown={e => {
if ((e.key === 'Enter' || e.key === ' ') && !(loading || isCreatingInvoice || !value.trim())) {
handleTopUp();
}
}}
className={`gap-2.5 self-start px-5 py-4 mt-4 text-base font-medium leading-tight text-center text-white whitespace-nowrap rounded-xl min-h-[50px] transition-colors duration-150
${loading || isCreatingInvoice || !value.trim()
? 'bg-red-300 cursor-not-allowed'
: 'bg-red-600 hover:bg-red-700 cursor-pointer'}
`}
>
{isCreatingInvoice ? 'Создаем счет...' : loading ? 'Пополняем...' : buttonLabel}
</div>
</div>
</div>
</div>
);
};
export default ProfileBalanceCard;

View File

@ -0,0 +1,348 @@
import * as React from "react";
import { useState } from "react";
import { useQuery, useMutation } from '@apollo/client';
import { GET_CLIENT_ME, CREATE_BALANCE_INVOICE } from '@/lib/graphql';
import toast from 'react-hot-toast';
import ProfileBalanceCard from "./ProfileBalanceCard";
interface LegalEntity {
id: string;
shortName: string;
fullName: string;
form: string;
inn: string;
}
interface Contract {
id: string;
contractNumber: string;
contractDate: string;
name: string;
ourLegalEntity: string;
clientLegalEntity: string;
balance: number;
currency: string;
isActive: boolean;
isDefault: boolean;
contractType: string;
relationship: string;
paymentDelay: boolean;
creditLimit?: number;
delayDays?: number;
fileUrl?: string;
createdAt: string;
updatedAt: string;
}
interface ClientData {
id: string;
name: string;
email?: string;
phone: string;
legalEntities: LegalEntity[];
contracts: Contract[];
}
const ProfileBalanceMain = () => {
const [isCreatingInvoice, setIsCreatingInvoice] = useState(false);
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [createBalanceInvoice] = useMutation(CREATE_BALANCE_INVOICE, {
onCompleted: async (data) => {
console.log('Счет на пополнение создан:', data.createBalanceInvoice);
const invoice = data.createBalanceInvoice;
// Проверяем, что счет создан корректно
if (!invoice || !invoice.id) {
toast.error('Ошибка: некорректные данные счета');
setIsCreatingInvoice(false);
return;
}
try {
// Получаем токен так же, как в Apollo Client
let token = null;
const userData = localStorage.getItem('userData');
if (userData) {
try {
const user = JSON.parse(userData);
if (!user.id) {
throw new Error('Отсутствует ID пользователя');
}
// Создаем токен в формате, который ожидает CMS
token = `client_${user.id}`;
} catch (error) {
console.error('Ошибка парсинга userData:', error);
toast.error('Ошибка получения данных пользователя');
setIsCreatingInvoice(false);
return;
}
}
if (!token) {
toast.error('Ошибка авторизации. Попробуйте перезайти.');
setIsCreatingInvoice(false);
return;
}
// Скачиваем PDF с токеном авторизации
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const downloadUrl = `${baseUrl}/api/invoice/${invoice.id}`;
if (!baseUrl) {
throw new Error('Не настроен URL API сервера');
}
const response = await fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch (jsonError) {
// Если не удалось распарсить JSON, используем текст ответа
try {
const errorText = await response.text();
if (errorText) {
errorMessage = errorText.substring(0, 100); // Ограничиваем длину
}
} catch (textError) {
// Если и текст не удалось получить, используем стандартное сообщение
errorMessage = `Ошибка ${response.status}: ${response.statusText}`;
}
}
throw new Error(errorMessage);
}
// Создаем blob и скачиваем файл
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${invoice.invoiceNumber}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// Показываем уведомление об успехе
toast.success(`Счет ${invoice.invoiceNumber} создан и загружен!`, {
duration: 5000,
icon: '📄'
});
} catch (error) {
console.error('Ошибка скачивания PDF:', error);
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
toast.error(`Ошибка скачивания PDF: ${errorMessage}`);
}
setIsCreatingInvoice(false);
refetch();
},
onError: (error) => {
console.error('Ошибка создания счета:', error);
toast.error('Ошибка создания счета: ' + error.message);
setIsCreatingInvoice(false);
}
});
const handleCreateInvoice = async (contractId: string, amount: number) => {
if (isCreatingInvoice) return;
setIsCreatingInvoice(true);
const loadingToast = toast.loading('Создаем счет на оплату...');
try {
const { data } = await createBalanceInvoice({
variables: {
contractId: contractId,
amount: amount
}
});
if (data?.createBalanceInvoice) {
toast.dismiss(loadingToast);
// Логика скачивания уже в onCompleted мутации
// Обновляем данные
await refetch();
}
} catch (error) {
toast.dismiss(loadingToast);
console.error('Ошибка создания счета:', error);
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
toast.error(`Ошибка создания счета: ${errorMessage}`);
setIsCreatingInvoice(false);
}
};
if (loading) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
// Проверяем есть ли у клиента юридические лица
if (!clientData?.legalEntities?.length) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-center">
<div className="text-lg font-semibold mb-2">Нет доступа к балансам</div>
<div className="text-sm text-gray-600 mb-4">
Для работы с балансами необходимо добавить юридическое лицо в настройках профиля
</div>
<a
href="/profile-settings"
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Перейти к настройкам
</a>
</div>
</div>
</div>
</div>
);
}
// Проверяем есть ли договоры
if (!clientData?.contracts?.length) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-center">
<div className="text-lg font-semibold mb-2">Нет активных договоров</div>
<div className="text-sm text-gray-600 mb-4">
Договоры с балансами будут созданы менеджером после подтверждения ваших юридических лиц
</div>
<div className="text-sm text-gray-500">
Обратитесь к менеджеру для создания договоров с возможностью покупки в долг
</div>
</div>
</div>
</div>
</div>
);
}
const formatCurrency = (amount: number, currency: string = 'RUB') => {
return `${amount.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU');
};
const calculateDaysLeft = (delayDays?: number) => {
if (!delayDays) return 'Без ограничений';
// Здесь должна быть логика расчета оставшихся дней
// Пока возвращаем статичное значение
return `Осталось ${Math.max(0, delayDays)} дней`;
};
const getLegalEntityName = (clientLegalEntity: string) => {
// Если поле пустое или null, показываем первое доступное юридическое лицо
if (!clientLegalEntity || clientLegalEntity.trim() === '') {
return clientData?.legalEntities?.[0]?.shortName || 'Не указано';
}
// Очищаем строку от лишних кавычек
const cleanedName = clientLegalEntity.replace(/^"(.*)"$/, '$1');
// Ищем по названию или ID
const entity = clientData?.legalEntities?.find(le =>
le.shortName === clientLegalEntity ||
le.shortName === cleanedName ||
le.id === clientLegalEntity ||
le.fullName === clientLegalEntity ||
le.fullName === cleanedName
);
return entity ? entity.shortName : cleanedName;
};
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-wrap flex-1 gap-5 size-full max-md:max-w-full">
{clientData.contracts.filter(contract => contract.isActive).map((contract) => {
const hasLimit = contract.creditLimit !== null && contract.creditLimit !== undefined;
const limitLeft = hasLimit ? Math.max(0, (contract.creditLimit || 0) + contract.balance) : 0;
const isOverLimit = contract.balance < 0 && hasLimit && Math.abs(contract.balance) > (contract.creditLimit || 0);
return (
<ProfileBalanceCard
key={contract.id}
contractId={contract.id}
orgName={getLegalEntityName(contract.clientLegalEntity)}
contract={`Договор № ${contract.contractNumber} от ${formatDate(contract.contractDate)}`}
balance={formatCurrency(contract.balance, contract.currency)}
limit={hasLimit ? formatCurrency(contract.creditLimit || 0, contract.currency) : 'Не установлен'}
limitLeft={hasLimit ? formatCurrency(limitLeft, contract.currency) : 'Не установлен'}
ordersSum="0 ₽" // TODO: Добавить расчет суммы заказов
days={contract.delayDays ? `${contract.delayDays} дней` : 'Без ограничений'}
daysLeft={calculateDaysLeft(contract.delayDays)}
paid="0 ₽" // TODO: Добавить расчет оплаченной суммы
inputValue="0 ₽"
buttonLabel="Пополнить"
onTopUp={handleCreateInvoice}
isOverLimit={Boolean(isOverLimit)}
isCreatingInvoice={isCreatingInvoice}
/>
);
})}
</div>
</div>
</div>
);
}
export default ProfileBalanceMain;

View File

@ -0,0 +1,446 @@
import * as React from "react";
import { useQuery, useMutation, useLazyQuery } from '@apollo/client';
import {
GET_USER_VEHICLES,
GET_VEHICLE_SEARCH_HISTORY,
CREATE_VEHICLE_FROM_VIN,
DELETE_USER_VEHICLE,
ADD_VEHICLE_FROM_SEARCH,
DELETE_SEARCH_HISTORY_ITEM,
UserVehicle,
VehicleSearchHistory
} from '@/lib/graphql/garage';
import { FIND_LAXIMO_VEHICLE_GLOBAL } from '@/lib/graphql';
import { LaximoVehicleSearchResult } from '@/types/laximo';
const ProfileGarageMain = () => {
const [searchQuery, setSearchQuery] = React.useState("");
const [vin, setVin] = React.useState("");
const [carComment, setCarComment] = React.useState("");
const [showAddCar, setShowAddCar] = React.useState(false);
const [expandedVehicle, setExpandedVehicle] = React.useState<string | null>(null);
const [isAddingVehicle, setIsAddingVehicle] = React.useState(false);
// GraphQL queries and mutations
const { data: vehiclesData, loading: vehiclesLoading, refetch: refetchVehicles } = useQuery(GET_USER_VEHICLES);
const { data: historyData, loading: historyLoading, refetch: refetchHistory } = useQuery(GET_VEHICLE_SEARCH_HISTORY);
const [searchVehicleByVin] = useLazyQuery(FIND_LAXIMO_VEHICLE_GLOBAL);
const [createVehicleFromVin] = useMutation(CREATE_VEHICLE_FROM_VIN, {
onCompleted: () => {
refetchVehicles();
setVin('');
setCarComment('');
setShowAddCar(false);
setIsAddingVehicle(false);
},
onError: (error) => {
console.error('Ошибка создания автомобиля:', error);
alert('Ошибка при добавлении автомобиля');
setIsAddingVehicle(false);
}
});
const [deleteVehicle] = useMutation(DELETE_USER_VEHICLE, {
onCompleted: () => refetchVehicles(),
onError: (error) => {
console.error('Ошибка удаления автомобиля:', error);
alert('Ошибка при удалении автомобиля');
}
});
const [addFromSearch] = useMutation(ADD_VEHICLE_FROM_SEARCH, {
onCompleted: () => {
refetchVehicles();
refetchHistory();
},
onError: (error) => {
console.error('Ошибка добавления из истории:', error);
alert('Ошибка при добавлении автомобиля из истории');
}
});
const [deleteHistoryItem] = useMutation(DELETE_SEARCH_HISTORY_ITEM, {
onCompleted: () => refetchHistory(),
onError: (error) => {
console.error('Ошибка удаления истории:', error);
alert('Ошибка при удалении из истории');
}
});
const vehicles: UserVehicle[] = vehiclesData?.userVehicles || [];
const searchHistory: VehicleSearchHistory[] = historyData?.vehicleSearchHistory || [];
// Фильтрация автомобилей по поисковому запросу
const filteredVehicles = vehicles.filter(vehicle =>
vehicle.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.vin?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.model?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSaveVehicle = async () => {
if (!vin.trim()) {
alert('Введите VIN номер');
return;
}
setIsAddingVehicle(true);
try {
await createVehicleFromVin({
variables: {
vin: vin.trim().toUpperCase(),
comment: carComment.trim() || null
}
});
} catch (error) {
console.error('Ошибка сохранения автомобиля:', error);
}
};
const handleDeleteVehicle = async (vehicleId: string) => {
if (confirm('Вы уверены, что хотите удалить этот автомобиль?')) {
try {
await deleteVehicle({ variables: { id: vehicleId } });
} catch (error) {
console.error('Ошибка удаления автомобиля:', error);
}
}
};
const handleAddFromHistory = async (historyItem: VehicleSearchHistory) => {
try {
await addFromSearch({
variables: {
vin: historyItem.vin,
comment: ''
}
});
} catch (error) {
console.error('Ошибка добавления из истории:', error);
}
};
const handleDeleteFromHistory = async (historyId: string) => {
try {
await deleteHistoryItem({ variables: { id: historyId } });
} catch (error) {
console.error('Ошибка удаления истории:', error);
}
};
const handleFindParts = (vehicle: UserVehicle) => {
// Переход к поиску запчастей для автомобиля
if (vehicle.vin) {
window.location.href = `/vehicle-search-results?q=${encodeURIComponent(vehicle.vin)}`;
}
};
const toggleVehicleExpanded = (vehicleId: string) => {
setExpandedVehicle(expandedVehicle === vehicleId ? null : vehicleId);
};
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full text-base leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full">
<input
type="text"
placeholder="Поиск по гаражу"
className="w-full bg-transparent outline-none text-gray-400"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<img
loading="lazy"
src="/images/search_ixon.svg"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Мои автомобили
</div>
{vehiclesLoading && (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
</div>
)}
{!vehiclesLoading && filteredVehicles.length === 0 && !showAddCar && (
<div className="text-center py-8 text-gray-500">
{vehicles.length === 0 ? 'У вас пока нет автомобилей в гараже' : 'Автомобили не найдены'}
</div>
)}
{!vehiclesLoading && filteredVehicles.map((vehicle) => (
<div key={vehicle.id} className="mt-8">
<div className="flex flex-col justify-center pr-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap gap-8 items-center w-full max-md:max-w-full">
<div className="flex gap-8 items-center self-stretch my-auto min-w-[240px] max-md:flex-col max-md:min-w-0 max-md:gap-2">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
{vehicle.name || `${vehicle.brand || ''} ${vehicle.model || ''}`.trim() || 'Неизвестный автомобиль'}
</div>
<div className="self-stretch my-auto text-sm leading-snug text-gray-600 max-md:whitespace-normal">
{vehicle.vin || 'VIN не указан'}
</div>
</div>
<div className="flex-1 shrink gap-2.5 self-stretch px-3.5 py-1.5 my-auto text-sm leading-snug whitespace-nowrap bg-white rounded border border-solid basis-3 border-zinc-100 min-h-[32px] min-w-[240px] text-stone-500 truncate overflow-hidden">
{vehicle.comment || 'Комментарий не добавлен'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-2 my-auto font-medium leading-tight text-center bg-red-600 rounded-lg min-h-[32px] cursor-pointer text-white hover:bg-red-700 transition-colors"
role="button"
tabIndex={0}
onClick={() => handleFindParts(vehicle)}
>
Найти запчасть
</div>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto text-sm leading-snug text-gray-600 whitespace-nowrap">
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer text-sm leading-snug text-gray-600 hover:text-red-600 transition-colors"
onClick={() => handleDeleteVehicle(vehicle.id)}
>
<img
loading="lazy"
src="/images/delete.svg"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<span className="self-stretch my-auto text-gray-600">
Удалить
</span>
</button>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer text-sm leading-snug text-gray-600 hover:text-blue-600 transition-colors"
onClick={() => toggleVehicleExpanded(vehicle.id)}
>
<span className="self-stretch my-auto text-gray-600">
{expandedVehicle === vehicle.id ? 'Свернуть' : 'Развернуть'}
</span>
<img
loading="lazy"
src="/images/arrow_drop.svg"
className={`object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square transition-transform ${
expandedVehicle === vehicle.id ? 'rotate-180' : ''
}`}
/>
</button>
</div>
</div>
</div>
{/* Расширенная информация об автомобиле */}
{expandedVehicle === vehicle.id && (
<div className="mt-4 px-5 py-4 bg-white rounded-lg border border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
{vehicle.brand && (
<div>
<span className="font-medium text-gray-700">Бренд:</span>
<span className="ml-2 text-gray-900">{vehicle.brand}</span>
</div>
)}
{vehicle.model && (
<div>
<span className="font-medium text-gray-700">Модель:</span>
<span className="ml-2 text-gray-900">{vehicle.model}</span>
</div>
)}
{vehicle.modification && (
<div>
<span className="font-medium text-gray-700">Модификация:</span>
<span className="ml-2 text-gray-900">{vehicle.modification}</span>
</div>
)}
{vehicle.year && (
<div>
<span className="font-medium text-gray-700">Год:</span>
<span className="ml-2 text-gray-900">{vehicle.year}</span>
</div>
)}
{vehicle.frame && (
<div>
<span className="font-medium text-gray-700">Номер кузова:</span>
<span className="ml-2 text-gray-900">{vehicle.frame}</span>
</div>
)}
{vehicle.licensePlate && (
<div>
<span className="font-medium text-gray-700">Госномер:</span>
<span className="ml-2 text-gray-900">{vehicle.licensePlate}</span>
</div>
)}
{vehicle.mileage && (
<div>
<span className="font-medium text-gray-700">Пробег:</span>
<span className="ml-2 text-gray-900">{vehicle.mileage.toLocaleString()} км</span>
</div>
)}
<div>
<span className="font-medium text-gray-700">Добавлен:</span>
<span className="ml-2 text-gray-900">
{new Date(vehicle.createdAt).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
</div>
)}
</div>
))}
{!showAddCar && (
<div className="flex mt-8">
<div
className="gap-2.5 self-stretch px-5 py-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center"
role="button"
tabIndex={0}
onClick={() => setShowAddCar(true)}
>
Добавить авто
</div>
</div>
)}
{showAddCar && (
<>
<div className="mt-8 text-3xl font-bold leading-none text-gray-950">
Добавить авто в гараж
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug whitespace-nowrap text-gray-950 max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full min-h-[78px] max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">VIN</div>
<div className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 max-md:max-w-full">
<input
type="text"
placeholder="VIN"
className="w-full bg-transparent outline-none text-gray-950"
value={vin}
onChange={e => setVin(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">Комментарий</div>
<div className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 max-md:max-w-full">
<input
type="text"
placeholder="Комментарий"
className="w-full bg-transparent outline-none text-gray-950"
value={carComment}
onChange={e => setCarComment(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className={`gap-2.5 self-stretch px-5 py-4 rounded-xl min-h-[50px] cursor-pointer text-white transition-colors ${
isAddingVehicle
? 'bg-gray-400 cursor-not-allowed'
: 'bg-red-600 hover:bg-red-700'
}`}
role="button"
tabIndex={0}
onClick={handleSaveVehicle}
>
{isAddingVehicle ? 'Сохранение...' : 'Сохранить'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50 transition-colors"
role="button"
tabIndex={0}
onClick={() => {
setShowAddCar(false);
setVin('');
setCarComment('');
}}
>
Отменить
</div>
</div>
</>
)}
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Ранее вы искали
</div>
{historyLoading && (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
</div>
)}
{!historyLoading && searchHistory.length === 0 && (
<div className="text-center py-8 text-gray-500">
История поиска пуста
</div>
)}
{!historyLoading && searchHistory.length > 0 && (
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
{searchHistory.map((historyItem) => (
<div key={historyItem.id} className="flex flex-col justify-center px-5 py-3 mb-2.5 w-full rounded-lg bg-slate-50 min-h-[44px] max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex gap-8 items-center self-stretch my-auto min-w-[240px] max-md:flex-col max-md:min-w-0 max-md:gap-2">
<div className="self-stretch my-auto text-lg font-bold leading-none text-gray-950">
{historyItem.brand && historyItem.model
? `${historyItem.brand} ${historyItem.model}`
: 'Автомобиль найден'}
</div>
<div className="self-stretch my-auto text-sm leading-snug text-gray-600">
{historyItem.vin}
</div>
</div>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600 cursor-pointer bg-transparent hover:text-green-600 transition-colors"
onClick={() => handleAddFromHistory(historyItem)}
>
<img
loading="lazy"
src="/images/add.svg"
className="object-contain shrink-0 self-stretch my-auto w-4 aspect-square"
/>
<span className="self-stretch my-auto text-gray-600">
Добавить в гараж
</span>
</button>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto text-sm leading-snug text-gray-600 whitespace-nowrap">
<div className="self-stretch my-auto text-gray-600">
{new Date(historyItem.searchDate).toLocaleDateString('ru-RU')}
</div>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600 cursor-pointer bg-transparent hover:text-red-600 transition-colors"
onClick={() => handleDeleteFromHistory(historyItem.id)}
>
<img
loading="lazy"
src="/images/delete.svg"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<span className="self-stretch my-auto text-gray-600">
Удалить
</span>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default ProfileGarageMain;

View File

@ -0,0 +1,111 @@
import React from "react";
interface VehicleInfo {
brand?: string;
model?: string;
year?: number;
}
interface ProfileHistoryItemProps {
id: string;
date: string;
manufacturer: string;
article: string;
name: string;
vehicleInfo?: VehicleInfo;
resultCount?: number;
onDelete?: (id: string) => void;
}
const ProfileHistoryItem: React.FC<ProfileHistoryItemProps> = ({
id,
date,
manufacturer,
article,
name,
vehicleInfo,
resultCount,
onDelete,
}) => {
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete) {
onDelete(id);
}
};
const getSearchTypeDisplay = (article: string) => {
if (article.includes('TEXT')) return 'Текстовый поиск';
if (article.includes('ARTICLE')) return 'По артикулу';
if (article.includes('OEM')) return 'По OEM';
if (article.includes('VIN')) return 'Поиск по VIN';
if (article.includes('PLATE')) return 'Поиск по госномеру';
if (article.includes('WIZARD')) return 'Поиск по параметрам';
if (article.includes('PART_VEHICLES')) return 'Поиск авто по детали';
return article;
};
return (
<>
<div className="mt-1.5 w-full border border-gray-200 border-solid min-h-[1px] max-md:max-w-full" />
<div className="flex justify-between items-center px-5 pt-1.5 pb-2 mt-1.5 w-full bg-white rounded-lg max-md:max-w-full max-md:flex-col max-md:min-w-0 hover:bg-gray-50 transition-colors">
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch pr-5 my-auto w-full basis-0 max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:p-0 max-md:min-w-0">
<div className="self-stretch my-auto w-40 max-md:w-full text-sm">
<div className="font-medium text-gray-900">{date}</div>
{vehicleInfo && (
<div className="text-xs text-gray-500 mt-1">
{vehicleInfo.brand} {vehicleInfo.model} {vehicleInfo.year}
</div>
)}
</div>
<div className="self-stretch my-auto w-40 font-bold leading-snug text-gray-950 max-md:w-full">
{manufacturer}
</div>
<div className="self-stretch my-auto font-medium leading-snug text-gray-700 w-[180px] max-md:w-full text-sm">
{getSearchTypeDisplay(article)}
{resultCount !== undefined && (
<div className="text-xs text-gray-500 mt-1">
Найдено: {resultCount} шт.
</div>
)}
</div>
<div className="flex-1 shrink self-stretch my-auto basis-0 max-md:max-w-full max-md:w-full">
<div className="font-medium text-gray-900">{name}</div>
</div>
{onDelete && (
<div className="w-16 text-center max-md:w-full">
<button
onClick={handleDeleteClick}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors group"
title="Удалить из истории"
aria-label="Удалить из истории"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-colors"
>
<path d="M3 6h18" className="group-hover:stroke-[#ec1c24]" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" className="group-hover:stroke-[#ec1c24]" />
<path d="M8 6V4c0-1 1-2 2-2h4c-1 0 2 1 2 2v2" className="group-hover:stroke-[#ec1c24]" />
</svg>
</button>
</div>
)}
</div>
</div>
</>
);
};
export default ProfileHistoryItem;

View File

@ -0,0 +1,433 @@
import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from '@apollo/client';
import ProfileHistoryItem from "./ProfileHistoryItem";
import SearchInput from "./SearchInput";
import ProfileHistoryTabs from "./ProfileHistoryTabs";
import {
GET_PARTS_SEARCH_HISTORY,
DELETE_SEARCH_HISTORY_ITEM,
CLEAR_SEARCH_HISTORY,
CREATE_SEARCH_HISTORY_ITEM,
PartsSearchHistoryItem,
PartsSearchHistoryResponse
} from '@/lib/graphql/search-history';
const ProfileHistoryMain = () => {
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("Все");
const [sortField, setSortField] = useState<"date" | "manufacturer" | "name">("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [filteredItems, setFilteredItems] = useState<PartsSearchHistoryItem[]>([]);
const tabOptions = ["Все", "Сегодня", "Вчера", "Эта неделя", "Этот месяц"];
// GraphQL запросы
const { data, loading, error, refetch } = useQuery<{ partsSearchHistory: PartsSearchHistoryResponse }>(
GET_PARTS_SEARCH_HISTORY,
{
variables: { limit: 100, offset: 0 },
fetchPolicy: 'cache-and-network',
onCompleted: (data) => {
console.log('История поиска загружена:', data);
},
onError: (error) => {
console.error('Ошибка загрузки истории поиска:', error);
}
}
);
const [deleteItem] = useMutation(DELETE_SEARCH_HISTORY_ITEM, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка удаления элемента истории:', error);
}
});
const [clearHistory] = useMutation(CLEAR_SEARCH_HISTORY, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка очистки истории:', error);
}
});
const [createHistoryItem] = useMutation(CREATE_SEARCH_HISTORY_ITEM, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка создания записи истории:', error);
}
});
const historyItems = data?.partsSearchHistory?.items || [];
// Отладочная информация
console.log('ProfileHistoryMain состояние:', {
loading,
error: error?.message,
data,
historyItemsCount: historyItems.length
});
// Фильтрация по времени
const getFilteredByTime = (items: PartsSearchHistoryItem[], timeFilter: string) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const monthAgo = new Date(today);
monthAgo.setMonth(monthAgo.getMonth() - 1);
switch (timeFilter) {
case "Сегодня":
return items.filter(item => new Date(item.createdAt) >= today);
case "Вчера":
return items.filter(item => {
const itemDate = new Date(item.createdAt);
return itemDate >= yesterday && itemDate < today;
});
case "Эта неделя":
return items.filter(item => new Date(item.createdAt) >= weekAgo);
case "Этот месяц":
return items.filter(item => new Date(item.createdAt) >= monthAgo);
default:
return items;
}
};
// Фильтрация и сортировка
useEffect(() => {
let filtered = [...getFilteredByTime(historyItems, activeTab)];
// Поиск
if (search.trim()) {
const searchLower = search.toLowerCase();
filtered = filtered.filter(item =>
item.searchQuery.toLowerCase().includes(searchLower) ||
item.brand?.toLowerCase().includes(searchLower) ||
item.articleNumber?.toLowerCase().includes(searchLower) ||
item.vehicleInfo?.brand?.toLowerCase().includes(searchLower) ||
item.vehicleInfo?.model?.toLowerCase().includes(searchLower)
);
}
// Сортировка
if (sortField) {
filtered.sort((a, b) => {
let aValue: string | number = '';
let bValue: string | number = '';
switch (sortField) {
case 'date':
aValue = new Date(a.createdAt).getTime();
bValue = new Date(b.createdAt).getTime();
break;
case 'manufacturer':
aValue = a.brand || a.vehicleInfo?.brand || '';
bValue = b.brand || b.vehicleInfo?.brand || '';
break;
case 'name':
aValue = a.searchQuery;
bValue = b.searchQuery;
break;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
setFilteredItems(filtered);
}, [historyItems, search, activeTab, sortField, sortOrder]);
const handleSort = (field: "date" | "manufacturer" | "name") => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("desc"); // По умолчанию сначала новые
}
};
const handleDeleteItem = async (id: string) => {
if (window.confirm('Удалить этот элемент из истории?')) {
try {
await deleteItem({ variables: { id } });
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleClearHistory = async () => {
if (window.confirm('Очистить всю историю поиска? Это действие нельзя отменить.')) {
try {
await clearHistory();
} catch (error) {
console.error('Ошибка очистки истории:', error);
}
}
};
const handleCreateTestData = async () => {
const testItems = [
{
searchQuery: "тормозные колодки",
searchType: "TEXT" as const,
brand: "BREMBO",
resultCount: 15,
vehicleBrand: "BMW",
vehicleModel: "X5",
vehicleYear: 2020
},
{
searchQuery: "0986424781",
searchType: "ARTICLE" as const,
brand: "BOSCH",
articleNumber: "0986424781",
resultCount: 3
},
{
searchQuery: "масляный фильтр",
searchType: "TEXT" as const,
brand: "MANN",
resultCount: 22,
vehicleBrand: "AUDI",
vehicleModel: "A4",
vehicleYear: 2018
},
{
searchQuery: "34116858652",
searchType: "OEM" as const,
brand: "BMW",
articleNumber: "34116858652",
resultCount: 8,
vehicleBrand: "BMW",
vehicleModel: "3 Series",
vehicleYear: 2019
},
{
searchQuery: "свечи зажигания",
searchType: "TEXT" as const,
brand: "NGK",
resultCount: 45
}
];
try {
for (const item of testItems) {
await createHistoryItem({
variables: { input: item }
});
// Небольшая задержка между запросами
await new Promise(resolve => setTimeout(resolve, 200));
}
} catch (error) {
console.error('Ошибка создания тестовых данных:', error);
}
};
if (loading && historyItems.length === 0) {
return (
<div className="flex flex-col justify-center text-base min-h-[526px] h-full">
<div className="flex justify-center items-center h-40">
<div className="text-gray-500">Загрузка истории поиска...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center text-base min-h-[526px]">
<div className="flex justify-center items-center h-40">
<div className="text-red-500">Ошибка загрузки истории поиска</div>
</div>
</div>
);
}
return (
<div className="flex flex-col min-h-[526px]">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full max-md:flex-col">
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full max-md:w-full">
<SearchInput
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск в истории..."
/>
</div>
<div className="flex gap-2">
{historyItems.length === 0 && (
<button
onClick={handleCreateTestData}
className="px-4 py-2 text-sm text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
>
Создать тестовые данные
</button>
)}
{historyItems.length > 0 && (
<button
onClick={handleClearHistory}
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Очистить историю
</button>
)}
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/02c9461c587bf477e8ee3187cb5faa1bccaf0900?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square max-md:mt-2"
/>
</div>
<div className="flex flex-col mt-5 w-full text-lg font-medium leading-tight whitespace-nowrap text-gray-950 max-md:max-w-full">
<ProfileHistoryTabs tabs={tabOptions} activeTab={activeTab} onTabChange={setActiveTab} />
</div>
<div className="flex flex-col mt-5 w-full text-gray-400 max-md:max-w-full flex-1 h-full">
<div className="flex flex-col p-2 w-full bg-white rounded-xl h-full max-md:max-w-full min-h-[250px] ">
<div className="hidden md:flex gap-10 items-center px-5 py-2 w-full text-sm max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:px-2">
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch pr-5 my-auto w-full basis-0 min-w-[240px] max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:p-0 max-md:min-w-0">
<div className={`flex gap-1.5 items-center self-stretch my-auto w-40 max-md:w-full ${sortField === 'date' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('date')}
>
Дата и время
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'date' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'date' ? 1 : 0.5,
stroke: sortField === 'date' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className={`flex gap-1.5 items-center self-stretch my-auto w-40 whitespace-nowrap max-md:w-full ${sortField === 'manufacturer' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('manufacturer')}
>
Производитель
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'manufacturer' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'manufacturer' ? 1 : 0.5,
stroke: sortField === 'manufacturer' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="gap-1.5 self-stretch my-auto whitespace-nowrap w-[180px] max-md:w-full">
Артикул/Тип
</div>
<div className={`flex flex-wrap flex-1 shrink gap-1.5 items-center self-stretch my-auto whitespace-nowrap basis-0 max-md:max-w-full max-md:w-full ${sortField === 'name' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('name')}
>
Поисковый запрос
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'name' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'name' ? 1 : 0.5,
stroke: sortField === 'name' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="w-16 text-center max-md:w-full">
Действия
</div>
</div>
</div>
{filteredItems.length === 0 ? (
<div className="flex justify-center items-center py-12 h-full max-md:h-full">
<div className="text-center text-gray-500 h-full">
{historyItems.length === 0 ? (
<>
<div className="text-lg mb-2 " >История поиска пуста</div>
<div className="text-sm">Ваши поисковые запросы будут отображаться здесь</div>
</>
) : (
<>
<div className="text-lg mb-2">Ничего не найдено</div>
<div className="text-sm">Попробуйте изменить фильтры или поисковый запрос</div>
</>
)}
</div>
</div>
) : (
filteredItems.map((item) => (
<ProfileHistoryItem
key={item.id}
id={item.id}
date={new Date(item.createdAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
manufacturer={item.brand || item.vehicleInfo?.brand || 'Не указан'}
article={item.articleNumber || `${item.searchType} поиск`}
name={item.searchQuery}
vehicleInfo={item.vehicleInfo}
resultCount={item.resultCount}
onDelete={handleDeleteItem}
/>
))
)}
</div>
{filteredItems.length > 0 && (
<div className="mt-4 text-center text-sm text-gray-500">
Показано {filteredItems.length} из {historyItems.length} записей
</div>
)}
</div>
</div>
);
};
export default ProfileHistoryMain;

View File

@ -0,0 +1,93 @@
import React, { useState, useRef } from "react";
interface ProfileHistoryTabsProps {
tabs: string[];
activeTab: string;
onTabChange: (tab: string) => void;
}
const manufacturers = ["Все", "VAG", "Toyota", "Ford", "BMW"];
const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
tabs,
activeTab,
onTabChange,
}) => {
const [selectedManufacturer, setSelectedManufacturer] = useState(manufacturers[0]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Закрытие дропдауна при клике вне
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isDropdownOpen]);
return (
<div className="flex flex-wrap gap-5 w-full max-md:max-w-full">
{tabs.map((tab) => (
<div
key={tab}
className={`flex flex-1 shrink gap-5 items-center h-full text-center rounded-xl basis-12 min-w-[240px] ${
activeTab === tab
? "text-white"
: "bg-slate-200 text-gray-950"
}`}
style={{ cursor: "pointer" }}
onClick={() => onTabChange(tab)}
>
<div
className={`flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 min-w-[240px] max-md:px-5 ${
activeTab === tab
? "text-white bg-red-600"
: "bg-slate-200 text-gray-950"
}`}
>
{tab}
</div>
</div>
))}
<div
className="relative w-[240px] max-w-full"
ref={dropdownRef}
tabIndex={0}
>
<div
className="flex justify-between items-center px-6 py-4 text-sm leading-snug bg-white rounded border border-solid border-stone-300 text-neutral-500 cursor-pointer select-none w-full"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
<span className="truncate">{selectedManufacturer}</span>
<span className="ml-2 flex-shrink-0 flex items-center">
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</span>
</div>
{isDropdownOpen && (
<ul className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full">
{manufacturers.map((option) => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === selectedManufacturer ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setSelectedManufacturer(option); setIsDropdownOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default ProfileHistoryTabs;

View File

@ -0,0 +1,52 @@
import React from "react";
import { useRouter } from "next/router";
const crumbsMap: Record<string, string> = {
"/profile-orders": "Заказы",
"/profile-history": "История поиска",
"/profile-announcement": "Уведомления",
"/profile-notification": "Оповещения",
"/profile-addresses": "Адреса доставки",
"/profile-gar": "Гараж",
"/profile-set": "Настройки аккаунта",
"/profile-balance": "Баланс",
"/profile-req": "Реквизиты",
"/profile-settlements": "Взаиморасчеты",
"/profile-acts": "Акты сверки",
};
function normalizePath(path: string): string {
return path.replace(/\/+$/, "");
}
export default function ProfileInfo() {
const router = useRouter();
const currentPath: string = normalizePath(router.asPath);
const crumbLabel: string = crumbsMap[currentPath] || "Профиль";
return (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block w-inline-block">
<div>Личный кабинет</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>{crumbLabel}</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">{crumbLabel}</h1>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,324 @@
import * as React from "react";
import { useQuery } from '@apollo/client';
import { GET_ORDERS } from '@/lib/graphql';
interface Order {
id: string;
orderNumber: string;
status: 'PENDING' | 'PAID' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'CANCELED' | 'REFUNDED';
totalAmount: number;
discountAmount: number;
finalAmount: number;
currency: string;
items: Array<{
id: string;
name: string;
article?: string;
brand?: string;
price: number;
quantity: number;
totalPrice: number;
}>;
deliveryAddress?: string;
comment?: string;
createdAt: string;
updatedAt: string;
}
interface ProfileOrdersMainProps {
// Добавьте необходимые пропсы, если они нужны
}
const tabs = [
{ label: "Все", status: null },
{ label: "Текущие", status: ['PENDING', 'PAID', 'PROCESSING', 'SHIPPED'] },
{ label: "Выполненные", status: ['DELIVERED'] },
{ label: "Отмененные", status: ['CANCELED', 'REFUNDED'] },
];
const statusLabels = {
PENDING: 'Ожидает оплаты',
PAID: 'Оплачен',
PROCESSING: 'В обработке',
SHIPPED: 'Отправлен',
DELIVERED: 'Доставлен',
CANCELED: 'Отменен',
REFUNDED: 'Возвращен'
};
const statusColors = {
PENDING: '#F59E0B',
PAID: '#10B981',
PROCESSING: '#3B82F6',
SHIPPED: '#8B5CF6',
DELIVERED: '#10B981',
CANCELED: '#EF4444',
REFUNDED: '#6B7280'
};
const ProfileOrdersMain: React.FC<ProfileOrdersMainProps> = (props) => {
const [activeTab, setActiveTab] = React.useState(0);
const [search, setSearch] = React.useState("");
const [period, setPeriod] = React.useState("Все");
const periodOptions = ["Все", "Сегодня", "Неделя", "Месяц", "Год"];
const [deliveryMethod, setDeliveryMethod] = React.useState("Все");
const deliveryOptions = ["Все", "Самовывоз", "Доставка"];
const [isPeriodOpen, setIsPeriodOpen] = React.useState(false);
const [isDeliveryOpen, setIsDeliveryOpen] = React.useState(false);
const [clientId, setClientId] = React.useState<string | null>(null);
// Получаем ID клиента из localStorage
React.useEffect(() => {
const userData = localStorage.getItem('userData');
if (userData) {
try {
const user = JSON.parse(userData);
setClientId(user.id);
} catch (error) {
console.error('Ошибка парсинга userData:', error);
}
}
}, []);
// Загружаем заказы
const { data, loading, error, refetch } = useQuery(GET_ORDERS, {
variables: {
clientId: clientId?.startsWith('client_') ? clientId.substring(7) : clientId,
limit: 100,
offset: 0
},
skip: !clientId, // Не выполняем запрос пока нет clientId
fetchPolicy: 'cache-and-network'
});
const orders: Order[] = data?.orders?.orders || [];
// Фильтруем заказы по активной вкладке
const filteredOrdersByTab = React.useMemo(() => {
const currentTab = tabs[activeTab];
if (!currentTab.status) {
return orders; // Все заказы
}
return orders.filter(order => currentTab.status!.includes(order.status));
}, [orders, activeTab]);
// Фильтруем по поиску
const filteredOrders = React.useMemo(() => {
if (!search) return filteredOrdersByTab;
const searchLower = search.toLowerCase();
return filteredOrdersByTab.filter(order =>
order.orderNumber.toLowerCase().includes(searchLower) ||
order.items.some(item =>
item.name.toLowerCase().includes(searchLower) ||
item.article?.toLowerCase().includes(searchLower) ||
item.brand?.toLowerCase().includes(searchLower)
)
);
}, [filteredOrdersByTab, search]);
const formatPrice = (price: number, currency = 'RUB') => {
return `${price.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
if (!clientId) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<p className="text-gray-500">Необходимо авторизоваться для просмотра заказов</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto mb-4"></div>
<p className="text-gray-500">Загрузка заказов...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<p className="text-red-500">Ошибка загрузки заказов: {error.message}</p>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Попробовать снова
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 w-full whitespace-nowrap max-md:max-w-full">
<div className="flex flex-wrap flex-1 shrink gap-5 self-start text-lg font-medium leading-tight text-center basis-[60px] min-w-[240px] text-gray-950 max-md:max-w-full">
{tabs.map((tab, idx) => (
<div
key={tab.label}
className={`flex flex-1 shrink gap-5 items-center h-full rounded-xl basis-0 ${activeTab === idx ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
style={{ cursor: "pointer" }}
onClick={() => setActiveTab(idx)}
>
<div
className={`flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5 ${activeTab === idx ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
>
{tab.label}
</div>
</div>
))}
</div>
<div className="flex flex-1 shrink gap-5 items-center px-8 py-3 h-full text-base leading-snug text-gray-400 bg-white rounded-lg basis-0 max-w-[360px] min-w-[240px] max-md:px-5">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по заказам"
className="flex-1 shrink self-stretch my-auto basis-0 text-ellipsis outline-none bg-transparent text-gray-950 placeholder:text-gray-400"
/>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/c08da0aac46dcf126a2a1a0e5832e3b069cd2d94?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">{tabs[activeTab].label}</div>
{filteredOrders.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">
{search ? 'Заказы не найдены' : 'У вас пока нет заказов'}
</div>
{!search && (
<div className="text-gray-500 text-sm">
Оформите первый заказ в нашем каталоге
</div>
)}
</div>
) : (
<div className="space-y-6 mt-5">
{filteredOrders.map((order) => (
<div key={order.id} className="flex flex-col justify-center px-5 py-8 w-full bg-white rounded-2xl border border-gray-200">
<div className="flex flex-col pr-7 pl-5 w-full max-md:pr-5 max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex gap-5 items-center self-stretch my-auto min-w-[240px]">
<div
className="gap-5 self-stretch px-6 py-3.5 my-auto text-sm font-medium leading-snug text-center text-white whitespace-nowrap rounded-xl max-md:px-5"
style={{ backgroundColor: statusColors[order.status] }}
>
{statusLabels[order.status]}
</div>
<div className="self-stretch my-auto text-xl font-semibold leading-tight text-gray-950">
Заказ {order.orderNumber} от {formatDate(order.createdAt)}
</div>
</div>
</div>
</div>
<div className="flex flex-col mt-5 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center pr-24 pb-2.5 pl-2 w-full text-sm text-gray-400 whitespace-nowrap border-b border-solid border-b-stone-300 max-md:pr-5 max-md:max-w-full">
<div className="gap-1.5 self-stretch my-auto w-9"></div>
<div className="flex gap-1.5 items-center self-stretch my-auto w-[130px]">
<div className="self-stretch my-auto">Производитель</div>
</div>
<div className="gap-1.5 self-stretch my-auto w-[120px]">Артикул</div>
<div className="flex flex-1 shrink gap-1.5 items-center self-stretch my-auto basis-0 min-w-[240px]">
<div className="self-stretch my-auto">Наименование</div>
</div>
<div className="self-stretch my-auto w-[60px]">Кол-во</div>
<div className="self-stretch my-auto text-right w-[90px]">Стоимость</div>
</div>
<div className="flex flex-col mt-1.5 w-full max-md:max-w-full">
{order.items.map((item, index) => (
<div key={item.id} className="flex flex-wrap gap-5 items-center pt-1.5 pr-7 pb-2 pl-2 w-full rounded-lg min-w-[420px] max-md:pr-5 max-md:max-w-full">
<div className="self-stretch my-auto w-9 text-sm leading-4 text-center text-black">
{index + 1}
</div>
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch my-auto basis-0 min-w-[240px] max-md:max-w-full">
<div className="self-stretch my-auto text-sm font-bold leading-snug text-gray-950 w-[130px]">
{item.brand || '-'}
</div>
<div className="self-stretch my-auto text-sm font-bold leading-snug text-gray-950 w-[120px]">
{item.article || '-'}
</div>
<div className="flex-1 shrink self-stretch my-auto text-sm text-gray-400 basis-0">
{item.name}
</div>
<div className="self-stretch text-sm text-gray-400 w-[60px]">
{item.quantity} шт.
</div>
<div className="flex flex-col justify-center self-stretch my-auto text-right w-[90px]">
<div className="text-sm font-bold leading-snug text-gray-950">
{formatPrice(item.totalPrice, order.currency)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Итоговая сумма */}
<div className="flex justify-end mt-4 pt-4 border-t border-gray-200">
<div className="text-right space-y-1">
<div className="text-sm text-gray-500">
Сумма товаров: {formatPrice(order.totalAmount, order.currency)}
</div>
{order.discountAmount > 0 && (
<div className="text-sm text-gray-500">
Скидка: -{formatPrice(order.discountAmount, order.currency)}
</div>
)}
<div className="text-lg font-bold text-gray-950">
Итого: {formatPrice(order.finalAmount, order.currency)}
</div>
</div>
</div>
{/* Адрес доставки */}
{order.deliveryAddress && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm text-gray-500 mb-1">Адрес доставки:</div>
<div className="text-sm text-gray-950">{order.deliveryAddress}</div>
</div>
)}
{/* Комментарий */}
{order.comment && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm text-gray-500 mb-1">Комментарий:</div>
<div className="text-sm text-gray-950">{order.comment}</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ProfileOrdersMain;

View File

@ -0,0 +1,103 @@
import React from "react";
interface ProfilePersonalDataProps {
firstName: string;
setFirstName: (v: string) => void;
lastName: string;
setLastName: (v: string) => void;
phone: string;
setPhone: (v: string) => void;
email: string;
setEmail: (v: string) => void;
phoneError: string;
emailError: string;
onSave?: () => void;
}
const ProfilePersonalData: React.FC<ProfilePersonalDataProps> = ({
firstName,
setFirstName,
lastName,
setLastName,
phone,
setPhone,
email,
setEmail,
phoneError,
emailError,
onSave,
}) => (
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Персональные данные
</div>
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Имя</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Имя"
className="w-full bg-transparent outline-none text-gray-600"
value={firstName}
onChange={e => setFirstName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Фамилия</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Фамилия"
className="w-full bg-transparent outline-none text-gray-600"
value={lastName}
onChange={e => setLastName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Номер телефона</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Телефон"
className={`w-full bg-transparent outline-none text-gray-600 ${phoneError ? 'border-red-500' : ''}`}
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</div>
{phoneError && <div className="text-red-500 text-xs mt-1 ml-2">{phoneError}</div>}
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">E-mail</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="email"
placeholder="E-mail"
className={`w-full bg-transparent outline-none text-neutral-500 ${emailError ? 'border-red-500' : ''}`}
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
{emailError && <div className="text-red-500 text-xs mt-1 ml-2">{emailError}</div>}
</div>
</div>
{onSave && (
<div className="flex justify-end mt-6 max-md:self-start">
<button
onClick={onSave}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
style={{ color: '#fff' }}
>
Сохранить изменения
</button>
</div>
)}
</div>
</div>
);
export default ProfilePersonalData;

View File

@ -0,0 +1,522 @@
import * as React from "react";
import { useState } from "react";
import { useQuery, useMutation } from '@apollo/client';
import { useRouter } from 'next/router';
import {
GET_CLIENT_ME,
CREATE_CLIENT_BANK_DETAILS,
UPDATE_CLIENT_BANK_DETAILS,
DELETE_CLIENT_BANK_DETAILS
} from '@/lib/graphql';
interface BankDetail {
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
legalEntityId: string;
legalEntity?: {
id: string;
shortName: string;
inn: string;
};
}
interface LegalEntity {
id: string;
shortName: string;
inn: string;
bankDetails: BankDetail[];
}
interface ClientData {
id: string;
name: string;
legalEntities: LegalEntity[];
}
const ProfileRequisitiesMain = () => {
const router = useRouter();
const [selectedBankDetailId, setSelectedBankDetailId] = useState<string | null>(null);
const [selectedLegalEntityId, setSelectedLegalEntityId] = useState<string | null>(null);
const [accountName, setAccountName] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [bik, setBik] = useState("");
const [bankName, setBankName] = useState("");
const [corrAccount, setCorrAccount] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [editingBankDetail, setEditingBankDetail] = useState<BankDetail | null>(null);
// GraphQL запросы
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onCompleted: (data) => {
console.log('Данные клиента загружены:', data);
// Устанавливаем первое юридическое лицо как выбранное по умолчанию
if (data?.clientMe?.legalEntities?.length > 0) {
setSelectedLegalEntityId(data.clientMe.legalEntities[0].id);
// Находим первый банковский счет среди всех юридических лиц
const allBankDetails = data.clientMe.legalEntities.flatMap((le: LegalEntity) => le.bankDetails || []);
if (allBankDetails.length > 0) {
setSelectedBankDetailId(allBankDetails[0].id);
}
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [createBankDetails] = useMutation(CREATE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты созданы');
clearForm();
setShowAddForm(false);
refetch();
},
onError: (error) => {
console.error('Ошибка создания банковских реквизитов:', error);
alert('Ошибка создания банковских реквизитов: ' + error.message);
}
});
const [updateBankDetails] = useMutation(UPDATE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты обновлены');
clearForm();
setShowAddForm(false);
setEditingBankDetail(null);
refetch();
},
onError: (error) => {
console.error('Ошибка обновления банковских реквизитов:', error);
alert('Ошибка обновления банковских реквизитов: ' + error.message);
}
});
const [deleteBankDetails] = useMutation(DELETE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты удалены');
refetch();
},
onError: (error) => {
console.error('Ошибка удаления банковских реквизитов:', error);
alert('Ошибка удаления банковских реквизитов: ' + error.message);
}
});
const clearForm = () => {
setAccountName("");
setAccountNumber("");
setBik("");
setBankName("");
setCorrAccount("");
};
const handleSave = async () => {
// Валидация
if (!accountName.trim()) {
alert('Введите название счета');
return;
}
if (!accountNumber.trim() || accountNumber.length < 20) {
alert('Введите корректный номер расчетного счета');
return;
}
if (!bik.trim() || bik.length !== 9) {
alert('Введите корректный БИК (9 цифр)');
return;
}
if (!bankName.trim()) {
alert('Введите наименование банка');
return;
}
if (!corrAccount.trim() || corrAccount.length < 20) {
alert('Введите корректный корреспондентский счет');
return;
}
// Определяем legalEntityId для сохранения
let legalEntityIdForSave = selectedLegalEntityId;
// Если юридическое лицо не выбрано, но есть только одно - используем его
if (!legalEntityIdForSave && legalEntities.length === 1) {
legalEntityIdForSave = legalEntities[0].id;
}
if (!legalEntityIdForSave) {
alert('Выберите юридическое лицо');
return;
}
try {
const input = {
name: accountName.trim(),
accountNumber: accountNumber.trim(),
bik: bik.trim(),
bankName: bankName.trim(),
correspondentAccount: corrAccount.trim()
};
if (editingBankDetail) {
// Обновляем существующие реквизиты
await updateBankDetails({
variables: {
id: editingBankDetail.id,
input,
legalEntityId: legalEntityIdForSave
}
});
} else {
// Создаем новые реквизиты
await createBankDetails({
variables: {
legalEntityId: legalEntityIdForSave,
input
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
};
const handleEdit = (bankDetail: BankDetail) => {
console.log('Редактирование банковских реквизитов:', bankDetail);
setEditingBankDetail(bankDetail);
setAccountName(bankDetail.name);
setAccountNumber(bankDetail.accountNumber);
setBik(bankDetail.bik);
setBankName(bankDetail.bankName);
setCorrAccount(bankDetail.correspondentAccount);
setSelectedLegalEntityId(bankDetail.legalEntityId);
setShowAddForm(true);
};
const handleDelete = async (bankDetailId: string, bankDetailName: string) => {
if (window.confirm(`Вы уверены, что хотите удалить банковские реквизиты "${bankDetailName}"?`)) {
try {
await deleteBankDetails({
variables: { id: bankDetailId }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleCancel = () => {
clearForm();
setShowAddForm(false);
setEditingBankDetail(null);
};
const handleAddNew = () => {
clearForm();
setEditingBankDetail(null);
setShowAddForm(true);
};
if (loading) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-2xl">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-2xl">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
const legalEntities = clientData?.legalEntities || [];
const selectedLegalEntity = legalEntities.find(le => le.id === selectedLegalEntityId);
const allBankDetails = legalEntities.flatMap(le => le.bankDetails?.filter(bd => bd && bd.id) || []);
if (legalEntities.length === 0) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950 max-md:max-w-full">
Банковские реквизиты
</div>
<div className="mt-8 text-gray-600">
У вас пока нет юридических лиц. Создайте юридическое лицо, чтобы добавить банковские реквизиты.
</div>
<div className="flex gap-8 items-start self-start mt-8">
<button
onClick={() => router.push('/profile-set')}
style={{ color: 'fff' }}
className="gap-2.5 self-stretch px-5 py-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center hover:bg-red-700"
>
Создать юридическое лицо
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 w-full">
<div className="flex overflow-hidden flex-col p-8 w-full text-3xl font-bold leading-none bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Реквизиты {selectedLegalEntity ? selectedLegalEntity.shortName : 'юридического лица'}
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug text-gray-600 max-md:max-w-full">
{allBankDetails.length === 0 ? (
<div className="text-gray-600 py-8 text-center">
У вас пока нет добавленных банковских реквизитов.
</div>
) : (
allBankDetails.map((bankDetail) => (
<div key={bankDetail.id} className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full mb-2.5">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center self-stretch my-auto min-w-[240px] max-md:max-w-full">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
{bankDetail.name}
</div>
<div className="self-stretch my-auto text-gray-600">
р/с {bankDetail.accountNumber}
</div>
<div className="self-stretch my-auto text-gray-600">
{bankDetail.bankName}
</div>
<div className="self-stretch my-auto text-gray-600 text-sm">
БИК: {bankDetail.bik}
</div>
<div className="flex gap-1.5 items-center self-stretch my-auto" role="button" tabIndex={0} aria-label="Юридическое лицо">
<img
src="/images/icon-setting.svg"
alt="ЮЛ"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
{(() => {
const entity = legalEntities.find(le => le.id === bankDetail.legalEntityId);
return entity ? entity.shortName : (bankDetail.legalEntityId ? 'Неизвестное ЮЛ' : 'Не привязан к ЮЛ');
})()}
</div>
</div>
<div className="flex gap-1.5 items-center self-stretch my-auto">
<div
className="relative aspect-[1/1] h-[18px] w-[18px] cursor-pointer"
onClick={() => setSelectedBankDetailId(bankDetail.id)}
>
<div>
<div
dangerouslySetInnerHTML={{
__html: selectedBankDetailId === bankDetail.id
? `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="9" cy="9" r="8.5" stroke="#EC1C24"/><circle cx="9.0001" cy="8.99961" r="5.4" fill="#FF0000"/></svg>`
: `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="9" cy="9" r="8.5" stroke="#D0D0D0"/></svg>`
}}
/>
</div>
</div>
<div className="text-sm leading-5 text-gray-600">
Основной счет
</div>
</div>
</div>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto whitespace-nowrap">
<div
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
aria-label="Редактировать счет"
onClick={() => handleEdit(bankDetail)}
>
<img
src="/images/edit.svg"
alt="Редактировать"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Редактировать
</div>
</div>
<div
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
aria-label="Удалить счет"
onClick={() => handleDelete(bankDetail.id, bankDetail.name)}
>
<img
src="/images/delete.svg"
alt="Удалить"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
{!showAddForm && (
<div
className="gap-2.5 self-stretch px-5 py-4 my-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center w-fit hover:bg-red-700"
onClick={handleAddNew}
>
Добавить реквизиты {selectedLegalEntity ? `для ${selectedLegalEntity.shortName}` : ''}
</div>
)}
{showAddForm && (
<>
<div className="mt-8 text-gray-950">
{editingBankDetail ? 'Редактирование реквизитов' : 'Добавление реквизитов'}
</div>
{/* Выбор юридического лица */}
{legalEntities.length > 1 && (
<div className="flex flex-col mt-4 w-full">
<div className="text-sm text-gray-950 mb-2">
Юридическое лицо
{editingBankDetail && (
<span className="text-xs text-gray-500 ml-2">
(при редактировании можно изменить привязку)
</span>
)}
</div>
<select
value={selectedLegalEntityId || ''}
onChange={(e) => setSelectedLegalEntityId(e.target.value)}
className="gap-2.5 px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-600 outline-none "
>
<option value="">Выберите юридическое лицо</option>
{legalEntities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.shortName} (ИНН: {entity.inn})
</option>
))}
</select>
</div>
)}
{/* Если юридическое лицо одно, показываем его */}
{legalEntities.length === 1 && (
<div className="flex flex-col mt-4 w-full">
<div className="text-sm text-gray-950 mb-2">Юридическое лицо</div>
<div className="gap-2.5 px-6 py-4 w-full bg-gray-50 rounded border border-solid border-stone-300 min-h-[52px] text-gray-600 flex items-center">
{legalEntities[0].shortName} (ИНН: {legalEntities[0].inn})
</div>
</div>
)}
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-row flex-wrap gap-5 items-start w-full min-h-[78px] max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Название счета</div>
<input
type="text"
value={accountName}
onChange={e => setAccountName(e.target.value)}
placeholder="Произвольное название"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full text-gray-600 bg-white rounded border border-solid border-stone-300 min-h-[52px] max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap"> Расчетного счета</div>
<input
type="text"
value={accountNumber}
onChange={e => setAccountNumber(e.target.value)}
placeholder="20 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">БИК</div>
<input
type="text"
value={bik}
onChange={e => setBik(e.target.value)}
placeholder="9 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Наименование банка</div>
<input
type="text"
value={bankName}
onChange={e => setBankName(e.target.value)}
placeholder="Наименование банка"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Корреспондентский счет</div>
<input
type="text"
value={corrAccount}
onChange={e => setCorrAccount(e.target.value)}
placeholder="20 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className="gap-2.5 self-stretch px-5 py-4 my-auto bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white hover:bg-red-700"
onClick={handleSave}
>
{editingBankDetail ? 'Сохранить' : 'Добавить'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-4 my-auto rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50"
onClick={handleCancel}
>
Отменить
</div>
</div>
</>
)}
</div>
<div className="flex overflow-hidden gap-10 items-center px-5 py-4 mt-5 w-full text-lg font-medium leading-tight text-center text-black bg-white rounded-2xl max-md:max-w-full">
<div
className="gap-2.5 self-stretch px-10 py-6 my-auto text-black rounded-xl border cursor-pointer border-red-600 border-solid min-w-[240px] max-md:px-5 hover:bg-gray-50"
onClick={() => router.push('/profile-set')}
>
Управление юридическими лицами
</div>
</div>
</div>
);
}
export default ProfileRequisitiesMain;

View File

@ -0,0 +1,18 @@
import React from "react";
interface ProfileSettingsActionsBlockProps {
onAddLegalEntity: () => void;
}
const ProfileSettingsActionsBlock: React.FC<ProfileSettingsActionsBlockProps> = ({ onAddLegalEntity }) => (
<div className="flex overflow-hidden flex-wrap gap-10 justify-between items-center px-5 py-4 mt-5 w-full text-base font-medium leading-tight text-center bg-white rounded-2xl max-md:max-w-full">
<div className="gap-2.5 self-stretch px-5 py-4 my-auto bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white">
Сохранить изменения
</div>
<div className="gap-2.5 self-stretch px-5 py-4 my-auto rounded-xl border border-red-600 min-h-[50px] min-w-[240px] cursor-pointer bg-white text-gray-950" onClick={onAddLegalEntity}>
Добавить юридическое лицо
</div>
</div>
);
export default ProfileSettingsActionsBlock;

View File

@ -0,0 +1,304 @@
import * as React from "react";
import { useQuery, useMutation } from '@apollo/client';
import { GET_CLIENT_ME, UPDATE_CLIENT_PERSONAL_DATA } from '@/lib/graphql';
import ProfilePersonalData from "./ProfilePersonalData";
import LegalEntityListBlock from "./LegalEntityListBlock";
import LegalEntityFormBlock from "./LegalEntityFormBlock";
import ProfileSettingsActionsBlock from "./ProfileSettingsActionsBlock";
interface ClientData {
id: string;
name: string;
email: string;
phone: string;
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
legalEntities: Array<{
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
bankDetails: Array<{
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
}>;
}>;
}
const ProfileSettingsMain = () => {
const [form, setForm] = React.useState("Выбрать");
const [isFormOpen, setIsFormOpen] = React.useState(false);
const formOptions = ["ООО", "ИП", "АО", "ПАО", "Другое"];
const [taxSystem, setTaxSystem] = React.useState("Выбрать");
const [isTaxSystemOpen, setIsTaxSystemOpen] = React.useState(false);
const taxSystemOptions = ["ОСНО", "УСН", "ЕНВД", "ПСН"];
const [nds, setNds] = React.useState("Выбрать");
const [isNdsOpen, setIsNdsOpen] = React.useState(false);
const ndsOptions = ["Без НДС", "НДС 10%", "НДС 20%", "Другое"];
const [showLegalEntityForm, setShowLegalEntityForm] = React.useState(false);
const [editingEntity, setEditingEntity] = React.useState<ClientData['legalEntities'][0] | null>(null);
// Состояние для формы юридического лица
const [inn, setInn] = React.useState("");
const [ogrn, setOgrn] = React.useState("");
const [kpp, setKpp] = React.useState("");
const [jurAddress, setJurAddress] = React.useState("");
const [shortName, setShortName] = React.useState("");
const [fullName, setFullName] = React.useState("");
const [factAddress, setFactAddress] = React.useState("");
const [ndsPercent, setNdsPercent] = React.useState("");
const [accountant, setAccountant] = React.useState("");
const [responsible, setResponsible] = React.useState("");
const [responsiblePosition, setResponsiblePosition] = React.useState("");
const [responsiblePhone, setResponsiblePhone] = React.useState("");
const [signatory, setSignatory] = React.useState("");
// Состояние для личных данных
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");
const [phone, setPhone] = React.useState("");
const [email, setEmail] = React.useState("");
const [phoneError, setPhoneError] = React.useState("");
const [emailError, setEmailError] = React.useState("");
// GraphQL запросы
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onCompleted: (data) => {
console.log('Данные клиента загружены:', data);
if (data?.clientMe) {
const client = data.clientMe;
// Разделяем имя на имя и фамилию
const nameParts = client.name?.split(' ') || ['', ''];
setFirstName(nameParts[0] || '');
setLastName(nameParts.slice(1).join(' ') || '');
setPhone(client.phone || '');
setEmail(client.email || '');
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [updatePersonalData] = useMutation(UPDATE_CLIENT_PERSONAL_DATA, {
onCompleted: () => {
console.log('Личные данные обновлены');
refetch();
},
onError: (error) => {
console.error('Ошибка обновления личных данных:', error);
}
});
const handleSavePersonalData = async () => {
try {
// Валидация
setPhoneError('');
setEmailError('');
if (!phone || phone.length < 10) {
setPhoneError('Введите корректный номер телефона');
return;
}
if (!email || !email.includes('@')) {
setEmailError('Введите корректный email');
return;
}
await updatePersonalData({
variables: {
input: {
type: 'INDIVIDUAL',
name: `${firstName} ${lastName}`.trim(),
phone,
email,
emailNotifications: false
}
}
});
alert('Личные данные сохранены!');
} catch (error) {
console.error('Ошибка сохранения:', error);
alert('Ошибка сохранения данных');
}
};
const handleEditEntity = (entity: ClientData['legalEntities'][0]) => {
setEditingEntity(entity);
setShowLegalEntityForm(true);
// Заполняем форму данными редактируемого юридического лица
setShortName(entity.shortName);
setFullName(entity.fullName || '');
setForm(entity.form || 'ООО');
setJurAddress(entity.legalAddress || '');
setFactAddress(entity.actualAddress || '');
setInn(entity.inn);
setOgrn(entity.ogrn || '');
setTaxSystem(entity.taxSystem || 'УСН');
setNdsPercent(entity.vatPercent.toString());
setAccountant(entity.accountant || '');
setResponsible(entity.responsibleName || '');
setResponsiblePosition(entity.responsiblePosition || '');
setResponsiblePhone(entity.responsiblePhone || '');
setSignatory(entity.signatory || '');
};
const handleAddEntity = () => {
setEditingEntity(null);
setShowLegalEntityForm(true);
// Очищаем форму для нового юридического лица
setShortName('');
setFullName('');
setForm('ООО');
setJurAddress('');
setFactAddress('');
setInn('');
setOgrn('');
setTaxSystem('УСН');
setNdsPercent('20');
setAccountant('');
setResponsible('');
setResponsiblePosition('');
setResponsiblePhone('');
setSignatory('');
};
if (loading) {
return (
<div className="flex flex-col justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center items-center p-8">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<ProfilePersonalData
firstName={firstName}
setFirstName={setFirstName}
lastName={lastName}
setLastName={setLastName}
phone={phone}
setPhone={setPhone}
email={email}
setEmail={setEmail}
phoneError={phoneError}
emailError={emailError}
onSave={handleSavePersonalData}
/>
<LegalEntityListBlock
legalEntities={clientData?.legalEntities || []}
onRefetch={refetch}
onEdit={handleEditEntity}
/>
{showLegalEntityForm && (
<LegalEntityFormBlock
inn={inn}
setInn={setInn}
form={form}
setForm={setForm}
isFormOpen={isFormOpen}
setIsFormOpen={setIsFormOpen}
formOptions={formOptions}
ogrn={ogrn}
setOgrn={setOgrn}
kpp={kpp}
setKpp={setKpp}
jurAddress={jurAddress}
setJurAddress={setJurAddress}
shortName={shortName}
setShortName={setShortName}
fullName={fullName}
setFullName={setFullName}
factAddress={factAddress}
setFactAddress={setFactAddress}
taxSystem={taxSystem}
setTaxSystem={setTaxSystem}
isTaxSystemOpen={isTaxSystemOpen}
setIsTaxSystemOpen={setIsTaxSystemOpen}
taxSystemOptions={taxSystemOptions}
nds={nds}
setNds={setNds}
isNdsOpen={isNdsOpen}
setIsNdsOpen={setIsNdsOpen}
ndsOptions={ndsOptions}
ndsPercent={ndsPercent}
setNdsPercent={setNdsPercent}
accountant={accountant}
setAccountant={setAccountant}
responsible={responsible}
setResponsible={setResponsible}
responsiblePosition={responsiblePosition}
setResponsiblePosition={setResponsiblePosition}
responsiblePhone={responsiblePhone}
setResponsiblePhone={setResponsiblePhone}
signatory={signatory}
setSignatory={setSignatory}
editingEntity={editingEntity}
onAdd={() => {
setShowLegalEntityForm(false);
setEditingEntity(null);
refetch(); // Обновляем данные после добавления/редактирования
}}
onCancel={() => {
setShowLegalEntityForm(false);
setEditingEntity(null);
}}
/>
)}
<ProfileSettingsActionsBlock onAddLegalEntity={handleAddEntity} />
</div>
);
}
export default ProfileSettingsMain;

View File

@ -0,0 +1,20 @@
import React from "react";
interface SearchInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
}
const SearchInput: React.FC<SearchInputProps> = ({ value, onChange, placeholder = "Поиск" }) => (
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
className="w-full bg-transparent outline-none text-gray-400 text-base"
style={{ border: "none", padding: 0, margin: 0 }}
/>
);
export default SearchInput;

View File

@ -0,0 +1,90 @@
/**
* ProductListSkeleton
* Скелетон для отображения списка товаров во время загрузки.
* Используйте этот компонент вместо списка товаров, когда данные еще не получены.
*
* @param {number} [count=4] - Количество скелетон-элементов для отображения.
* @example
* <ProductListSkeleton count={6} />
*/
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
interface ProductListSkeletonProps {
count?: number;
}
const ProductListSkeleton: React.FC<ProductListSkeletonProps> = ({ count = 8 }) => {
return (
<>
{Array.from({ length: count }, (_, index) => (
<div key={index} className="w-layout-vflex flex-block-15-copy animate-pulse">
{/* Избранное */}
<div className="favcardcat">
<div className="w-4 h-4 bg-gray-200 rounded"></div>
</div>
{/* Изображение товара */}
<div className="div-block-4">
<div className="w-full h-48 bg-gray-200 rounded"></div>
<div className="absolute top-2 right-2 w-12 h-6 bg-gray-200 rounded"></div>
</div>
{/* Информация о товаре */}
<div className="div-block-3">
{/* Цена */}
<div className="w-layout-hflex flex-block-16">
<div className="w-20 h-6 bg-gray-200 rounded"></div>
<div className="w-16 h-4 bg-gray-200 rounded"></div>
</div>
{/* Название товара */}
<div className="space-y-2 mt-2">
<div className="w-full h-4 bg-gray-200 rounded"></div>
<div className="w-3/4 h-4 bg-gray-200 rounded"></div>
</div>
{/* Бренд */}
<div className="w-1/2 h-4 bg-gray-200 rounded mt-2"></div>
</div>
{/* Кнопка "Купить" */}
<div className="catc w-inline-block">
<div className="div-block-25">
<div className="w-6 h-6 bg-gray-200 rounded"></div>
</div>
<div className="w-12 h-4 bg-gray-200 rounded"></div>
</div>
</div>
))}
</>
);
};
// Улучшенный компонент скелетона только для цены
export const PriceSkeleton: React.FC = () => {
return (
<div className="inline-flex items-center">
<div className="animate-pulse">
<div className="h-6 bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-[shimmer_1.5s_ease-in-out_infinite] rounded" style={{
width: '80px',
backgroundImage: 'linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%)',
animation: 'shimmer 1.5s ease-in-out infinite'
}}></div>
</div>
<style jsx>{`
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
`}</style>
</div>
);
};
export default ProductListSkeleton;

View File

@ -0,0 +1,38 @@
import React from "react";
const DescWholesale = () => (
<div className="w-layout-vflex flex-block-72">
<div className="w-layout-vflex desc-wholesale">
<div className="w-layout-hflex flex-block-74">
<h3 className="heading-14">Оптовые поставки автозапчастей более 35 000 000 наименований</h3>
<div className="text-block-36">Масштабный складской запас более полумиллиарда позиций автозапчастей открывает широкие возможности для развития вашего бизнеса!</div>
<div className="w-layout-vflex flex-block-95">
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor" /></svg></div>
<div className="text-block-36">Доставка по всей России</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor" /></svg></div>
<div className="text-block-36">Вычет НДС 20%</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor" /></svg></div>
<div className="text-block-36">Напрямую с 200+ оптовых складов</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor" /></svg></div>
<div className="text-block-36">Закрывающие документы</div>
</div>
<div className="w-layout-hflex flex-block-75">
<div className="code-embed-3 w-embed"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" clipRule="evenodd" d="M12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12C3 14.3869 3.94821 16.6761 5.63604 18.364C7.32387 20.0518 9.61305 21 12 21ZM11.768 15.64L16.768 9.64L15.232 8.36L10.932 13.519L8.707 11.293L7.293 12.707L10.293 15.707L11.067 16.481L11.768 15.64Z" fill="currentColor" /></svg></div>
<div className="text-block-36">Персональный менеджер</div>
</div>
</div>
<a href="#" className="submit-button-copy w-button">Получить доступ к оптовым ценам</a>
</div>
</div>
<div className="w-layout-vflex image-wholesale"></div>
</div>
);
export default DescWholesale;

View File

@ -0,0 +1,26 @@
import React from "react";
const HowToBuy = () => (
<div className="w-layout-hflex flex-block-96">
<h2 className="heading-13">Как покупать по оптовой цене</h2>
<div className="text-block-50"><span className="text-span">Зарегистрируйтесь</span> как юридическое лицо</div>
<div className="div-block-33">
<div className="w-embed"><svg width="40" height="40" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M55 30C55 16.2 43.8 5 30 5C16.2 5 5 16.2 5 30C5 43.8 16.2 55 30 55C43.8 55 55 43.8 55 30ZM30 36.975V32.5H22.5C21.125 32.5 20 31.375 20 30C20 28.625 21.125 27.5 22.5 27.5H30V23.025C30 21.9 31.35 21.35 32.125 22.15L39.1 29.125C39.6 29.625 39.6 30.4 39.1 30.9L32.125 37.875C31.9488 38.0478 31.7253 38.1645 31.4829 38.2105C31.2404 38.2564 30.9897 38.2296 30.7625 38.1334C30.5352 38.0371 30.3416 37.8758 30.2059 37.6696C30.0702 37.4635 29.9986 37.2218 30 36.975Z" fill="currentColor" /></svg></div>
</div>
<div className="text-block-50"><span className="text-span-2">Подберите запчасти</span> или воспользуйтесь <span className="text-span-3">помощью</span></div>
<div className="div-block-33">
<div className="w-embed"><svg width="40" height="40" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M55 30C55 16.2 43.8 5 30 5C16.2 5 5 16.2 5 30C5 43.8 16.2 55 30 55C43.8 55 55 43.8 55 30ZM30 36.975V32.5H22.5C21.125 32.5 20 31.375 20 30C20 28.625 21.125 27.5 22.5 27.5H30V23.025C30 21.9 31.35 21.35 32.125 22.15L39.1 29.125C39.6 29.625 39.6 30.4 39.1 30.9L32.125 37.875C31.9488 38.0478 31.7253 38.1645 31.4829 38.2105C31.2404 38.2564 30.9897 38.2296 30.7625 38.1334C30.5352 38.0371 30.3416 37.8758 30.2059 37.6696C30.0702 37.4635 29.9986 37.2218 30 36.975Z" fill="currentColor" /></svg></div>
</div>
<div className="text-block-50">Добавьте адрес доставки и оформите заказ</div>
<div className="div-block-33">
<div className="w-embed"><svg width="40" height="40" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M55 30C55 16.2 43.8 5 30 5C16.2 5 5 16.2 5 30C5 43.8 16.2 55 30 55C43.8 55 55 43.8 55 30ZM30 36.975V32.5H22.5C21.125 32.5 20 31.375 20 30C20 28.625 21.125 27.5 22.5 27.5H30V23.025C30 21.9 31.35 21.35 32.125 22.15L39.1 29.125C39.6 29.625 39.6 30.4 39.1 30.9L32.125 37.875C31.9488 38.0478 31.7253 38.1645 31.4829 38.2105C31.2404 38.2564 30.9897 38.2296 30.7625 38.1334C30.5352 38.0371 30.3416 37.8758 30.2059 37.6696C30.0702 37.4635 29.9986 37.2218 30 36.975Z" fill="currentColor" /></svg></div>
</div>
<div className="text-block-50">Скачайте счет на оплату или оплатите заказ картой</div>
<div className="div-block-33">
<div className="w-embed"><svg width="40" height="40" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M55 30C55 16.2 43.8 5 30 5C16.2 5 5 16.2 5 30C5 43.8 16.2 55 30 55C43.8 55 55 43.8 55 30ZM30 36.975V32.5H22.5C21.125 32.5 20 31.375 20 30C20 28.625 21.125 27.5 22.5 27.5H30V23.025C30 21.9 31.35 21.35 32.125 22.15L39.1 29.125C39.6 29.625 39.6 30.4 39.1 30.9L32.125 37.875C31.9488 38.0478 31.7253 38.1645 31.4829 38.2105C31.2404 38.2564 30.9897 38.2296 30.7625 38.1334C30.5352 38.0371 30.3416 37.8758 30.2059 37.6696C30.0702 37.4635 29.9986 37.2218 30 36.975Z" fill="currentColor" /></svg></div>
</div>
<div className="text-block-50">Получите заказ и закрывающие документы</div>
</div>
);
export default HowToBuy;

View File

@ -0,0 +1,26 @@
import React from "react";
const InfoWholesale = () => (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="#" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>Оптовым клиентам</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">Оптовым клиентам</h1>
</div>
</div>
</div>
</div>
</section>
);
export default InfoWholesale;

View File

@ -0,0 +1,20 @@
import React from "react";
const ServiceWholesale = () => (
<div className="w-layout-hflex service-wholesale-block">
<h2 className="heading-13">Сервисы для удобной работы с нами</h2>
<div className="w-layout-hflex flex-block-71-copy">
<button className="service-wholesale">
<div className="text-block-41">Онлайн-проценка по API</div>
</button>
<button className="service-wholesale">
<div className="text-block-41">Прайс листы нашего наличия</div>
</button>
<button className="service-wholesale">
<div className="text-block-41">Онлайн заказ</div>
</button>
</div>
</div>
);
export default ServiceWholesale;

View File

@ -0,0 +1,67 @@
import React from "react";
const WhyWholesale = () => (
<div className="w-layout-hflex flex-block-69-copy">
<h2 className="heading-13">Почему оптовые покупатели выбирают PROTEK</h2>
<div className="w-layout-vflex why-wholesale">
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.4167 18.417V29.7504H29.75V18.417H18.4167ZM4.25 29.7504H15.5833V18.417H4.25V29.7504ZM4.25 4.25036V15.5837H15.5833V4.25036H4.25ZM23.6017 2.39453L15.5833 10.3987L23.6017 18.417L31.62 10.3987L23.6017 2.39453Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14-copy">Широкий ассортимент</h3>
<div className="text-block-37">Все запчасти можно заказать в одном месте, с Российских и зарубежных складов</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Бесплатный подбор 24/7</h3>
<div className="text-block-37">Наши эксперты круглосуточно подберут нужные запчасти по VIN</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Минимальные цены и сроки</h3>
<div className="text-block-37">Сравнивайте предложения сотен поставщиков и выбирайте лучшие</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Широкий ассортимент</h3>
<div className="text-block-37">Все запчасти можно заказать в одном месте, с Российских и зарубежных складов</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Удобная доставка</h3>
<div className="text-block-37">До двери или в пункты выдачи транспортных компаний по всей России</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Минимальные цены и сроки</h3>
<div className="text-block-37">Сравнивайте предложения сотен поставщиков и выбирайте лучшие</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Лояльный возврат</h3>
<div className="text-block-37">Быстрый учет и возврат денег на баланс личного кабинета</div>
</div>
<div className="w-layout-vflex flex-block-70">
<div className="div-block-32">
<div className="w-embed"><svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.9845 2.83301C9.16453 2.83301 2.83203 9.17968 2.83203 16.9997C2.83203 24.8197 9.16453 31.1663 16.9845 31.1663C24.8187 31.1663 31.1654 24.8197 31.1654 16.9997C31.1654 9.17968 24.8187 2.83301 16.9845 2.83301ZM21.6595 23.6722L15.582 17.5805V9.91634H18.4154V16.4188L23.6712 21.6747L21.6595 23.6722Z" fill="currentColor" /></svg></div>
</div>
<h3 className="heading-14">Бесплатный подбор 24/7</h3>
<div className="text-block-37">Наши эксперты круглосуточно подберут нужные запчасти по VIN</div>
</div>
</div>
</div>
);
export default WhyWholesale;